Spaces:
Sleeping
Sleeping
| """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 | |
| 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) | |