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)