File size: 26,474 Bytes
0cfc3a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ac702e3
3cb8d24
0cfc3a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ac702e3
0cfc3a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ac702e3
 
 
8d3d08e
 
 
 
 
 
 
 
 
 
 
 
 
 
ac702e3
 
8d3d08e
ac702e3
8d3d08e
ac702e3
 
8d3d08e
 
 
0cfc3a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ac702e3
 
0cfc3a7
 
 
 
ac702e3
0cfc3a7
 
 
 
 
 
 
 
ac702e3
 
 
 
0cfc3a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ac702e3
 
0cfc3a7
ac702e3
0cfc3a7
ac702e3
0cfc3a7
 
 
 
 
 
 
8d3d08e
 
 
 
 
 
 
 
 
 
 
 
ac702e3
8d3d08e
 
 
 
 
 
0a65ab2
 
 
 
 
 
ac702e3
 
 
 
 
 
 
 
8d3d08e
 
 
0cfc3a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8d3d08e
0cfc3a7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8d3d08e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ac702e3
8d3d08e
ac702e3
8d3d08e
 
 
 
 
 
 
 
 
 
 
 
 
 
0a65ab2
 
 
 
8d3d08e
 
 
 
 
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
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
import io
import wave

import numpy as np
from test_scoring import emissions, scorer_for

from app.auth import AuthUnavailable
from app.lab import ITEMS, benchmark, public_items, still_flagged
from app.translit import to_hebrew


def tone_wav():
    t = np.arange(16000) / 16000
    pcm = (0.3 * np.sin(2 * np.pi * 220 * t) * 32767).astype("<i2").tobytes()
    buf = io.BytesIO()
    with wave.open(buf, "wb") as w:
        w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000); w.writeframes(pcm)
    return buf.getvalue()


class NoTTS:
    """No native baseline: the stub model would score the voice as it scores the learner, and forgive everything."""

    def wav(self, text, voice):
        raise OSError("no TTS in tests")

    def cached(self, text, voice):
        return False


class FakeAuth:
    url, key, teachers, tutors = "https://proj.supabase.co", "sb_publishable_x", frozenset({"t@b.c", "u@b.c"}), frozenset()
    developers = frozenset({"t@b.c", "u@b.c"})

    def __init__(self):
        self.samples, self.missing = [], False
        self.flags = [{"id": 7, "phrase": "nope", "he": "מצב", "ar": "حال", "ipa": "ħ aː l", "score": 40, "audio": "x.wav",
                       "result": {"words": [{"phonemes": [{"ipa": "ħ", "score": 1, "flagged": True, "heard": "χ"}]}]},
                       "false_issues": [{"word": 0, "expected": "ħ", "heard": "χ"}]}]

    def list_flags(self, status="pending"):
        return self.flags if status == "confirmed" else []

    def flag(self, flag_id):
        return next((f for f in self.flags if f["id"] == flag_id), None)

    def flag_audio(self, flag_id):
        return (tone_wav(), "audio/wav") if self.flag(flag_id) else None

    def user(self, token):
        return {"id": "u1", "email": {"teacher": "T@b.c", "teacher2": "u@b.c"}.get(token, "s@b.c")}

    def classes(self, fresh=False):
        return []

    def ranked(self, fresh=False):
        return frozenset()

    def lab_samples(self, full=False):
        if self.missing:
            raise AuthUnavailable("supabase answered 404: no table")
        return self.samples

    def add_lab_sample(self, row, audio, mime, name):
        if self.missing:
            raise AuthUnavailable("supabase answered 404: no table")
        self.samples.append({**row, "id": len(self.samples) + 1, "audio": name})
        return True

    def lab_sample(self, sample_id):
        return next((s for s in self.samples if s["id"] == sample_id), None)

    def lab_audio(self, sample_id):
        return (tone_wav(), "audio/wav") if self.lab_sample(sample_id) else None

    def update_lab_result(self, sample_id, result):
        self.lab_sample(sample_id)["result"] = result

    def delete_lab_samples(self, speaker=None):
        gone = [s for s in self.samples if speaker is None or (s.get("speaker") or "") == speaker]
        self.samples = [s for s in self.samples if s not in gone]
        return len(gone)


