File size: 16,274 Bytes
a13f875
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0cb7e78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7ee0711
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
eddd90b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b852032
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1743612
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
"""Tests for space/proxy.py turn logic (offline; mock mode + monkeypatch)."""
import os
import sys

_HERE = os.path.dirname(os.path.abspath(__file__))
_SPACE_DIR = os.path.dirname(_HERE)
if _SPACE_DIR not in sys.path:
    sys.path.insert(0, _SPACE_DIR)

import arc        # noqa: E402
import content    # noqa: E402
import proxy      # noqa: E402


def test_templated_rejoin_names_the_dj():
    assert arc.DJ_NAME in proxy._templated_text("rejoin")


def test_templated_caller_intro_nonempty():
    assert proxy._templated_text("caller_intro")


def test_song_intro_template_includes_handle():
    t = proxy._templated_text(
        "song_intro",
        {"title": "Neon Rain", "artist": "The Tuesday Ghosts", "recommended_by": "@mara"},
    )
    assert "@mara" in t


def test_song_intro_template_omits_handle_when_absent():
    t = proxy._templated_text(
        "song_intro", {"title": "Neon Rain", "artist": "The Tuesday Ghosts"}
    )
    assert "@" not in t


def test_wmo_phrase_mapping():
    assert proxy._wmo_phrase(0) == "clear skies"
    assert proxy._wmo_phrase(61) == "light rain"
    assert proxy._wmo_phrase(999) == "quiet skies"   # unknown -> safe default


def test_fmt_clock():
    assert proxy._fmt_clock("2026-06-15T02:14") == "2:14 AM"
    assert proxy._fmt_clock("2026-06-15T13:05") == "1:05 PM"
    assert proxy._fmt_clock(None) is None


def test_resolve_locale_mock(monkeypatch):
    monkeypatch.setenv("NIGHTWAVE_MOCK", "1")
    f = proxy.resolve_locale(30.27, -97.74)
    assert f["resolved"] is True
    assert f["city"] and f["temp_f"] is not None and f["sky"]


def test_local_weather_falls_back_to_fictional_on_junk(monkeypatch):
    monkeypatch.setattr(proxy, "call_brain",
                        lambda system, messages: {"text": "<sentence 1>", "mood": "warm",
                                                  "arc_cue": "none"})
    monkeypatch.setattr(proxy, "call_speak",
                        lambda text, voice=arc.VOICE: {"audio_b64": "", "words": [],
                                                       "wtimes": [], "wdurations": []})
    out = proxy.segment_turn("local_weather", {"city": "Austin", "temp_f": 58})
    assert out["text"] in content.WEATHER     # junk brain text -> fictional fallback
    assert out["kind"] == "local_weather"


# ---------------------------------------------------------------------------
# Regression coverage for the segment_turn junk-fallback path and the two
# segment helpers server.py calls (segment_fallback, get_stalls). `_clean_dj_text`
# is the junk-detector the Task-5 local_weather fallback test relies on; cover it
# directly so that load-bearing behaviour can't silently regress.
# ---------------------------------------------------------------------------
def test_clean_dj_text_rejects_degenerate_text_as_none():
    # Empty / placeholder / angle-bracket template / too-few-letters -> None,
    # so segment_turn falls back to a clean templated line instead of junk. This
    # is the exact mechanism the Task-5 local_weather fallback test depends on.
    assert proxy._clean_dj_text("") is None
    assert proxy._clean_dj_text(None) is None
    assert proxy._clean_dj_text("<sentence 1>") is None
    assert proxy._clean_dj_text("a b c") is None
    assert proxy._clean_dj_text("Sit tight, night owls.") == "Sit tight, night owls."


def test_get_stalls_returns_cached_clip_list(monkeypatch):
    # server.py /api/stalls calls this; reset the module cache for order-independence.
    monkeypatch.setattr(proxy, "_stall_cache", None)
    clips = proxy.get_stalls()
    assert isinstance(clips, list) and len(clips) == len(proxy._STALL_LINES)
    assert all(isinstance(c, str) and c for c in clips)   # each is a base64 WAV
    assert proxy.get_stalls() is clips                    # second call is cached


