| """Tests for contesting an assumption - step 3 of the defensible recommender. |
| |
| Focused on the parts that do not need a live model: that the verdict stays |
| consistent with the boolean it summarises, and that a nullable prose field the |
| model answered with the word "None" does not reach the user as text. |
| """ |
| from unittest.mock import AsyncMock, MagicMock, patch |
|
|
| import pytest |
|
|
| from app.core.schemas import ArchitectureOption |
| from app.services.architecture_contest import ContestOutcome, contest_assumption |
|
|
|
|
| def _option() -> ArchitectureOption: |
| return ArchitectureOption( |
| id="opt_1", |
| name="Modular Monolith", |
| description="A single deployable service with internal module boundaries.", |
| components=["api", "worker"], |
| tech_stack={"backend": "Node.js"}, |
| pros=["one deploy target"], |
| cons=["scales vertically first"], |
| assumptions=["traffic is evenly distributed"], |
| ) |
|
|
|
|
| def _outcome(**overrides) -> ContestOutcome: |
| payload = { |
| "assumption_was_wrong": True, |
| "changes_recommendation": False, |
| "impact": ( |
| "The correction is right about the concurrency figure, but a modular " |
| "monolith still handles this load without re-architecture." |
| ), |
| "affected_dimensions": ["scalability"], |
| "revised_assumption": None, |
| "still_recommended": True, |
| "follow_up_question": None, |
| } |
| payload.update(overrides) |
| return ContestOutcome(**payload) |
|
|
|
|
| async def _contest(outcome: ContestOutcome) -> dict: |
| """Run contest_assumption with the model's reply stubbed out. |
| |
| `with_structured_output` is called synchronously by the service, so the LLM |
| double must be a MagicMock - an AsyncMock returns a coroutine there, the |
| service raises, and every assertion then reads the error fallback instead of |
| the outcome under test. |
| """ |
| structured = MagicMock() |
| structured.ainvoke = AsyncMock(return_value=outcome) |
| llm = MagicMock() |
| llm.with_structured_output.return_value = structured |
|
|
| with patch("app.services.architecture_contest.get_chat_model", return_value=llm): |
| result = await contest_assumption( |
| option=_option(), |
| assumption="traffic is evenly distributed", |
| correction="all 5000 users arrive in the same 20 minutes", |
| requirements="5000 users year one.", |
| ) |
|
|
| |
| |
| assert "error" not in result, f"hit the error fallback: {result.get('impact')}" |
| return result |
|
|
|
|
| class TestVerdictIsDerived: |
| @pytest.mark.asyncio |
| async def test_holding_the_conclusion_reads_as_defended(self): |
| result = await _contest(_outcome(changes_recommendation=False)) |
| assert result["verdict"] == "defended" |
|
|
| @pytest.mark.asyncio |
| async def test_changing_the_conclusion_reads_as_revised(self): |
| result = await _contest(_outcome(changes_recommendation=True)) |
| assert result["verdict"] == "revised" |
|
|
| @pytest.mark.asyncio |
| async def test_accepting_a_fact_does_not_imply_revising(self): |
| """The distinction the split schema exists to preserve.""" |
| result = await _contest( |
| _outcome(assumption_was_wrong=True, changes_recommendation=False) |
| ) |
| assert result["assumption_was_wrong"] is True |
| assert result["verdict"] == "defended" |
| assert result["still_recommended"] is True |
|
|
|
|
| class TestPlaceholderProseIsNormalized: |
| @pytest.mark.asyncio |
| @pytest.mark.parametrize("written", ["None", "none", "N/A", "null", "-"]) |
| async def test_revised_assumption_placeholder_becomes_null(self, written): |
| """Rendered unguarded, this showed the user 'Now assumes: None'.""" |
| result = await _contest(_outcome(revised_assumption=written)) |
| assert result["revised_assumption"] is None |
|
|
| @pytest.mark.asyncio |
| @pytest.mark.parametrize("written", ["None", "n/a", "not applicable"]) |
| async def test_follow_up_question_placeholder_becomes_null(self, written): |
| result = await _contest(_outcome(follow_up_question=written)) |
| assert result["follow_up_question"] is None |
|
|
| @pytest.mark.asyncio |
| async def test_real_prose_is_preserved(self): |
| result = await _contest( |
| _outcome( |
| revised_assumption="Traffic spikes to 2000+ concurrent on Saturdays.", |
| follow_up_question="What is the p99 latency budget for search?", |
| ) |
| ) |
| assert result["revised_assumption"] == "Traffic spikes to 2000+ concurrent on Saturdays." |
| assert result["follow_up_question"] == "What is the p99 latency budget for search?" |
|
|
|
|
| class TestFailureIsSurfaced: |
| @pytest.mark.asyncio |
| async def test_a_failed_reassessment_does_not_invent_agreement(self): |
| """Falling back must not look like the model considered and agreed.""" |
| llm = MagicMock() |
| llm.with_structured_output.side_effect = RuntimeError("provider down") |
|
|
| with patch("app.services.architecture_contest.get_chat_model", return_value=llm): |
| result = await contest_assumption( |
| option=_option(), |
| assumption="traffic is evenly distributed", |
| correction="it is not", |
| requirements="5000 users year one.", |
| ) |
|
|
| assert result["error"] is True |
| assert result["changes_recommendation"] is False |
| assert "unavailable" in result["impact"].lower() |
|
|