def test_lab_deletes_one_speaker_or_everyone():
    from fastapi.testclient import TestClient

    from app.main import create_app

    auth, teacher = FakeAuth(), {"Authorization": "Bearer teacher"}
    audio = {"audio": ("a.wav", tone_wav(), "audio/wav")}
    with TestClient(create_app(scorer_for(emissions(["ħ", "aː", "l"])), NoTTS(), auth=auth)) as c:
        for token in ("teacher", "teacher", "teacher2"):   # the speaker is who is signed in
            c.post("/api/lab/score", data={"item": "pair-ħaal"}, files=audio, headers={"Authorization": f"Bearer {token}"})
        assert c.delete("/api/lab/samples", headers=teacher).status_code == 400          # never all by omission
        assert c.delete("/api/lab/samples?speaker=t@b.c&everyone=true", headers=teacher).status_code == 400
        assert c.delete("/api/lab/samples?everyone=true", headers={"Authorization": "Bearer student"}).status_code == 403
        assert c.delete("/api/lab/samples?speaker=%20t@b.c%20", headers=teacher).json() == {"deleted": 2}
        assert c.get("/api/lab/results", headers=teacher).json()["speakers"] == ["u@b.c"]
        assert c.delete("/api/lab/samples?everyone=true", headers=teacher).json() == {"deleted": 1}
        assert c.get("/api/lab/results", headers=teacher).json()["totals"]["recordings"] == 0


def test_items_are_well_formed():
    assert len({i["id"] for i in ITEMS}) == len(ITEMS)
    for item in ITEMS:
        for ar, ipa in item["variants"]:
            assert len(ar.split()) == len(ipa.split("|")), item["id"]
            assert item["focus"] in ipa.split(), item["id"]     # the item says the sound it tests
            assert all(to_hebrew(ipa, ar)), item["id"]
    pairs = {}
    for item in ITEMS:
        if item["pair"]:
            pairs.setdefault(item["pair"], []).append(item)
    assert all(len(words) >= 2 for words in pairs.values())      # a minimal pair is two words (or three)
    assert all(it["sounds"] for it in public_items())


def test_lab_scores_and_keeps_labelled_recordings():
    from fastapi.testclient import TestClient

    from app.main import create_app

    auth = FakeAuth()
    teacher, student = {"Authorization": "Bearer teacher"}, {"Authorization": "Bearer student"}
    audio = {"audio": ("a.wav", tone_wav(), "audio/wav")}
    # حال said with a Hebrew ח: the model hears χ for ħ
    with TestClient(create_app(scorer_for(emissions(["χ", "aː", "l"])), NoTTS(), auth=auth)) as c:
        assert c.get("/api/lab", headers=student).status_code == 403              # teachers only
        assert c.post("/api/lab/score", data={"item": "pair-ħaal"}, files=audio, headers=student).status_code == 403
        d = c.get("/api/lab", headers=teacher).json()
        assert d["setup"] == "" and d["counts"] == {} and d["items"][0]["id"] == ITEMS[0]["id"]

        r = c.post("/api/lab/score", data={"item": "pair-ħaal", "mistake": 0, "speaker": "מרב"}, files=audio,
                   headers=teacher)   # a speaker sent by the page is ignored: it is the signed-in teacher
        assert r.status_code == 200, r.text
        d = r.json()
        assert d["saved"] and "trace" in d["result"]                              # the teacher's stage-by-stage view
        row = auth.samples[-1]
        assert row["label"] == "mistake" and row["mistake"] == {"index": 0, "expected": "ħ"} and row["speaker"] == "t@b.c"
        assert row["flagged"] == ["ħ>χ"] and row["email"] == "t@b.c"

        # said right (the default), and not kept when saving is off
        c.post("/api/lab/score", data={"item": "pair-ħaal"}, files=audio, headers=teacher)
        assert auth.samples[-1]["label"] == "correct" and auth.samples[-1]["mistake"] is None
        d = c.post("/api/lab/score", data={"item": "pair-ħaal", "save": "false"}, files=audio, headers=teacher).json()
        assert not d["saved"] and len(auth.samples) == 2
        assert c.get("/api/lab", headers=teacher).json()["counts"] == {"pair-ħaal": {"correct": 1, "mistake": 1}}
        assert c.get("/api/lab", headers=teacher).json()["mine"] == {"pair-ħaal": {"correct": 1, "mistake": 1}}
        assert c.get("/api/lab", headers={"Authorization": "Bearer teacher2"}).json()["mine"] == {}
        assert c.get("/api/lab?speaker=t@b.c", headers={"Authorization": "Bearer teacher2"}).json()["mine"] \
            == {"pair-ħaal": {"correct": 1, "mistake": 1}}
        assert c.get("/api/lab?speaker=אחר", headers=teacher).json()["mine"] == {}

        assert c.post("/api/lab/score", data={"item": "nope"}, files=audio, headers=teacher).status_code == 404
        assert c.post("/api/lab/score", data={"item": "pair-ħaal", "mistake": 3}, files=audio,
                      headers=teacher).status_code == 400                         # حال has three sounds

        # before the table is created: the list still shows and scores, with the SQL to run
        auth.missing = True
        d = c.get("/api/lab", headers=teacher).json()
        assert "create table public.lab_samples" in d["setup"]
        d = c.post("/api/lab/score", data={"item": "pair-ħaal"}, files=audio, headers=teacher).json()
        assert not d["saved"] and "lab_samples" in d["error"] and d["result"]["score"] is not None