def test_segment_fallback_is_never_failing_and_well_shaped():
    # server.py /api/segment uses this on any failure so the show never breaks.
    fb = proxy.segment_fallback("thought")
    assert fb["kind"] == "thought"
    assert fb["text"] in content.THOUGHTS        # templated, stage-appropriate
    assert fb["audio_b64"]                        # a real (silent) WAV bed
    for key in ("mood", "arc_cue", "words", "wtimes", "wdurations"):
        assert key in fb


# ---------------------------------------------------------------------------
# Regression (bug 2): the all-caps station name must be normalized before TTS so
# Kokoro speaks "Nightwave" instead of spelling N-I-G-H-T-W-A-V-E. _speakable was
# dropped during a proxy rebuild and call_speak sent raw text.
# ---------------------------------------------------------------------------
def test_speakable_titlecases_nightwave():
    assert proxy._speakable("You're on NIGHTWAVE 98.6") == "You're on Nightwave 98.6"
    assert proxy._speakable("night wave") == "Nightwave"
    assert proxy._speakable(None) == ""


def test_call_speak_normalizes_nightwave_before_tts(monkeypatch):
    sent = {}

    class _Resp:
        def raise_for_status(self):
            pass

        def json(self):
            return {"audio_b64": "", "words": [], "wtimes": [], "wdurations": []}

    class _Client:
        def post(self, url, headers=None, json=None):
            sent.update(json or {})
            return _Resp()

    monkeypatch.setattr(proxy, "is_mock", lambda: False)
    monkeypatch.setattr(proxy, "_modal_url", lambda: "http://modal.test")
    monkeypatch.setattr(proxy, "_modal_headers", lambda: {})
    monkeypatch.setattr(proxy, "_get_client", lambda: _Client())
    proxy.call_speak("This is NIGHTWAVE, the last station on the dial.")
    assert "NIGHTWAVE" not in sent["text"]
    assert "Nightwave" in sent["text"]


# ---------------------------------------------------------------------------
# Regression (bug 4): the caller turn must ANSWER the caller, not echo. The
# straight station uses build_host_prompt("caller") + an answer-directive user
# turn so a 1B model doesn't parrot a question that appears verbatim in context.
# call_turn had reverted to the arc builder + raw caller_text as the user message.
# ---------------------------------------------------------------------------
def test_call_turn_answers_without_echoing_the_question(monkeypatch):
    captured = {}
    monkeypatch.setattr(proxy, "call_asr", lambda a: {"text": "are you real?"})

    def _brain(system, messages):
        captured["system"] = system
        captured["messages"] = messages
        return {"text": "Real as the rain on the glass, friend.", "mood": "warm", "arc_cue": "none"}

    monkeypatch.setattr(proxy, "call_brain", _brain)
    monkeypatch.setattr(proxy, "call_speak",
                        lambda text, voice=arc.VOICE: {"audio_b64": "", "words": [],
                                                       "wtimes": [], "wdurations": []})
    out = proxy.call_turn("oblivious", 0, "<b64>")
    # straight-station host prompt (names the DJ), NOT the arc builder
    assert arc.DJ_NAME in captured["system"]
    # the user turn carries an answer-directive, not the bare echoed question
    user = captured["messages"][-1]["content"]
    assert "do not repeat" in user.lower()
    assert user.strip() != "are you real?"
    assert out["caller_text"] == "are you real?"
    assert out["meter_delta"] >= 14   # an identity/reality question pushes the meter


# ---------------------------------------------------------------------------
# Prompt-audit fixes: structured field labels (title:/artist:/temp:/...) must
# never reach TTS; degenerate/echo output is rejected -> templated fallback.
# ---------------------------------------------------------------------------
def test_clean_dj_text_rejects_label_leakage():
    assert proxy._clean_dj_text("title: Neon Rain, artist: The Tuesday Ghosts") is None
    assert proxy._clean_dj_text('{"text": "hi", "mood": "warm"}') is None
    assert proxy._clean_dj_text("[song]") is None
    assert proxy._clean_dj_text("temp: 58, sky: clear skies") is None
    assert proxy._clean_dj_text("Coming up next, a quiet one for the night owls.") == \
        "Coming up next, a quiet one for the night owls."


