From bfd8c6e56d13502dca1c00784df2400130584f4d Mon Sep 17 00:00:00 2001 From: JRS1986 <1651269+JRS1986@users.noreply.github.com> Date: Tue, 19 May 2026 19:59:36 +0200 Subject: [PATCH] Add shared test fixture factories --- tests/conftest.py | 147 ++++++++++++++++++++++++ tests/test_model_selection.py | 204 +++++++++++++++------------------- tests/test_router.py | 85 +++++++++----- tests/test_updater.py | 162 +++++++++++++++++---------- tests/test_writers.py | 56 +++------- 5 files changed, 409 insertions(+), 245 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..417a5f9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +import pytest + +from coding_scaffold.hardware import HardwareProfile +from coding_scaffold.intake import IntakeAnswers +from coding_scaffold.model_catalog import ROUTELLM_MF_DEFAULT_THRESHOLD +from coding_scaffold.providers import Provider +from coding_scaffold.router import RoutingPlan + + +@dataclass(frozen=True) +class ScaffoldInputs: + intake: IntakeAnswers + hardware: HardwareProfile + providers: list[Provider] + routing: RoutingPlan + + +@pytest.fixture +def hardware_profile() -> Callable[..., HardwareProfile]: + def factory( + os_name: str = "linux", + is_wsl: bool = False, + cpu_count: int = 8, + ram_gb: float = 32, + gpu_name: str | None = None, + vram_gb: float | None = None, + llmfit_available: bool = True, + local_runtimes: list[str] | None = None, + ) -> HardwareProfile: + return HardwareProfile( + os_name, + is_wsl, + cpu_count, + ram_gb, + gpu_name, + vram_gb, + llmfit_available, + ["ollama"] if local_runtimes is None else local_runtimes, + ) + + return factory + + +@pytest.fixture +def provider_factory() -> Callable[..., Provider]: + def factory( + name: str = "ollama", + kind: str = "local", + available: bool = True, + status: str = "CLI found", + endpoint: str | None = "http://127.0.0.1:11434/v1", + model_family: str | None = "local", + deployment: str | None = None, + redact_fields: tuple[str, ...] = (), + ) -> Provider: + return Provider( + name, + kind, + available, + status, + endpoint, + model_family, + deployment, + redact_fields, + ) + + return factory + + +@pytest.fixture +def local_provider(provider_factory: Callable[..., Provider]) -> Provider: + return provider_factory() + + +@pytest.fixture +def intake_answers() -> Callable[..., IntakeAnswers]: + def factory( + language: str = "python", + project_target: str = "CLI", + existing_codebase: bool = True, + privacy: str = "local-first", + tool: str = "manual", + preferred_local_model: str | None = None, + mode: str | None = None, + ) -> IntakeAnswers: + return IntakeAnswers( + language=language, + project_target=project_target, + existing_codebase=existing_codebase, + privacy=privacy, + tool=tool, + preferred_local_model=preferred_local_model, + mode=mode, + ) + + return factory + + +@pytest.fixture +def routing_plan_factory() -> Callable[..., RoutingPlan]: + def factory( + strategy: str = "local-first-router", + weak_model: str | None = "qwen2.5-coder:14b-instruct", + strong_model: str | None = "qwen2.5-coder:32b-instruct", + route_threshold: float = ROUTELLM_MF_DEFAULT_THRESHOLD, + local_endpoint: str | None = "http://127.0.0.1:11434/v1", + cloud_provider: str | None = None, + cloud_model_family: str | None = None, + route_rules: list[str] | None = None, + model_policy: dict[str, object] | None = None, + ) -> RoutingPlan: + return RoutingPlan( + strategy, + weak_model, + strong_model, + route_threshold, + local_endpoint, + cloud_provider, + cloud_model_family, + ["route locally"] if route_rules is None else route_rules, + {"selection_mode": "recommend"} if model_policy is None else model_policy, + ) + + return factory + + +@pytest.fixture +def scaffold_inputs( + intake_answers: Callable[..., IntakeAnswers], + hardware_profile: Callable[..., HardwareProfile], + local_provider: Provider, + routing_plan_factory: Callable[..., RoutingPlan], +) -> Callable[..., ScaffoldInputs]: + def factory(language: str = "python", tool: str | None = "manual") -> ScaffoldInputs: + return ScaffoldInputs( + intake=intake_answers(language=language, tool=tool), + hardware=hardware_profile(), + providers=[local_provider], + routing=routing_plan_factory(route_threshold=0.1), + ) + + return factory diff --git a/tests/test_model_selection.py b/tests/test_model_selection.py index 5b32a57..8052396 100644 --- a/tests/test_model_selection.py +++ b/tests/test_model_selection.py @@ -1,31 +1,25 @@ from coding_scaffold.model_selection import select_model_for_prompt -from coding_scaffold.model_catalog import ROUTELLM_MF_DEFAULT_THRESHOLD -from coding_scaffold.providers import Provider -from coding_scaffold.router import RoutingPlan - - -def test_select_model_routes_security_review_to_heavy_lift_provider() -> None: - routing = RoutingPlan( - "local-first-router", - "qwen-small", - "azure-ai/team-sonnet", - ROUTELLM_MF_DEFAULT_THRESHOLD, - "http://127.0.0.1:11434/v1", - "azure-ai", - "anthropic", - ["route locally first"], - {"selection_mode": "recommend"}, + + +def test_select_model_routes_security_review_to_heavy_lift_provider( + routing_plan_factory, provider_factory +) -> None: + routing = routing_plan_factory( + weak_model="qwen-small", + strong_model="azure-ai/team-sonnet", + cloud_provider="azure-ai", + cloud_model_family="anthropic", + route_rules=["route locally first"], ) providers = [ - Provider("ollama", "local", True, "CLI found", "http://127.0.0.1:11434/v1"), - Provider( - "azure-ai", - "cloud", - True, - "Azure AI endpoint and key set", - "https://example.services.ai.azure.com", - "anthropic", - "team-sonnet", + provider_factory(), + provider_factory( + name="azure-ai", + kind="cloud", + status="Azure AI endpoint and key set", + endpoint="https://example.services.ai.azure.com", + model_family="anthropic", + deployment="team-sonnet", ), ] @@ -41,24 +35,31 @@ def test_select_model_routes_security_review_to_heavy_lift_provider() -> None: assert selection.model == "azure-ai/team-sonnet" -def test_select_model_routes_short_fix_to_local_routine_model() -> None: - routing = RoutingPlan( - "local-first-router", - "qwen-small", - "azure-openai/team-gpt", - ROUTELLM_MF_DEFAULT_THRESHOLD, - "http://127.0.0.1:11434/v1", - "azure-openai", - "openai", - ["route locally first"], - {"selection_mode": "recommend"}, +def test_select_model_routes_short_fix_to_local_routine_model( + routing_plan_factory, provider_factory +) -> None: + routing = routing_plan_factory( + weak_model="qwen-small", + strong_model="azure-openai/team-gpt", + cloud_provider="azure-openai", + cloud_model_family="openai", + route_rules=["route locally first"], ) providers = [ - Provider("ollama", "local", True, "CLI found", "http://127.0.0.1:11434/v1"), - Provider("azure-openai", "cloud", True, "Azure OpenAI env set", None, "openai", "team-gpt"), + provider_factory(), + provider_factory( + name="azure-openai", + kind="cloud", + status="Azure OpenAI env set", + endpoint=None, + model_family="openai", + deployment="team-gpt", + ), ] - selection = select_model_for_prompt("Fix this failing formatter test.", routing, providers, "auto") + selection = select_model_for_prompt( + "Fix this failing formatter test.", routing, providers, "auto" + ) assert selection.route == "routine" assert selection.provider == "ollama" @@ -67,17 +68,12 @@ def test_select_model_routes_short_fix_to_local_routine_model() -> None: assert selection.mode == "auto" -def test_select_model_does_not_match_markers_inside_words() -> None: - routing = RoutingPlan( - "local-first-router", - "qwen-small", - "qwen-large", - ROUTELLM_MF_DEFAULT_THRESHOLD, - None, - None, - None, - ["route locally first"], - {"selection_mode": "recommend"}, +def test_select_model_does_not_match_markers_inside_words(routing_plan_factory) -> None: + routing = routing_plan_factory( + weak_model="qwen-small", + strong_model="qwen-large", + local_endpoint=None, + route_rules=["route locally first"], ) selection = select_model_for_prompt( @@ -92,17 +88,13 @@ def test_select_model_does_not_match_markers_inside_words() -> None: assert "doc" not in joined_reasons -def test_select_model_empty_prompt_profile() -> None: - routing = RoutingPlan( - "local-first-router", - None, - None, - ROUTELLM_MF_DEFAULT_THRESHOLD, - None, - None, - None, - [], - {}, +def test_select_model_empty_prompt_profile(routing_plan_factory) -> None: + routing = routing_plan_factory( + weak_model=None, + strong_model=None, + local_endpoint=None, + route_rules=[], + model_policy={}, ) selection = select_model_for_prompt("", routing, []) @@ -111,17 +103,13 @@ def test_select_model_empty_prompt_profile() -> None: assert selection.confidence == 0.1 -def test_select_model_heavy_marker_wins_over_routine_marker() -> None: - routing = RoutingPlan( - "local-first-router", - "qwen-small", - "qwen-large", - ROUTELLM_MF_DEFAULT_THRESHOLD, - None, - None, - None, - [], - {}, +def test_select_model_heavy_marker_wins_over_routine_marker(routing_plan_factory) -> None: + routing = routing_plan_factory( + weak_model="qwen-small", + strong_model="qwen-large", + local_endpoint=None, + route_rules=[], + model_policy={}, ) selection = select_model_for_prompt("Review this small test migration.", routing, []) @@ -130,17 +118,13 @@ def test_select_model_heavy_marker_wins_over_routine_marker() -> None: assert selection.model == "qwen-large" -def test_select_model_long_prompt_without_markers_routes_heavy_lift() -> None: - routing = RoutingPlan( - "local-first-router", - "qwen-small", - "qwen-large", - ROUTELLM_MF_DEFAULT_THRESHOLD, - None, - None, - None, - [], - {}, +def test_select_model_long_prompt_without_markers_routes_heavy_lift(routing_plan_factory) -> None: + routing = routing_plan_factory( + weak_model="qwen-small", + strong_model="qwen-large", + local_endpoint=None, + route_rules=[], + model_policy={}, ) prompt = " ".join(f"word{i}" for i in range(181)) @@ -150,17 +134,13 @@ def test_select_model_long_prompt_without_markers_routes_heavy_lift() -> None: assert selection.prompt_profile == "complex-change" -def test_select_model_word_count_boundary() -> None: - routing = RoutingPlan( - "local-first-router", - "qwen-small", - "qwen-large", - ROUTELLM_MF_DEFAULT_THRESHOLD, - None, - None, - None, - [], - {}, +def test_select_model_word_count_boundary(routing_plan_factory) -> None: + routing = routing_plan_factory( + weak_model="qwen-small", + strong_model="qwen-large", + local_endpoint=None, + route_rules=[], + model_policy={}, ) forty_words = " ".join(f"word{i}" for i in range(40)) forty_one_words = " ".join(f"word{i}" for i in range(41)) @@ -169,17 +149,13 @@ def test_select_model_word_count_boundary() -> None: assert select_model_for_prompt(forty_one_words, routing, []).prompt_profile == "standard-change" -def test_select_model_common_agent_design_review_words_stay_routine() -> None: - routing = RoutingPlan( - "local-first-router", - "qwen-small", - "qwen-large", - ROUTELLM_MF_DEFAULT_THRESHOLD, - None, - None, - None, - [], - {}, +def test_select_model_common_agent_design_review_words_stay_routine(routing_plan_factory) -> None: + routing = routing_plan_factory( + weak_model="qwen-small", + strong_model="qwen-large", + local_endpoint=None, + route_rules=[], + model_policy={}, ) prompts = [ @@ -193,17 +169,13 @@ def test_select_model_common_agent_design_review_words_stay_routine() -> None: assert selection.route == "routine" -def test_select_model_heavy_phrases_still_escalate() -> None: - routing = RoutingPlan( - "local-first-router", - "qwen-small", - "qwen-large", - ROUTELLM_MF_DEFAULT_THRESHOLD, - None, - None, - None, - [], - {}, +def test_select_model_heavy_phrases_still_escalate(routing_plan_factory) -> None: + routing = routing_plan_factory( + weak_model="qwen-small", + strong_model="qwen-large", + local_endpoint=None, + route_rules=[], + model_policy={}, ) selection = select_model_for_prompt("Write a system design for the agent runtime.", routing, []) diff --git a/tests/test_router.py b/tests/test_router.py index 6125044..ed7cfaf 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -1,17 +1,25 @@ import random -from coding_scaffold.hardware import HardwareProfile from coding_scaffold.intake import IntakeAnswers from coding_scaffold.model_catalog import LOCAL_CODER_MODELS, ROUTELLM_MF_DEFAULT_THRESHOLD -from coding_scaffold.providers import Provider from coding_scaffold.router import _select_local_model, build_routing_plan -def test_local_only_never_selects_cloud_provider() -> None: +def test_local_only_never_selects_cloud_provider(hardware_profile, provider_factory) -> None: plan = build_routing_plan( IntakeAnswers(privacy="local-only"), - HardwareProfile("linux", False, 16, 64, "GPU", 48, False, ["ollama"]), - [Provider("openai", "cloud", True, "OPENAI_API_KEY set")], + hardware_profile( + cpu_count=16, ram_gb=64, gpu_name="GPU", vram_gb=48, llmfit_available=False + ), + [ + provider_factory( + name="openai", + kind="cloud", + status="OPENAI_API_KEY set", + endpoint=None, + model_family="openai", + ) + ], ) assert plan.cloud_provider is None @@ -20,11 +28,19 @@ def test_local_only_never_selects_cloud_provider() -> None: assert plan.route_threshold == ROUTELLM_MF_DEFAULT_THRESHOLD -def test_cloud_can_backfill_strong_model() -> None: +def test_cloud_can_backfill_strong_model(hardware_profile, provider_factory) -> None: plan = build_routing_plan( IntakeAnswers(privacy="local-first"), - HardwareProfile("linux", False, 8, 16, None, None, False, []), - [Provider("anthropic", "cloud", True, "ANTHROPIC_API_KEY set")], + hardware_profile(ram_gb=16, llmfit_available=False, local_runtimes=[]), + [ + provider_factory( + name="anthropic", + kind="cloud", + status="ANTHROPIC_API_KEY set", + endpoint=None, + model_family="anthropic", + ) + ], ) assert plan.cloud_provider == "anthropic" @@ -32,10 +48,10 @@ def test_cloud_can_backfill_strong_model() -> None: assert plan.strong_model == "anthropic/claude-sonnet" -def test_strong_route_falls_back_to_routine_when_no_heavy_model_exists() -> None: +def test_strong_route_falls_back_to_routine_when_no_heavy_model_exists(hardware_profile) -> None: plan = build_routing_plan( IntakeAnswers(privacy="local-first"), - HardwareProfile("linux", False, 8, 16, None, None, False, []), + hardware_profile(ram_gb=16, llmfit_available=False, local_runtimes=[]), [], ) @@ -43,16 +59,17 @@ def test_strong_route_falls_back_to_routine_when_no_heavy_model_exists() -> None assert plan.strong_model == "qwen2.5-coder:7b-instruct" -def test_azure_provider_keeps_endpoint_and_model_family_separate() -> None: +def test_azure_provider_keeps_endpoint_and_model_family_separate( + hardware_profile, provider_factory +) -> None: plan = build_routing_plan( IntakeAnswers(privacy="local-first"), - HardwareProfile("linux", False, 8, 16, None, None, False, []), + hardware_profile(ram_gb=16, llmfit_available=False, local_runtimes=[]), [ - Provider( - "azure-ai", - "cloud", - True, - "Azure AI endpoint and key set", + provider_factory( + name="azure-ai", + kind="cloud", + status="Azure AI endpoint and key set", endpoint="https://example.services.ai.azure.com", model_family="anthropic", deployment="team-sonnet", @@ -65,15 +82,24 @@ def test_azure_provider_keeps_endpoint_and_model_family_separate() -> None: assert plan.strong_model == "azure-ai/team-sonnet" -def test_local_model_thresholds_pick_expected_strong_models() -> None: +def test_local_model_thresholds_pick_expected_strong_models(hardware_profile) -> None: qwen_32b_plan = build_routing_plan( IntakeAnswers(privacy="local-only"), - HardwareProfile("linux", False, 16, 32, "GPU", 24, False, []), + hardware_profile( + cpu_count=16, gpu_name="GPU", vram_gb=24, llmfit_available=False, local_runtimes=[] + ), [], ) qwen_40b_plan = build_routing_plan( IntakeAnswers(privacy="local-only"), - HardwareProfile("linux", False, 16, 56, "GPU", 32, False, []), + hardware_profile( + cpu_count=16, + ram_gb=56, + gpu_name="GPU", + vram_gb=32, + llmfit_available=False, + local_runtimes=[], + ), [], ) @@ -81,18 +107,25 @@ def test_local_model_thresholds_pick_expected_strong_models() -> None: assert qwen_40b_plan.strong_model == "qwen/qwen3-coder-40b" -def test_missing_vram_does_not_exclude_ram_only_candidates() -> None: +def test_missing_vram_does_not_exclude_ram_only_candidates(hardware_profile) -> None: plan = build_routing_plan( IntakeAnswers(privacy="local-only"), - HardwareProfile("linux", False, 8, 10, None, None, False, []), + hardware_profile(ram_gb=10, llmfit_available=False, local_runtimes=[]), [], ) assert plan.weak_model == "qwen2.5-coder:7b-instruct" -def test_select_local_model_is_independent_of_catalog_order(monkeypatch) -> None: - hardware = HardwareProfile("linux", False, 16, 64, "GPU", 48, False, []) +def test_select_local_model_is_independent_of_catalog_order(monkeypatch, hardware_profile) -> None: + hardware = hardware_profile( + cpu_count=16, + ram_gb=64, + gpu_name="GPU", + vram_gb=48, + llmfit_available=False, + local_runtimes=[], + ) baseline = _select_local_model(hardware, "strong") @@ -111,10 +144,10 @@ def test_select_local_model_is_independent_of_catalog_order(monkeypatch) -> None assert _select_local_model(hardware, "strong") == baseline -def test_low_memory_machine_has_no_local_model_candidate() -> None: +def test_low_memory_machine_has_no_local_model_candidate(hardware_profile) -> None: plan = build_routing_plan( IntakeAnswers(privacy="local-only"), - HardwareProfile("linux", False, 4, 8, None, None, False, []), + hardware_profile(cpu_count=4, ram_gb=8, llmfit_available=False, local_runtimes=[]), [], ) diff --git a/tests/test_updater.py b/tests/test_updater.py index f26e3f8..4237c89 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -13,48 +13,16 @@ ``xfail(strict=True, ...)`` assertion documents the desired behavior and will flip green once #34 is fixed. """ + from __future__ import annotations import json from pathlib import Path -from coding_scaffold.hardware import HardwareProfile -from coding_scaffold.intake import IntakeAnswers -from coding_scaffold.providers import Provider -from coding_scaffold.router import RoutingPlan from coding_scaffold.scaffold_version import SCAFFOLD_VERSION_FILE from coding_scaffold.updater import refresh_scaffold -def _fixture(language: str = "python") -> tuple[IntakeAnswers, HardwareProfile, list[Provider], RoutingPlan]: - """Return a deterministic intake/hardware/providers/routing fixture. - - Pass a different ``language`` between calls to produce content drift in - the generated AGENTS.md / GETTING_STARTED.md files. - """ - intake = IntakeAnswers( - language=language, - project_target="CLI", - existing_codebase=True, - privacy="local-first", - tool="manual", - ) - hardware = HardwareProfile("linux", False, 8, 32, None, None, True, ["ollama"]) - providers = [Provider("ollama", "local", True, "CLI found", "http://127.0.0.1:11434/v1")] - routing = RoutingPlan( - "local-first-router", - "qwen2.5-coder:14b-instruct", - "qwen2.5-coder:32b-instruct", - 0.1, - "http://127.0.0.1:11434/v1", - None, - None, - ["route locally"], - {"selection_mode": "recommend"}, - ) - return intake, hardware, providers, routing - - def _agents_md(root: Path) -> Path: return root / ".coding-scaffold" / "AGENTS.md" @@ -67,10 +35,18 @@ def _read_version_hashes(root: Path) -> dict[str, str]: return json.loads(_version_file(root).read_text(encoding="utf-8"))["files"] -def test_first_run_writes_everything_and_creates_version_file(tmp_path: Path) -> None: - intake, hardware, providers, routing = _fixture() +def test_first_run_writes_everything_and_creates_version_file( + tmp_path: Path, scaffold_inputs +) -> None: + fixture = scaffold_inputs() - result = refresh_scaffold(tmp_path, intake, hardware, providers, routing) + result = refresh_scaffold( + tmp_path, + fixture.intake, + fixture.hardware, + fixture.providers, + fixture.routing, + ) # Every generated file landed on disk. assert _agents_md(tmp_path).exists() @@ -87,13 +63,19 @@ def test_first_run_writes_everything_and_creates_version_file(tmp_path: Path) -> assert any("scaffold-version.json" in w for w in result.warnings) -def test_clean_rerun_skips_everything(tmp_path: Path) -> None: - intake, hardware, providers, routing = _fixture() - refresh_scaffold(tmp_path, intake, hardware, providers, routing) +def test_clean_rerun_skips_everything(tmp_path: Path, scaffold_inputs) -> None: + fixture = scaffold_inputs() + refresh_scaffold(tmp_path, fixture.intake, fixture.hardware, fixture.providers, fixture.routing) version_before = _version_file(tmp_path).read_bytes() agents_before = _agents_md(tmp_path).read_bytes() - result = refresh_scaffold(tmp_path, intake, hardware, providers, routing) + result = refresh_scaffold( + tmp_path, + fixture.intake, + fixture.hardware, + fixture.providers, + fixture.routing, + ) # No file should have been rewritten (other than the version file, which # ``refresh_scaffold`` always rewrites at the end — but with identical @@ -109,10 +91,16 @@ def test_clean_rerun_skips_everything(tmp_path: Path) -> None: assert _version_file(tmp_path).read_bytes() == version_before -def test_drift_with_unmodified_user_file_overwrites(tmp_path: Path) -> None: +def test_drift_with_unmodified_user_file_overwrites(tmp_path: Path, scaffold_inputs) -> None: # Initial scaffold under language=python. - intake_v1, hardware, providers, routing = _fixture(language="python") - refresh_scaffold(tmp_path, intake_v1, hardware, providers, routing) + fixture_v1 = scaffold_inputs(language="python") + refresh_scaffold( + tmp_path, + fixture_v1.intake, + fixture_v1.hardware, + fixture_v1.providers, + fixture_v1.routing, + ) agents = _agents_md(tmp_path) original_content = agents.read_text(encoding="utf-8") assert "Project language: python" in original_content @@ -121,8 +109,14 @@ def test_drift_with_unmodified_user_file_overwrites(tmp_path: Path) -> None: # Second run with different intake => generated content drifts. The user # has not touched AGENTS.md, so the previous-hash matches the current # on-disk hash and the updater overwrites with the new desired content. - intake_v2, _, _, _ = _fixture(language="typescript") - result = refresh_scaffold(tmp_path, intake_v2, hardware, providers, routing) + fixture_v2 = scaffold_inputs(language="typescript") + result = refresh_scaffold( + tmp_path, + fixture_v2.intake, + fixture_v1.hardware, + fixture_v1.providers, + fixture_v1.routing, + ) new_content = agents.read_text(encoding="utf-8") assert "Project language: typescript" in new_content @@ -135,9 +129,17 @@ def test_drift_with_unmodified_user_file_overwrites(tmp_path: Path) -> None: assert hashes_after[".coding-scaffold/AGENTS.md"] != hashes_before[".coding-scaffold/AGENTS.md"] -def test_drift_with_user_edited_file_stages_new_keeps_user_copy(tmp_path: Path) -> None: - intake_v1, hardware, providers, routing = _fixture(language="python") - refresh_scaffold(tmp_path, intake_v1, hardware, providers, routing) +def test_drift_with_user_edited_file_stages_new_keeps_user_copy( + tmp_path: Path, scaffold_inputs +) -> None: + fixture_v1 = scaffold_inputs(language="python") + refresh_scaffold( + tmp_path, + fixture_v1.intake, + fixture_v1.hardware, + fixture_v1.providers, + fixture_v1.routing, + ) agents = _agents_md(tmp_path) # User edits AGENTS.md locally. @@ -145,8 +147,14 @@ def test_drift_with_user_edited_file_stages_new_keeps_user_copy(tmp_path: Path) agents.write_text(edited, encoding="utf-8") # Second run with drifted intake. - intake_v2, _, _, _ = _fixture(language="typescript") - result = refresh_scaffold(tmp_path, intake_v2, hardware, providers, routing) + fixture_v2 = scaffold_inputs(language="typescript") + result = refresh_scaffold( + tmp_path, + fixture_v2.intake, + fixture_v1.hardware, + fixture_v1.providers, + fixture_v1.routing, + ) # The user's edit is preserved on disk. assert agents.read_text(encoding="utf-8") == edited @@ -157,17 +165,31 @@ def test_drift_with_user_edited_file_stages_new_keeps_user_copy(tmp_path: Path) assert new_path in result.staged -def test_drift_with_user_edited_file_does_not_advance_version_for_staged(tmp_path: Path) -> None: - intake_v1, hardware, providers, routing = _fixture(language="python") - refresh_scaffold(tmp_path, intake_v1, hardware, providers, routing) +def test_drift_with_user_edited_file_does_not_advance_version_for_staged( + tmp_path: Path, scaffold_inputs +) -> None: + fixture_v1 = scaffold_inputs(language="python") + refresh_scaffold( + tmp_path, + fixture_v1.intake, + fixture_v1.hardware, + fixture_v1.providers, + fixture_v1.routing, + ) agents = _agents_md(tmp_path) hashes_v1 = _read_version_hashes(tmp_path) edited = agents.read_text(encoding="utf-8") + "\nLocal note from the user.\n" agents.write_text(edited, encoding="utf-8") - intake_v2, _, _, _ = _fixture(language="typescript") - refresh_scaffold(tmp_path, intake_v2, hardware, providers, routing) + fixture_v2 = scaffold_inputs(language="typescript") + refresh_scaffold( + tmp_path, + fixture_v2.intake, + fixture_v1.hardware, + fixture_v1.providers, + fixture_v1.routing, + ) hashes_v2 = _read_version_hashes(tmp_path) # Desired behavior (will fail until #34 is fixed): because the user's @@ -177,17 +199,29 @@ def test_drift_with_user_edited_file_does_not_advance_version_for_staged(tmp_pat assert hashes_v2[".coding-scaffold/AGENTS.md"] == hashes_v1[".coding-scaffold/AGENTS.md"] -def test_user_accepts_staged_new_then_rerun_is_clean(tmp_path: Path) -> None: - intake_v1, hardware, providers, routing = _fixture(language="python") - refresh_scaffold(tmp_path, intake_v1, hardware, providers, routing) +def test_user_accepts_staged_new_then_rerun_is_clean(tmp_path: Path, scaffold_inputs) -> None: + fixture_v1 = scaffold_inputs(language="python") + refresh_scaffold( + tmp_path, + fixture_v1.intake, + fixture_v1.hardware, + fixture_v1.providers, + fixture_v1.routing, + ) agents = _agents_md(tmp_path) # User edits the file then accepts the upstream update on a drifted run. edited = agents.read_text(encoding="utf-8") + "\nLocal note.\n" agents.write_text(edited, encoding="utf-8") - intake_v2, _, _, _ = _fixture(language="typescript") - refresh_scaffold(tmp_path, intake_v2, hardware, providers, routing) + fixture_v2 = scaffold_inputs(language="typescript") + refresh_scaffold( + tmp_path, + fixture_v2.intake, + fixture_v1.hardware, + fixture_v1.providers, + fixture_v1.routing, + ) new_path = agents.with_name(agents.name + ".new") assert new_path.exists() @@ -197,7 +231,13 @@ def test_user_accepts_staged_new_then_rerun_is_clean(tmp_path: Path) -> None: new_path.unlink() # A subsequent refresh with the same v2 intake should now see a clean tree. - result = refresh_scaffold(tmp_path, intake_v2, hardware, providers, routing) + result = refresh_scaffold( + tmp_path, + fixture_v2.intake, + fixture_v1.hardware, + fixture_v1.providers, + fixture_v1.routing, + ) assert result.staged == [] # AGENTS.md should be in the skipped list (its content already matches). diff --git a/tests/test_writers.py b/tests/test_writers.py index 206ea56..029c2f1 100644 --- a/tests/test_writers.py +++ b/tests/test_writers.py @@ -1,29 +1,16 @@ import json -from coding_scaffold.hardware import HardwareProfile -from coding_scaffold.intake import IntakeAnswers -from coding_scaffold.providers import Provider -from coding_scaffold.router import RoutingPlan from coding_scaffold.writers import write_scaffold -def test_write_scaffold_creates_expected_files(tmp_path) -> None: +def test_write_scaffold_creates_expected_files(tmp_path, scaffold_inputs) -> None: + fixture = scaffold_inputs(tool=None) manifest = write_scaffold( tmp_path, - IntakeAnswers(language="python", project_target="CLI", existing_codebase=True, privacy="local-first"), - HardwareProfile("linux", False, 8, 32, None, None, True, ["ollama"]), - [Provider("ollama", "local", True, "CLI found", "http://127.0.0.1:11434/v1")], - RoutingPlan( - "local-first-router", - "qwen2.5-coder:14b-instruct", - "qwen2.5-coder:32b-instruct", - 0.1, - "http://127.0.0.1:11434/v1", - None, - None, - ["route locally"], - {"selection_mode": "recommend"}, - ), + fixture.intake, + fixture.hardware, + fixture.providers, + fixture.routing, ) names = {path.name for path in manifest.files} @@ -52,29 +39,20 @@ def test_write_scaffold_creates_expected_files(tmp_path) -> None: assert ".coding-scaffold/AGENTS.md" in version["files"] -def test_providers_json_redacts_azure_endpoint(tmp_path, monkeypatch) -> None: +def test_providers_json_redacts_azure_endpoint(tmp_path, monkeypatch, scaffold_inputs) -> None: from coding_scaffold.providers import detect_providers monkeypatch.setenv("AZURE_OPENAI_API_KEY", "k") monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://contoso.openai.azure.com/") monkeypatch.setenv("AZURE_OPENAI_DEPLOYMENT", "internal-gpt") + fixture = scaffold_inputs(tool=None) write_scaffold( tmp_path, - IntakeAnswers(language="python", project_target="CLI", existing_codebase=True, privacy="local-first"), - HardwareProfile("linux", False, 8, 32, None, None, True, ["ollama"]), + fixture.intake, + fixture.hardware, detect_providers(), - RoutingPlan( - "local-first-router", - "qwen2.5-coder:14b-instruct", - "qwen2.5-coder:32b-instruct", - 0.1, - "http://127.0.0.1:11434/v1", - None, - None, - ["route locally"], - {"selection_mode": "recommend"}, - ), + fixture.routing, ) providers_json = (tmp_path / ".coding-scaffold" / "providers.json").read_text(encoding="utf-8") @@ -82,23 +60,17 @@ def test_providers_json_redacts_azure_endpoint(tmp_path, monkeypatch) -> None: assert "internal-gpt" not in providers_json -def test_routellm_yaml_quotes_model_names_with_special_chars() -> None: +def test_routellm_yaml_quotes_model_names_with_special_chars(routing_plan_factory) -> None: from coding_scaffold.writers import _routellm_yaml - plan = RoutingPlan( - strategy="local-first-router", + plan = routing_plan_factory( weak_model="weird: 'value with # hash'", strong_model="qwen2.5-coder:7b-instruct", route_threshold=0.1, - local_endpoint="http://127.0.0.1:11434/v1", - cloud_provider=None, - cloud_model_family=None, - route_rules=["route locally"], - model_policy={"selection_mode": "recommend"}, ) output = _routellm_yaml(plan) - assert '"weird: \'value with # hash\'"' in output + assert "\"weird: 'value with # hash'\"" in output assert '"qwen2.5-coder:7b-instruct"' in output assert '"http://127.0.0.1:11434/v1"' in output