diff --git a/tests/test_mcp_tool_systems.py b/tests/test_mcp_tool_systems.py index 2af2e3f..9432553 100644 --- a/tests/test_mcp_tool_systems.py +++ b/tests/test_mcp_tool_systems.py @@ -243,33 +243,11 @@ async def test_populated_vocabulary_never_counts_records(): notes.list_notes.assert_not_awaited() -@pytest.mark.asyncio -async def test_create_system_same_normalized_name_is_duplicate_gated(): - existing = fake_system(id=7, name="Scrape Pipeline") - existing.id = 7 - existing.name = "Scrape Pipeline" - with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ - patch("scribe.mcp.tools.systems.systems_svc") as svc: - svc.list_systems = AsyncMock(return_value=[existing]) - svc.create_system = AsyncMock() - from scribe.mcp.tools.systems import create_system - result = await create_system(project_id=5, name=" scrape pipeline ") - assert result["duplicate"] is True - assert result["existing_id"] == 7 - svc.create_system.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_create_system_distinct_name_passes_the_gate(): - other = fake_system(id=7, name="Workers") - other.name = "Workers" - with patch("scribe.mcp.tools.systems.current_user_id", return_value=1), \ - patch("scribe.mcp.tools.systems.systems_svc") as svc: - svc.list_systems = AsyncMock(return_value=[other]) - svc.create_system = AsyncMock(return_value=fake_system(id=8, name="Exporter")) - from scribe.mcp.tools.systems import create_system - result = await create_system(project_id=5, name="Exporter") - assert result["name"] == "Exporter" +# The name gate's own cases (case/whitespace normalisation, exact-over-overlap, +# fail-open) moved to tests/test_services_systems.py with the logic itself — +# services/systems.assess_system_name, so both doors share one answer (#2482). +# What stays the TOOL's job — rendering a duplicate, applying an exact area, +# offering an overlap — is covered at the top of this file. @pytest.mark.asyncio diff --git a/tests/test_services_systems.py b/tests/test_services_systems.py index 905090b..a15decf 100644 --- a/tests/test_services_systems.py +++ b/tests/test_services_systems.py @@ -59,3 +59,70 @@ async def test_count_open_issues_denied_returns_zero(): from scribe.services.systems import count_open_issues result = await count_open_issues(user_id=1, project_id=5) assert result == 0 + + +# ── assess_system_name — the one gate both doors call (milestone 307) ── + +@pytest.mark.asyncio +async def test_assess_matches_an_existing_system_on_case_and_spacing(): + """The local gate compares the operator's own spelling: case and runs of + whitespace are not a different System. Anything stronger belongs to the + CANONICAL slug, which is a different question (is this the same AREA as + another project's System) with a different answer.""" + existing = MagicMock() + existing.id, existing.name = 7, "Scrape Pipeline" + with patch("scribe.services.systems.list_systems", AsyncMock(return_value=[existing])): + from scribe.services.systems import assess_system_name + out = await assess_system_name(1, 5, " scrape pipeline ") + assert out["duplicate"] == {"id": 7, "name": "Scrape Pipeline"} + # A duplicate short-circuits: there is nothing to file when nothing is created. + assert out["canonical"] is None + + +@pytest.mark.asyncio +async def test_assess_prefers_an_exact_area_over_a_merely_similar_one(): + from scribe.services.systems import assess_system_name + exact = MagicMock() + exact.id, exact.name = 3, "CI & Release" + with patch("scribe.services.systems.list_systems", AsyncMock(return_value=[])), \ + patch("scribe.services.systems.canonical_systems_svc") as canon: + canon.find_by_name = AsyncMock(return_value=exact) + canon.best_overlap = AsyncMock() + out = await assess_system_name(1, 5, "CI and Release") + assert out["canonical"] == {"id": 3, "name": "CI & Release", "basis": "exact"} + # An exact hit is the answer — no need to go looking for a lesser one. + canon.best_overlap.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_assess_falls_back_to_overlap_and_never_invents_a_match(): + from scribe.services.systems import assess_system_name + near = {"id": 3, "name": "CI & Release", "basis": "overlap", "score": 0.33} + with patch("scribe.services.systems.list_systems", AsyncMock(return_value=[])), \ + patch("scribe.services.systems.canonical_systems_svc") as canon: + canon.find_by_name = AsyncMock(return_value=None) + canon.best_overlap = AsyncMock(return_value=near) + assert (await assess_system_name(1, 5, "CI & runners"))["canonical"] == near + canon.best_overlap = AsyncMock(return_value=None) + # Nothing resembles it: a project-specific area, and silence is the + # right answer — unmapped is a valid resting state. + assert (await assess_system_name(1, 5, "Soundboard"))["canonical"] is None + + +@pytest.mark.asyncio +async def test_assess_fails_open_so_a_naming_aid_cannot_block_a_create(): + """Both arms degrade to "no opinion" rather than raising. A create must + never fail because the catalog was unreachable.""" + from scribe.services.systems import assess_system_name + with patch("scribe.services.systems.list_systems", AsyncMock(side_effect=RuntimeError("db down"))): + assert await assess_system_name(1, 5, "Reader") == {"duplicate": None, "canonical": None} + with patch("scribe.services.systems.list_systems", AsyncMock(return_value=[])), \ + patch("scribe.services.systems.canonical_systems_svc") as canon: + canon.find_by_name = AsyncMock(side_effect=RuntimeError("db down")) + assert (await assess_system_name(1, 5, "Reader"))["canonical"] is None + + +@pytest.mark.asyncio +async def test_assess_says_nothing_about_a_nameless_system(): + from scribe.services.systems import assess_system_name + assert await assess_system_name(1, 5, " ") == {"duplicate": None, "canonical": None}