def test_call_turn_degenerate_uses_caller_fallback_not_crash(monkeypatch):
    # The model leaks labels -> _clean_dj_text returns None -> must use a clean
    # caller fallback line (the old arc.sample_line was undefined and crashed here).
    monkeypatch.setattr(proxy, "call_asr", lambda a: {"text": "you there?"})
    monkeypatch.setattr(proxy, "call_brain",
                        lambda s, m: {"text": "title: X, artist: Y", "mood": "warm", "arc_cue": "none"})
    monkeypatch.setattr(proxy, "call_speak",
                        lambda text, voice=arc.VOICE: {"audio_b64": "", "words": [],
                                                       "wtimes": [], "wdurations": []})
    out = proxy.call_turn("oblivious", 0, "<b64>")
    assert out["text"] in content.CALLER_FALLBACKS


def test_broadcast_turn_uses_host_path_and_sanitizes(monkeypatch):
    captured = {}

    def _brain(system, messages):
        captured["system"] = system
        return {"text": "artist: X", "mood": "warm", "arc_cue": "none"}  # degenerate

    monkeypatch.setattr(proxy, "call_brain", _brain)
    monkeypatch.setattr(proxy, "call_speak",
                        lambda text, voice=arc.VOICE: {"audio_b64": "", "words": [],
                                                       "wtimes": [], "wdurations": []})
    out = proxy.broadcast_turn("oblivious", 0, None)
    assert arc.DJ_NAME in captured["system"]          # straight-station host, not arc builder
    assert out["text"] in content.THOUGHTS            # degenerate -> templated thought
    assert out["arc_cue"] == "none"


def test_extract_name_place():
    assert proxy._extract_name("Hi, I'm Mira and I'm calling in") == "Mira"
    assert proxy._extract_name("just wanted to say hi") is None
    assert proxy._extract_place("I'm driving in Austin tonight") == "Austin"
    assert proxy._extract_place("no place here") is None


def test_caller_gist():
    assert proxy._caller_gist("  I'm driving home   after a double shift ") == \
        "I'm driving home after a double shift"
    assert proxy._caller_gist("hi") is None
    assert proxy._caller_gist("") is None


def test_build_memory_patch():
    p = proxy._build_memory_patch("I'm Mira, driving home after a double shift", "tender")
    assert p["caller_name"] == "Mira" and p["topic"] and p["mood"] == "tender"
    assert proxy._build_memory_patch("hi", "warm") is None        # too short -> None
    assert proxy._build_memory_patch("I'm driving all night", "bogus")["mood"] == "warm"  # bad mood -> warm


def test_call_turn_returns_memory_patch(monkeypatch):
    monkeypatch.setattr(proxy, "call_asr",
                        lambda a: {"text": "I'm Mira, driving home after a double shift"})
    monkeypatch.setattr(proxy, "call_brain",
                        lambda s, m: {"text": "Drive safe, friend.", "mood": "tender", "arc_cue": "none"})
    monkeypatch.setattr(proxy, "call_speak",
                        lambda t, voice=arc.VOICE: {"audio_b64": "", "words": [], "wtimes": [], "wdurations": []})
    out = proxy.call_turn("oblivious", 0, "<b64>")
    assert out["queue_dedication"] is True
    assert out["memory_patch"]["caller_name"] == "Mira"
    assert out["memory_patch"]["mood"] == "tender"


def test_call_turn_no_memory_on_short_caller(monkeypatch):
    monkeypatch.setattr(proxy, "call_asr", lambda a: {"text": "hi"})
    monkeypatch.setattr(proxy, "call_brain",
                        lambda s, m: {"text": "Hey there, friend.", "mood": "warm", "arc_cue": "none"})
    monkeypatch.setattr(proxy, "call_speak",
                        lambda t, voice=arc.VOICE: {"audio_b64": "", "words": [], "wtimes": [], "wdurations": []})
    out = proxy.call_turn("oblivious", 0, "<b64>")
    assert out["queue_dedication"] is False and out["memory_patch"] is None


