"""Tests for the notes expander — rule-based mock path and LLM-error fallback. The live OpenAI path is not tested here (requires a real API key). All tests exercise the rule-based expansion, empty/edge inputs, and the fallback behaviour when the LLM path fails. """ import builtins import pytest from app.generator.notes_expander import _rule_based_expand, expand_notes # ── expand_notes — no API key (rule-based mock path) ───────────────────────── def test_empty_bullets_returned_unchanged() -> None: """expand_notes with an empty list must return an empty list.""" assert expand_notes([], openai_api_key="") == [] def test_returns_one_result_per_bullet() -> None: """The number of expanded bullets must equal the number of input bullets.""" bullets = ["semi det nw3", "roof bad needs work", "gas elec mains ok"] result = expand_notes(bullets, openai_api_key="") assert len(result) == len(bullets) def test_all_results_are_strings() -> None: """Every expanded bullet must be a non-empty string.""" bullets = ["roof bad", "dg windows ok"] result = expand_notes(bullets, openai_api_key="") assert all(isinstance(r, str) and r for r in result) # ── _rule_based_expand — abbreviation expansion ─────────────────────────────── def test_expands_semi_det() -> None: """'semi det' should expand to 'semi-detached'.""" result = _rule_based_expand("semi det house") assert "semi-detached" in result.lower() def test_expands_dg() -> None: """'dg' should expand to 'double-glazed'.""" result = _rule_based_expand("dg windows") assert "double-glazed" in result.lower() def test_expands_ch() -> None: """'ch' should expand to 'central heating'.""" result = _rule_based_expand("ch old boiler") assert "central heating" in result.lower() def test_expands_elec() -> None: """'elec' should expand to 'electricity'.""" result = _rule_based_expand("mains elec ok") assert "electricity" in result.lower() def test_expands_sqm() -> None: """'sqm' should expand to 'sq m'.""" result = _rule_based_expand("95sqm floor area") assert "sq m" in result.lower() def test_digit_glued_sqm_has_space() -> None: """'95sqm' must expand to '95 sq m', not '95sq m'.""" result = _rule_based_expand("95sqm floor area") assert "95 sq m" in result, f"Got: {result!r}" def test_expands_yr() -> None: """'yr' should expand to 'years old'.""" result = _rule_based_expand("maybe 10yr felt tiles") assert "years old" in result.lower() def test_digit_glued_yr_has_space() -> None: """'10yr' must expand to '10 years old', not '10years old'.""" result = _rule_based_expand("10yr felt tiles") assert "10 years old" in result, f"Got: {result!r}" def test_digit_glued_sqft_has_space() -> None: """'200sqft' must expand to '200 sq ft', not '200sq ft'.""" result = _rule_based_expand("200sqft garage") assert "200 sq ft" in result, f"Got: {result!r}" # ── _rule_based_expand — condition-word mapping ─────────────────────────────── def test_maps_bad_to_professional() -> None: """'bad' should be mapped to professional condition language.""" result = _rule_based_expand("roof bad") assert "bad" not in result.lower() or "poor condition" in result.lower() def test_maps_ok_to_professional() -> None: """'ok' should be mapped to 'in satisfactory condition'.""" result = _rule_based_expand("services ok") assert "satisfactory" in result.lower() def test_preserves_numbers_unchanged() -> None: """Exact numbers must survive expansion unchanged.""" result = _rule_based_expand("floor area 95 sqm built 1968") assert "95" in result assert "1968" in result def test_preserves_postcode_unchanged() -> None: """Postcodes and location references must survive expansion unchanged.""" result = _rule_based_expand("property NW3 postcode") assert "NW3" in result def test_no_double_spaces_in_output() -> None: """The rule-based expander must not introduce double spaces.""" result = _rule_based_expand("semi det roof bad dg windows") assert " " not in result # ── expand_notes — LLM error fallback ──────────────────────────────────────── def test_falls_back_to_rule_based_when_openai_import_fails( monkeypatch: pytest.MonkeyPatch, ) -> None: """If the openai package is unavailable, rule-based expansion must be used.""" real_import = builtins.__import__ def _block_openai(name: str, *args, **kwargs): # type: ignore[override] if name == "openai": raise ImportError("openai blocked in test") return real_import(name, *args, **kwargs) monkeypatch.setattr(builtins, "__import__", _block_openai) result = expand_notes(["roof bad needs work"], openai_api_key="sk-fake") assert len(result) == 1 assert isinstance(result[0], str) # ── Regression: intra-_ABBREV poor cascade (Bug 1) ─────────────────────────── def test_pf_does_not_double_poor() -> None: """'pf' expands to 'in poor/fair condition'; the 'poor' abbrev key must not then re-expand the word 'poor' in that output to give 'in in poor condition...'.""" result = _rule_based_expand("roof pf") assert "in in" not in result.lower(), f"Got: {result!r}" assert "condition condition" not in result.lower(), f"Got: {result!r}" assert "poor/fair condition" in result.lower(), f"Got: {result!r}" def test_v_bad_does_not_double_poor() -> None: """'v bad' expands to include 'poor'; the 'poor' abbrev must not re-fire on that output producing 'in very in poor condition condition...'.""" result = _rule_based_expand("v bad boiler") assert "in in" not in result.lower(), f"Got: {result!r}" assert "condition condition" not in result.lower(), f"Got: {result!r}" assert "poor condition" in result.lower(), f"Got: {result!r}" def test_vbad_does_not_double_poor() -> None: """Same check for compact form 'vbad'.""" result = _rule_based_expand("vbad boiler") assert "in in" not in result.lower(), f"Got: {result!r}" assert "condition condition" not in result.lower(), f"Got: {result!r}" assert "poor condition" in result.lower(), f"Got: {result!r}" # ── Regression: direction-vs-service ordering (Bug 1) ──────────────────────── def test_mains_e_expands_to_electricity_not_east() -> None: """'mains e' must expand to 'mains electricity', not 'mains east'.""" result = _rule_based_expand("mains e ok") assert "electricity" in result.lower(), f"Got: {result!r}" assert "east" not in result.lower(), f"Got: {result!r}" def test_mains_w_expands_to_water_not_west() -> None: """'mains w' must expand to 'mains water', not 'mains west'.""" result = _rule_based_expand("mains w and mains g connected") assert "water" in result.lower(), f"Got: {result!r}" assert "west" not in result.lower(), f"Got: {result!r}" def test_mains_g_expands_to_gas() -> None: """'mains g' must expand to 'mains gas'.""" result = _rule_based_expand("mains g connected") assert "gas" in result.lower(), f"Got: {result!r}" # ── Regression: no cascading condition-word expansion (Bug 2) ───────────────── def test_cracked_does_not_cascade_to_cracking() -> None: """'cracked' must not produce double-expanded output like 'exhibiting exhibiting cracking...'.""" result = _rule_based_expand("wall cracked") assert "exhibiting exhibiting" not in result.lower(), f"Got: {result!r}" assert "cracking" in result.lower(), f"Got: {result!r}" def test_terrible_does_not_cascade_through_urgent() -> None: """'terrible' must not fire the 'urgent' rule a second time on its own output.""" result = _rule_based_expand("roof terrible") assert "requiring requiring" not in result.lower(), f"Got: {result!r}" assert "urgent" in result.lower(), f"Got: {result!r}" # ── Regression: w/ and w/o must not be corrupted by single-letter "w" → "west" ─ def test_w_slash_o_expands_to_without_not_west() -> None: """'w/o' must expand to 'without', not 'west/o'.""" result = _rule_based_expand("w/o insulation") assert "without" in result.lower(), f"Got: {result!r}" assert "west" not in result.lower(), f"Got: {result!r}" def test_w_slash_expands_to_with_not_west() -> None: """'w/' must expand to 'with', not 'west/'.""" result = _rule_based_expand("w/ dg windows") assert result.lower().startswith("with"), f"Got: {result!r}" assert "west" not in result.lower(), f"Got: {result!r}" def test_w_slash_o_mid_sentence() -> None: """'w/o' mid-sentence must expand to 'without'.""" result = _rule_based_expand("roof w/o felt underlay") assert "without" in result.lower(), f"Got: {result!r}" assert "west" not in result.lower(), f"Got: {result!r}" def test_standalone_w_still_expands_to_west() -> None: """A bare 'w' (compass direction) must still expand to 'west'.""" result = _rule_based_expand("facing w elevation") assert "west" in result.lower(), f"Got: {result!r}" def test_v_bad_abbrev_does_not_re_expand_urgent() -> None: """'v bad' expands via _ABBREV to text containing 'urgent'; the condition-words pass must not then re-expand 'urgent' a second time, which would produce 'requiring requiring urgent attention attention'.""" result = _rule_based_expand("roof v bad") assert "requiring requiring" not in result.lower(), f"Got: {result!r}" assert "attention attention" not in result.lower(), f"Got: {result!r}" assert "urgent" in result.lower(), f"Got: {result!r}" def test_vbad_abbrev_does_not_re_expand_urgent() -> None: """Compact form 'vbad' has the same cross-pass guard as 'v bad'.""" result = _rule_based_expand("boiler vbad replace") assert "requiring requiring" not in result.lower(), f"Got: {result!r}" assert "attention attention" not in result.lower(), f"Got: {result!r}" assert "urgent" in result.lower(), f"Got: {result!r}" def test_falls_back_to_rule_based_on_json_decode_error( monkeypatch: pytest.MonkeyPatch, ) -> None: """If the LLM returns non-JSON, rule-based fallback must be used.""" from unittest.mock import MagicMock fake_response = MagicMock() fake_response.choices[0].message.content = "NOT VALID JSON {{{" fake_client = MagicMock() fake_client.chat.completions.create.return_value = fake_response import app.generator.notes_expander as ne_module monkeypatch.setattr(ne_module, "expand_notes", ne_module.expand_notes) # Patch OpenAI constructor inside the function's local scope import builtins as _builtins real_import = _builtins.__import__ class _FakeOpenAI: def __init__(self, *args, **kwargs): pass @property def chat(self): return fake_client.chat def _patched_import(name, *args, **kwargs): if name == "openai": import types mod = types.ModuleType("openai") mod.OpenAI = _FakeOpenAI return mod return real_import(name, *args, **kwargs) monkeypatch.setattr(_builtins, "__import__", _patched_import) bullets = ["roof bad", "semi det house"] result = expand_notes(bullets, openai_api_key="sk-fake") assert len(result) == len(bullets) assert all(isinstance(r, str) for r in result)