def phones(*ps):
    return {"score": 90, "words": [{"phonemes": [{"ipa": i, "score": sc, "flagged": f, "heard": h} for i, sc, f, h in ps]}]}


def test_benchmark_counts_false_rejections_and_caught_mistakes():
    samples = [
        {"id": 1, "label": "correct", "result": phones(("ħ", 20, True, "χ"), ("aː", 100, False, None), ("l", 95, False, None))},
        {"id": 2, "label": "correct", "result": phones(("ħ", 90, False, None), ("aː", 100, False, None), ("l", 95, False, None))},
        # ħ said wrong on purpose and caught; and one not caught
        {"id": 3, "label": "mistake", "mistake": {"index": 0, "expected": "ħ"},
         "result": phones(("ħ", 5, True, "h"), ("aː", 100, False, None), ("l", 95, False, None))},
        {"id": 4, "label": "mistake", "mistake": {"index": 0, "expected": "ħ"},
         "result": phones(("ħ", 70, False, None), ("aː", 100, False, None), ("l", 30, True, "r"))},
        {"id": 5, "label": "correct", "result": {**phones(("ħ", 0, True, "_")), "unrelated": True}},   # tells nothing
    ]
    b = benchmark(samples, reports=[{"false_issues": [{"word": 0, "expected": "ħ", "heard": "χ"}]}])
    by = {r["sound"]: r for r in b["sounds"]}
    assert by["ħ"]["right"] == 2 and by["ħ"]["false_flags"] == 1 and by["ħ"]["frr"] == 0.5
    assert by["ħ"]["planted"] == 2 and by["ħ"]["caught"] == 1 and by["ħ"]["detection"] == 0.5
    assert by["ħ"]["confusions"] == {"χ": 1} and by["ħ"]["reports"] == 1
    assert by["l"]["right"] == 4 and by["l"]["false_flags"] == 1          # said right in a mistake recording too
    assert b["sounds"][0]["sound"] == "ħ"                                  # the worst first
    t = b["totals"]
    assert t["recordings"] == 5 and t["unrelated"] == 1 and t["planted"] == 2 and t["good_share"] == 1.0
    # a rescore replaces the stored result
    fixed = benchmark(samples, {1: phones(("ħ", 95, False, None), ("aː", 100, False, None), ("l", 95, False, None))})
    assert {r["sound"]: r for r in fixed["sounds"]}["ħ"]["frr"] == 0.0


def test_still_flagged():
    r = phones(("ħ", 1, True, "χ"), ("aː", 100, False, None))
    assert still_flagged(r, {"word": 0, "expected": "ħ"}) and not still_flagged(r, {"word": 0, "expected": "aː"})
    assert not still_flagged(r, {"word": 3, "expected": "ħ"})


