Download tests/test_scoring.py from wzender/mathly: direct link, hf CLI and curl.
- Browser
- Download file 150 kB
-
https://huggingface.co/spaces/wzender/mathly/resolve/main/tests/test_scoring.py
- Command line
-
hf download hf://spaces/wzender/mathly/tests/test_scoring.py
-
curl -L -o test_scoring.py https://huggingface.co/spaces/wzender/mathly/resolve/main/tests/test_scoring.py
150 kB
| """Tests with synthetic CTC emissions: no model download needed.""" | |
| import io | |
| import re | |
| import wave | |
| from dataclasses import replace | |
| import numpy as np | |
| import pytest | |
| from app.align import ctc_viterbi, greedy_decode | |
| from app.phrases import PHRASES | |
| from app.scorer import Scorer, Vocab, tokenize | |
| SYMS = ["<pad>", "w", "eː", "n", "i", "l", "ħ", "a", "m", "aː", "χ", "h", "k", "ʔ", "d", "ʃ", "s", "ʕ", "q", "e"] | |
| VOCAB = {s: i for i, s in enumerate(SYMS)} | |
| def emissions(phones, frames=3, gap=2, peak=0.9): | |
| """Log-posteriors where each phone peaks for `frames` frames, separated by blanks.""" | |
| V = len(SYMS) | |
| rows = [] | |
| def row(sym, p): | |
| r = np.full(V, (1 - p) / (V - 1)) | |
| r[VOCAB[sym]] = p | |
| return r | |
| for _ in range(gap): | |
| rows.append(row("<pad>", 0.95)) | |
| for ph in phones: | |
| for _ in range(frames): | |
| rows.append(row(ph, peak)) | |
| for _ in range(gap): | |
| rows.append(row("<pad>", 0.95)) | |
| return np.log(np.array(rows)) | |
| def find(he): | |
| return next(p for p in PHRASES if p["he"] == he) | |
| def scorer_for(logp): | |
| return Scorer(lambda wav: logp, Vocab(VOCAB)) | |
| TOILET = find("איפה השירותים?") # وين الحمام w eː n | i l ħ a m m aː m | |
| TIME = find("מה השעה?") # قديش الساعة | |
| def test_viterbi_spans_cover_every_token(): | |
| toks = [VOCAB[s] for s in ["w", "eː", "n"]] | |
| logp = emissions(["w", "eː", "n"]) | |
| al = ctc_viterbi(logp, toks, 0) | |
| assert al is not None | |
| assert all(0 <= a < b for a, b in al.spans) | |
| assert [a for a, _ in al.spans] == sorted(a for a, _ in al.spans) | |
| assert greedy_decode(logp, 0) == toks | |
| def test_too_short_returns_none(): | |
| logp = emissions(["w"], frames=1, gap=0) | |
| assert ctc_viterbi(logp, [VOCAB["w"], VOCAB["eː"], VOCAB["n"]], 0) is None | |
| def test_geminates_collapse(): | |
| toks = tokenize("i l ħ a m m aː m", Vocab(VOCAB)) | |
| assert [t.phoneme for t in toks] == ["i", "l", "ħ", "a", "m", "aː", "m"] | |
| def test_correct_pronunciation_scores_high(): | |
| logp = emissions(["w", "eː", "n", "i", "l", "ħ", "a", "m", "aː", "m"]) | |
| r = scorer_for(logp).score(None, TOILET) | |
| assert r["score"] >= 90 | |
| assert r["tips"] == [] | |
| assert not r["unrelated"] | |
| def test_teacher_diagnostics_include_compact_phoneme_heatmap(): | |
| logp = emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"]) | |
| scorer = scorer_for(logp) | |
| assert "phoneme_heatmap" not in scorer.score(None, TOILET) | |
| heatmap = scorer.score_logp(logp, TOILET, diagnostics=True, duration_s=2.5)["phoneme_heatmap"] | |
| assert heatmap["duration_ms"] == 2500 | |
| assert heatmap["frame_bins"] == len(logp) | |
| assert {"ħ", "χ", "blank"} <= {row["phoneme"] for row in heatmap["rows"]} | |
| assert all(len(row["values"]) == heatmap["frame_bins"] for row in heatmap["rows"]) | |
| assert [p["phoneme"] for p in heatmap["tracks"]["expected"]] == [ | |
| "w", "eː", "n", "i", "l", "ħ", "a", "m", "aː", "m"] | |
| assert [p["phoneme"] for p in heatmap["tracks"]["heard"]] == [ | |
| "w", "e", "n", "i", "l", "χ", "a", "m", "a", "m"] | |
| def test_hebrew_chet_flagged_on_hammam(): | |
| # ħ said as the Hebrew uvular χ | |
| logp = emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"]) | |
| r = scorer_for(logp).score(None, TOILET) | |
| word = r["words"][1] | |
| h = next(p for p in word["phonemes"] if p["ipa"] == "ħ") | |
| assert h["flagged"] and h["heard"] == "χ" | |
| assert r["words"][0]["score"] > 90 # وين is fine | |
| assert r["tips"][0]["key"] == "ħ>x" | |
| assert r["tips"][0]["word"] == "الحمّام" | |
| assert 50 <= r["score"] < 85 # one wrong consonant: "almost", not "excellent" | |
| def test_applied_phrase_calibration_raises_only_the_matching_confusion(): | |
| logp = emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"]) | |
| scorer = scorer_for(logp) | |
| key = TOILET.get("key", TOILET["he"]) | |
| scorer.set_calibrations([{"expected": "ħ", "heard": "χ", "floor": 70, | |
| "scope": "phrase", "phrase": key}]) | |
| r = scorer.score(None, TOILET) | |
| h = next(p for p in r["words"][1]["phonemes"] if p["ipa"] == "ħ") | |
| assert h["score"] == 70 and not h["flagged"] | |
| assert r["calibrated"] == [{"expected": "ħ", "heard": "χ", "from": 2, "to": 70}] | |
| def test_deleted_ayin_gets_conservative_builtin_calibration(): | |
| phrase = {"variants": [("مع", "m a ʕ")]} | |
| scorer = scorer_for(emissions(["m", "a"])) | |
| r = scorer.score(None, phrase, diagnostics=True) | |
| ayin = next(p for p in r["words"][0]["phonemes"] if p["ipa"] == "ʕ") | |
| trace = next(p for p in r["trace"]["tokens"] if p["ipa"] == "ʕ") | |
| calibration = next(s for s in trace["stages"] if s["stage"] == "calibration") | |
| assert ayin["score"] == 60 and not ayin["flagged"] | |
| assert calibration == {"stage": "calibration", "score": 60, "floor": 60, "applied": True} | |
| applied = r["calibrated"][0] | |
| assert applied["expected"] == "ʕ" and applied["heard"] == "_" | |
| assert applied["from"] < 50 and applied["to"] == 60 | |
| scorer.set_calibrations([{"expected": "ʕ", "heard": "_", "floor": 75, "scope": "global"}]) | |
| overridden = scorer.score(None, phrase) | |
| assert next(p for p in overridden["words"][0]["phonemes"] if p["ipa"] == "ʕ")["score"] == 75 | |
| def test_h_instead_of_pharyngeal(): | |
| logp = emissions(["w", "eː", "n", "i", "l", "h", "a", "m", "aː", "m"]) | |
| r = scorer_for(logp).score(None, TOILET) | |
| assert r["tips"][0]["key"] == "ħ>h" | |
| def test_k_for_qaf_is_rural_and_correct(): | |
| # learner says "kaddeesh" for قديش: rural Palestinian, so ق is accepted as k; the answer stays the urban one | |
| logp = emissions(["k", "a", "d", "eː", "ʃ", "i", "s", "aː", "ʕ", "a"]) | |
| r = scorer_for(logp).score(None, TIME) | |
| q = r["words"][0]["phonemes"][0] | |
| assert r["variant"]["ipa"].startswith("ʔ") and q["sound"] == "Q" and q["realised"] == "k" | |
| assert q["score"] >= 90 and not q["flagged"] and not r["tips"] and r["score"] >= 90 | |
| assert r["words"][0]["heard_tr"].startswith("כּ") # written as said, not coloured as a mistake | |
| # ---------- dialects and Hebrew-speaker rules (docs/scoring-dialects-plan.md) ---------- | |
| def custom_scorer(extra, phones, frames=3, gap=2, peak=0.9): | |
| """A scorer over SYMS plus `extra`, hearing `phones` cleanly.""" | |
| syms = SYMS + [s for s in extra if s not in SYMS] | |
| vocab = {s: i for i, s in enumerate(syms)} | |
| def row(sym, p): | |
| r = np.full(len(syms), (1 - p) / (len(syms) - 1)) | |
| r[vocab[sym]] = p | |
| return r | |
| rows = [row("<pad>", 0.95)] * gap | |
| for ph in phones: | |
| rows += [row(ph, peak)] * frames + [row("<pad>", 0.95)] * gap | |
| return Scorer(lambda wav: np.log(np.array(rows)), Vocab(vocab)) | |
| COFFEE = {"variants": [("قهوة", "ʔ a h w e")]} | |
| def test_q_for_qaf_is_accepted(): | |
| r = scorer_for(emissions(["q", "a", "d", "eː", "ʃ", "i", "s", "aː", "ʕ", "a"])).score(None, TIME) | |
| assert r["words"][0]["phonemes"][0]["realised"] == "q" and not r["tips"] and r["score"] >= 90 | |
| def test_dropped_qaf_between_vowels_is_not_an_error(): | |
| r = scorer_for(emissions(["a", "h", "w", "e"])).score(None, COFFEE) | |
| assert r["score"] >= 90 and not r["tips"] and not any(i["missing"] for i in r["issues"]) | |
| def test_hamza_is_not_a_qaf(): | |
| # اهلا is written with ء: kahla is not a dialect form, it is a mistake | |
| phrase = {"variants": [("اهلا", "ʔ a h l a")]} | |
| r = scorer_for(emissions(["k", "a", "h", "l", "a"])).score(None, phrase) | |
| p = r["words"][0]["phonemes"][0] | |
| assert "sound" not in p and p["flagged"] and p["heard"] == "k" | |
| assert [t["key"] for t in r["tips"]] == ["ʔ>k"] | |
| def test_reference_is_matched_by_letter_not_by_realisation(): | |
| ref = scorer_for(emissions(["q", "a", "h", "w", "e"])).reference(None, COFFEE) # the voice says ق as q | |
| assert ref.sounds[0] == (0, "Q") | |
| r = scorer_for(emissions(["ʔ", "a", "h", "w", "e"])).score(None, COFFEE, [ref]) | |
| assert r["score"] >= 95 and not r["tips"] and r["references"] == 1 | |
| def test_mixed_dialect_is_noted_but_not_marked_down(): | |
| phrase = {"variants": [("قديش قهوة", "ʔ a d d eː ʃ | ʔ a h w e")]} | |
| mixed = scorer_for(emissions(["ʔ", "a", "d", "eː", "ʃ", "k", "a", "h", "w", "e"])).score(None, phrase) | |
| assert mixed["mixed_dialect"] and mixed["score"] >= 90 and not mixed["tips"] | |
| rural = scorer_for(emissions(["k", "a", "d", "eː", "ʃ", "k", "a", "h", "w", "e"])).score(None, phrase) | |
| assert rural["dialect"] == "rural" and "mixed_dialect" not in rural | |
| def test_rural_kaf_and_tha(): | |
| phrase = {"variants": [("كثير", "k t iː r")]} | |
| r = custom_scorer(["tʃ", "θ", "r", "iː", "t"], ["tʃ", "θ", "iː", "r"]).score(None, phrase) | |
| assert [p.get("realised") for p in r["words"][0]["phonemes"][:2]] == ["tʃ", "θ"] | |
| assert r["score"] >= 90 and not r["tips"] and r["dialect"] == "rural" | |
| r = custom_scorer(["tʃ", "θ", "r", "iː", "t"], ["tʃ", "t", "iː", "r"]).score(None, phrase) | |
| assert r["mixed_dialect"] and r["score"] >= 90 # rural ك with urban ث | |
| def test_final_ta_marbuta_vowel_may_be_a_e_or_i(): | |
| for vowel in ("a", "e", "i"): | |
| r = scorer_for(emissions(["ʔ", "a", "h", "w", vowel])).score(None, COFFEE) | |
| assert r["words"][0]["phonemes"][-1]["score"] >= 95, vowel | |
| def test_diphthong_for_long_vowel(): | |
| # بيت said as bayt (rural / older speech) for beet | |
| phrase = {"variants": [("بيت", "b eː t")]} | |
| r = custom_scorer(["b", "t", "j"], ["b", "a", "j", "t"]).score(None, phrase) | |
| assert r["score"] >= 90 and not r["tips"] and r["variant"]["ipa"] == "b a j t" | |
| def test_archify_places_each_letter(): | |
| from app.archi import archify, unresolved | |
| assert archify("قديش الساعة", "ʔ a d d eː ʃ | i s s aː ʕ a") == "Q:ʔ a d d eː ʃ | i s s aː ʕ E:a" | |
| assert archify("انا من القدس", "ʔ a n a | m i n | i l q u d s") == "ʔ a n a | m i n | i l Q:q u d s" | |
| assert archify("شكرا كثير", "ʃ u k r a n | k t iː r") == "ʃ u K:k r a n | K:k TH:t iː r" | |
| assert archify("قهوة", "Q:k a h w e") == "Q:k a h w e" # written by hand: left alone | |
| assert unresolved("بعثت", "b a ʕ a t t") == [("بعثت", "ث")] # ث and ت said as one geminate | |
| def test_every_qaf_in_the_bank_is_placed(): | |
| from app.archi import unresolved | |
| bad = {(w, ipa) for p in PHRASES for ar, ipa in p["variants"] for w, letter in unresolved(ar, ipa) if letter == "ق"} | |
| assert not bad, f"mark these ق by hand in the phrase (Q:ʔ): {sorted(bad)[:10]}" | |
| def test_emphatic_heard_plain_is_a_mistake_with_the_rules_feedback(): | |
| from app.rules import load | |
| phrase = {"variants": [("ظهر", "ðˤ u h r")]} | |
| r = custom_scorer(["ðˤ", "dˤ", "zˤ", "z", "u", "r"], ["z", "u", "h", "r"]).score(None, phrase) | |
| assert [t["key"] for t in r["tips"]] == ["ZH>z"] | |
| assert r["tips"][0]["text"] == load().check("ZH", "z").feedback and r["tips"][0]["severity"] == 2 | |
| def margin_scorer(competitor_ratio): | |
| """طين where tˤ is weak (p 0.07) and plain t is `competitor_ratio` times likelier in the same frames.""" | |
| syms = SYMS + ["tˤ", "t"] | |
| vocab = {s: i for i, s in enumerate(syms)} | |
| V = len(syms) | |
| def row(probs): | |
| r = np.full(V, 1e-4) | |
| for sym, p in probs.items(): | |
| r[vocab[sym]] = p | |
| return r / r.sum() | |
| rows = [row({"<pad>": 0.95})] * 2 | |
| rows += [row({"tˤ": 0.07, "t": 0.07 * competitor_ratio, "<pad>": 0.8})] * 3 + [row({"<pad>": 0.95})] * 2 | |
| for ph in ("i", "n"): | |
| rows += [row({ph: 0.9})] * 3 + [row({"<pad>": 0.95})] * 2 | |
| return Scorer(lambda wav: np.log(np.array(rows)), Vocab(vocab)) | |
| def test_emphatic_needs_a_clear_win_before_it_is_flagged(): | |
| phrase = {"variants": [("طين", "tˤ i n")]} | |
| under = margin_scorer(1.8).score(None, phrase) # plain t under twice as likely: the model's doubt | |
| p = under["words"][0]["phonemes"][0] | |
| assert p["score"] < 50 and not p["flagged"] and not under["tips"] | |
| over = margin_scorer(2.5).score(None, phrase) | |
| assert over["words"][0]["phonemes"][0]["flagged"] and [t["key"] for t in over["tips"]] == ["tˤ>t"] | |
| def test_hebrew_speaker_substitutions(ar, ipa, extra, said, key, heard_letter): | |
| from app.rules import load | |
| r = custom_scorer(extra, said).score(None, {"variants": [(ar, ipa)]}) | |
| tip = next(t for t in r["tips"] if t["key"] == key) | |
| expected, heard = key.split(">") | |
| assert tip["text"] == load().check(expected, heard).feedback | |
| if heard_letter: # a sound Arabic does not have is still written in the heard line | |
| assert heard_letter in r["heard_tr"].replace("\u05b0", "").replace("\u05b7", "").replace("\u05b4", "") | |
| def test_a_sound_outside_arabic_and_hebrew_is_not_named(): | |
| # the multilingual model offers a click for ħ: reported as unclear, not as "heard as ǀ" | |
| r = custom_scorer(["ǀ"], ["w", "eː", "n", "i", "l", "ǀ", "a", "m", "aː", "m"]).score(None, TOILET) | |
| h = next(p for p in r["words"][1]["phonemes"] if p["ipa"] == "ħ") | |
| assert h["flagged"] and h["heard"] is None | |
| def test_meaning_changing_mistakes_lead_the_tips(): | |
| # w said as v (severity 2) and ħ as Hebrew ח (severity 3): ħ leads even though v scored lower | |
| r = custom_scorer(["v"], ["v", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"]).score(None, TOILET) | |
| assert [t["key"] for t in r["tips"]][:2] == ["ħ>x", "w>v"] | |
| def test_unrelated_sentence(): | |
| logp = emissions(["ʃ", "a", "l", "a", "m"]) | |
| r = scorer_for(logp).score(None, TOILET) | |
| assert r["unrelated"] and r["score"] <= 30 | |
| def test_phrase_bank_word_counts_match(p): | |
| for ar, ipa in p["variants"]: | |
| assert len(ar.split()) == len(ipa.split("|")), (ar, ipa) | |
| def test_api_end_to_end_with_stub_model(): | |
| from fastapi.testclient import TestClient | |
| from app.main import create_app | |
| logp = emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"]) | |
| app = create_app(scorer_for(logp)) | |
| # 1 s of a 220 Hz tone as a WAV upload, so ffmpeg decoding is exercised | |
| 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) | |
| with TestClient(app) as c: | |
| assert c.get("/").status_code == 200 | |
| ph = c.get("/api/phrases").json() | |
| shown = {c["id"] for c in ph["categories"]} # the phrases of no lesson are in the bank, not on the page | |
| assert len(ph["phrases"]) == sum(p["cat"] in shown for p in PHRASES) and "ipa" not in ph["phrases"][0] | |
| assert TOILET["cat"] not in shown and [l["id"] for l in ph["lessons"] if not l.get("soon")] == ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9"] | |
| assert len(ph["lessons"]) == 20 and [v["name"] for v in ph["levels"]] == ["שלב א", "שלב ב"] | |
| pid = PHRASES.index(TOILET) | |
| r = c.post("/api/score", data={"phrase_id": pid}, files={"audio": ("a.wav", buf.getvalue(), "audio/wav")}) | |
| assert r.status_code == 200, r.text | |
| assert r.json()["tips"][0]["key"] == "ħ>x" | |
| def test_anything_below_100_says_what_was_wrong(): | |
| for phones in (["w", "eː", "n", "i", "l", "ħ", "a", "m", "aː", "m"], # clean | |
| ["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"], # wrong consonant | |
| ["w", "e", "n", "i", "l", "ħ", "a", "m", "a", "m"]): # short vowels | |
| r = scorer_for(emissions(phones)).score(None, TOILET) | |
| assert r["score"] == 100 or r["unrelated"] or r["tips"] or r["issues"], phones | |
| for i in r["issues"]: | |
| assert i["word_tr"] and i["sound"] and not i["sound"].isascii() | |
| def test_dropped_glottal_stop_is_not_an_error(): | |
| # اهلا said without the initial ʔ, as natives do | |
| phrase = {"variants": [("اهلا", "ʔ a h l a")]} | |
| r = scorer_for(emissions(["a", "h", "l", "a"])).score(None, phrase) | |
| assert r["words"][0]["score"] >= 90 and r["score"] >= 90 | |
| assert not r["tips"] and not any("עצירה" in i["sound"] for i in r["issues"]) | |
| def test_one_frame_onset_spike_does_not_become_a_glottal_substitution(competitor): | |
| """A generic microphone/breath transient must not be reported as a consonant the learner produced.""" | |
| syms = SYMS + ([competitor] if competitor not in SYMS else []) | |
| vocab = {s: i for i, s in enumerate(syms)} | |
| V = len(syms) | |
| def row(sym, peak): | |
| p = np.full(V, (1 - peak) / (V - 1)) | |
| p[vocab[sym]] = peak | |
| return p | |
| # No glottal stop: one weak arbitrary-phone spike, then the clean vowel-initial word. | |
| rows = [row("<pad>", .95), row(competitor, .23)] | |
| for ph in ["a", "h", "l", "a"]: | |
| rows += [row(ph, .9)] * 3 + [row("<pad>", .95)] * 2 | |
| logp = np.log(np.array(rows)) | |
| phrase = {"variants": [("اهلا", "ʔ a h l a")]} | |
| r = Scorer(lambda wav: logp, Vocab(vocab)).score_logp(logp, phrase, diagnostics=True) | |
| onset = r["trace"]["tokens"][0] | |
| assert onset["competitor_frames"] == 1 | |
| assert onset["stages"][1]["applied"] == "glottal_dropped" | |
| assert r["words"][0]["score"] >= 90 and not r["tips"] | |
| def test_sustained_initial_consonant_is_still_a_glottal_substitution(): | |
| phrase = {"variants": [("اهلا", "ʔ a h l a")]} | |
| r = scorer_for(emissions(["s", "a", "h", "l", "a"])).score(None, phrase, diagnostics=True) | |
| onset = r["trace"]["tokens"][0] | |
| assert onset["competitor_frames"] >= 2 | |
| assert onset["stages"][1]["applied"] is None | |
| assert r["words"][0]["phonemes"][0]["heard"] == "s" | |
| assert r["tips"][0]["key"] == "ʔ>s" | |
| TAWLITNA = {"variants": [("طاولتنا", "tˤ aː w l i t n a")]} | |
| def onset_logp(lead, competitor_frames=1, target_peak=.10, competitor="j"): | |
| """طاولتنا whose first consonant (after the `lead` phones) peaks weakly, behind `competitor_frames` frames of a | |
| competitor at 23%: the shape of a breath or microphone onset in front of a word-initial stop.""" | |
| syms = ["<pad>", "tˤ", "aː", "w", "l", "i", "t", "n", "a", "j", "o"] | |
| vocab = {s: i for i, s in enumerate(syms)} | |
| def row(sym, peak): | |
| p = np.full(len(syms), (1 - peak) / (len(syms) - 1)) | |
| p[vocab[sym]] = peak | |
| return p | |
| rows = [row("<pad>", .95)] | |
| for ph in lead: | |
| rows += [row(ph, .9)] * 3 + [row("<pad>", .95)] * 2 | |
| rows += [row(competitor, .23)] * competitor_frames + [row("tˤ", target_peak), row("<pad>", .95)] | |
| for ph in ["aː", "w", "l", "i", "t", "n", "a"]: | |
| rows += [row(ph, .9)] * 3 + [row("<pad>", .95)] * 2 | |
| logp = np.log(np.array(rows)) | |
| return Scorer(lambda wav: logp, Vocab(vocab)), logp | |
| def test_one_frame_spike_before_the_first_consonant_is_not_a_substitution(): | |
| """Rule 31: tˤ said but weak at onset, outscored by one j frame (the reviewed طاولتنا recording).""" | |
| scorer, logp = onset_logp([]) | |
| r = scorer.score_logp(logp, TAWLITNA, diagnostics=True) | |
| onset = r["trace"]["tokens"][0] | |
| assert onset["competitor_frames"] == 1 and onset["gop"] < 0 | |
| assert onset["stages"][1]["applied"] == "onset_transient" | |
| assert onset["final"] >= 90 and not onset["flagged"] | |
| assert not r["tips"] and all(i["heard"] is None for i in r["issues"]) # listed as the weakest, j not named | |
| def test_two_frame_competitor_before_the_first_consonant_is_still_a_substitution(): | |
| scorer, logp = onset_logp([], competitor_frames=2) | |
| r = scorer.score_logp(logp, TAWLITNA, diagnostics=True) | |
| onset = r["trace"]["tokens"][0] | |
| assert onset["competitor_frames"] >= 2 | |
| assert onset["stages"][1]["applied"] is None | |
| assert onset["flagged"] and r["tips"][0]["key"] == "tˤ>j" | |
| def test_one_frame_spike_inside_the_phrase_is_not_an_onset_transient(): | |
| scorer, logp = onset_logp(["a"]) | |
| phrase = {"variants": [("اطاولتنا", "a tˤ aː w l i t n a")]} | |
| r = scorer.score_logp(logp, phrase, diagnostics=True) | |
| tok = next(t for t in r["trace"]["tokens"] if t["ipa"] == "tˤ") | |
| assert tok["i"] > 0 and tok["stages"][1]["applied"] != "onset_transient" | |
| assert tok["flagged"] | |
| def test_onset_transient_toggle_switches_the_exemption_off(): | |
| scorer, logp = onset_logp([]) | |
| scorer.disabled = {"onset_transient"} | |
| r = scorer.score_logp(logp, TAWLITNA, diagnostics=True) | |
| assert r["trace"]["tokens"][0]["stages"][1]["applied"] is None | |
| assert r["trace"]["tokens"][0]["flagged"] | |
| def test_ana_explicitly_accepts_the_natural_vowell_initial_form(): | |
| from app.grammar import PRONOUNS | |
| ana = next(p for p in PRONOUNS if p[0] == "ana") | |
| assert ("انا", "a n a") in ana[3] | |
| phrase = {"variants": ana[3]} | |
| r = scorer_for(emissions(["a", "n", "a"])).score(None, phrase) | |
| assert r["variant"]["ipa"] == "a n a" and r["score"] >= 90 | |
| assert not r["tips"] and not r["issues"] | |
| def test_tts_endpoint_serves_phrase_audio_and_503_without_key(): | |
| from fastapi.testclient import TestClient | |
| from app.main import create_app | |
| class FakeTTS: | |
| from app.tts import RAEF_VOICE_ID | |
| voice_id, voice_name = RAEF_VOICE_ID, "רַאאִף" | |
| def wav(self, text, voice=""): | |
| from app.arabic_diacritics import point_arabic | |
| assert text == point_arabic(*PHRASES[0]["variants"][0]) | |
| assert voice == self.voice_id | |
| return b"RIFFfake", self.voice_id | |
| with TestClient(create_app(scorer_for(emissions(["w"])), FakeTTS())) as c: | |
| r = c.get("/api/tts/0") | |
| assert r.status_code == 200 and r.headers["content-type"] == "audio/wav" and r.content == b"RIFFfake" | |
| assert r.headers["x-tts-provider"] == "sawtakarabi.ai" and r.headers["x-tts-voice-id"] == FakeTTS.voice_id | |
| from app.tts import VOICES | |
| voices = c.get("/api/phrases").json()["voices"] | |
| assert [v["name"] for v in voices] == list(VOICES) | |
| assert voices == [{"id": FakeTTS.voice_id, "name": "רַאאִף"}] | |
| assert c.get("/api/tts/9999").status_code == 404 | |
| health = c.get("/api/health").json() | |
| assert health["tts"] is True and health["rules"]["pharyngeal_h"]["detectable"] | |
| def test_spelling_variants_of_a_sound_are_not_errors(): | |
| # the multilingual vocab spells one sound several ways; the model saying t̪ / ɾ / æ for t / r / a is not a mistake | |
| syms = SYMS + ["t", "t̪", "tː", "r", "ɾ", "æ", "b"] | |
| vocab = {s: i for i, s in enumerate(syms)} | |
| V = len(syms) | |
| def row(sym, p): | |
| r = np.full(V, (1 - p) / (V - 1)) | |
| r[vocab[sym]] = p | |
| return r | |
| rows = [row("<pad>", 0.95)] * 2 | |
| for ph in ["m", "æ", "ɾ", "ħ", "æ", "b", "æ"]: # مرحبا as the model may spell it | |
| rows += [row(ph, 0.9)] * 3 + [row("<pad>", 0.95)] * 2 | |
| phrase = {"variants": [("مرحبا", "m a r ħ a b a")]} | |
| r = Scorer(lambda wav: np.log(np.array(rows)), Vocab(vocab)).score(None, phrase) | |
| assert r["score"] >= 95 and not r["unrelated"] and not r["tips"] | |
| import unicodedata | |
| assert unicodedata.normalize("NFC", r["heard_tr"]) == unicodedata.normalize("NFC", "מַרְחַבַּא") | |
| assert r["words"][0]["heard_tr"] == r["heard_tr"] | |
| def test_heard_is_reported_per_word_in_hebrew(): | |
| # ħ said as χ: the second word shows the ח' the model heard | |
| r = scorer_for(emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"])).score(None, TOILET) | |
| from app.translit import to_hebrew | |
| assert r["words"][0]["heard_tr"] == r["words"][0]["tr"] | |
| assert [w["heard_tr"] for w in r["words"]] == to_hebrew("w eː n | i l x a m m aː m") | |
| assert r["words"][1]["tr"] != r["words"][1]["heard_tr"] | |
| def test_day2_grammar_reaches_every_pronoun_and_negation(): | |
| from app.grammar import PRONOUNS, SENTENCES | |
| # day 2 has no tab on the page, so the bank is read directly, in the page's shape | |
| day2 = [{**p, "id": i, "ar": p["variants"][0][0]} for i, p in enumerate(PHRASES) if p["cat"] == "day2"] | |
| listed = [p for p in day2 if p["listed"]] | |
| assert len(listed) == len(SENTENCES) and not any(p["neg"] for p in listed) | |
| for p in listed: # each taught sentence has its negation and the other seven subjects | |
| same = [q for q in day2 if q["grp"] == p["grp"]] | |
| assert len(same) == 2 * len(PRONOUNS) | |
| neg = next(q for q in same if q["pron"] == p["pron"] and q["neg"]) | |
| assert " לא " in neg["he"] and " ما " in neg["ar"] and " מַא " in neg["tr"] | |
| she = next(p for p in day2 if p["he"] == "היא לא פתחה את הדלת ולא אכלה עם אחמד") | |
| assert she["ar"] == "هيّ ما فتحت الباب وما اكلت مع احمد" | |
| assert PHRASES[she["id"]]["variants"][0][1] == "h i j j e | m a | f a t ħ a t | i l b aː b | u m a | ʔ a k l a t | m a ʕ | ʔ a ħ m a d" | |
| def test_hebrew_prompt_audio_uses_requested_engine_and_falls_back(tmp_path, monkeypatch): | |
| from fastapi.testclient import TestClient | |
| from app import prompt_tts | |
| from app.main import create_app | |
| said = [] | |
| def broken(text): | |
| raise OSError("blocked") | |
| monkeypatch.setenv("TTS_CACHE_DIR", str(tmp_path)) | |
| monkeypatch.setitem(prompt_tts._SYNTH, "edge", lambda text: said.append(("edge", text)) or b"EDGE") | |
| monkeypatch.setitem(prompt_tts._SYNTH, "gtts", broken) | |
| pid = PHRASES.index(find("מה שלומך? (לגבר)")) | |
| with TestClient(create_app(scorer_for(emissions(["w"])))) as c: | |
| r = c.get(f"/api/prompt-audio/{pid}?engine=edge") | |
| assert r.status_code == 200 and r.content == b"EDGE" and r.headers["x-tts-engine"] == "edge" | |
| assert said == [("edge", "מה שלומך?")] # the bracketed note is not read out | |
| r = c.get(f"/api/prompt-audio/{pid}?engine=gtts") # gTTS down: served by the other engine | |
| assert r.status_code == 200 and r.headers["x-tts-engine"] == "edge" and len(said) == 1 # ... from cache | |
| assert c.get(f"/api/prompt-audio/{pid}?engine=other").status_code == 400 | |
| r = c.get("/api/say", params={"text": "ציון 82. כמעט.", "engine": "edge"}) # driving mode's spoken assessment | |
| assert r.status_code == 200 and said[-1] == ("edge", "ציון 82. כמעט.") and not list(tmp_path.rglob("*82*")) | |
| assert len(list((tmp_path / "he").glob("*.mp3"))) == 1 # ... is not cached | |
| assert c.get("/api/say", params={"text": "hello"}).status_code == 400 | |
| monkeypatch.setitem(prompt_tts._SYNTH, "edge", broken) | |
| assert c.get("/api/prompt-audio/0?engine=edge").status_code == 503 | |
| def test_tips_come_most_crucial_first(): | |
| # ħ badly wrong (χ), ʕ only swallowed into the vowel... both flagged: the worse sound leads | |
| phrase = {"variants": [("ساعة الحمام", "s aː ʕ a | i l ħ a m aː m")]} | |
| r = scorer_for(emissions(["s", "aː", "ʔ", "a", "i", "l", "χ", "a", "m", "aː", "m"])).score(None, phrase) | |
| assert len(r["tips"]) == 2 | |
| assert [t["score"] for t in r["tips"]] == sorted(t["score"] for t in r["tips"]) | |
| assert all("idx" not in t for t in r["tips"]) | |
| def test_raised_a_is_a_near_vowel(): | |
| # اكلتي said with e for a, as the voices do: not marked down | |
| phrase = {"variants": [("اكلتي", "ʔ a k a l t i")]} | |
| syms = SYMS + ["t"] | |
| vocab = {s: i for i, s in enumerate(syms)} | |
| rows = [np.full(len(syms), 0.05 / (len(syms) - 1)) for _ in range(2)] | |
| for r in rows: | |
| r[0] = 0.95 | |
| for ph in ["ʔ", "e", "k", "e", "l", "t", "i"]: | |
| for p, sym in [(0.9, ph)] * 3 + [(0.95, "<pad>")] * 2: | |
| r = np.full(len(syms), (1 - p) / (len(syms) - 1)); r[vocab[sym]] = p | |
| rows.append(r) | |
| r = Scorer(lambda wav: np.log(np.array(rows)), Vocab(vocab)).score(None, phrase) | |
| assert r["score"] >= 90 and not r["tips"] | |
| def test_article_vowel_after_a_vowel_may_be_dropped(): | |
| phrase = {"variants": [("على الحمام", "ʕ a l a | i l ħ a m m aː m")]} | |
| r = scorer_for(emissions(["ʕ", "a", "l", "a", "l", "ħ", "a", "m", "aː", "m"])).score(None, phrase) | |
| assert r["score"] >= 95 and not r["tips"] and not any(i["missing"] for i in r["issues"]) | |
| # ... but not after a consonant: وين الحمام without the i is still noted | |
| r = scorer_for(emissions(["w", "eː", "n", "l", "ħ", "a", "m", "aː", "m"])).score(None, TOILET) | |
| assert any(i["missing"] for i in r["issues"]) | |
| def test_rule_28_exempts_final_b_heard_as_p_only_before_a_voiceless_onset(): | |
| phrase = {"variants": [("حليب طارة", "ħ a l iː b | tˤ aː r e")]} | |
| extra = ["iː", "b", "p", "tˤ", "r"] | |
| phones = ["ħ", "a", "l", "iː", "p", "tˤ", "aː", "r", "e"] | |
| scorer = custom_scorer(extra, phones) | |
| result = scorer.score(None, phrase, diagnostics=True) | |
| b = next(t for t in result["trace"]["tokens"] if t["ipa"] == "b") | |
| exemption = next(stage for stage in b["stages"] if stage["stage"] == "exemption") | |
| assert b["competitor"] == "p" and exemption == { | |
| "stage": "exemption", "score": 100.0, "applied": "final_b_devoicing" | |
| } | |
| assert result["words"][0]["phonemes"][-1]["score"] == 100 | |
| scorer.set_disabled({"final_b_devoicing"}) | |
| without_rule = scorer.score(None, phrase) | |
| assert without_rule["words"][0]["phonemes"][-1]["score"] < 50 | |
| # The same model confusion before a voiced onset is not covered by the narrow contextual rule. | |
| voiced = custom_scorer(extra, ["ħ", "a", "l", "iː", "p", "d", "a", "r"]) | |
| no_exemption = voiced.score(None, {"variants": [("حليب دار", "ħ a l iː b | d a r")]}) | |
| assert no_exemption["words"][0]["phonemes"][-1]["score"] < 50 | |
| def test_rule_29_floors_a_deleted_final_short_vowel_before_a_vowel_initial_word(): | |
| phrase = {"variants": [("على إيدك", "ʕ a l a | ʔ iː d e k")]} | |
| scorer = custom_scorer(["iː"], ["ʕ", "a", "l", "ʔ", "iː", "d", "e", "k"]) | |
| result = scorer.score(None, phrase, diagnostics=True) | |
| final_a = [t for t in result["trace"]["tokens"] if t["word"] == 0][-1] | |
| exemption = next(stage for stage in final_a["stages"] if stage["stage"] == "exemption") | |
| assert final_a["ipa"] == "a" and final_a["deleted"] | |
| assert exemption == {"stage": "exemption", "score": 90.0, "applied": "word_final_vowel_linking"} | |
| scorer.set_disabled({"word_final_vowel_linking"}) | |
| without_rule = scorer.score(None, phrase, diagnostics=True) | |
| final_a = [t for t in without_rule["trace"]["tokens"] if t["word"] == 0][-1] | |
| exemption = next(stage for stage in final_a["stages"] if stage["stage"] == "exemption") | |
| assert exemption == {"stage": "exemption", "score": 90.0, "applied": "unmarked_short_vowel"} | |
| # A deleted final vowel before a consonant is not a hiatus and remains penalised. | |
| consonant = custom_scorer(["r"], ["ʕ", "a", "l", "d", "a", "r"]) | |
| no_exemption = consonant.score(None, {"variants": [("على دار", "ʕ a l a | d a r")]}, diagnostics=True) | |
| final_a = [t for t in no_exemption["trace"]["tokens"] if t["word"] == 0][-1] | |
| assert next(s for s in final_a["stages"] if s["stage"] == "exemption")["applied"] != "word_final_vowel_linking" | |
| def test_rule_30_floors_an_unmarked_short_vowel_but_not_checked_internal_u(): | |
| # The conjunction's word-initial u is not a learner check; a native u mislabelled v cannot collapse the word. | |
| scorer = custom_scorer(["u", "v"], ["a", "v", "ʕ"]) | |
| result = scorer.score(None, {"variants": [("ا و", "a | u ʕ")]}, diagnostics=True) | |
| u = next(t for t in result["trace"]["tokens"] if t["ipa"] == "u") | |
| exemption = next(stage for stage in u["stages"] if stage["stage"] == "exemption") | |
| assert u["competitor"] == "v" | |
| assert exemption == {"stage": "exemption", "score": 90.0, "applied": "unmarked_short_vowel"} | |
| assert result["words"][1]["phonemes"][0]["score"] == 90 | |
| # u after a consonant in the same word remains checked by the kubutz rule (3.11). | |
| checked = custom_scorer(["u", "v"], ["k", "v", "l"]) | |
| result = checked.score(None, {"variants": [("كل", "k u l")]}, diagnostics=True) | |
| u = next(t for t in result["trace"]["tokens"] if t["ipa"] == "u") | |
| exemption = next(stage for stage in u["stages"] if stage["stage"] == "exemption") | |
| assert exemption["applied"] is None and exemption["score"] < 50 | |
| ALA = {"variants": [("على الحمام", "ʕ a l a | i l ħ a m m aː m")]} | |
| MISHEARD = ["ʔ", "a", "l", "a", "i", "l", "ħ", "a", "m", "aː", "m"] # the model's ʕ in على: a glottal stop | |
| def test_native_reference_forgives_what_the_model_mishears(): | |
| s = scorer_for(emissions(MISHEARD)) | |
| plain = s.score(None, ALA) | |
| assert [t["key"] for t in plain["tips"]] == ["ʕ>ʔ"] and plain["words"][0]["heard_tr"] != plain["words"][0]["tr"] | |
| ref = s.reference(None, ALA) # the native voice is heard the same way | |
| r = s.score(None, ALA, [ref]) | |
| assert r["score"] == 100 and not r["tips"] and not r["issues"] and r["references"] == 1 | |
| assert r["words"][0]["heard_tr"] == r["words"][0]["tr"] | |
| def test_native_reference_keeps_real_errors(): | |
| ref = scorer_for(emissions(MISHEARD)).reference(None, ALA) | |
| # the learner also says χ for ħ, which the voice did not | |
| r = scorer_for(emissions(["ʔ", "a", "l", "a", "i", "l", "χ", "a", "m", "aː", "m"])).score(None, ALA, [ref]) | |
| assert [t["key"] for t in r["tips"]] == ["ħ>x"] and r["score"] < 85 | |
| # a reference in which the model does not recognise the phrase is not a baseline | |
| assert scorer_for(emissions(["ʃ", "e", "k", "e", "d"])).reference(None, ALA) is None | |
| assert scorer_for(emissions(["ʕ", "χ", "l", "χ", "i", "l", "χ", "a", "χ", "aː", "χ"])).reference(None, ALA) is None | |
| def test_references_use_one_generated_voice_and_any_cached_one(): | |
| from app.reference import References | |
| from app.tts import TTSUnavailable | |
| calls = [] | |
| class FakeTTS: | |
| down = False | |
| def cached(self, text, voice): | |
| return voice == "played" | |
| def wav(self, text, voice=""): | |
| calls.append(voice) | |
| if self.down: | |
| raise TTSUnavailable("down") | |
| return b"wav", voice | |
| import app.reference as reference | |
| tts, s = FakeTTS(), scorer_for(emissions(MISHEARD)) | |
| orig, reference.decode_audio = reference.decode_audio, lambda data: None | |
| try: | |
| refs = References(s, tts, ["first", "never-played", "played"]) | |
| assert len(refs.get(7, ALA)) == 2 and calls == ["first", "played"] | |
| assert len(refs.get(7, ALA)) == 2 and len(calls) == 2 # kept in memory | |
| assert refs.invalidate(7, "first") == 1 | |
| assert len(refs.get(7, ALA)) == 2 and calls == ["first", "played", "first"] | |
| tts.down = True | |
| assert refs.get(8, ALA) == [] and refs.get(8, ALA) == [] and len(calls) == 5 # failures are not retried at once | |
| finally: | |
| reference.decode_audio = orig | |
| WORD_TOPICS = lambda c: c.get("learn") or c.get("match") # a lesson's new words and their game (test_workbook_new_words) | |
| def test_workbook_lesson_1(): | |
| from app.phrases import CATEGORIES, GAMES, LESSONS, public_phrases | |
| assert [l["id"] for l in LESSONS if not l.get("soon")] == ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6", "2.7", "2.8", "2.9"] | |
| assert [c["id"] for c in CATEGORIES if c["lesson"] == "1"] == ["l1-new", "l1-pairs", "l1-pron","l1-verbs", "l1-past", "l1-say", "l1-read", "l1-hw"] | |
| assert GAMES == [{"id": "game-yalla-verb", "name": "יאללה הפועל", "lesson": "1", "bubble": True, "hidden": True, | |
| "unit": "פועל", "task": "אמרו את הפועל בערבית מדוברת: בעבר ואז בעתיד, לפני שהבועה נעלמת"}] | |
| hw = next(c for c in CATEGORIES if c["id"] == "l1-hw")["write"] # the workbook's homework: a task and five sentences to write, with nothing to say | |
| assert hw["lines"] == 5 and hw["task"].startswith("כתבו חמישה משפטים") and "מִין - מי" in hw["task"] | |
| pub = public_phrases() | |
| count = lambda cat: sum(p["cat"] == cat for p in pub) | |
| assert [count(c) for c in ("l1-pron", "l1-verbs", "game-yalla-verb", "l1-past", "l1-say", "l1-read")] == [8, 4, 4, 32, 10, 22] | |
| assert all(p["cat"] in {c["id"] for c in CATEGORIES + GAMES} for p in pub) | |
| assert all(p["read"] == (p["cat"] == "l1-read") and "#" not in p["ar"] for p in pub if p["cat"].startswith("l1-")) | |
| assert all("seq" not in p for p in pub if p["cat"] == "l1-past") # conjugations are drilled in random order | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| assert by["l1-pron", "אנחנו"]["tr"] == "אִחְנַא" | |
| assert by["l1-verbs", "ישב (עבר, עתיד)"]["tr"] == "קַעַד, יֻקְעֹד" | |
| assert by["game-yalla-verb", "ישב (עבר, עתיד)"]["tr"] == "קַעַד, יֻקְעֹד" | |
| assert by["l1-past", "היא שאלה"]["tr"] == "הִיֵّ סַאְלַת" | |
| # the prompt voice gets pointed Hebrew (פתח is otherwise read "petach"); the page shows it plain | |
| assert by["l1-verbs", "פתח (עבר, עתיד)"]["he_say"] == "פָּתַח" | |
| assert by["l1-past", "את ישבת"]["he_say"] == "אַתְּ יָשַׁבְתְּ" and by["l1-pron", "הם / הן"]["he_say"] == "הֵם" | |
| assert by["l1-past", "אתה פתחת"]["variants"] == [("انت فتحت", "ʔ i n t e | f a t a ħ t"), ("انت فتحت", "ʔ i n t a | f a t a ħ t")] | |
| p = next(p for p in PHRASES if p["cat"] == "l1-read" and p["seq"] == 11) | |
| assert p["tr"] == "אֵלְמְעַלֵّם קַעַד עַלַא אֵ(ל)טַّאוְלֵה וּסַאַל-נַא סֻאַאל." | |
| ar, ipa = p["variants"][0] | |
| assert ar.split()[-2] == "وسألنا" and ipa.split(" | ")[-2] == "u s a ʔ a l n a" | |
| assert any(i.startswith("i l m ʕ a l l i m | q a ʕ a d") for _, i in p["variants"]) # rural ق accepted | |
| def test_workbook_new_words(): | |
| from app.phrases import CATEGORIES, LESSONS, public_phrases | |
| # every lesson opens with its new words, learned before anything is said, then a game on them; lesson 7, a review, | |
| # brings too few new words for either | |
| for l in (l["id"] for l in LESSONS if not l.get("soon")): | |
| mine = [c for c in CATEGORIES if c["lesson"] == l] | |
| if l == "7": | |
| assert not any(WORD_TOPICS(c) for c in mine) | |
| continue | |
| words, pairs = mine[:2] | |
| assert (words["id"], words["name"], words["learn"]) == (f"l{l}-new", "מילים חדשות", True) | |
| assert (pairs["id"], pairs["name"], pairs["match"]) == (f"l{l}-pairs", "התאימו זוגות", f"l{l}-new") # it deals the words' cards | |
| assert not any(WORD_TOPICS(c) for c in mine[2:]) | |
| cards = [p for p in public_phrases() if p["cat"].endswith("-new")] | |
| assert all(p["listed"] is False and not p["read"] for p in cards) # no tally, daily round or teacher figure counts them | |
| assert not any(p["cat"].endswith("-pairs") for p in PHRASES) | |
| count = lambda l: sum(p["cat"] == f"l{l}-new" for p in cards) | |
| assert [count(l) for l in ("1", "2", "6", "2.8")] == [19, 20, 10, 10] and min(map(count, ["3", "4", "5", "8", "9", "10"])) >= 13 | |
| # added at the bank's end: the ids of the phrases before them, which recorded attempts name, do not move | |
| ids = [i for i, p in enumerate(PHRASES) if p["cat"].endswith("-new")] | |
| assert ids == list(range(len(PHRASES) - len(ids), len(PHRASES))) | |
| by = {(p["cat"], p["he"]): p for p in cards} | |
| assert (by["l1-new", "דלת"]["ar"], by["l1-new", "דלת"]["tr"]) == ("باب", "בַּאבּ") and by["l1-new", "מתי"]["tr"] == "אֵימְתַא" | |
| assert by["l5-new", "בבקשה"]["ar"] == "من فضلك" and by["l2.1-new", "מוקדם"]["ar"] == "بكّير" | |
| def test_levels_plan_every_lesson(): | |
| from app.phrases import CATEGORIES, LESSONS, LEVELS | |
| from app.workbook import LESSONS as WRITTEN, LEVELS as PLAN, lesson_id | |
| # every lesson names its level, and each level lists all of its planned lessons, the unwritten ones as soon | |
| assert [v["id"] for v in LEVELS] == ["1", "2"] and len(LESSONS) == 20 | |
| assert all(isinstance(key, tuple) and key[0] in PLAN for key in WRITTEN) | |
| # level 2's workbook goes on counting from level 1's: its first lesson is shown as the tenth, and keeps the id "2.1" | |
| assert [(l["level"], l["n"]) for l in LESSONS] == [("1", n) for n in range(1, 11)] + [("2", n) for n in range(10, 20)] | |
| assert [l["name"] for l in LESSONS if l["level"] == "2"][:2] == ["שיעור 10", "שיעור 11"] and LESSONS[0]["name"] == "שיעור 1" | |
| assert [l["id"] for l in LESSONS if l["level"] == "2"][:2] == ["2.1", "2.2"] # level 1 keeps "1", "2": attempts name them | |
| assert {l["id"] for l in LESSONS if not l.get("soon")} == {lesson_id(*k) for k in WRITTEN} == {c["lesson"] for c in CATEGORIES} | |
| assert lesson_id(2, 1) == "2.1" and lesson_id(1, 3) == "3" | |
| def test_workbook_lesson_2(): | |
| from app.phrases import CATEGORIES, public_phrases | |
| cats = ["l2-dual", "l2-family", "l2-poss", "l2-say", "l2-say2", "l2-read", "l2-talk", "l2-meet"] | |
| assert [c["id"] for c in CATEGORIES if c["lesson"] == "2" and not WORD_TOPICS(c)] == cats + ["l2-hw"] # the lesson ends with its written homework | |
| hw = next(c for c in CATEGORIES if c["id"] == "l2-hw")["write"] | |
| assert hw["lines"] == 5 and "פתרו את כל השיעור השני" in hw["task"] and "קַעַד / יֻקְעֹד – ישב" in hw["task"] | |
| pub = public_phrases() | |
| assert [sum(p["cat"] == c for p in pub) for c in cats] == [17, 24, 48, 23, 25, 13, 27, 24] | |
| assert all(p["read"] == (p["cat"] == "l2-read") and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2-")) | |
| assert all("seq" not in p for p in pub if p["cat"] == "l2-poss") # drilled in random order, like the conjugations | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| assert by["l2-dual", "שולחן, שני שולחנות"]["tr"] == "טַאוְלֵה, טַאוְלְתֵין" # singular and dual said together | |
| grandpa = by["l2-family", "סבא (שתי מילים)"] | |
| grandma = by["l2-family", "סבתא (שתי מילים)"] | |
| assert grandpa["variants"] == [("سيد، جدّ", "s iː d | dʒ i d d"), ("سيد", "s iː d"), ("جدّ", "dʒ i d d")] | |
| assert grandma["variants"] == [("ستّ، جدّة", "s i t t | dʒ i d d e"), ("ستّ", "s i t t"), ("جدّة", "dʒ i d d e")] | |
| # כינויי הקניין: the stem changes before a consonant suffix, and שלך is pointed for the prompt voice | |
| assert by["l2-poss", "השולחן שלי"]["tr"] == "טַאוְלְת-י" and by["l2-poss", "השולחן שלנו"]["tr"] == "טַאוְלֵת-נַא" | |
| you = by["l2-poss", "הבת שלך (לאישה)"] | |
| assert you["tr"] == "בִּנְתֵ-כּ" and you["he_say"] == "הבת שֶׁלָּךְ" and you["variants"] == [("بنتك", "b i n t i k")] | |
| assert by["l2-poss", "הכלב שלכם"]["variants"] == [("كلبكم", "k a l b k o m"), ("كلبكم", "k a l b k u")] | |
| assert ("جدّي", "s iː d i") in by["l2-poss", "הסבא שלי"]["variants"] # the other word for סבא is accepted | |
| # דוד is pointed for the prompt voice, which otherwise reads it "David"; דודה is read correctly as it is | |
| assert by["l2-family", "דוד (מצד האב)"]["he_say"] == "דּוֹד (מצד האב)" and "he_say" not in by["l2-family", "דודה (מצד האב)"] | |
| assert by["l2-say", "איפה כלבכם? הוא אצל דודי (מצד אמא)."]["he_say"] == "איפה כלבכם? הוא אצל דּוֹדִי (מצד אמא)." | |
| assert by["l2-talk", "ה' ישמור אותם!"]["he_say"] == "אלוהים ישמור אותם!" # the voice says אלוהים for ה' | |
| p = next(p for p in PHRASES if p["cat"] == "l2-talk" and p["seq"] == 2) | |
| assert p["tr"] == "וּעַלֵיכֹּם אֵ(ל)סַّלַאםֻ" and p["variants"][0][1] == "u ʕ a l eː k u m | i s s a l aː m u" | |
| p = next(p for p in PHRASES if p["cat"] == "l2-say" and p["seq"] == 22) | |
| assert p["tr"].endswith("אִמّ-י סַאְלַת-וֹ: לֵיש פַתַחְת-וֹ?") | |
| # חברי is pointed for the prompt voice, which otherwise reads it "chaverei" (my friends) | |
| assert by["l2-say", "אני ישבתי עם חברי לפני יומיים ואכלנו פיצה בסלון."]["he_say"].split()[3] == "חֲבֵרִי" | |
| assert "וַחֲבֵרִי" in by["l2-say", "מי ישב בבית השיח'? אני וחברי ישבנו בבית השיח'."]["he_say"] | |
| assert by["l2-dual", "שבוע, שבועיים"]["he_say"] == "שָׁבוּעַ, שבועיים" # not "shvua" | |
| def test_workbook_lesson_3(): | |
| from app.phrases import CATEGORIES, public_phrases | |
| cats = ["l3-verbs", "l3-past", "l3-read", "l3-say", "l3-say2", "l3-nums", "l3-numread", "l3-numsay", "l3-story"] | |
| mine = [c for c in CATEGORIES if c["lesson"] == "3" and not WORD_TOPICS(c)] | |
| assert [c["id"] for c in mine] == cats + ["l3-hw"] | |
| hw = mine[-1]["write"] | |
| assert hw["lines"] == 5 and "פתרו את כל השיעור השלישי" in hw["task"] and "נַקַל / יֻנְקֹל – עבר (דירה, עבודה)" in hw["task"] | |
| # the story is laid out like the conversation, but told in the student's own voice: it names none | |
| assert mine[-2]["story"] and "voices" not in mine[-2] and CATEGORIES[[c["id"] for c in CATEGORIES].index("l2-talk")]["voices"] == ["אמיר", "ראאף"] | |
| pub = public_phrases() | |
| assert [sum(p["cat"] == c for p in pub) for c in cats] == [4, 32, 26, 12, 14, 37, 11, 11, 10] | |
| assert all(p["read"] == (p["cat"] in ("l3-read", "l3-numread")) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l3-")) | |
| assert all("seq" not in p for p in pub if p["cat"] == "l3-past") | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| # a verb is said in the past and the future together; עבר alone would read as "past", so it names what is moved | |
| assert by["l3-verbs", "בישל (עבר, עתיד)"]["tr"] == "טַבַּח', יֻטְבֹּח'" and by["l3-verbs", "בישל (עבר, עתיד)"]["he_say"] == "בִּישֵּׁל" | |
| assert by["l3-verbs", "עבר דירה (עבר, עתיד)"]["variants"][:2] == [("نقل، ينقل", "n a ʔ a l | j u n ʔ u l"), ("نقل، ينقل", "n a ʔ a l | j u n q u l")] | |
| assert by["l3-past", "היא שחתה"]["tr"] == "הִיֵّ סַבְּחַת" and by["l3-past", "אנחנו בישלנו"]["tr"] == "אִחְנַא טַבַּחְ'נַא" | |
| # a number shows its digits; the prompt voice says the Hebrew word only | |
| assert by["l3-nums", "שבע עשרה (17)"]["tr"] == "סַבְּעְתַעַש" and by["l3-nums", "שבע עשרה (17)"]["he_say"] == "שבע עשרה" | |
| assert by["l3-nums", "עשרת אלפים (10,000)"]["tr"] == "עַשְרַת אַלַאף" and by["l3-nums", "שלוש מאות (300)"]["tr"] == "תַלַאת מִיֵّה" | |
| p = next(p for p in PHRASES if p["cat"] == "l3-read" and p["seq"] == 9) | |
| assert "הִיֵّ טַבְּחַ'ת-לוֹ מַקְלוּבֵּה טַיְّבֵּה." in p["tr"] | |
| p = next(p for p in PHRASES if p["cat"] == "l3-numsay" and p["seq"] == 6) | |
| assert p["tr"] == "עִנְד-נַא חְדַעְשַר עַםّ וּסַבְּעְתַעְשַר עַמֵّה." # 11-19 gain a ר before a noun, which stays singular | |
| p = next(p for p in PHRASES if p["cat"] == "l3-story" and p["seq"] == 1) | |
| assert p["tr"] == "צַאחִבְּת-י רוּבַּה נַקְלַת עַלַא בֵּית גְ'דִיד פִי חַארֵת-נַא." | |
| def test_workbook_lesson_4(): | |
| from app.phrases import CATEGORIES, public_phrases | |
| cats = ["l4-verbs", "l4-past", "l4-read", "l4-read2", "l4-say", "l4-say2", "l4-story", "l4-meet", "l4-meetread"] | |
| mine = [c for c in CATEGORIES if c["lesson"] == "4" and not WORD_TOPICS(c)] | |
| assert [c["id"] for c in mine] == cats + ["l4-hw"] | |
| hw = mine[-1]["write"] | |
| assert hw["lines"] == 5 and "פתרו את כל השיעור הרביעי" in hw["task"] and "אִשְתַרַא / יִשְתְרִי – קנה" in hw["task"] | |
| story = mine[cats.index("l4-story")] | |
| assert story["story"] and "voices" not in story | |
| pub = public_phrases() | |
| assert [sum(p["cat"] == c for p in pub) for c in cats] == [4, 32, 12, 14, 12, 12, 20, 18, 6] | |
| reads = ("l4-read", "l4-read2", "l4-meetread") | |
| assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l4-")) | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| # verbs that end in ا: the ا becomes ي before the endings of "I", "you" and "we" | |
| assert by["l4-verbs", "בא (עבר, עתיד)"]["tr"] == "אַגַ'א, יִגִ'י" and by["l4-verbs", "נתן (עבר, עתיד)"]["tr"] == "אַעְטַא, יֵעַטִי" | |
| assert by["l4-past", "אנחנו דיברנו"]["tr"] == "אִחְנַא חַכֵּינַא" and by["l4-past", "היא קנתה"]["tr"] == "הִיֵّ אִשְתַרַת" | |
| assert by["l4-past", "הם באו"]["variants"][0] == ("همّ اجو", "h u m m e | ʔ a dʒ u") | |
| p = next(p for p in PHRASES if p["cat"] == "l4-read" and p["seq"] == 4) | |
| assert p["tr"].startswith("גַ'אר-י אַגַ'א וּאַעַטַא-נִי תִסְעַה וּתַמַאנִין שֵיכֵּל") | |
| p = next(p for p in PHRASES if p["cat"] == "l4-story" and p["seq"] == 1) | |
| assert p["tr"] == "קַבֵּל אֻסְבּוּע אַגַ'א עַלַא חַארֵת-נַא גַ'אר גְ'דִיד." | |
| assert by["l4-story", "הוא אמר: \"ה' ישמור אותם!\""]["he_say"] == "הוא אמר: \"אלוהים ישמור אותם!\"" | |
| assert by["l4-meet", "אני רוצה קפה עם סוכר, תודה!"]["tr"] == "בִּדّ-י קַהְוֵה מַע סֻכַּّר, שֻכְּרַן!" | |
| def test_workbook_lesson_5(): | |
| from app.phrases import CATEGORIES, public_phrases | |
| # the workbook's order: more of lesson 4's story, the negation, then the new verbs and the market story | |
| cats = ["l5-meet", "l5-meetread", "l5-neg", "l5-neg2", "l5-verbs", "l5-past", "l5-read", "l5-story", "l5-aread", | |
| "l5-say", "l5-say2", "l5-say3"] | |
| mine = [c for c in CATEGORIES if c["lesson"] == "5" and not WORD_TOPICS(c)] | |
| assert [c["id"] for c in mine] == cats + ["l5-hw"] | |
| hw = mine[-1]["write"] | |
| assert hw["lines"] == 5 and "פתרו את כל השיעור החמישי" in hw["task"] and "זַאר / יִזוּר – ביקר" in hw["task"] | |
| story = mine[cats.index("l5-story")] | |
| assert story["story"] and "voices" not in story | |
| pub = public_phrases() | |
| assert [sum(p["cat"] == c for p in pub) for c in cats] == [13, 12, 13, 13, 4, 32, 23, 15, 12, 13, 11, 12] | |
| reads = ("l5-meetread", "l5-neg", "l5-neg2", "l5-read", "l5-aread") | |
| assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l5-")) | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| assert by["l5-verbs", "היה (עבר, עתיד)"]["tr"] == "כַּאן, יִכּוּן" and by["l5-past", "אנחנו היינו"]["tr"] == "אִחְנַא כֻּנַّא" | |
| assert ("انا قلت", "ʔ a n a | q u l t") in by["l5-past", "אני אמרתי"]["variants"] # rural ق accepted | |
| # the negation: مش before a noun or an adjective, ما before a verb, which may also end in ش | |
| p = next(p for p in PHRASES if p["cat"] == "l5-neg" and p["seq"] == 9) | |
| assert p["tr"].startswith("אַנַא מַא קַעַדְתֵש עִנְד גַ'אר-י") and p["he"].startswith("אני לא ישבתי") | |
| p = next(p for p in PHRASES if p["cat"] == "l5-neg2" and p["seq"] == 8) | |
| assert p["tr"].endswith("קֻלְתִ-לّוֹ: וַאללַّה מַא אִשְתַרֵינַא אִשִי!") # والله is one word, not "and" + الله | |
| p = next(p for p in PHRASES if p["cat"] == "l5-story" and p["seq"] == 5) | |
| assert p["tr"].startswith("לַמַّא וְצִלְנַא עַלַא אֵ(ל)סّוּק") and p["variants"][0][1].split(" | ")[1] == "w sˤ i l n a" | |
| assert by["l5-story", "ה' ייתן לך בריאות!"]["he_say"] == "אלוהים ייתן לך בריאות!" | |
| def test_workbook_lesson_6(): | |
| from app.phrases import CATEGORIES, public_phrases | |
| cats = ["l6-fut", "l6-futsay", "l6-verbs", "l6-past", "l6-read", "l6-read2", "l6-story", "l6-meet", "l6-meetread"] | |
| mine = [c for c in CATEGORIES if c["lesson"] == "6" and not WORD_TOPICS(c)] | |
| assert [c["id"] for c in mine] == cats + ["l6-hw"] | |
| hw = mine[-1]["write"] | |
| assert hw["lines"] == 5 and "פתרו את כל השיעור השישי" in hw["task"] and "פעם אחת בעבר ופעם אחת בעתיד" in hw["task"] | |
| story = mine[cats.index("l6-story")] | |
| assert story["story"] and story["voices"] == ["איוב", "נסרין"] | |
| pub = public_phrases() | |
| assert [sum(p["cat"] == c for p in pub) for c in cats] == [24, 13, 5, 40, 11, 13, 12, 12, 12] | |
| reads = ("l6-read", "l6-read2", "l6-meetread") | |
| assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l6-")) | |
| assert all("seq" not in p for p in pub if p["cat"] in ("l6-fut", "l6-past")) # conjugations are drilled in random order | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| # the future: the Hebrew letters אית"ן on the future's base form, and אַ for "I" whatever the base form's vowel | |
| assert by["l6-fut", "אני אשב"]["tr"] == "אַנַא אַקְעֹד" and by["l6-fut", "אנחנו נשב"]["tr"] == "אִחְנַא נֻקְעֹד" | |
| assert by["l6-fut", "את תבקרי"]["tr"] == "אִנְתִי תְזוּרִי" and by["l6-fut", "הם ישמעו"]["he_say"] == "הֵם יִשְׁמְעוּ" | |
| # فات is "entered" like lesson 3's دخل, so its card says more; اخد is the homework's fifth verb | |
| assert by["l6-verbs", "נכנס ועבר (עבר, עתיד)"]["tr"] == "פַאת, יִפוּת" and by["l6-verbs", "לקח (עבר, עתיד)"]["tr"] == "אַחַ'ד, יֹאחֵ'ד" | |
| assert by["l6-past", "אתם נכנסתם"]["tr"] == "אִנְתוּ פֻתّוּ" and by["l6-past", "אני ראיתי"]["tr"] == "אַנַא שֻפֵת" | |
| p = next(p for p in PHRASES if p["cat"] == "l6-futsay" and p["seq"] == 3) | |
| assert p["tr"] == "אִחְנַא סַבַּחְנַא פִי אֵלְבַּחַר, בֻּכְּרַא נִסְבַּח פִי אֵלְבִּרְכֵּה." | |
| # a dialogue: the speaker is written before a turn's first line but neither said nor scored; | |
| # נסרין's turns are in Hanan's voice, איוב's in Raef's | |
| lines = [p for p in PHRASES if p["cat"] == "l6-story"] | |
| assert not any(":" in p["he"] or ":" in p["variants"][0][0] for p in lines) | |
| assert [(p["seq"], p["speaker"]) for p in lines if "speaker" in p] == [(1, "איוב"), (2, "נסרין"), (3, "איוב"), (10, "נסרין")] | |
| assert [p["seq"] for p in lines if p.get("voice") == "חנאן"] == [2, 10, 11, 12] | |
| p = next(p for p in PHRASES if p["cat"] == "l6-story" and p["seq"] == 9) | |
| assert p["tr"] == "לַוְ כַּאן עִנְד-י מַרַה זַיֵّ-כּ..." and p["variants"][0][0].endswith("زيّك...") | |
| def test_workbook_lesson_7(): | |
| from app.phrases import CATEGORIES, public_phrases | |
| # a review lesson: no new verbs and no story | |
| cats = ["l7-fem", "l7-dual", "l7-nums", "l7-count", "l7-verbs", "l7-read", "l7-say", "l7-say2"] | |
| mine = [c for c in CATEGORIES if c["lesson"] == "7" and not WORD_TOPICS(c)] | |
| assert [c["id"] for c in mine] == cats + ["l7-hw"] and not any(c.get("story") for c in mine) | |
| assert mine[0]["task"] == "אמרו בערבית מדוברת: בזכר ואז בנקבה" and mine[1]["task"].endswith("ביחיד ואז בזוגי") | |
| hw = mine[-1]["write"] | |
| assert hw["lines"] == 5 and "בשיעור השביעי" in hw["task"] and "פעם אחת בעבר ופעם אחת בעתיד" in hw["task"] | |
| pub = public_phrases() | |
| assert [sum(p["cat"] == c for p in pub) for c in cats] == [8, 13, 12, 15, 21, 23, 14, 13] | |
| assert all(p["read"] == (p["cat"] == "l7-read") and "#" not in p["ar"] for p in pub if p["cat"].startswith("l7-")) | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| assert by["l7-fem", "רווק, רווקה"]["tr"] == "עַזַّאבִּי, עַזַّאבִּיֵّה" and by["l7-dual", "מיטה, שתי מיטות"]["tr"] == "תַחְ'ת, תַחְ'תֵין" | |
| assert by["l7-nums", "מאה שבעים וחמש (175)"]["tr"] == "מִיֵّה חַ'מְסֵה וּסַבְּעִין" # units before tens | |
| # 3-10: the noun in the plural and the number without its ة; from 11: the noun in the singular, and 11-19 with a ר | |
| assert by["l7-count", "שבעה ילדים"]["tr"] == "סַבְּע אַוְלַאד" and by["l7-count", "שתים עשרה בנות"]["tr"] == "תְנַעְשַר בִּנְת" | |
| # every verb of lessons 1-6, each still said in the past and the future together | |
| verbs = [p for p in PHRASES if p["cat"] == "l7-verbs"] | |
| assert [p["tr"] for p in verbs][:2] == ["פַתַח, יִפְתַח", "אַכַּל, יֹאכֹּל"] and verbs[-1]["tr"] == "פַאת, יִפוּת" | |
| assert all(" | " in p["variants"][0][1] and p["he"].endswith("(עבר, עתיד)") for p in verbs) | |
| def test_workbook_lesson_8(): | |
| from app.phrases import CATEGORIES, public_phrases | |
| cats = ["l8-howmany", "l8-verbs", "l8-past", "l8-read", "l8-read2", "l8-read3", "l8-colors", "l8-colread", "l8-story", | |
| "l8-colsay", "l8-colsay2"] | |
| mine = [c for c in CATEGORIES if c["lesson"] == "8" and not WORD_TOPICS(c)] | |
| assert [c["id"] for c in mine] == cats + ["l8-hw"] | |
| hw = mine[-1]["write"] | |
| assert hw["lines"] == 5 and "פתרו את כל השיעור השמיני" in hw["task"] and "פעלים וצבעים" in hw["task"] | |
| song = mine[cats.index("l8-story")] | |
| assert song["name"] == "שיר הצבעים" and song["story"] and "voices" not in song | |
| assert mine[cats.index("l8-colors")]["task"].endswith("בזכר ואז בנקבה") | |
| pub = public_phrases() | |
| assert [sum(p["cat"] == c for p in pub) for c in cats] == [13, 5, 40, 12, 12, 13, 17, 13, 25, 10, 12] | |
| reads = ("l8-read", "l8-read2", "l8-read3", "l8-colread") | |
| assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l8-")) | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| # גזרת פִעֵל: the first vowel is lost before the endings of "I", "you" and "we" | |
| assert by["l8-verbs", "הבין (עבר, עתיד)"]["tr"] == "פִהֵם, יִפְהַם" and by["l8-verbs", "יצא ועלה (עבר, עתיד)"]["tr"] == "טִלֵע, יִטְלַע" | |
| assert by["l8-past", "אני שתיתי"]["tr"] == "אַנַא שְרִבֵּת" and by["l8-past", "היא חזרה"]["tr"] == "הִיֵّ רִגְ'עַת" | |
| # a colour is said in the masculine and the feminine together | |
| assert by["l8-colors", "לבן, לבנה"]["tr"] == "אַבְּיַדֿ, בֵּידַֿא" and by["l8-colors", "לבן, לבנה"]["variants"][0][1] == "ʔ a b j a dˤ | b eː dˤ a" | |
| assert by["l8-colors", "אפור, אפורה (מהמילה אפר)"]["tr"] == "רַמַאדִי, רַמַאדִיֵّה" | |
| # how many: اكم and a singular noun for what is counted, قدّيش for what is not | |
| p = next(p for p in PHRASES if p["cat"] == "l8-howmany" and p["seq"] == 9) | |
| assert p["tr"].startswith("קַדֵّיש מַצַארִי בִּדّ-וֹ?") | |
| p = next(p for p in PHRASES if p["cat"] == "l8-howmany" and p["seq"] == 2) | |
| assert p["tr"].startswith("אַכַּם וַאחַד כַּאן פִי אֵ(ל)סַّיַّארַה?") | |
| assert by["l8-story", "אני בשמיים נמצא"]["tr"] == "אַנַא פִי אֵ(ל)סַّמַאִי מַוְג'וּד" | |
| def test_workbook_lesson_9(): | |
| from app.phrases import CATEGORIES, public_phrases | |
| cats = ["l9-story", "l9-aread", "l9-asay", "l9-verbs", "l9-past", "l9-want", "l9-read", "l9-read2", "l9-say", "l9-mix", | |
| "l9-mixread"] | |
| mine = [c for c in CATEGORIES if c["lesson"] == "9" and not WORD_TOPICS(c)] | |
| assert [c["id"] for c in mine] == cats + ["l9-hw"] | |
| hw = mine[-1]["write"] | |
| assert hw["lines"] == 5 and "השיעור התשיעי" in hw["task"] and "אחד בעבר ואחד בעתיד" in hw["task"] | |
| story = mine[cats.index("l9-story")] | |
| assert story["name"] == "סיפור הקפה" and story["story"] and "voices" not in story | |
| pub = public_phrases() | |
| assert [sum(p["cat"] == c for p in pub) for c in cats] == [15, 13, 12, 2, 16, 24, 13, 13, 12, 6, 6] | |
| reads = ("l9-aread", "l9-read", "l9-read2", "l9-mixread") | |
| assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l9-")) | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| assert by["l9-verbs", "הודה (עבר, עתיד)"]["tr"] == "שַכַּר, יֻשְכֹּר" | |
| # "to lunch" is one verb in Arabic: the Hebrew prompt names the meal, once | |
| assert by["l9-verbs", "אכל צהריים (עבר, עתיד)"]["tr"] == "תְעַ'דַّא, יִתְעַ'דַّא" | |
| assert by["l9-past", "אנחנו אכלנו צהריים"]["tr"] == "אִחְנַא תְעַ'דֵّינַא" | |
| # wanting: بدّ with the person's suffix, then the verb in the future (Arabic has no infinitive) | |
| assert by["l9-want", "אני רוצה לצאת"]["tr"] == "בִּדّ-י אַטְלַע" and by["l9-want", "את רוצה לצאת"]["tr"] == "בִּדֵّ-כּ תִטְלַעִי" | |
| assert by["l9-want", "הם רוצים לאכול צהריים"]["tr"] == "בִּדّ-הֹם יִתְעַ'דּוּ" | |
| assert by["l9-want", "אתה רוצה לשמוע"]["variants"][0][1] == "b i d d a k | t i s m a ʕ" | |
| p = next(p for p in PHRASES if p["cat"] == "l9-story" and p["seq"] == 8) | |
| assert p["tr"].startswith("שְרִבְּנַא קַהְוֵה סַאדַא") | |
| def test_workbook_lesson_10(): | |
| from app.phrases import CATEGORIES, public_phrases | |
| cats = ["l10-adj", "l10-compread", "l10-compread2", "l10-verbs", "l10-past", "l10-read", "l10-read2", "l10-say", "l10-say2", | |
| "l10-dem", "l10-story", "l10-phrases"] | |
| mine = [c for c in CATEGORIES if c["lesson"] == "10" and not WORD_TOPICS(c)] | |
| assert [c["id"] for c in mine] == cats + ["l10-hw"] | |
| hw = mine[-1]["write"] | |
| assert hw["lines"] == 5 and "השיעור העשירי" in hw["task"] and "אחד בעבר ואחד בעתיד" in hw["task"] | |
| story = mine[cats.index("l10-story")] | |
| assert story["name"] == "סיפור דודי סעיד" and story["story"] and "voices" not in story | |
| assert mine[0]["unit"] == "תואר" and mine[0]["task"].endswith("ערך היתרון שלו") | |
| pub = public_phrases() | |
| assert [sum(p["cat"] == c for p in pub) for c in cats] == [33, 8, 8, 5, 40, 12, 9, 12, 9, 12, 25, 19] | |
| reads = ("l10-compread", "l10-compread2", "l10-read", "l10-read2") | |
| assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l10-")) | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| # an adjective is said with its comparative (אַפְעַל) | |
| assert by["l10-adj", "גדול, יותר גדול"]["tr"] == "כְּבִּיר, אַכְּבַּר" | |
| assert by["l10-adj", "מתוק, יותר מתוק"]["tr"] == "חִלוּ, אַחְלַא" and by["l10-adj", "חשוב, יותר חשוב"]["tr"] == "מֻהִםّ, אַהַםّ" | |
| assert by["l10-adj", "טעים, יותר טעים (לאוכל בלבד)"]["tr"] == "זַאכִּי, אַזְכַּא" | |
| assert by["l10-adj", "נקי, יותר נקי"]["variants"][0][1] == "n dˤ iː f | ʔ a n dˤ a f" | |
| # גזרת פִעֵל again | |
| assert by["l10-verbs", "שיחק (עבר, עתיד)"]["tr"] == "לִעֵבּ, יִלְעַבּ" and by["l10-verbs", "הגיע (עבר, עתיד)"]["tr"] == "וִצֵל, יוֹצַל" | |
| assert by["l10-past", "אני שיחקתי"]["tr"] == "אַנַא לְעִבֵּת" and by["l10-past", "היא צחקה"]["tr"] == "הִיֵّ דִֿחְכַּת" | |
| assert by["l10-past", "אנחנו הגענו"]["tr"] == "אִחְנַא וְצִלְנַא" | |
| assert by["l10-dem", "אלה"]["tr"] == "הַדוֹל" and by["l10-dem", "הפעם הזאת"]["tr"] == "הַאדִי אֵלְמַרַّה" | |
| p = next(p for p in PHRASES if p["cat"] == "l10-story" and p["seq"] == 8) | |
| assert p["tr"].startswith("הוּّ קַאלִّ-י: וַלַא יְהִמֵّ-כּ") | |
| def test_workbook_level_2_lesson_1(): | |
| from app.phrases import CATEGORIES, public_phrases | |
| # the second workbook (שלב ב') opens with the lesson it numbers the tenth: five verbs, and the future after لازم and رح | |
| cats = ["l2.1-verbs", "l2.1-past", "l2.1-must", "l2.1-will", "l2.1-read", "l2.1-read2", "l2.1-say", "l2.1-say2", "l2.1-story"] | |
| mine = [c for c in CATEGORIES if c["lesson"] == "2.1" and not WORD_TOPICS(c)] | |
| assert [c["id"] for c in mine] == cats + ["l2.1-hw"] | |
| hw = mine[-1]["write"] | |
| assert hw["lines"] == 5 and "רַח ולַאזֵם" in hw["task"] and "רַבַּט / יֻרְבֹּט – קשר" in hw["task"] | |
| story = mine[cats.index("l2.1-story")] | |
| assert story["name"] == "סיפור המכולת" and story["story"] and "voices" not in story | |
| assert mine[0]["unit"] == "פועל" and mine[0]["task"].endswith("בעבר ואז בעתיד") | |
| pub = public_phrases() | |
| assert [sum(p["cat"] == c for p in pub) for c in cats] == [5, 40, 24, 24, 12, 11, 12, 11, 29] | |
| reads = ("l2.1-read", "l2.1-read2") | |
| assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2.1-")) | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| # a verb is said in the past and the future together | |
| assert by["l2.1-verbs", "רץ (עבר, עתיד)"]["tr"] == "רַכַּדֿ, יֻרְכֹּדֿ" and by["l2.1-verbs", "שלח (עבר, עתיד)"]["tr"] == "בַּעַת', יִבְּעַת'" | |
| assert by["l2.1-past", "אני רצתי"]["tr"] == "אַנַא רַכַּדֵֿת" and by["l2.1-past", "היא שילמה"]["tr"] == "הִיֵّ דַפְעַת" | |
| assert by["l2.1-past", "הם קשרו"]["variants"][0][1] == "h u m m e | r a b a tˤ u" | |
| # لازم and رح stay as they are; the verb after them is in the future | |
| assert by["l2.1-must", "את צריכה לראות"]["tr"] == "אִנְתִי לַאזֵם תְשוּפִי" and by["l2.1-must", "הם צריכים לקשור"]["tr"] == "הֹםّ לַאזֵם יֻרֻבְּטוּ" | |
| assert by["l2.1-will", "אני הולך לרוץ"]["tr"] == "אַנַא רַח אַרְכֹּדֿ" and by["l2.1-will", "אנחנו הולכים לשתות"]["tr"] == "אִחְנַא רַח נִשְרַבּ" | |
| p = next(p for p in PHRASES if p["cat"] == "l2.1-story" and p["seq"] == 10) | |
| assert "אֻרְבֹּט (א)לִחְמַאר מַטְרַח מַא יֵקוּל-לַّכּ צַאחְבּ-וֹ" in p["tr"] and "קְשׁוֹר" in p["he_say"] | |
| def test_workbook_level_2_lesson_2(): | |
| from app.phrases import CATEGORIES, public_phrases | |
| from app.workbook import _present_of | |
| # the workbook's eleventh lesson: the days of the week and the present, which is بـ on the future form | |
| cats = ["l2.2-days", "l2.2-pres", "l2.2-read", "l2.2-read2", "l2.2-read3", "l2.2-say", "l2.2-say2", "l2.2-story", "l2.2-fread"] | |
| mine = [c for c in CATEGORIES if c["lesson"] == "2.2" and not WORD_TOPICS(c)] | |
| assert [c["id"] for c in mine] == cats + ["l2.2-hw"] | |
| hw = mine[-1]["write"] | |
| assert hw["lines"] == 5 and "ואז בהוו'ה" in hw["task"] | |
| story = mine[cats.index("l2.2-story")] | |
| assert story["name"] == "סיפור הפלאפל" and story["story"] and "voices" not in story | |
| assert mine[0]["unit"] == "מילה" | |
| pub = public_phrases() | |
| assert [sum(p["cat"] == c for p in pub) for c in cats] == [10, 24, 8, 8, 8, 8, 8, 19, 11] | |
| reads = ("l2.2-read", "l2.2-read2", "l2.2-read3", "l2.2-fread") | |
| assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2.2-")) | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| assert by["l2.2-days", "יום שלישי"]["tr"] == "יוֹם אֵ(ל)תַّלַאתַא" and by["l2.2-days", "חודש"]["tr"] == "שַהֵר" | |
| # the prefix takes the vowel the form needs, and "we" may take مـ | |
| assert by["l2.2-pres", "אני שואל"]["tr"] == "אַנַא בַּסְאַל" and by["l2.2-pres", "אתה שואל"]["tr"] == "אִנְתֵ בְּתִסְאַל" | |
| assert by["l2.2-pres", "הוא רואה"]["tr"] == "הוּّ בִּישוּף" and by["l2.2-pres", "אנחנו רואים"]["tr"] == "אִחְנַא בִּנְשוּף" | |
| assert by["l2.2-pres", "הם שותים"]["variants"][0][1] == "h u m m e | b i ʃ r a b u" | |
| assert _present_of("منيجي") == ("מְנִיגִ'י", ["m n iː dʒ i"]) and _present_of("بيشوفني")[0] == "בִּישוּפ-נִי" | |
| assert _present_of("باكل")[0] == "בַּאכֹּל" and _present_of("بيت") is None and _present_of("بدّي") is None | |
| p = next(p for p in PHRASES if p["cat"] == "l2.2-fread" and p["seq"] == 5) | |
| assert p["tr"].endswith("וּבְּנִשְרַבּ שַאיְ מַע בַּעַדֿ.") # "and" on a present form | |
| def test_workbook_level_2_lesson_3(): | |
| from app.phrases import CATEGORIES, public_phrases | |
| # the workbook's twelfth lesson: the clock, and בניין שני (פַעַّל) beside the פִעֵל verbs it is made from | |
| cats = ["l2.3-hours", "l2.3-clock", "l2.3-talk", "l2.3-read", "l2.3-read2", "l2.3-say", "l2.3-say2", "l2.3-verbs", "l2.3-past", | |
| "l2.3-vread", "l2.3-vread2", "l2.3-vsay", "l2.3-vsay2", "l2.3-story"] | |
| mine = [c for c in CATEGORIES if c["lesson"] == "2.3" and not WORD_TOPICS(c)] | |
| assert [c["id"] for c in mine] == cats + ["l2.3-hw"] | |
| hw = mine[-1]["write"] | |
| assert hw["lines"] == 5 and "שַרַّבּ – השקה" in hw["task"] and "עבר – עתיד – הווה" in hw["task"] | |
| talk, song = mine[cats.index("l2.3-talk")], mine[cats.index("l2.3-story")] | |
| assert talk["story"] and talk["voices"] and song["story"] and "voices" not in song # a talk has two speakers, a song none | |
| assert mine[0]["unit"] == "שעה" and mine[cats.index("l2.3-verbs")]["unit"] == "פועל" | |
| pub = public_phrases() | |
| assert [sum(p["cat"] == c for p in pub) for c in cats] == [12, 15, 6, 7, 7, 9, 8, 9, 16, 12, 12, 12, 12, 14] | |
| reads = ("l2.3-read", "l2.3-read2", "l2.3-vread", "l2.3-vread2") | |
| assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2.3-")) | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| # an hour is feminine, and the minutes come after it: a quarter to ten is "ten less a quarter" | |
| assert by["l2.3-hours", "השעה שתיים"]["tr"] == "אֵ(ל)סֵّיעַה תִנְתֵין" and by["l2.3-hours", "השעה אחת עשרה"]["tr"] == "אֵ(ל)סֵّיעַה חְדַעַש" | |
| assert by["l2.3-clock", "השעה רבע לעשר"]["tr"] == "אֵ(ל)סֵّיעַה עַשַרַה אִלַّא רֻבֵּע" | |
| assert by["l2.3-clock", "השעה אחת עשרה שלושים וחמש"]["variants"][0][1] == "i s s eː ʕ a | ħ d a ʕ a ʃ | u n u sˤ sˤ | u x a m s e" | |
| # a verb is said in the past and the future together; لبس beside لبّس in the past | |
| assert by["l2.3-verbs", "הוריד (עבר, עתיד)"]["tr"] == "נַזַّל, יְנַזֵّל" and by["l2.3-verbs", "השקה (עבר, עתיד)"]["tr"] == "שַרַّבּ, יְשַרֵّבּ" | |
| assert by["l2.3-past", "היא לבשה"]["tr"] == "הִיֵّ לִבְּסַת" and by["l2.3-past", "היא הלבישה"]["tr"] == "הִיֵّ לַבַּّסַת" | |
| assert by["l2.3-past", "אנחנו הלבשנו"]["variants"][0][1] == "ʔ i ħ n a | l a b b a s n a" | |
| # the song asks (שַרֵّבּ), where the exercises tell (שַרַّבּ) | |
| first = next(p for p in PHRASES if p["cat"] == "l2.3-story" and p["seq"] == 1) | |
| assert first["tr"] == "שַרֵّבְּ-נִי קַהְוֵה פַתֵّח לִי רַאס-י!" | |
| told = next(p for p in PHRASES if p["cat"] == "l2.3-vread2" and "קפה קר" in p["he"]) | |
| assert "וּשַרַّבְּ-נִי קַהְוֵה בַּארְדֵה" in told["tr"] | |
| def test_workbook_level_2_lesson_4(): | |
| from app.phrases import CATEGORIES, public_phrases | |
| # the workbook's thirteenth lesson: five more of בניין שני, the three words for "that" (انّه, ما, الّي), and a phone call | |
| cats = ["l2.4-verbs", "l2.4-past", "l2.4-vread", "l2.4-vread2", "l2.4-vsay", "l2.4-vsay2", "l2.4-rel", "l2.4-rread", "l2.4-rread2", | |
| "l2.4-rsay", "l2.4-rsay2", "l2.4-words", "l2.4-talk", "l2.4-talk2"] | |
| mine = [c for c in CATEGORIES if c["lesson"] == "2.4" and not WORD_TOPICS(c)] | |
| assert [c["id"] for c in mine] == cats + ["l2.4-hw"] | |
| hw = mine[-1]["write"] | |
| assert hw["lines"] == 5 and "פַכַּّר / יְפַכֵּّר – חשב" in hw["task"] | |
| assert all(mine[cats.index(c)]["story"] and mine[cats.index(c)]["voices"] for c in ("l2.4-talk", "l2.4-talk2")) # two speakers each | |
| assert mine[0]["unit"] == "פועל" | |
| pub = public_phrases() | |
| assert [sum(p["cat"] == c for p in pub) for c in cats] == [5, 16, 13, 13, 13, 13, 14, 7, 7, 7, 7, 12, 14, 11] | |
| reads = ("l2.4-vread", "l2.4-vread2", "l2.4-rread", "l2.4-rread2") | |
| assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2.4-")) | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| assert by["l2.4-verbs", "שר (עבר, עתיד)"]["tr"] == "עַ'נַّא, יְעַ'נִّי" and by["l2.4-verbs", "חשב (עבר, עתיד)"]["tr"] == "פַכַּّר, יְפַכֵּّר" | |
| # غنّى ends in a vowel: its past takes ـيت, and "she" and "they" drop it | |
| assert by["l2.4-past", "אני שרתי"]["tr"] == "אַנַא עַ'נֵّית" and by["l2.4-past", "היא שרה"]["tr"] == "הִיֵّ עַ'נַّת" | |
| assert by["l2.4-past", "הם חשבו"]["variants"][0][1] == "h u m m e | f a k k a r u" | |
| assert by["l2.4-rel", "הילד שהלך"]["tr"] == "אֵלְוַלַד אִלִّי רַאח" and by["l2.4-rel", "לפני שאצא מהבית שלו"]["tr"] == "קַבֵּל מַא אַטְלַע מִן בֵּית-וֹ" | |
| # the present of a פַעַّל verb: בִּי on "he", בַּ on "I" | |
| son = next(p for p in PHRASES if p["cat"] == "l2.4-rread" and "עברתי לבית חדש" in p["he"]) | |
| assert "אִבְּנ-י בִּיפַכֵּّר יֻנְקֹל" in son["tr"] | |
| call = next(p for p in PHRASES if p["cat"] == "l2.4-talk2" and p["seq"] == 7) | |
| assert call["tr"].startswith("טַבַּעַן בַּחַ'בְּّרַ-כּ") and "אוֹתְךָ" in call["he_say"] | |
| lunch = next(p for p in PHRASES if p["cat"] == "l2.4-rread" and "לבדך" in p["he"]) | |
| assert "כֻּנֵת בַּתְעַ'דַّא מַעַ-כּ" in lunch["tr"] # "I", where بـ on تتغدّى would be "you" | |
| def test_workbook_level_2_lesson_5(): | |
| from app.phrases import CATEGORIES, public_phrases | |
| # the workbook's fourteenth lesson: גזרת ע"י, what صار does, and the garage story | |
| cats = ["l2.5-verbs", "l2.5-past", "l2.5-pres", "l2.5-sar", "l2.5-vread", "l2.5-vread2", "l2.5-vsay", "l2.5-words", "l2.5-story", | |
| "l2.5-ask", "l2.5-sread", "l2.5-ssay", "l2.5-adj"] | |
| mine = [c for c in CATEGORIES if c["lesson"] == "2.5" and not WORD_TOPICS(c)] | |
| assert [c["id"] for c in mine] == cats + ["l2.5-hw"] | |
| hw = mine[-1]["write"] | |
| assert hw["lines"] == 5 and "צַאר / יְצִיר – נהיה" in hw["task"] | |
| story = mine[cats.index("l2.5-story")] | |
| assert story["name"] == "סיפור המוסך" and story["story"] and "voices" not in story | |
| assert mine[0]["unit"] == "פועל" | |
| pub = public_phrases() | |
| assert [sum(p["cat"] == c for p in pub) for c in cats] == [6, 16, 16, 10, 8, 7, 7, 12, 14, 10, 12, 12, 10] | |
| reads = ("l2.5-vread", "l2.5-vread2", "l2.5-ask", "l2.5-sread") | |
| assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2.5-")) | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| # the past's ا is the future's ي, and the past of "I" loses it: גַ'אבּ, יְגִ'יבּ, גִ'בֵּת | |
| assert by["l2.5-verbs", "הביא (עבר, עתיד)"]["tr"] == "גַ'אבּ, יְגִ'יבּ" and by["l2.5-verbs", "חיכה (עבר, עתיד)"]["tr"] == "אִסְתַנַّא, יִסְתַנַّא" | |
| assert by["l2.5-past", "אני הבאתי"]["tr"] == "אַנַא גִ'בֵּת" and by["l2.5-past", "היא נהיתה"]["tr"] == "הִיֵّ צַארַת" | |
| assert by["l2.5-pres", "הוא מביא"]["tr"] == "הוּّ בִּיגִ'יבּ" and by["l2.5-pres", "אנחנו נהיים"]["variants"][0][1] == "ʔ i ħ n a | b i n sˤ iː r" | |
| assert by["l2.5-sar", "אתה כבר חצי שעה פה?"]["tr"] == "צַאר-לַכּ נֻצّ סֵיעַה הוֹן?" | |
| # استنّى is the past of "he"; the present of "I" is made from the future, which is written the same | |
| line = next(p for p in PHRASES if p["cat"] == "l2.5-story" and p["seq"] == 7) | |
| assert "צַאר-לִי נֻצّ סֵיעַה בַּסְתַנַّא-כּ הוֹן!" in line["tr"] and "לְךָ" in line["he_say"] | |
| assert next(p for p in PHRASES if p["cat"] == "l2.5-story" and p["seq"] == 8)["tr"].startswith("טַיֵّבּ וֵינַ-כּ חַבִּיבּ-י?") | |
| assert by["l2.5-adj", "גדול, גדולה, גדולים"]["tr"] == "כְּבִּיר, כְּבִּירֵה, כְּבַּאר" | |
| def test_workbook_level_2_lesson_6(): | |
| from app.phrases import CATEGORIES, public_phrases | |
| # the workbook's fifteenth lesson: בניין שלישי, the old jar's story, and "him" after a vowel | |
| cats = ["l2.6-verbs", "l2.6-past", "l2.6-pres", "l2.6-vread", "l2.6-vread2", "l2.6-vread3", "l2.6-vsay", "l2.6-words", "l2.6-story", | |
| "l2.6-ask", "l2.6-him"] | |
| mine = [c for c in CATEGORIES if c["lesson"] == "2.6" and not WORD_TOPICS(c)] | |
| assert [c["id"] for c in mine] == cats + ["l2.6-hw"] | |
| hw = mine[-1]["write"] | |
| assert hw["lines"] == 5 and "לַאקַא / יְלַאקִי – מצא" in hw["task"] | |
| story = mine[cats.index("l2.6-story")] | |
| assert story["name"] == "סיפור הכד העתיק" and story["story"] and "voices" not in story | |
| assert mine[0]["unit"] == "פועל" | |
| pub = public_phrases() | |
| assert [sum(p["cat"] == c for p in pub) for c in cats] == [5, 16, 16, 8, 8, 7, 7, 14, 20, 10, 12] | |
| reads = ("l2.6-vread", "l2.6-vread2", "l2.6-vread3", "l2.6-ask") | |
| assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2.6-")) | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| assert by["l2.6-verbs", "עזר (עבר, עתיד)"]["tr"] == "סַאעַד, יְסַאעֵד" and by["l2.6-verbs", "מצא, פגש (עבר, עתיד)"]["tr"] == "לַאקַא, יְלַאקִי" | |
| assert by["l2.6-past", "אני מצאתי"]["tr"] == "אַנַא לַאקֵית" and by["l2.6-past", "היא עזרה"]["tr"] == "הִיֵّ סַאעַדַת" | |
| assert by["l2.6-pres", "הוא עוזר"]["tr"] == "הוּّ בִּיסַאעֵד" and by["l2.6-pres", "אנחנו מוצאים"]["variants"][0][1] == "ʔ i ħ n a | b i n l aː ʔ i" | |
| # after a vowel "him" is a ه that is hardly heard, and "her" is whole | |
| assert by["l2.6-him", "אנחנו פתחנו אותו"]["tr"] == "אִחְנַא פַתַחְנַא-ה" and by["l2.6-him", "אנחנו פתחנו אותה"]["tr"] == "אִחְנַא פַתַחְנַא-הַא" | |
| assert by["l2.6-him", "ה' ישמור אותו"]["variants"][0][1] == "ʔ a l l a | j x a l l iː h" | |
| line = next(p for p in PHRASES if p["cat"] == "l2.6-story" and p["seq"] == 8) | |
| assert "תַעַאל סַאעֵד-נִי וִחְיַאתַ-כּ" in line["tr"] and "בְּחַיֶּיךָ" in line["he_say"] # a request: סַאעֵד, not the past's סַאעַד | |
| def test_workbook_level_2_lesson_7(): | |
| from app.phrases import CATEGORIES, public_phrases | |
| # the workbook's sixteenth lesson: the imperative, more of פִעֵל, and Yusef's bus | |
| cats = ["l2.7-imp", "l2.7-verbs", "l2.7-past", "l2.7-pres", "l2.7-ex", "l2.7-fill", "l2.7-fill2", "l2.7-isay", "l2.7-words", "l2.7-pre", | |
| "l2.7-story", "l2.7-ask", "l2.7-sread", "l2.7-ssay"] | |
| mine = [c for c in CATEGORIES if c["lesson"] == "2.7" and not WORD_TOPICS(c)] | |
| assert [c["id"] for c in mine] == cats + ["l2.7-hw"] | |
| hw = mine[-1]["write"] | |
| assert hw["lines"] == 5 and "ואז בציווי" in hw["task"] and "וִקֵע / יוֹקַע – נפל" in hw["task"] | |
| story = mine[cats.index("l2.7-story")] | |
| assert story["name"] == "סיפור יוסף והאוטובוס" and story["story"] and "voices" not in story | |
| assert mine[cats.index("l2.7-verbs")]["unit"] == "פועל" | |
| pub = public_phrases() | |
| assert [sum(p["cat"] == c for p in pub) for c in cats] == [19, 9, 16, 16, 5, 6, 6, 7, 14, 8, 17, 10, 9, 12] | |
| reads = ("l2.7-fill", "l2.7-fill2", "l2.7-ask", "l2.7-sread") | |
| assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2.7-")) | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| # the future without its first letter; two consonants at the start take a helping ا with the future's vowel | |
| assert by["l2.7-imp", "חשוב!"]["tr"] == "פַכֵּّר!" and by["l2.7-imp", "שתה!"]["tr"] == "אִשְרַבּ!" and by["l2.7-imp", "שב!"]["tr"] == "אֻקְעֹד!" | |
| assert by["l2.7-imp", "קח! קחי! קחו!"]["variants"][0][1] == "x u d | x u d i | x u d u" | |
| assert by["l2.7-imp", "אל תשתה קולה ואל תשתה זירו"]["tr"] == "לַא תִשְרַבּ כּוֹלַה וּמַא תִשְרַבּ זִירוֹ" # "don't" is the future | |
| assert by["l2.7-verbs", "שכח (עבר, עתיד)"]["tr"] == "נִסִי, יִנְסַא" and by["l2.7-verbs", "נפל (עבר, עתיד)"]["tr"] == "וִקֵע, יוֹקַע" | |
| assert by["l2.7-past", "אני שכחתי"]["tr"] == "אַנַא נְסִית" and by["l2.7-past", "היא כעסה"]["tr"] == "הִיֵّ זִעְלַת" | |
| assert by["l2.7-pres", "הוא רץ"]["tr"] == "הוּّ בְּיֻרְכֹּדֿ" and by["l2.7-pres", "הוא כועס"]["tr"] == "הוּّ בִּיזְעַל" | |
| line = next(p for p in PHRASES if p["cat"] == "l2.7-story" and p["seq"] == 12) | |
| assert line["tr"].startswith("יַא שַבַּאבּ, סַאעְדוּ-נִי, וַאללַّה וְקִעֵת") | |
| go = next(p for p in PHRASES if p["cat"] == "l2.7-fill" and "לכי" in p["he"]) | |
| assert "יַאללַّה רוּחִי וּבַּעַדֵין תַעַאלִי" in go["tr"] | |
| def test_workbook_level_2_lesson_8(): | |
| from app.phrases import CATEGORIES, public_phrases | |
| # the workbook's seventeenth lesson: גזרת הכפולים, عم for what is happening now, and the song شاطر شاطر | |
| cats = ["l2.8-verbs", "l2.8-past", "l2.8-pres", "l2.8-vread", "l2.8-vread2", "l2.8-vsay", "l2.8-vsay2", "l2.8-am", "l2.8-amsay", | |
| "l2.8-amsay2", "l2.8-words", "l2.8-story"] | |
| mine = [c for c in CATEGORIES if c["lesson"] == "2.8" and not WORD_TOPICS(c)] | |
| assert [c["id"] for c in mine] == cats + ["l2.8-hw"] | |
| hw = mine[-1]["write"] | |
| assert hw["lines"] == 5 and "דַֿלّ / יְדַֿלّ – נשאר" in hw["task"] and "ורק אז בציווי" in hw["task"] | |
| song = mine[cats.index("l2.8-story")] | |
| assert song["name"] == "השיר: שַאטֵר שַאטֵר" and song["story"] and "voices" not in song | |
| assert mine[0]["unit"] == "פועל" | |
| pub = public_phrases() | |
| assert [sum(p["cat"] == c for p in pub) for c in cats] == [7, 16, 16, 10, 9, 10, 9, 9, 9, 8, 12, 10] | |
| reads = ("l2.8-vread", "l2.8-vread2") | |
| assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2.8-")) | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| assert by["l2.8-verbs", "שם, הניח (עבר, עתיד)"]["tr"] == "חַטّ, יְחֻטّ" and by["l2.8-verbs", "נשאר, המשיך (עבר, עתיד)"]["tr"] == "דַֿלّ, יְדַֿלّ" | |
| # "I" and "you" take ـيت in the past, "she" and "they" only their ending | |
| assert by["l2.8-past", "אני נשארתי"]["tr"] == "אַנַא דַֿלֵّית" and by["l2.8-past", "היא נשארה"]["tr"] == "הִיֵّ דַֿלַّת" | |
| assert by["l2.8-past", "הם שמו"]["variants"][0][1] == "h u m m e | ħ a tˤ tˤ u" | |
| assert by["l2.8-pres", "אתה נשאר"]["tr"] == "אִנְתֵ בִּתְדַֿלّ" and by["l2.8-pres", "אנחנו אוהבים"]["tr"] == "אִחְנַא בִּנְחִבّ" | |
| assert by["l2.8-am", "אני מדבר איתך כעת"]["tr"] == "אַנַא עַםְ בַּחְכִּי מַעַ-כּ" and by["l2.8-am", "אני עובד כעת"]["tr"] == "אַנַא עַםְ בַּשְתְעֵ'ל" | |
| # a request (חֻטّ) beside the past (חַטّ), and the song's "we": מִנְ before the future | |
| order = next(p for p in PHRASES if p["cat"] == "l2.8-vread" and "צ'יפס" in p["he"]) | |
| assert "חֻטّ-לִי כַּמַאן טְחִינֵה" in order["tr"] and "הוּّ חַטّ-לִי זַיّ מַא" in order["tr"] | |
| assert next(p for p in PHRASES if p["cat"] == "l2.8-story" and p["seq"] == 6)["tr"] == "מִנְחִבّ-וֹ וּמִנְדַֿלّ מִנְקוּל-לّוֹ שַאטֵר שַאטֵר" | |
| def test_workbook_level_2_lesson_9(): | |
| from app.phrases import CATEGORIES, public_phrases | |
| # the workbook's eighteenth and last lesson: the active participle, a song from Egypt, and the father, the son and the donkey | |
| cats = ["l2.9-part", "l2.9-part2", "l2.9-verbs", "l2.9-ex", "l2.9-pread", "l2.9-pread2", "l2.9-psay", "l2.9-psay2", "l2.9-psay3", | |
| "l2.9-words", "l2.9-song", "l2.9-story"] | |
| mine = [c for c in CATEGORIES if c["lesson"] == "2.9" and not WORD_TOPICS(c)] | |
| assert [c["id"] for c in mine] == cats + ["l2.9-hw"] | |
| hw = mine[-1]["write"] | |
| assert hw["lines"] == 5 and "בבינוני הפועל" in hw["task"] and "עַאש / יְעִיש – חי" in hw["task"] | |
| for c in ("l2.9-song", "l2.9-story"): | |
| assert mine[cats.index(c)]["story"] and "voices" not in mine[cats.index(c)] | |
| assert mine[cats.index("l2.9-verbs")]["unit"] == "פועל" | |
| pub = public_phrases() | |
| assert [sum(p["cat"] == c for p in pub) for c in cats] == [8, 16, 5, 17, 10, 10, 10, 9, 9, 14, 8, 14] | |
| reads = ("l2.9-pread", "l2.9-pread2") | |
| assert all(p["read"] == (p["cat"] in reads) and "#" not in p["ar"] for p in pub if p["cat"].startswith("l2.9-")) | |
| by = {(p["cat"], p["he"]): p for p in PHRASES} | |
| # the participle's four forms, and where the middle letter is weak a י comes in (رايح, نايم) | |
| assert by["l2.9-part", "גר, גרה, גרים, גרות"]["tr"] == "סַאכֵּן, סַאכְּנֵה, סַאכְּנִין, סַאכְּנַאת" | |
| assert by["l2.9-part", "ישן, ישנה, ישנים, ישנות"]["tr"] == "נַאיֵם, נַאיְמֵה, נַאיְמִין, נַאיְמַאת" | |
| assert by["l2.9-part2", "ישב – יושב"]["tr"] == "קַעַד, קַאעֵד" and by["l2.9-part2", "אהב, רצה – אוהב, רוצה"]["tr"] == "חַבّ, חַאבֵּבּ" | |
| assert by["l2.9-verbs", "ישן (עבר, עתיד)"]["tr"] == "נַאם, יְנַאם" and by["l2.9-verbs", "חי (עבר, עתיד)"]["tr"] == "עַאש, יְעִיש" | |
| # the habit is the present (בַּרוּח), this moment the participle (רַאיֵח) | |
| assert by["l2.9-ex", "כל יום שני אני הולך לחבר שלי"]["tr"].endswith("אַנַא בַּרוּח לַעִנְד צַאחְבּ-י") | |
| assert by["l2.9-ex", "אני הולך לחבר שלי עכשיו"]["tr"] == "אַנַא רַאיֵח לַעִנְד צַאחְבּ-י הַלַّק" | |
| song = [p for p in PHRASES if p["cat"] == "l2.9-song"] | |
| assert song[2]["tr"].endswith("עַלַא בֻּעְדַ-כּ") and song[3]["tr"].endswith("חַבִּיבּ בַּעְדַ-כּ") # your distance, and after you | |
| assert "מַא בֵּינַא זַמַאן" in song[5]["tr"] | |
| last = next(p for p in PHRASES if p["cat"] == "l2.9-story" and p["seq"] == 14) | |
| assert last["tr"].startswith("אִנְתֵ עַאיֵש וִלַّא מַאיֵת") and "בְּךָ" in last["he_say"] | |
| def test_sign_in_guards_the_practice_endpoints(): | |
| from fastapi.testclient import TestClient | |
| from app.main import create_app | |
| VERB = next(p for p in PHRASES if p["cat"] == "l1-verbs") | |
| class FakeAuth: | |
| url, key, teachers, tutors = "https://proj.supabase.co", "sb_publishable_x", frozenset(), frozenset() | |
| def __init__(self): | |
| self.rows, self.cls, self.vids, self.txts, self.days, self.wrote = [], [], [], [], {"dailies": [], "library": []}, {} | |
| self.rank = frozenset() | |
| def writings(self, token): | |
| return {t: lines for (u, t), lines in self.wrote.items() if u == "u1"} | |
| def save_writing(self, token, user_id, topic, lines): | |
| self.wrote[user_id, topic] = lines | |
| def all_writings(self): | |
| return [{"user_id": u, "topic": t, "lines": lines} for (u, t), lines in self.wrote.items()] | |
| def classes(self, fresh=False): | |
| return self.cls | |
| def ranked(self, fresh=False): | |
| return self.rank | |
| def set_tutor(self, email, on, by): | |
| if self.rank is None: | |
| raise AuthUnavailable("supabase answered 404: no table") | |
| self.rank = self.rank | {email} if on else self.rank - {email} | |
| self.by = by | |
| def save_class(self, id, name, emails, nicknames, lessons, homeworks=(), dailies=None, subject=""): | |
| row = {"name": name, "emails": emails, "nicknames": nicknames, "lessons": lessons, "homeworks": list(homeworks), | |
| **({} if dailies is None else {"dailies": list(dailies)}), **({"subject": subject} if id is None and subject else {})} | |
| if id is None: | |
| self.cls.insert(0, {"id": len(self.cls) + 1, **row}) | |
| elif c := next((c for c in self.cls if c["id"] == id), None): | |
| c.update(row) | |
| else: | |
| raise KeyError(id) | |
| def delete_class(self, id): | |
| self.cls = [c for c in self.cls if c["id"] != id] | |
| def videos(self, fresh=False): | |
| return self.vids | |
| def level_videos(self, table, fresh=False): | |
| return self.days[table] | |
| def add_level_video(self, table, level, title, url): | |
| self.days[table].append({"id": len(self.days[table]) + 1, "level": level, "title": title, "url": url}) | |
| def delete_level_video(self, table, id): | |
| self.days[table] = [v for v in self.days[table] if v["id"] != id] | |
| def rename_level_video(self, table, id, title): | |
| next(v for v in self.days[table] if v["id"] == id)["title"] = title | |
| def order_level_videos(self, table, ids): | |
| self.days[table].sort(key=lambda v: ids.index(v["id"])) | |
| def add_video(self, lesson, title, url, pos, ord): | |
| self.vids.append({"id": len(self.vids) + 1, "lesson": lesson, "title": title, "url": url, "pos": pos, "ord": ord}) | |
| def place(self, places): | |
| for table, id, pos, ord in places: | |
| next(v for v in {"videos": self.vids, "texts": self.txts}[table] if v["id"] == id).update(pos=pos, ord=ord) | |
| def texts(self, fresh=False): | |
| return self.txts | |
| def add_text(self, lesson, title, voices, lines, pos, ord): | |
| self.txts.append({"id": len(self.txts) + 1, "lesson": lesson, "title": title, "voices": voices, "lines": lines, "pos": pos, "ord": ord}) | |
| def delete_text(self, id): | |
| self.txts = [t for t in self.txts if t["id"] != id] | |
| def delete_video(self, id): | |
| self.vids = [v for v in self.vids if v["id"] != id] | |
| def user(self, token): | |
| return {"id": "u1", "email": "A@b.c"} if token == "good" else None | |
| def record(self, token, row): | |
| self.rows.append((token, row)) | |
| def attempts(self, token): # two tries of one sentence on one Israeli day (across midnight UTC), one unlisted sentence | |
| return [{"cat": VERB["cat"], "phrase": VERB["he"], "score": 62, "created_at": "2026-10-08T21:30:00.5+00:00"}, | |
| {"cat": VERB["cat"], "phrase": VERB["he"], "score": 88, "created_at": "2026-10-09T10:00:00+00:00"}, | |
| {"cat": TOILET["cat"], "phrase": TOILET["he"], "score": 90, "created_at": "2026-10-01T10:00:00.123456+00:00"}] | |
| def users(self): # the teacher, a student, and an account that is not on the list | |
| return [{"id": "u1", "email": "A@b.c", "name": "Alef"}, {"id": "u2", "email": "s@b.c", "name": ""}, | |
| {"id": "u3", "email": "out@b.c", "name": ""}] | |
| def all_attempts(self): | |
| return [{**r, "user_id": "u2"} for r in self.attempts("")[:2]] | |
| def activity(self, hours=24, bucket_minutes=15): | |
| self.activity_args = hours, bucket_minutes | |
| return [{"bucket": "2026-10-13T10:00:00+00:00", "attempts": 4, "active_students": 2}] | |
| def all_attempt_issues(self): | |
| return [] | |
| with TestClient(create_app(scorer_for(emissions(["w"])), auth=FakeAuth())) as c: | |
| page = c.get("/").text | |
| assert 'AUTH_URL="https://proj.supabase.co"' in page and 'AUTH_KEY="sb_publishable_x"' in page | |
| assert c.get("/api/phrases").status_code == 200 # the phrase list stays public | |
| assert c.get("/api/prompt-audio/0").status_code == 401 | |
| assert c.get("/api/tts/0", headers={"Authorization": "Bearer bad"}).status_code == 401 | |
| assert c.get("/api/tts/0", headers={"Authorization": "Bearer good"}).status_code == 503 # in, but no TTS key | |
| good = {"Authorization": "Bearer good"} | |
| from app.phrases import CATEGORIES, LESSONS | |
| ALL = [l["id"] for l in LESSONS] | |
| NO_HW = {"homework": "", "due": None, "homeworks": [], "dailies": []} | |
| T1, T2 = (next(k["id"] for k in CATEGORIES if k["lesson"] == l) for l in ALL[:2]) # a topic of each of the first two lessons | |
| # written homework is kept for the student, a topic's lines in place of the ones before, and read back anywhere | |
| assert c.get("/api/writing").status_code == 401 and c.get("/api/writing", headers=good).json() == {"writing": {}} | |
| put = lambda topic, lines: c.put(f"/api/writing/{topic}", json={"lines": lines}, headers=good).status_code | |
| assert put("l1-hw", ["אַנַא אַכַּלֵת ", ""]) == 200 and put("l1-hw", ["אַנַא אַכַּלֵת", "", "הִיֵّ פַתְחַת"]) == 200 | |
| assert c.get("/api/writing", headers=good).json() == {"writing": {"l1-hw": ["אַנַא אַכַּלֵת", "", "הִיֵّ פַתְחַת"]}} | |
| assert put(T1, ["x"]) == 404 and put("l1-hw", ["x"] * 6) == 400 and put("l1-hw", ["x" * 301]) == 400 and put("l1-hw", "x") == 400 | |
| assert c.put("/api/writing/l1-hw", json={"lines": ["x"]}).status_code == 401 | |
| # no class yet: any account is let in, to every lesson | |
| assert c.get("/api/me", headers=good).json() == {"email": "A@b.c", "teacher": False, "classes": [], "lessons": ALL} | |
| assert c.get("/api/class").status_code == 401 and c.get("/api/class", headers=good).status_code == 403 | |
| assert c.post("/api/classes", json={"name": "x", "emails": "", "lessons": ALL}, headers=good).status_code == 403 # teachers only | |
| assert c.get("/api/classmates", headers=good).json() == {"classes": []} | |
| beginners = {"id": 1, "name": "Beginners", "emails": ["other@b.c"], "nicknames": {}, "lessons": ALL[:1]} | |
| c.app.state.auth.cls = [beginners] # a class without this account | |
| assert c.get("/api/me", headers=good).status_code == 403 and c.get("/api/tts/0", headers=good).status_code == 403 | |
| beginners["emails"].append("a@b.c") # case does not matter; the student studies their class's lessons | |
| assert c.get("/api/me", headers=good).json() == {"email": "A@b.c", "teacher": False, "lessons": ALL[:1], | |
| "classes": [{"name": "Beginners", "lessons": ALL[:1], **NO_HW}]} | |
| # the class's homework reaches its students: a topic of its lessons, and the day it is due | |
| beginners.update(homework=T1, due="2026-10-15") | |
| assert c.get("/api/me", headers=good).json()["classes"] == [{"name": "Beginners", "lessons": ALL[:1], "homework": T1, "due": "2026-10-15", | |
| "homeworks": [{"topic": T1, "due": "2026-10-15"}], "dailies": []}] | |
| beginners.update(homework=T2) # a topic of a lesson the class does not study is no homework | |
| assert c.get("/api/me", headers=good).json()["classes"] == [{"name": "Beginners", "lessons": ALL[:1], **NO_HW}] | |
| del beginners["homework"], beginners["due"] | |
| # in a second class too: the lessons of both, in the course's order | |
| c.app.state.auth.cls = [{"id": 2, "name": "Advanced", "emails": ["a@b.c"], "nicknames": {}, "lessons": ALL[1:]}, beginners] | |
| me = c.get("/api/me", headers=good).json() # each class with its own lessons: the student chooses one to practise in | |
| assert me["lessons"] == ALL and me["classes"] == [{"name": "Advanced", "lessons": ALL[1:], **NO_HW}, {"name": "Beginners", "lessons": ALL[:1], **NO_HW}] | |
| c.app.state.auth.cls = [{**beginners, "lessons": []}] # a class saved before classes had lessons has every one | |
| assert c.get("/api/me", headers=good).json()["lessons"] == ALL | |
| # a teacher is let in without a class, studies every lesson, and opens classes in the panel | |
| c.app.state.auth.cls, c.app.state.auth.teachers = [], frozenset({"a@b.c"}) | |
| assert c.post("/api/classes", json={"name": "September 2026", "emails": "x@b.c", "lessons": ALL[:1]}, | |
| headers=good).status_code == 200 | |
| assert c.get("/api/me", headers=good).json() == {"email": "A@b.c", "teacher": True, "tutor": True, "classes": [], "lessons": ALL, "dailies": []} | |
| activity = c.get("/api/activity?hours=12&bucket_minutes=30", headers=good) | |
| assert activity.json() == {"hours": 12, "bucket_minutes": 30, "buckets": [ | |
| {"bucket": "2026-10-13T10:00:00+00:00", "attempts": 4, "active_students": 2}]} | |
| assert c.app.state.auth.activity_args == (12, 30) | |
| assert c.get("/api/activity?hours=0", headers=good).status_code == 422 | |
| # the content part: a teacher adds a video to a ready lesson by its Vimeo link, kept without the sharing parameters | |
| READY = [l["id"] for l in LESSONS if not l.get("soon")] | |
| SOON = next(l["id"] for l in LESSONS if l.get("soon")) | |
| link = "https://vimeo.com/1233611297/d46ac373c8?share=copy&fl=sv&fe=ci" | |
| assert c.get("/api/videos").status_code == 401 and c.get("/api/videos", headers=good).json() == {"videos": []} | |
| for body, code in (({"lesson": READY[0], "title": " פתיחה לשיעור ", "url": link}, 200), | |
| ({"lesson": READY[1], "title": "public", "url": "vimeo.com/76979871"}, 200), | |
| ({"lesson": READY[0], "title": "", "url": link}, 400), ({"lesson": READY[0], "title": "x" * 81, "url": link}, 400), | |
| ({"lesson": SOON, "title": "x", "url": link}, 400), ({"lesson": "no-such-lesson", "title": "x", "url": link}, 400), | |
| ({"lesson": READY[0], "title": "x", "url": "https://youtube.com/watch?v=1233611297"}, 400), | |
| ({"lesson": READY[0], "title": "x", "url": "https://www.youtube.com/playlist?list=PLdQw4w9WgXcQ"}, 400), | |
| ({"lesson": READY[0], "title": "x", "url": "https://evil.example/watch?v=dQw4w9WgXcQ"}, 400), | |
| ({"lesson": READY[0], "title": "x", "url": "https://evil.example/?u=https://vimeo.com/1233611297"}, 400), | |
| ({"lesson": READY[0], "title": "x", "url": "https://vimeo.com/channels/staffpicks"}, 400)): | |
| r = c.post("/api/videos", json=body, headers=good) | |
| assert r.status_code == code, (body, r.text) | |
| first, second = c.get("/api/videos", headers=good).json()["videos"] | |
| N1 = sum(k["lesson"] == READY[0] for k in CATEGORIES) # a new video stands after its lesson's topics | |
| assert first == {"id": 1, "lesson": READY[0], "title": "פתיחה לשיעור", "url": "https://vimeo.com/1233611297/d46ac373c8", | |
| "embed": "https://player.vimeo.com/video/1233611297?h=d46ac373c8&dnt=1&vimeo_logo=0&title=0&byline=0&portrait=0", "pos": N1, "ord": 1} | |
| assert (second["url"], second["embed"]) == ("https://vimeo.com/76979871", "https://player.vimeo.com/video/76979871?dnt=1&vimeo_logo=0&title=0&byline=0&portrait=0") | |
| from app.main import vimeo_link, video_embed, video_link | |
| assert vimeo_link("https://player.vimeo.com/video/1233611297?h=d46ac373c8") == first["url"] | |
| # a YouTube link, in any of its forms, is kept as the watch link and played without cookies | |
| for yt in ("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=5s", "https://youtube.com/watch?list=PL1&v=dQw4w9WgXcQ", "youtu.be/dQw4w9WgXcQ?si=abc", | |
| "https://m.youtube.com/shorts/dQw4w9WgXcQ", "https://www.youtube.com/live/dQw4w9WgXcQ?feature=share", | |
| "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?rel=0"): | |
| assert video_link(yt) == "https://www.youtube.com/watch?v=dQw4w9WgXcQ", yt | |
| assert video_embed(video_link("youtu.be/dQw4w9WgXcQ")) == "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ?rel=0" | |
| assert video_link("https://www.youtube.com/watch?v=dQw4w9WgXcQQ") is None | |
| # the teacher moves a lesson's videos among its topics: another one is added, and the two are placed in a new order | |
| assert c.post("/api/videos", json={"lesson": READY[0], "title": "b", "url": link}, headers=good).status_code == 200 | |
| seen = lambda: [(v["id"], v["pos"]) for v in c.get("/api/videos", headers=good).json()["videos"] if v["lesson"] == READY[0]] | |
| assert seen() == [(1, N1), (3, N1)] | |
| order = lambda *o: c.put("/api/content/order", json={"lesson": READY[0], "order": [{"kind": "video", "id": i, "pos": p} for i, p in o]}, headers=good).status_code | |
| assert order((3, 0), (1, 0)) == 200 and seen() == [(3, 0), (1, 0)] # both before the first topic, the new one first | |
| assert order((3, 0), (1, 2)) == 200 and seen() == [(3, 0), (1, 2)] | |
| assert order((3, 0)) == 409 and order((3, 0), (2, 0)) == 409 # every video of the lesson, and no other's | |
| assert order((3, 0), (1, N1 + 1)) == 400 and order((3, -1), (1, 0)) == 400 and seen() == [(3, 0), (1, 2)] | |
| assert c.put("/api/content/order", json={"lesson": SOON, "order": []}, headers=good).status_code == 400 | |
| c.app.state.auth.vids[0].update(pos=None) # kept before videos had places: after the topics | |
| assert seen() == [(3, 0), (1, N1)] | |
| assert c.delete("/api/videos/3", headers=good).status_code == 200 | |
| first.update(pos=N1, ord=1) | |
| second = c.get("/api/videos", headers=good).json()["videos"][1] | |
| # a text: its lines in Hebrew and in Arabic written in Hebrew letters, the same number of each; a dialogue names two voices | |
| he, tr = ["שלום", "מה שלומך?", "שלום"], ["מַרְחַבַּא", "כִּיף חַאלַכּ?", "מַרְחַבַּא יַא זַלַמֶה"] | |
| talk = {"lesson": READY[0], "title": " בשוק ", "he": he, "tr": tr, "voices": ["אמיר", "ראאף"]} | |
| assert c.get("/api/texts").status_code == 401 and c.get("/api/texts", headers=good).json() == {"categories": [], "phrases": []} | |
| for body, code in ((talk, 200), ({**talk, "title": "story", "voices": []}, 200), | |
| ({**talk, "tr": tr[:2]}, 400), ({**talk, "he": [], "tr": []}, 400), ({**talk, "tr": ["x", "1", "-"]}, 400), # not Arabic in Hebrew letters | |
| ({**talk, "voices": ["אמיר"]}, 400), ({**talk, "voices": ["אמיר", "nobody"]}, 400), | |
| ({**talk, "lesson": SOON}, 400), ({**talk, "title": ""}, 400), ({**talk, "he": he * 80, "tr": tr * 80}, 400)): | |
| r = c.post("/api/texts", json=body, headers=good) | |
| assert r.status_code == code, (body, r.text) | |
| d = c.get("/api/texts", headers=good).json() | |
| assert d["categories"][0] == {"id": "t1", "name": "בשוק", "lesson": READY[0], "story": True, "text": 1, "voices": ["אמיר", "ראאף"], | |
| "pos": N1, "ord": 2, "guessed": ["זַלַמֶה"]} # the course has the other words | |
| assert "voices" not in d["categories"][1] and d["categories"][1]["ord"] == 3 | |
| line1, line2, line3 = d["phrases"][:3] | |
| assert line1 == {"id": 1_001_000, "cat": "t1", "he": "שלום", "tr": "מַרְחַבַּא", "ar": "مرحبا", | |
| "ar_pointed": "مَرْحَبَا", "seq": 1} | |
| assert (line2["id"], line2["ar"], line3["ar"]) == (1_001_001, "كيف حالك؟", "مرحبا يا زلمة") and len(d["phrases"]) == 6 | |
| # a line is a phrase like the bank's: it has a prompt and an answer to play, and its recordings count (two lines | |
| # with the same Hebrew stay apart) | |
| assert c.get("/api/prompt-audio/1001999", headers=good).status_code == 404 and c.get("/api/tts/1001001", headers=good).status_code == 503 | |
| from app.main import built, summary as sums, text_bank | |
| bank = text_bank(c.app.state.auth.txts) | |
| assert bank[1_001_002]["key"] == "שלום (2)" and bank[1_001_001]["variants"][0] == ("كيف حالك؟", "k iː f | ħ aː l a k") | |
| got = sums([{"cat": "t1", "phrase": "שלום (2)", "score": 80, "created_at": "2026-10-09T10:00:00+00:00"}, | |
| {"cat": "t1", "phrase": "שלום", "score": 60, "created_at": "2026-10-09T10:00:00+00:00"}, | |
| {"cat": "t9", "phrase": "שלום", "score": 99, "created_at": "2026-10-09T10:00:00+00:00"}], bank) # a deleted text's | |
| assert got["best"] == {1_001_002: 80, 1_001_000: 60} and got["recordings"] == 3 | |
| for ar, ipa in (v for p in bank.values() for v in p["variants"]): | |
| assert len(ar.split()) == len(ipa.split("|")) | |
| # a text is placed among the topics like a video, and may be a class's homework | |
| mixed = lambda *o: c.put("/api/content/order", json={"lesson": READY[0], "order": [{"kind": k, "id": i, "pos": p} for k, i, p in o]}, headers=good).status_code | |
| assert mixed(("text", 2, 0), ("video", 1, 1), ("text", 1, 1)) == 200 and mixed(("text", 2, 0), ("text", 1, 1)) == 409 | |
| assert [(k["id"], k["pos"], k["ord"]) for k in c.get("/api/texts", headers=good).json()["categories"]] == [("t2", 0, 0), ("t1", 1, 2)] | |
| assert c.get("/api/videos", headers=good).json()["videos"][0]["ord"] == 1 | |
| first.update(pos=1, ord=1) | |
| assert c.post("/api/classes", json={"name": "T", "emails": "a@b.c", "lessons": READY[:1], "homework": "t1"}, headers=good).status_code == 200 | |
| assert c.get("/api/me", headers=good).json()["classes"][-1]["homework"] == "t1" | |
| assert c.delete("/api/texts/1", headers=good).status_code == 200 # with its text gone, the homework is none | |
| assert c.get("/api/me", headers=good).json()["classes"][-1]["homework"] == "" | |
| assert c.delete("/api/texts/2", headers=good).status_code == 200 | |
| c.app.state.auth.cls = [k for k in c.app.state.auth.cls if k["name"] != "T"] | |
| # a student gets the videos of their classes' lessons only, and adds or deletes none | |
| kept, c.app.state.auth.teachers = c.app.state.auth.cls, frozenset() | |
| c.app.state.auth.cls = [{"id": 7, "name": "V", "emails": ["a@b.c"], "nicknames": {}, "lessons": READY[1:2]}] | |
| assert c.get("/api/videos", headers=good).json()["videos"] == [{k: v for k, v in second.items() if k != "url"}] # without the link | |
| assert c.post("/api/videos", json={"lesson": READY[0], "title": "x", "url": link}, headers=good).status_code == 403 | |
| assert c.delete("/api/videos/1", headers=good).status_code == 403 | |
| assert c.put("/api/content/order", json={"lesson": READY[0], "order": []}, headers=good).status_code == 403 | |
| assert c.post("/api/texts", json={}, headers=good).status_code == 403 and c.delete("/api/texts/1", headers=good).status_code == 403 | |
| c.app.state.auth.cls, c.app.state.auth.teachers = kept, frozenset({"a@b.c"}) | |
| assert c.delete("/api/videos/1", headers=good).status_code == 200 | |
| assert c.get("/api/videos", headers=good).json()["videos"] == [second] | |
| # the daily videos: a library by level; a class gives a video its day, and its students get it with that day | |
| from app.phrases import LEVELS | |
| L1, L2 = LEVELS[0]["id"], LEVELS[1]["id"] | |
| daily = lambda **body: c.post("/api/dailies", json=body, headers=good).status_code | |
| assert c.get("/api/dailies").status_code == 401 and c.get("/api/dailies", headers=good).json() == {"dailies": []} | |
| assert daily(level=L1, title=" יום 1 ", url=link + "?share=copy") == 200 and daily(level=L2, title="other", url="https://youtu.be/dQw4w9WgXcQ") == 200 | |
| assert daily(level="9", title="x", url=link) == 400 and daily(level=L1, title="", url=link) == 400 and daily(level=L1, title="x", url="https://example.com/1") == 400 | |
| lib = c.get("/api/dailies", headers=good).json()["dailies"] | |
| assert [(v["id"], v["level"], v["title"]) for v in lib] == [(1, L1, "יום 1"), (2, L2, "other")] and lib[0]["embed"].startswith("https://player.vimeo.com/video/") | |
| D = {"name": "D", "emails": "a@b.c", "lessons": [l["id"] for l in LESSONS if l["level"] == L1][:1]} | |
| save = lambda days: c.post("/api/classes", json={**D, "dailies": days}, headers=good).status_code | |
| assert save([{"video": 9, "date": "2026-10-12"}]) == 400 and save([{"video": 1, "date": "12.10"}]) == 400 | |
| assert save([{"video": 1, "date": "2026-10-12"}, {"video": 1, "date": "2026-10-13"}]) == 400 and save("x") == 400 | |
| assert save([{"video": 1, "date": "2026-10-12"}, {"video": 2, "date": "2026-10-11"}, {"video": 9, "date": ""}]) == 200 # one without a day is dropped | |
| d = c.app.state.auth.cls[0] | |
| assert d["dailies"] == [{"video": 1, "date": "2026-10-12"}, {"video": 2, "date": "2026-10-11"}] | |
| mine = lambda: next(k for k in c.get("/api/me", headers=good).json()["classes"] if k["name"] == "D")["dailies"] | |
| assert mine() == [{"id": 1, "title": "יום 1", "embed": lib[0]["embed"], "date": "2026-10-12"}] # the other level's is not the class's | |
| assert c.get("/api/me", headers=good).json()["dailies"] == mine() # a teacher gets every class's | |
| # saved without the days they stay; saved with none they are gone | |
| assert c.put(f"/api/classes/{d['id']}", json=D, headers=good).status_code == 200 and len(mine()) == 1 | |
| assert c.put(f"/api/classes/{d['id']}", json={**D, "dailies": []}, headers=good).status_code == 200 and mine() == [] and d["dailies"] == [] | |
| assert c.put(f"/api/classes/{d['id']}", json={**D, "dailies": [{"video": 1, "date": "2026-10-12"}]}, headers=good).status_code == 200 | |
| assert c.delete("/api/dailies/1", headers=good).status_code == 200 and mine() == [] # gone from the library, gone from the class | |
| # the library: videos for every student, whatever the level and the class | |
| shelf = lambda **body: c.post("/api/library", json=body, headers=good).status_code | |
| assert c.get("/api/library").status_code == 401 and c.get("/api/library", headers=good).json() == {"library": []} | |
| assert shelf(title="two", url=link) == 200 and shelf(level=L1, title=" one ", url=link) == 200 and shelf(title="gone", url=link) == 200 | |
| assert shelf(title="", url=link) == 400 and shelf(title="x", url="nope") == 400 | |
| assert c.delete("/api/library/3", headers=good).status_code == 200 | |
| shelves = c.get("/api/library", headers=good).json()["library"] | |
| assert [(v["id"], v["title"], v["url"]) for v in shelves] == [(1, "two", link.split("?")[0]), (2, "one", link.split("?")[0])] | |
| # a video of the library is renamed, and the library is put in the teacher's order | |
| rename = lambda id, **body: c.put(f"/api/library/{id}", json=body, headers=good).status_code | |
| order = lambda ids: c.put("/api/library/order", json={"order": ids}, headers=good).status_code | |
| assert rename(1, title=" the first ") == 200 and rename(1, title="") == 400 and rename(1, title="x" * 200) == 400 and rename(9, title="x") == 404 | |
| assert order([2]) == 409 and order([2, 2]) == 409 and order([2, 1, 9]) == 409 and order("x") == 400 and order(["2", "1"]) == 400 | |
| assert order([2, 1]) == 200 | |
| shelves = c.get("/api/library", headers=good).json()["library"] | |
| assert [(v["id"], v["title"]) for v in shelves] == [(2, "one"), (1, "the first")] | |
| c.app.state.auth.teachers = frozenset() | |
| assert c.get("/api/library", headers=good).json()["library"] == [{k: v for k, v in s.items() if k != "url"} for s in shelves] # all of it, without the links | |
| assert shelf(title="x", url=link) == 403 and c.delete("/api/library/1", headers=good).status_code == 403 | |
| assert rename(1, title="x") == 403 and order([1, 2]) == 403 | |
| assert c.get("/api/dailies", headers=good).status_code == 403 and daily(level=L1, title="x", url=link) == 403 and c.delete("/api/dailies/2", headers=good).status_code == 403 | |
| c.app.state.auth.cls, c.app.state.auth.teachers = [k for k in c.app.state.auth.cls if k["name"] != "D"], frozenset({"a@b.c"}) | |
| new_class = {"name": " October 2026 ", "emails": "S@b.c, new@b.c\nnew@b.c דנה \nx@b.c\n", # an optional nickname | |
| "lessons": ALL[::-1], # x is in September's class too: a student may be in several | |
| "homework": T2, "due": "2026-10-15"} | |
| assert c.post("/api/classes", json=new_class, headers=good).status_code == 200 | |
| assert c.app.state.auth.cls[0] == {"id": 2, "name": "October 2026", "emails": ["new@b.c", "s@b.c", "x@b.c"], | |
| "nicknames": {"new@b.c": "דנה"}, "lessons": ALL, "homeworks": [{"topic": T2, "due": "2026-10-15"}]} | |
| for body, code in (({"name": "", "emails": "", "lessons": ALL}, 400), ({"name": "y", "emails": "not-an-email", "lessons": ALL}, 400), | |
| ({"name": "y", "emails": "z@b.c w@b.c Dana", "lessons": ALL}, 400), # whose nickname? | |
| ({"name": "y", "emails": "", "lessons": []}, 400), ({"name": "y", "emails": ""}, 400), # no lesson | |
| ({"name": "y", "emails": "", "lessons": ["no-such-lesson"]}, 400), | |
| ({"name": "y", "emails": "", "lessons": ALL[:1], "homework": T2}, 400), # not of the class's lessons | |
| ({"name": "y", "emails": "", "lessons": ALL, "homework": ALL[0]}, 400), # a lesson is not a topic | |
| ({"name": "y", "emails": "", "lessons": ALL, "homework": T1, "due": "15/10"}, 400), | |
| ({"name": "October 2026", "emails": "", "lessons": ALL}, 409), # the name is taken, | |
| ({"name": " october 2026", "emails": "", "lessons": ALL}, 409)): # in any letter case and spacing | |
| r = c.post("/api/classes", json=body, headers=good) | |
| assert r.status_code == code, (body, r.text) | |
| assert "October 2026" in r.json()["detail"] | |
| # renaming a class to another's name is refused too; keeping its own name is not | |
| assert c.put("/api/classes/1", json={"name": "OCTOBER 2026", "emails": "", "lessons": ALL}, headers=good).status_code == 409 | |
| assert c.put("/api/classes/9", json={"name": "z", "emails": "", "lessons": ALL}, headers=good).status_code == 404 | |
| assert c.put("/api/classes/1", json={"name": "September 2026", "emails": "x@b.c y@b.c", "lessons": ALL[:1]}, | |
| headers=good).status_code == 200 | |
| # the panel: the classes with their lessons; the classes' accounts, each with {class: nickname} | |
| d = c.get("/api/class", headers=good).json() | |
| assert [(k["name"], k["lessons"]) for k in d["classes"]] == [("October 2026", ALL), ("September 2026", ALL[:1])] | |
| assert [(k["homework"], k["due"]) for k in d["classes"]] == [(T2, "2026-10-15"), ("", None)] | |
| # a date without homework is dropped; saving without the two clears them | |
| assert c.put("/api/classes/2", json={**new_class, "homework": ""}, headers=good).status_code == 200 | |
| assert c.app.state.auth.cls[0]["homeworks"] == [] | |
| # a class may have several homeworks, a topic each with its own day: they come back in the order of its lessons | |
| several = {**new_class, "homework": "", "homeworks": [{"topic": T2, "due": ""}, {"topic": T1, "due": "2026-10-20"}, {"topic": "", "due": "2026-10-21"}]} | |
| assert c.put("/api/classes/2", json=several, headers=good).status_code == 200 | |
| k = c.get("/api/class", headers=good).json()["classes"][0] | |
| assert k["homeworks"] == [{"topic": T1, "due": "2026-10-20"}, {"topic": T2, "due": None}] and (k["homework"], k["due"]) == (T1, "2026-10-20") | |
| for bad in ([{"topic": T1}, {"topic": T1}], [{"topic": "no-such-topic"}], [{"topic": T1, "due": "soon"}], "x", ["x"]): | |
| assert c.put("/api/classes/2", json={**several, "homeworks": bad}, headers=good).status_code == 400, bad | |
| assert c.put("/api/classes/2", json={**new_class, "homework": ""}, headers=good).status_code == 200 | |
| student, new, x, y = d["students"] # the teacher, in no class, is not a student | |
| assert student["best"] == {str(PHRASES.index(VERB)): 88} and student["tries"] == {str(PHRASES.index(VERB)): 2} | |
| assert (student["recordings"], student["days"], student["last"]) == (2, 1, "2026-10-09T10:00:00+00:00") | |
| assert (new["email"], new["joined"], new["recordings"]) == ("new@b.c", False, 0) and student["joined"] | |
| c.app.state.auth.wrote["u2", "l1-hw"] = ["הִיֵّ פַתְחַת"] # the panel reads what each student wrote | |
| assert [k["written"] for k in c.get("/api/class", headers=good).json()["students"][:2]] == [{"l1-hw": ["הִיֵّ פַתְחַת"]}, {}] | |
| assert (student["classes"], new["classes"]) == ({"October 2026": ""}, {"October 2026": "דנה"}) | |
| assert x["classes"] == {"October 2026": "", "September 2026": ""} and y["classes"] == {"September 2026": ""} | |
| # a class counts what was practised since it was opened: a new one starts clean (the student's own summary stays whole) | |
| assert student["since"]["October 2026"]["recordings"] == 2 # a class without its opening time counts everything | |
| c.app.state.auth.cls[0]["created_at"] = "2026-10-09T00:00:00.123+00:00" # between the student's two tries | |
| student = c.get("/api/class", headers=good).json()["students"][0] | |
| assert student["recordings"] == 2 and student["since"] == {"October 2026": { | |
| "best": {str(PHRASES.index(VERB)): 88}, "tries": {str(PHRASES.index(VERB)): 1}, "missed": {}, "unmatched": 0, | |
| "recordings": 1, "days": 1, | |
| "week": student["since"]["October 2026"]["week"], "last": "2026-10-09T10:00:00+00:00"}} | |
| # a recording with speech completes the exercise even when it did not match; it is also counted diagnostically | |
| from app.main import summary | |
| off = summary([{"cat": VERB["cat"], "phrase": VERB["he"], "score": 12, "unrelated": True, "created_at": "2026-10-09T11:00:00+00:00"}, | |
| {"cat": VERB["cat"], "phrase": VERB["he"], "score": 70, "unrelated": False, "created_at": "2026-10-09T10:00:00+00:00"}]) | |
| assert (off["best"], off["tries"], off["missed"]) == ({PHRASES.index(VERB): 70}, {PHRASES.index(VERB): 2}, {PHRASES.index(VERB): 1}) | |
| assert (off["unmatched"], off["recordings"], off["last"]) == (1, 2, "2026-10-09T11:00:00+00:00") | |
| c.app.state.auth.cls[0]["created_at"] = "2026-10-10T00:00:00+00:00" # opened after both | |
| since = c.get("/api/class", headers=good).json()["students"][0]["since"]["October 2026"] | |
| assert (since["best"], since["recordings"], since["last"]) == ({}, 0, None) | |
| assert c.delete("/api/classes/1", headers=good).status_code == 200 and len(c.app.state.auth.cls) == 1 | |
| # a student sees each of their classes: by nickname, else the Google name; one with neither is only counted | |
| c.app.state.auth.cls[0]["emails"].append("a@b.c") | |
| assert c.get("/api/classmates", headers=good).json() == {"classes": [{"name": "October 2026", "unnamed": 2, "students": [ | |
| {"name": "Alef", "me": True}, {"name": "דנה", "me": False}]}]} | |
| c.app.state.auth.cls[0]["emails"].remove("a@b.c") | |
| c.app.state.auth.cls = [] # no class: every account that signed in | |
| assert [s["email"] for s in c.get("/api/class", headers=good).json()["students"]] == ["A@b.c", "s@b.c", "out@b.c"] | |
| # a scored recording is recorded as the student's attempt | |
| auth, pid = FakeAuth(), PHRASES.index(TOILET) | |
| buf = io.BytesIO() | |
| with wave.open(buf, "wb") as w: | |
| w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000) | |
| w.writeframes((0.3 * np.sin(2 * np.pi * 220 * np.arange(16000) / 16000) * 32767).astype("<i2").tobytes()) | |
| logp = emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"]) | |
| with TestClient(create_app(scorer_for(logp), auth=auth)) as c: | |
| files = {"audio": ("a.wav", buf.getvalue(), "audio/wav")} | |
| assert c.post("/api/score", data={"phrase_id": pid}, files=files).status_code == 401 and not auth.rows | |
| r = c.post("/api/score", data={"phrase_id": pid}, files=files, headers={"Authorization": "Bearer good"}) | |
| assert r.status_code == 200, r.text | |
| (token, row), = auth.rows | |
| assert token == "good" and row["phrase"] == TOILET["he"] and row["cat"] == TOILET["cat"] | |
| assert row["score"] == round(r.json()["score"]) and row["unrelated"] is False | |
| assert c.get("/api/progress").status_code == 401 | |
| progress = c.get("/api/progress", headers={"Authorization": "Bearer good"}).json() | |
| assert {k: progress[k] for k in ("best", "tries", "recordings", "days")} == { | |
| "best": {str(PHRASES.index(VERB)): 88}, "tries": {str(PHRASES.index(VERB)): 2}, "recordings": 3, "days": 2} | |
| assert 0 <= progress["week_days"] <= 7 | |
| assert set(progress["streak"]) == {"current", "best", "last"} | |
| with TestClient(create_app(scorer_for(emissions(["w"])))) as c: # no sign-in: nothing to show | |
| assert c.get("/api/progress").json() == {"best": {}, "tries": {}, "recordings": 0, "days": 0, | |
| "week_days": 0, "streak": {"current": 0, "best": 0, "last": None}} | |
| with TestClient(create_app(scorer_for(emissions(["w"])))) as c: # not configured: open, as before | |
| assert 'AUTH_URL=""' in c.get("/").text and c.get("/api/tts/0").status_code == 503 | |
| def test_letters_cover_the_word_and_its_phonemes(p): | |
| from app.translit import word_letters, word_to_hebrew | |
| for ar, ipa in p["variants"]: | |
| for a, w in zip(ar.split(), ipa.split("|")): | |
| letters = word_letters(w, a) | |
| assert "".join(t for t, _ in letters) == word_to_hebrew(w, a) | |
| assert [j for _, at in letters for j in at] == list(range(len(w.split()))) # each phoneme once, in order | |
| def test_result_scores_each_letter(): | |
| # ħ said as χ: of الحمام, the ח is the weak letter and the rest are fine | |
| s = scorer_for(emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"])) | |
| for _, ipa in TOILET["variants"]: # every target token is some phoneme's, the doubled م sharing one | |
| carried = [i for word in s._carriers(ipa) for idx in word for i in idx] | |
| assert sorted(set(carried)) == list(range(len(s._targets(ipa)))) | |
| r = s.score(None, TOILET) | |
| for w in r["words"]: | |
| assert "".join(l["t"] for l in w["letters"]) == w["tr"] | |
| by = {l["t"][0]: l["score"] for l in r["words"][1]["letters"] if l["score"] is not None} | |
| assert by["ח"] < 50 and by["מ"] > 75 | |
| # the coloured letter says why, with the advice for that mistake; a letter that was fine says nothing | |
| het, mem = (next(l for l in r["words"][1]["letters"] if l["t"][0] == c) for c in "חמ") | |
| assert het["why"] == "ח (ح) נשמעה כמו ח' (خ)" and het["tip"] == r["tips"][0]["text"] and "why" not in mem | |
| def test_a_tutor_flags_a_recording_scored_wrong(monkeypatch): | |
| from fastapi.testclient import TestClient | |
| monkeypatch.setenv("SAWTAK_API_KEY", "") # no native baseline from a local .env: the score is the stub's own | |
| from app.auth import AuthUnavailable | |
| from app.main import MODEL_ID, create_app | |
| class FakeAuth: | |
| url, key, teachers, tutors = "https://proj.supabase.co", "sb_publishable_x", frozenset(), frozenset({"t@b.c"}) | |
| def __init__(self): | |
| self.cls, self.flags, self.table, self.rank, self.calibration_rows = [], [], True, frozenset(), [] | |
| self.rule_rows = {} | |
| def user(self, token): | |
| return {"t": {"id": "u1", "email": "T@b.c"}, "s": {"id": "u2", "email": "s@b.c"}}.get(token) | |
| def classes(self, fresh=False): | |
| return self.cls | |
| def ranked(self, fresh=False): | |
| return self.rank | |
| def set_tutor(self, email, on, by): | |
| if self.rank is None: | |
| raise AuthUnavailable("supabase answered 404: no table") | |
| self.rank = self.rank | {email} if on else self.rank - {email} | |
| self.by = by | |
| def level_videos(self, table, fresh=False): | |
| return [] | |
| def texts(self, fresh=False): | |
| return [] | |
| def add_flag(self, row, audio, mime, name): | |
| if not self.table: | |
| raise AuthUnavailable("supabase answered 404: no table") | |
| self.flag_recordings = getattr(self, "flag_recordings", {}) | |
| self.flag_recordings[len(self.flags) + 1] = (audio, mime) | |
| self.flags.append({**row, "audio": name}) | |
| return True | |
| def list_flags(self, status="pending"): | |
| if status != "pending": | |
| return [] | |
| return [{"id": i + 1, **row, "status": status} for i, row in enumerate(self.flags)] | |
| def review_flag(self, flag_id, values): | |
| self.review = (flag_id, values) | |
| def flag_audio(self, flag_id): | |
| return getattr(self, "flag_recordings", {}).get(flag_id) | |
| def calibrations(self, status=None): | |
| return [r for r in self.calibration_rows if not status or r["status"] == status] | |
| def add_calibration(self, row): | |
| made = {"id": len(self.calibration_rows) + 1, **row} | |
| self.calibration_rows.append(made) | |
| return made | |
| def update_calibration(self, calibration_id, values): | |
| next(r for r in self.calibration_rows if r["id"] == calibration_id).update(values) | |
| def delete_calibration(self, calibration_id): | |
| before = len(self.calibration_rows) | |
| self.calibration_rows = [r for r in self.calibration_rows if r["id"] != calibration_id] | |
| return len(self.calibration_rows) < before | |
| def scoring_rules(self): | |
| if self.rule_rows is None: | |
| raise AuthUnavailable("supabase answered 404: no table") | |
| return list(self.rule_rows.values()) | |
| def set_scoring_rule(self, rule_id, active, by): | |
| if self.rule_rows is None: | |
| raise AuthUnavailable("supabase answered 404: no table") | |
| self.rule_rows[rule_id] = {"id": rule_id, "active": active, "updated_by": by} | |
| def all_attempt_issues(self): | |
| return [{"user_id": f"u{i}", "phrase": TOILET["he"], "score": 60, | |
| "issues": [{"expected": "ħ", "heard": "χ", "score": 20}], | |
| "model": MODEL_ID, "version": "v"} for i in range(3)] | |
| t = np.arange(16000) / 16000 | |
| buf = io.BytesIO() | |
| with wave.open(buf, "wb") as w: | |
| w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000) | |
| w.writeframes((0.3 * np.sin(2 * np.pi * 220 * t) * 32767).astype("<i2").tobytes()) | |
| pid = PHRASES.index(TOILET) | |
| send = lambda c, token, **data: c.post("/api/flags", data={"phrase_id": pid, **data}, headers={"Authorization": f"Bearer {token}"}, | |
| files={"audio": ("rec", buf.getvalue(), "audio/wav")}) | |
| # said with a Hebrew chet, as the model hears it: the tutor says it was right | |
| with TestClient(create_app(scorer_for(emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"])), auth=FakeAuth())) as c: | |
| a = c.app.state.auth | |
| assert c.get("/api/me", headers={"Authorization": "Bearer t"}).json()["tutor"] is True | |
| assert "tutor" not in c.get("/api/me", headers={"Authorization": "Bearer s"}).json() | |
| assert c.post("/api/flags", data={"phrase_id": pid}, files={"audio": ("rec", b"x", "audio/wav")}).status_code == 401 | |
| assert send(c, "s").status_code == 403 and not a.flags # tutors only | |
| r = send(c, "t", note=" אמרתי ח נכונה ") | |
| row, = a.flags | |
| assert r.status_code == 200 and r.json() == {"ok": True, "audio": True, "flag": row}, r.text | |
| assert row["email"] == "t@b.c" and row["cat"] == TOILET["cat"] and row["he"] == TOILET["he"] and row["note"] == "אמרתי ח נכונה" | |
| assert row["ipa"] == "w eː n | i l ħ a m m aː m" and "χ" in row["heard_ipa"] and row["score"] == row["result"]["score"] < 100 | |
| assert row["result"]["tips"][0]["key"] == "ħ>x" and row["result"]["trace"]["tokens"] | |
| assert "phoneme_heatmap" not in row["result"] | |
| assert send(c, "t", phrase_id=10**6).status_code == 404 | |
| a.teachers = frozenset({"s@b.c"}) # a teacher flags too | |
| assert c.get("/api/me", headers={"Authorization": "Bearer s"}).json()["tutor"] is True and send(c, "s").status_code == 200 | |
| hs = {"Authorization": "Bearer s"} | |
| listed = c.get("/api/flags", headers=hs) | |
| assert listed.status_code == 200 and len(listed.json()["flags"]) == 2 | |
| played = c.get("/api/flags/1/audio", headers=hs) | |
| assert played.status_code == 200 and played.content == buf.getvalue() | |
| assert c.get("/api/flags/999/audio", headers=hs).status_code == 404 | |
| review = c.put("/api/flags/1", headers=hs, json={"status": "confirmed", "category": "recognition", | |
| "false_issues": [{"word": 1, "expected": "ħ", "heard": "χ"}], "review_note": " תקין "}) | |
| assert review.status_code == 200 | |
| assert a.review[0] == 1 and a.review[1]["false_issues"] == [{"word": 1, "expected": "ħ", "heard": "χ"}] | |
| assert a.review[1]["review_note"] == "תקין" and a.review[1]["reviewed_by"] == "s@b.c" | |
| assert c.put("/api/flags/1", headers=hs, json={"status": "pending", "category": "other"}).status_code == 400 | |
| patterns = c.get("/api/attempt-patterns", headers=hs).json()["patterns"] | |
| assert patterns[0]["expected"] == "ħ" and patterns[0]["heard"] == "χ" | |
| assert patterns[0]["students"] == 3 and patterns[0]["student_rate"] == 1 | |
| proposal = c.post("/api/calibrations", headers=hs, json={"expected": "ħ", "heard": "χ", | |
| "evidence": {"reports": 5, "reporters": 2, "phrases": [TOILET["he"]]}}) | |
| assert proposal.status_code == 200 and proposal.json()["calibration"]["status"] == "draft" | |
| applied = c.put("/api/calibrations/1", headers=hs, json={"status": "applied", "floor": 70, | |
| "scope": "phrase", "phrase": TOILET["he"]}) | |
| assert applied.status_code == 200 and applied.json()["active"] == 1 | |
| assert c.app.state.scorer.calibrations[("ħ", "χ", TOILET["he"])] == 70 | |
| deleted = c.delete("/api/calibrations/1", headers=hs) | |
| assert deleted.status_code == 200 and deleted.json()["active"] == 0 | |
| assert not c.app.state.scorer.calibrations and c.delete("/api/calibrations/1", headers=hs).status_code == 404 | |
| dismissed = c.post("/api/calibrations", headers=hs, json={"expected": "r", "heard": "ɣ", | |
| "evidence": {"dismissed": True, "source": "confirmed_flags"}}) | |
| assert dismissed.status_code == 200 and dismissed.json()["calibration"]["status"] == "draft" | |
| assert dismissed.json()["calibration"]["evidence"] == {"dismissed": True, "source": "confirmed_flags"} | |
| # the rules of pronunciation-rules.md: all on, a teacher switches one off, and the scorer follows at once | |
| listed = c.get("/api/scoring-rules", headers=hs).json() | |
| assert listed["stored"] and all(r["active"] for r in listed["rules"]) and len(listed["rules"]) >= 20 | |
| assert all(isinstance(r["detectable"], bool) and r["title"] and r["description"] for r in listed["rules"]) | |
| assert c.put("/api/scoring-rules/dialect_Q", headers=hs, json={"active": False}).json()["disabled"] == ["dialect_Q"] | |
| assert c.app.state.scorer.disabled == {"dialect_Q"} and a.rule_rows["dialect_Q"]["updated_by"] == "s@b.c" | |
| assert not next(r for r in c.get("/api/scoring-rules", headers=hs).json()["rules"] if r["id"] == "dialect_Q")["active"] | |
| assert c.put("/api/scoring-rules/nope", headers=hs, json={"active": False}).status_code == 400 | |
| assert c.put("/api/scoring-rules/margins", headers=hs, json={"active": "no"}).status_code == 400 | |
| assert c.put("/api/scoring-rules/margins", headers={"Authorization": "Bearer t"}, json={"active": False}).status_code == 403 | |
| assert c.put("/api/scoring-rules/dialect_Q", headers=hs, json={"active": True}).json()["disabled"] == [] | |
| a.rule_rows = None # no table yet: the list still shows, all on, with the SQL to create it | |
| missing = c.get("/api/scoring-rules", headers=hs).json() | |
| assert not missing["stored"] and "create table public.scoring_rules" in missing["create_sql"] | |
| assert c.put("/api/scoring-rules/margins", headers=hs, json={"active": False}).status_code == 503 | |
| a.teachers = frozenset() | |
| a.table = False # before the table is created: the command to create it | |
| r = send(c, "t") | |
| assert r.status_code == 503 and "create table public.flags" in r.json()["detail"] | |
| # a tutor is let in without a class, a student is not | |
| a.cls = [{"id": 1, "name": "B", "emails": ["other@b.c"], "nicknames": {}, "lessons": []}] | |
| assert c.get("/api/me", headers={"Authorization": "Bearer t"}).status_code == 200 | |
| assert c.get("/api/me", headers={"Authorization": "Bearer s"}).status_code == 403 | |
| # the rank the teacher gives a student in the panel: theirs in every class, and it lets nobody in by itself | |
| tok = lambda t: {"Authorization": f"Bearer {t}"} | |
| rank = lambda t, **body: c.put("/api/tutors", json=body, headers=tok(t)) | |
| a.users, a.all_attempts, a.all_writings = lambda: [{"id": "u2", "email": "s@b.c", "name": ""}], lambda: [], lambda: [] | |
| assert rank("s", email="s@b.c", tutor=True).status_code == 403 # teachers only (s is not in a class either) | |
| a.cls[0]["emails"].append("s@b.c"); a.teachers = frozenset({"t@b.c"}) | |
| assert rank("s", email="s@b.c", tutor=True).status_code == 403 and "tutor" not in c.get("/api/me", headers=tok("s")).json() | |
| assert rank("t", email="nobody", tutor=True).status_code == 400 and rank("t", email="s@b.c", tutor="yes").status_code == 400 | |
| assert rank("t", email=" S@b.c ", tutor=True).status_code == 200 and a.rank == {"s@b.c"} and a.by == "t@b.c" | |
| assert c.get("/api/me", headers=tok("s")).json()["tutor"] is True and send(c, "s").status_code == 503 # may flag (the table is still missing) | |
| panel = c.get("/api/class", headers=tok("t")).json() | |
| assert [(s["email"], s["tutor"], s.get("tutor_fixed")) for s in panel["students"]] == [("s@b.c", True, None), ("other@b.c", False, None)] | |
| assert rank("t", email="s@b.c", tutor=False).status_code == 200 and "tutor" not in c.get("/api/me", headers=tok("s")).json() | |
| a.rank = frozenset({"out@b.c"}) # ranked, but in no class: not let in | |
| known = a.user | |
| a.user = lambda token: {"id": "u3", "email": "out@b.c"} if token == "x" else known(token) | |
| assert c.get("/api/me", headers=tok("x")).status_code == 403 | |
| a.rank = None # before the table is created: the panel says what to run, and so does giving the rank | |
| assert "create table public.tutors" in c.get("/api/class", headers=tok("t")).json()["setup"][-1]["sql"] | |
| r = rank("t", email="s@b.c", tutor=True) | |
| assert r.status_code == 503 and "create table public.tutors" in r.json()["detail"] | |
| def test_switched_off_rules_bring_back_the_old_behaviour(): | |
| kaddeesh = emissions(["k", "a", "d", "eː", "ʃ", "i", "s", "aː", "ʕ", "a"]) | |
| s = scorer_for(kaddeesh) | |
| assert not s.score(None, TIME)["tips"] # rural ق accepted | |
| s.set_disabled({"dialect_Q", "not-a-rule"}) | |
| assert s.disabled == {"dialect_Q"} | |
| assert [t["key"] for t in s.score(None, TIME)["tips"]] == ["ʔ>k"] # as before the document | |
| # the emphatic margin: off, plain t winning by less than twice is a mistake again | |
| phrase = {"variants": [("طين", "tˤ i n")]} | |
| s = margin_scorer(1.8) | |
| assert not s.score(None, phrase)["tips"] | |
| s.set_disabled({"margins"}) | |
| assert [t["key"] for t in s.score(None, phrase)["tips"]] == ["tˤ>t"] | |
| # a section-3 rule off: the general tip and severity instead of the document's | |
| s = custom_scorer(["p", "f", "dʒ"], ["p", "i", "n", "dʒ", "aː", "n"]) | |
| fin = {"variants": [("فنجان", "f i n dʒ aː n")]} | |
| assert s.score(None, fin)["tips"][0]["text"].startswith("נהגתה p") | |
| s.set_disabled({"rule_f"}) | |
| tip = s.score(None, fin)["tips"][0] | |
| assert tip["key"] == "f>p" and not tip["text"].startswith("נהגתה p") and tip["severity"] == 2 | |
| # the final vowel of ة: off, i for e is marked down like any other vowel | |
| s = scorer_for(emissions(["ʔ", "a", "h", "w", "i"])) | |
| assert s.score(None, COFFEE)["words"][0]["phonemes"][-1]["score"] >= 95 | |
| s.set_disabled({"final_vowel", "near_vowel"}) | |
| assert s.score(None, COFFEE)["words"][0]["phonemes"][-1]["score"] < 50 | |
| THANKS = {"variants": [("شكرا كتير", "ʃ u k r a n | k t iː r")]} | |
| CLUSTER_SYMS = ["r", "t", "iː", "u", "ʃ"] | |
| def test_vowel_between_two_consonants_is_a_mistake_mid_sentence(): | |
| clean = custom_scorer(CLUSTER_SYMS, ["ʃ", "u", "k", "r", "a", "n", "k", "t", "iː", "r"]).score(None, THANKS) | |
| assert not clean["tips"] and clean["score"] >= 95 | |
| s = custom_scorer(CLUSTER_SYMS, ["ʃ", "u", "k", "r", "a", "n", "k", "a", "t", "iː", "r"]) # katiir | |
| r = s.score(None, THANKS) | |
| t = next(p for p in r["words"][1]["phonemes"] if p["ipa"] == "t") | |
| assert t["inserted"] == "a" and t["score"] == 60 and [x["key"] for x in r["tips"]] == ["cluster"] | |
| assert r["tips"][0]["severity"] == 1 and "ktiir" in r["tips"][0]["text"] and r["score"] < clean["score"] | |
| letter = next(l for l in r["words"][1]["letters"] if "ת" in l["t"]) | |
| assert letter["why"].startswith("נוספה") and "ktiir" in letter["tip"] | |
| s.set_disabled({"rule_cluster"}) # switched off: not checked, as before | |
| assert not s.score(None, THANKS)["tips"] | |
| def test_vowel_in_the_cluster_that_opens_the_sentence_is_fine(): | |
| # ketiir at the start of a sentence (6.3 #16), and the way into a glide (shuway) | |
| r = custom_scorer(["t", "iː", "r"], ["k", "e", "t", "iː", "r"]).score(None, {"variants": [("كتير", "k t iː r")]}) | |
| assert not r["tips"] and r["score"] >= 95 | |
| phrase = {"variants": [("شو شوي", "ʃ u | ʃ w a j j")]} | |
| r = custom_scorer(["ʃ", "u", "j"], ["ʃ", "u", "ʃ", "u", "w", "a", "j"]).score(None, phrase) | |
| assert not any(t["key"] == "cluster" for t in r["tips"]) | |
| LAWYER = {"variants": [("محامي", "m u ħ aː m i")]} | |
| def test_kubutz_is_a_distinct_vowel(said, flagged): | |
| r = custom_scorer(["u", "o"], said).score(None, LAWYER) | |
| u = r["words"][0]["phonemes"][1] | |
| assert u["ipa"] == "u" and u["flagged"] == flagged, u | |
| if flagged: | |
| assert r["tips"][0]["text"].startswith("ה־u") and r["tips"][0]["severity"] == 1 | |
| def test_only_short_u_after_a_consonant_is_flagged(): | |
| s = custom_scorer(["u", "o"], ["m", "ħ", "aː", "m", "i"]) | |
| s.set_disabled({"rule_kubutz"}) # switched off: like any other short vowel | |
| assert not s.score(None, LAWYER)["words"][0]["phonemes"][1]["flagged"] | |
| # the و of "and" (u m a) and a long uː (ʃ uː f) are not the kubutz rule's | |
| r = custom_scorer(["u"], ["m", "a"]).score(None, {"variants": [("وما", "u m a")]}) | |
| assert not r["words"][0]["phonemes"][0]["flagged"] | |
| r = custom_scorer(["u", "uː", "f", "ʃ"], ["ʃ", "e", "f"]).score(None, {"variants": [("شوف", "ʃ uː f")]}) | |
| assert not r["words"][0]["phonemes"][1]["flagged"] and not r["tips"] | |
| def test_teacher_trace_follows_every_stage(): | |
| # ħ said as χ, then forgiven by a teacher floor: each stage of the trace shows where the score came from | |
| logp = emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"]) | |
| scorer = scorer_for(logp) | |
| assert "trace" not in scorer.score(None, TOILET) # students do not get it | |
| scorer.set_calibrations([{"expected": "ħ", "heard": "χ", "floor": 60}]) | |
| t = scorer.score_logp(logp, TOILET, diagnostics=True)["trace"] | |
| assert t["config"]["flag_below"] == 50 and t["config"]["leniency"] == 0.35 and t["variants"][0]["chosen"] | |
| assert t["config"]["word_min_weight"] == 0.35 | |
| h = next(x for x in t["tokens"] if x["ipa"] == "ħ") | |
| assert [s["stage"] for s in h["stages"]] == ["gop", "exemption", "insertion", "baseline", "calibration"] | |
| assert h["stages"][0]["score"] < 50 and h["stages"][4]["applied"] and h["final"] == 60 | |
| assert h["competitor"] == "χ" and h["rule"]["severity"] == 3 and not h["flagged"] | |
| assert t["sentence"]["final"] == round(0.5 * t["sentence"]["phone_mean"] + 0.5 * t["sentence"]["word_mean"]) | |
| def test_trace_carries_frames_curves_and_paths(): | |
| from app.scorer import frame_energy | |
| r = scorer_for(emissions(["w", "eː", "n", "i", "l", "ħ", "a", "m", "aː", "m"])).score(None, TOILET, diagnostics=True) | |
| t = r["trace"] | |
| assert t["frames"]["count"] > 0 and t["frames"]["energy"] is None # no audio: no energy | |
| tok = t["tokens"][0] | |
| assert len(tok["curves"]["target"]) == len(tok["curves"]["blank"]) and tok["curves"]["prev"] is None | |
| assert all(v["tokens"] for v in t["variants"]) and sum(v["chosen"] for v in t["variants"]) == 1 | |
| e = frame_energy(np.r_[np.zeros(320), np.ones(320)], 2) | |
| assert np.allclose(e, [0, 1]) | |
| def test_flag_threshold_per_sound(): | |
| # ħ said as χ: flagged at the global 50, not once its own threshold is under the score; other sounds unchanged | |
| logp = emissions(["w", "eː", "n", "i", "l", "χ", "a", "m", "aː", "m"]) | |
| scorer = scorer_for(logp) | |
| h = lambda r: next(p for w in r["words"] for p in w["phonemes"] if p["ipa"] == "ħ") | |
| before = h(scorer.score(None, TOILET)) | |
| assert before["flagged"] and before["heard"] == "χ" | |
| scorer.cfg = replace(scorer.cfg, flag_below_sound={"ħ": before["score"]}) | |
| r = scorer.score_logp(logp, TOILET, diagnostics=True) | |
| assert not h(r)["flagged"] and h(r)["score"] == before["score"] | |
| assert r["trace"]["config"]["flag_below_sound"] == {"ħ": before["score"]} | |
| def test_flag_thresholds_belong_to_their_model(): | |
| from app.scorer import scorer_config | |
| assert scorer_config("facebook/wav2vec2-xlsr-53-espeak-cv-ft").flag_below_sound == {"ħ": 20.0} | |
| assert scorer_config("checkpoints/ihki-ft").flag_below_sound == {} | |
| assert scorer_config("checkpoints/ihki-ft").flag_below == 50 | |
| def test_each_class_topic_opens_on_its_own_screen(): | |
| from fastapi.testclient import TestClient | |
| from app.main import create_app | |
| with TestClient(create_app(scorer_for(emissions(["w"])))) as c: | |
| for path in ("/", "/arabic"): # "/" stays Arabic | |
| page = c.get(path).text | |
| assert 'data-subject="arabic"' in page and "/static/logo-intro.svg" in page and "<title>אהלא וסהלא</title>" in page | |
| math = c.get("/math").text | |
| assert 'data-subject="math"' in math and "/static/logo-math.svg" in math and "<title>מתמטיקה בכיף</title>" in math | |
| assert "__SUBJECT__" not in math and "__START_LABEL__" not in math | |
| assert c.get("/history").status_code == 404 | |
| assert c.get("/static/logo-math.svg").status_code == 200 | |
| # its installed app has its own name, colour and start page; Arabic keeps the static manifest | |
| assert '<meta name="theme-color" content="#000000">' in math and 'href="/math/manifest.webmanifest"' in math | |
| assert '"__' not in math and '<meta name="theme-color" content="#4B6A2C">' in c.get("/").text | |
| assert re.search(r'<script src="/static/math\.js\?v=[0-9a-f]{7}" defer>', math) # the path's script, stamped | |
| assert 'href="/static/manifest.webmanifest"' in c.get("/").text | |
| installed = c.get("/math/manifest.webmanifest") | |
| assert installed.headers["content-type"].startswith("application/manifest+json") | |
| assert {k: installed.json()[k] for k in ("id", "name", "start_url", "scope")} == { | |
| "id": "/math", "name": "מתמטיקה בכיף", "start_url": "/math", "scope": "/"} | |
| assert c.get("/arabic/manifest.webmanifest").status_code == 404 | |
| def test_an_account_is_sent_to_its_subjects_pages(): | |
| from fastapi.testclient import TestClient | |
| from app.main import create_app, subject_code | |
| assert subject_code("מתמטיקה") == "math" and subject_code(" ערבית ") == "arabic" | |
| assert subject_code("חשבון") == subject_code("") == subject_code(None) == "" | |
| class FakeAuth: | |
| url, key, teachers, tutors, developers = "https://proj.supabase.co", "sb_publishable_x", frozenset(), frozenset(), {"d@b.c"} | |
| users_ = {"s": {"id": "u1", "email": "s@b.c"}, "t": {"id": "u2", "email": "t@b.c", "name": "T"}, | |
| "d": {"id": "u3", "email": "d@b.c"}} | |
| def __init__(self): | |
| self.cls, self.access = [], {"u2": {"user_id": "u2", "roles": ["admin"], "subject": "מתמטיקה"}} | |
| def user(self, token): | |
| return self.users_.get(token) | |
| def users(self): | |
| return list(self.users_.values()) | |
| def classes(self, fresh=False): | |
| return self.cls | |
| def access_for(self, user_id): | |
| return self.access.get(user_id) | |
| def access_rows(self): | |
| return list(self.access.values()) | |
| def set_admin_subject(self, target, subject, actor): | |
| self.access[target["id"]]["subject"] = subject | |
| def ranked(self, fresh=False): | |
| return frozenset() | |
| def __getattr__(self, name): # the content tables /api/me reads: empty | |
| return lambda *a, **k: [] | |
| tok = lambda t: {"Authorization": f"Bearer {t}"} | |
| with TestClient(create_app(scorer_for(emissions(["w"])), auth=FakeAuth())) as c: | |
| auth = c.app.state.auth | |
| auth.cls = [{"id": 1, "name": "ט1", "emails": ["s@b.c"], "nicknames": {}, "lessons": [], "subject": "מתמטיקה"}] | |
| me = c.get("/api/me", headers=tok("s")).json() # a student goes where their class is | |
| assert me["subject"] == "math" and me["classes"][0]["subject"] == "math" | |
| assert c.get("/api/me", headers=tok("t")).json()["subject"] == "math" # an admin, where their own subject is | |
| assert "subject" not in c.get("/api/me", headers=tok("d")).json() # a developer stays on the page they opened | |
| auth.cls[0]["subject"] = "חשבון" # a subject outside the list sends nowhere | |
| assert "subject" not in c.get("/api/me", headers=tok("s")).json() | |
| # the developer picks an admin's subject from the list; free text is refused | |
| listed = c.get("/api/admin-subjects", headers=tok("d")).json() | |
| assert listed["subjects"] == [{"code": "arabic", "label": "ערבית"}, {"code": "math", "label": "מתמטיקה"}] | |
| assert listed["admins"] == [{"id": "u2", "email": "t@b.c", "name": "T", "subject": "מתמטיקה"}] | |
| put = lambda subject: c.put("/api/admin-subjects/u2", json={"subject": subject}, headers=tok("d")) | |
| assert put("חשבון").status_code == 400 and auth.access["u2"]["subject"] == "מתמטיקה" | |
| assert put(" ערבית ").json() == {"ok": True, "subject": "ערבית"} and auth.access["u2"]["subject"] == "ערבית" | |
| assert c.get("/api/me", headers=tok("t")).json()["subject"] == "arabic" | |