def test_segment_dedication_memory_aware(monkeypatch):
    captured = {}

    def _brain(system, messages):
        captured["user"] = messages[-1]["content"]
        return {"text": "This one's for the night-shift drivers, headlights home.",
                "mood": "tender", "arc_cue": "none"}

    monkeypatch.setattr(proxy, "call_brain", _brain)
    monkeypatch.setattr(proxy, "call_speak",
                        lambda t, voice=arc.VOICE: {"audio_b64": "", "words": [], "wtimes": [], "wdurations": []})
    out = proxy.segment_turn("dedication",
                             {"topic": "driving home after a double shift", "caller_name": "Mira", "mood": "tender"})
    assert "Mira" in captured["user"] and "double shift" in captured["user"]
    assert out["text"].startswith("This one's for")


def test_segment_dedication_no_memory_is_templated(monkeypatch):
    monkeypatch.setattr(proxy, "call_speak",
                        lambda t, voice=arc.VOICE: {"audio_b64": "", "words": [], "wtimes": [], "wdurations": []})
    out = proxy.segment_turn("dedication", None)          # no model call on this path
    assert out["text"].startswith("This next one's for")  # from content.DEDICATIONS


def test_segment_dedication_junk_falls_back_to_name(monkeypatch):
    monkeypatch.setattr(proxy, "call_brain", lambda s, m: {"text": "title: X", "mood": "warm", "arc_cue": "none"})
    monkeypatch.setattr(proxy, "call_speak",
                        lambda t, voice=arc.VOICE: {"audio_b64": "", "words": [], "wtimes": [], "wdurations": []})
    out = proxy.segment_turn("dedication", {"topic": "waiting up", "caller_name": "Sam"})
    assert "Sam" in out["text"]                            # _dedication_fallback weaves the name


def test_song_intro_weaves_segue(monkeypatch):
    captured = {}

    def _brain(system, messages):
        captured["user"] = messages[-1]["content"]
        return {"text": "After that caller, a soft one -- 'Neon Rain'.", "mood": "tender", "arc_cue": "none"}

    monkeypatch.setattr(proxy, "call_brain", _brain)
    monkeypatch.setattr(proxy, "call_speak",
                        lambda t, voice=arc.VOICE: {"audio_b64": "", "words": [], "wtimes": [], "wdurations": []})
    proxy.segment_turn("song_intro", {"title": "Neon Rain", "artist": "The Tuesday Ghosts",
                                      "vibe": "melancholy", "segue": "right after that caller"})
    assert "right after that caller" in captured["user"]


def test_song_intro_no_segue_when_absent(monkeypatch):
    captured = {}

    def _brain(system, messages):
        captured["user"] = messages[-1]["content"]
        return {"text": "Coming up, 'Neon Rain'.", "mood": "warm", "arc_cue": "none"}

    monkeypatch.setattr(proxy, "call_brain", _brain)
    monkeypatch.setattr(proxy, "call_speak",
                        lambda t, voice=arc.VOICE: {"audio_b64": "", "words": [], "wtimes": [], "wdurations": []})
    proxy.segment_turn("song_intro", {"title": "Neon Rain", "artist": "The Tuesday Ghosts", "vibe": "melancholy"})
    assert "through-line" not in captured["user"]


def test_fragment_user_mentions_another_station():
    u = proxy._fragment_user(None)
    assert "another station" in u and len(u) > 20


def test_segment_fragment_is_persona_free(monkeypatch):
    captured = {}

    def _brain(system, messages):
        captured["system"] = system
        return {"text": "calling all night nurses near mile marker nine", "mood": "warm", "arc_cue": "none"}

    monkeypatch.setattr(proxy, "call_brain", _brain)
    monkeypatch.setattr(proxy, "call_speak",
                        lambda t, voice=arc.VOICE: {"audio_b64": "", "words": [], "wtimes": [], "wdurations": []})
    out = proxy.segment_turn("fragment", None)
    assert arc.HOST_PERSONA not in captured["system"]     # not the Sam Dusk host persona
    assert out["text"].startswith("calling all night nurses")


def test_segment_fragment_junk_falls_back(monkeypatch):
    monkeypatch.setattr(proxy, "call_brain", lambda s, m: {"text": "title: X", "mood": "warm", "arc_cue": "none"})
    monkeypatch.setattr(proxy, "call_speak",
                        lambda t, voice=arc.VOICE: {"audio_b64": "", "words": [], "wtimes": [], "wdurations": []})
    out = proxy.segment_turn("fragment", None)
    assert out["text"] in content.FRAGMENTS               # junk -> templated bank