def test_lab_results_and_flag_rescore():
    from fastapi.testclient import TestClient

    from app.main import create_app

    auth, teacher = FakeAuth(), {"Authorization": "Bearer teacher"}
    audio = {"audio": ("a.wav", tone_wav(), "audio/wav")}
    with TestClient(create_app(scorer_for(emissions(["χ", "aː", "l"])), NoTTS(), auth=auth)) as c:
        for token in ("teacher", "teacher2"):
            c.post("/api/lab/score", data={"item": "pair-ħaal"}, files=audio, headers={"Authorization": f"Bearer {token}"})
        d = c.get("/api/lab/results", headers=teacher).json()
        assert d["speakers"] == ["t@b.c", "u@b.c"] and d["totals"]["recordings"] == 2
        assert {r["sound"]: r for r in d["sounds"]}["ħ"]["false_flags"] == 2 and d["sounds"][0]["reports"] == 1
        assert c.get("/api/lab/results?speaker=t@b.c", headers=teacher).json()["totals"]["recordings"] == 1
        assert c.get("/api/lab/results", headers={"Authorization": "Bearer student"}).status_code == 403

        d = c.post("/api/lab/flags/7/rescore", headers=teacher).json()
        assert d["old"] == {"score": 40, "flagged": ["ħ>χ"]} and d["issues"][0]["still"] and "trace" in d["result"]
        assert c.post("/api/lab/flags/8/rescore", headers=teacher).status_code == 404


def test_minerva_lab_groups_imported_segments_by_video():
    from fastapi.testclient import TestClient

    from app.main import create_app

    auth, teacher = FakeAuth(), {"Authorization": "Bearer teacher"}
    auth.samples = [
        {"id": 2, "item": "minerva:U8aBRd3-wlM:02", "speaker": "מינרווה", "device": "minerva:U8aBRd3-wlM:4.2-7.8",
         "ar": "مرحبا", "ipa": "m a r ħ a b a", "score": 81, "heard_ipa": "m a r ħ a b a", "flagged": [],
         "result": {}, "audio": "two.webm"},
        {"id": 1, "item": "minerva:U8aBRd3-wlM:01", "speaker": "מינרווה", "device": "minerva:U8aBRd3-wlM:1.0-4.0",
         "ar": "أهلا", "ipa": "ʔ a h l a", "score": 70, "heard_ipa": "ʔ a h l a", "flagged": ["h>x"],
         "result": {"unrelated": False, "words": [{"ar": "أهلا", "score": 70, "phonemes": []}]}, "audio": "one.webm"},
        {"id": 3, "item": "pair-ħaal", "speaker": "אחר", "device": "", "audio": "other.wav"},
    ]
    with TestClient(create_app(scorer_for(emissions(["ħ", "aː", "l"])), NoTTS(), auth=auth)) as c:
        d = c.get("/api/lab/minerva", headers=teacher).json()
        assert len(d["videos"]) == 1 and d["videos"][0]["id"] == "U8aBRd3-wlM"
        assert [s["line"] for s in d["videos"][0]["samples"]] == [1, 2]
        # Sentence data comes from data/minerva_metadata.json, not the deliberately different stored row above.
        first = d["videos"][0]["samples"][0]
        assert first["start"] == 9.0 and first["end"] == 10.8
        assert first["ar"] == "فيه عندك حليب طارة؟"
        assert first["ipa"] == "f iː h | ʕ i n d a k | ħ a l iː b | tˤ aː r e"
        assert first["tr"] == "פִיה עִנְדַכּ חַלִיב טַארֶה?" and first["he"] == "יש לך חלב טרי?"
        audio = c.get("/api/lab/samples/1/audio", headers=teacher)
        assert audio.status_code == 200 and audio.headers["content-type"] == "audio/wav"
        assert c.get("/api/lab/samples/99/audio", headers=teacher).status_code == 404
        excluded = c.post("/api/lab/samples/1/exclude-word",
                          json={"word": 0, "excluded": True, "reason": "חיתוך פגום"}, headers=teacher).json()
        assert excluded["excluded_words"] == {"0": "חיתוך פגום"}
        assert c.get("/api/lab/minerva", headers=teacher).json()["videos"][0]["samples"][0]["excluded_words"] == excluded["excluded_words"]
        assert c.post("/api/lab/samples/1/exclude-word", json={"word": 2, "excluded": True}, headers=teacher).status_code == 400
        assert c.get("/api/lab/minerva", headers={"Authorization": "Bearer student"}).status_code == 403


