RandomZ / app /tests /test_notes_expander.py
StormShadow308's picture
- Replaced timezone.utc with UTC from datetime for consistent datetime handling.
faa8fb3
Raw
History Blame Contribute Delete
11.8 kB
"""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)