Spaces:
Sleeping
Sleeping
File size: 11,770 Bytes
0136798 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 | """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)
|