def test_lab_rescores_its_recordings_and_compares_with_the_stored_scores():
    from fastapi.testclient import TestClient

    from app.main import create_app

    auth, teacher = FakeAuth(), {"Authorization": "Bearer teacher"}
    audio = {"audio": ("a.wav", tone_wav(), "audio/wav")}
    with TestClient(create_app(scorer_for(emissions(["χ", "aː", "l"])), NoTTS(), auth=auth)) as c:
        c.post("/api/lab/score", data={"item": "pair-ħaal"}, files=audio, headers=teacher)   # ħ said as χ: flagged
        d = c.get("/api/lab/results", headers=teacher).json()
        assert d["samples"] == [1] and d["version"]
        # the scorer changed since: now it hears ħ
        c.app.state.scorer = scorer_for(emissions(["ħ", "aː", "l"]))
        r = c.post("/api/lab/samples/1/rescore", headers=teacher).json()
        assert r["id"] == 1 and not any(p["flagged"] for w in r["result"]["words"] for p in w["phonemes"])
        assert "trace" not in r["result"]
        d = c.post("/api/lab/results", json={"results": {"1": r["result"]}}, headers=teacher).json()
        assert d["pool"] == c.app.state.scorer.cfg.gop.ratio_pool
        before, after = ({x["sound"]: x for x in d[k]["sounds"]}["ħ"] for k in ("stored", "now"))
        assert before["frr"] == 1.0 and after["frr"] == 0.0
        assert c.post("/api/lab/samples/9/rescore", headers=teacher).status_code == 404
        assert c.post("/api/lab/results", json={"results": []}, headers=teacher).status_code == 400
        assert c.post("/api/lab/samples/1/rescore", headers={"Authorization": "Bearer student"}).status_code == 403


class FakeTTS:
    """Says anything as the test tone; counts what it was asked."""

    def __init__(self):
        self.said = []

    def wav(self, text, voice=""):
        self.said.append((text, voice))
        return tone_wav(), voice

    def cached(self, text, voice):
        return True


def test_lab_automatic_test_with_tts():
    from fastapi.testclient import TestClient

    from app.main import LAB_TTS_VOICES, create_app

    auth, tts, teacher = FakeAuth(), FakeTTS(), {"Authorization": "Bearer teacher"}
    with TestClient(create_app(scorer_for(emissions(["ħ", "aː", "l"])), tts, auth=auth)) as c:
        assert c.get("/api/lab/tts", headers=teacher).json()["voices"] == list(LAB_TTS_VOICES)
        tts.said.clear()   # (nothing else may ask the voice: the TTS run is scored without the native baseline)
        d = c.post("/api/lab/tts", data={"item": "pair-ħaal", "voice": "ראאף"}, headers=teacher).json()
        assert d["saved"] and d["speaker"] == "TTS ראאף" and d["result"]["references"] == 0
        assert len(tts.said) == 1 and "ح" in tts.said[0][0]
        row = auth.samples[-1]
        assert row["speaker"] == "TTS ראאף" and row["label"] == "correct" and row["device"].startswith("tts:")
        assert row["audio"].endswith(".wav")
        assert c.get("/api/lab?speaker=TTS ראאף", headers=teacher).json()["mine"] == {"pair-ħaal": {"correct": 1, "mistake": 0}}
        r = c.get("/api/lab/tts/pair-ħaal?voice=ראאף", headers=teacher)
        assert r.status_code == 200 and r.headers["content-type"] == "audio/wav"
        assert c.post("/api/lab/tts", data={"item": "pair-ħaal", "voice": "x"}, headers=teacher).status_code == 400
        assert c.post("/api/lab/tts", data={"item": "nope", "voice": "ראאף"}, headers=teacher).status_code == 404
        assert c.post("/api/lab/tts", data={"item": "pair-ħaal", "voice": "ראאף"},
                      headers={"Authorization": "Bearer student"}).status_code == 403
    with TestClient(create_app(scorer_for(emissions(["ħ", "aː", "l"])), NoTTS(), auth=FakeAuth())) as c:
        c.app.state.tts = None
        assert c.get("/api/lab/tts", headers=teacher).json()["voices"] == []
        assert c.post("/api/lab/tts", data={"item": "pair-ħaal", "voice": "ראאף"}, headers=teacher).status_code == 503


def test_lab_sweeps_every_gop_setting():
    from fastapi.testclient import TestClient

    from app import lab
    from app.main import create_app

    assert len(lab.SWEEP) == 24 and len(lab.SWEEP_KEYS) == 24
    assert lab.quality({"frr": .1, "detection": .5}) == 70.0 and lab.quality({"frr": .1, "detection": None}) == 90.0
    assert lab.quality({"frr": None}) is None and lab.quality_partial({"frr": .1, "detection": None})

    auth, teacher = FakeAuth(), {"Authorization": "Bearer teacher"}
    audio = {"audio": ("a.wav", tone_wav(), "audio/wav")}
    with TestClient(create_app(scorer_for(emissions(["χ", "aː", "l"])), NoTTS(), auth=auth)) as c:
        c.post("/api/lab/score", data={"item": "pair-ħaal"}, files=audio, headers=teacher)          # ħ heard as χ
        c.post("/api/lab/score", data={"item": "pair-ħaal", "mistake": "0"}, files=audio,
               headers=teacher)
        before = c.app.state.scorer.cfg.gop
        r = c.post("/api/lab/samples/1/sweep", headers=teacher).json()
        assert r["id"] == 1 and set(r["results"]) == set(lab.SWEEP_KEYS)
        assert c.app.state.scorer.cfg.gop == before                     # the server's own setting is untouched
        r2 = c.post("/api/lab/samples/2/sweep", headers=teacher).json()
        d = c.post("/api/lab/sweep", json={"results": {"1": r["results"], "2": r2["results"]}}, headers=teacher).json()
        assert len(d["settings"]) == 24 and d["recordings"] == 2
        assert d["current"] == lab.sweep_key(before.ratio_pool, before.vowel_window)
        s = d["settings"][0]
        assert {"key", "pool", "window", "quality", "partial", "totals"} <= set(s)
        qs = [x["quality"] for x in d["settings"]]
        assert qs == sorted(qs, reverse=True)
        assert c.post("/api/lab/samples/9/sweep", headers=teacher).status_code == 404
        assert c.post("/api/lab/sweep", json={"results": []}, headers=teacher).status_code == 400
        assert c.post("/api/lab/samples/1/sweep", headers={"Authorization": "Bearer student"}).status_code == 403


def test_vowel_stats_score_vowels_not_flags():
    from app.lab import vowel_stats

    def sample(i, phones, mistake=None):
        return {"id": i, "label": "mistake" if mistake else "correct",
                "mistake": {"index": 0, "expected": mistake} if mistake else None,
                "result": {"words": [{"phonemes": [{"ipa": p, "score": sc} for p, sc in phones]}]}}

    rows = [sample(1, [("k", 40), ("a", 95), ("i", 70)]), sample(2, [("a", 30), ("l", 100)], mistake="a"),
            {**sample(3, [("a", 10)]), "result": {"unrelated": True, "words": [{"phonemes": [{"ipa": "a", "score": 10}]}]}}]
    v = vowel_stats(rows)
    assert v == {"right": 2, "mean": 82.5, "weak": 0.5, "planted": 1, "auc": 1.0}   # k is no vowel, #3 is unrelated
    assert vowel_stats(rows[:1])["auc"] is None
    assert vowel_stats(rows, {2: {"words": [{"phonemes": [{"ipa": "a", "score": 70}]}]}})["auc"] == 0.75


def test_the_sweep_scores_every_setting_whatever_the_env(monkeypatch):
    """The sweep passes each setting to the scorer itself: the env only picks the server's own one."""
    from fastapi.testclient import TestClient

    import app.scorer as scorer_module
    from app import lab
    from app.main import create_app

    monkeypatch.setenv("GOP_RATIO_POOL", "max")
    monkeypatch.setenv("GOP_VOWEL_WINDOW", "wide")
    seen, real = [], scorer_module.score_tokens
    monkeypatch.setattr(scorer_module, "score_tokens",
                        lambda logp, al, ids, blank, cfg, *a, **k: seen.append((cfg.ratio_pool, cfg.vowel_window))
                        or real(logp, al, ids, blank, cfg, *a, **k))
    auth, teacher = FakeAuth(), {"Authorization": "Bearer teacher"}
    with TestClient(create_app(scorer_for(emissions(["ħ", "aː", "l"])), NoTTS(), auth=auth)) as c:
        c.post("/api/lab/score", data={"item": "pair-ħaal"}, files={"audio": ("a.wav", tone_wav(), "audio/wav")},
               headers=teacher)
        assert c.app.state.scorer.cfg.gop.ratio_pool == "max"
        seen.clear()
        r = c.post("/api/lab/samples/1/sweep", headers=teacher).json()
        assert sorted(seen, key=str) == sorted(lab.SWEEP, key=str)
        d = c.post("/api/lab/sweep", json={"results": {"1": r["results"]}}, headers=teacher).json()
        assert all({"right", "mean", "weak", "planted", "auc"} <= set(s["vowels"]) for s in d["settings"])
        base = next(s for s in d["settings"] if s["key"] == "max|wide")
        assert base["changed"] == 0 and base["sounds"] == 3
        assert d["current"] == "max|wide"


def test_the_sweep_shows_which_settings_changed_a_score():
    """A long vowel with an /i/ spike inside its window: max|wide marks it down, the pooled settings do not."""
    from fastapi.testclient import TestClient

    from app.main import create_app
    from test_scoring import SYMS, VOCAB

    lp = emissions(["ħ", "aː", "l"], gap=5)
    p = np.exp(lp)
    vowel = [t for t in range(len(p)) if lp[t].argmax() == VOCAB["aː"]]
    for t in vowel:
        p[t] = (1 - .45) / (len(SYMS) - 1); p[t, VOCAB["aː"]] = .45
    p[vowel[-1] + 3] = .1 / (len(SYMS) - 1); p[vowel[-1] + 3, VOCAB["i"]] = .9
    auth, teacher = FakeAuth(), {"Authorization": "Bearer teacher"}
    with TestClient(create_app(scorer_for(np.log(p / p.sum(1, keepdims=True))), NoTTS(), auth=auth)) as c:
        c.post("/api/lab/score", data={"item": "pair-ħaal"}, files={"audio": ("a.wav", tone_wav(), "audio/wav")},
               headers=teacher)
        r = c.post("/api/lab/samples/1/sweep", headers=teacher).json()
        d = {s["key"]: s for s in c.post("/api/lab/sweep", json={"results": {"1": r["results"]}}, headers=teacher).json()["settings"]}
        assert d["max|wide"]["changed"] == 0 and d["max|wide"]["vowels"]["mean"] < 80
        for key in ("mean|wide", "gated|wide", "topk|wide", "max|1"):
            assert d[key]["changed"] == 1 and d[key]["vowels"]["mean"] > 95, key


def test_lab_opens_the_trace_of_any_saved_recording():
    from fastapi.testclient import TestClient

    from app.main import create_app

    auth, teacher = FakeAuth(), {"Authorization": "Bearer teacher"}
    audio = {"audio": ("a.wav", tone_wav(), "audio/wav")}
    with TestClient(create_app(scorer_for(emissions(["χ", "aː", "l"])), NoTTS(), auth=auth)) as c:
        c.post("/api/lab/score", data={"item": "pair-ħaal"}, files=audio, headers=teacher)
        recs = c.get("/api/lab/results", headers=teacher).json()["recordings"]
        assert [(r["id"], r["item"], r["speaker"], r["flagged"]) for r in recs] == [(1, "pair-ħaal", "t@b.c", ["ħ>χ"])]
        assert recs[0]["unrelated"] is False
        d = c.post("/api/lab/samples/1/rescore?trace=true", headers=teacher).json()
        assert "trace" in d["result"] and d["result"]["trace"]["tokens"] and d["sample"]["item"] == "pair-ħaal"
        g = c.app.state.scorer.cfg.gop
        assert d["setting"] == f"{g.ratio_pool}|{'wide' if g.vowel_window is None else g.vowel_window}"
        d = c.post("/api/lab/samples/1/rescore?trace=true&setting=max|1", headers=teacher).json()
        assert d["setting"] == "max|1" and "trace" in d["result"]
        assert "trace" not in c.post("/api/lab/samples/1/rescore", headers=teacher).json()["result"]
        assert c.post("/api/lab/samples/1/rescore?trace=true&setting=nope", headers=teacher).status_code == 400


def test_imported_lab_line_reconstructs_its_phrase_outside_the_fixed_list():
    from app.lab import sample_phrase

    reviewed = sample_phrase({"id": 6, "item": "minerva:U8aBRd3-wlM:01", "ar": "wrong", "ipa": "x"})
    assert reviewed["variants"] == [("فيه عندك حليب طارة؟",
                                      "f iː h | ʕ i n d a k | ħ a l iː b | tˤ aː r e")]

    phrase = sample_phrase({"id": 7, "item": "minerva:video:01", "ar": "حال", "ipa": "ħ aː l",
                            "speaker": "מינרווה"})
    assert phrase == {"cat": "lab", "key": "minerva:video:01", "he": "מינרווה",
                      "variants": [("حال", "ħ aː l")]}
    assert sample_phrase({"id": 8, "item": "removed-item"}) is None