/* The vocabulary bands and the phonological ladder — the two things that decide what the tutor is allowed to reach for. */ import { describe, it, expect } from "vitest"; import { BANDS, REFERENCE_BAND, bandForPhase, bandForUnit, bandOf, ceilingForBand } from "@shared/bands.mjs"; import { featureLevel, isReadableAt, phonologyViolations, LADDER_COMPLETE } from "@shared/phonology.mjs"; import { flatten } from "@lib/gate.js"; import curriculum from "@data/curriculum.json"; import type { Curriculum } from "@lib/gate.js"; const units = flatten(curriculum as unknown as Curriculum); const indexOf = (id: string) => units.findIndex((u) => u.id === id); describe("bands", () => { it("gives one band per curriculum phase, widening as it goes", () => { expect(BANDS).toHaveLength(6); const ceilings = BANDS.map((b) => b.maxFreq); for (let i = 1; i < ceilings.length; i++) { expect(ceilings[i]!, `band ${i} must not be narrower than band ${i - 1}`).toBeGreaterThan( ceilings[i - 1]!, ); } }); it("maps phases and unit ids onto bands", () => { expect(bandForPhase(1)).toBe(0); expect(bandForPhase(6)).toBe(5); expect(bandForUnit("1.1")).toBe(0); expect(bandForUnit("3.4")).toBe(2); expect(bandForUnit("6.8")).toBe(5); }); it("clamps a phase outside the curriculum rather than throwing", () => { expect(bandForPhase(0)).toBe(0); expect(bandForPhase(99)).toBe(5); }); it("admits phase 1 to curated words only — no frequency band at all", () => { expect(ceilingForBand(0)).toBe(0); // Even the single most frequent word in Korean is not admitted by rank. expect(bandOf({ source: "kaikki", freqRank: 1, level: null })).toBeGreaterThan(0); // But a curated word is always available. expect(bandOf({ source: "curated", freqRank: null, level: null })).toBe(0); expect(bandOf({ source: "grammar", freqRank: null, level: null })).toBe(0); }); it("puts a common word in an early band and a rare one late", () => { const common = bandOf({ source: "kaikki", freqRank: 200, level: null }); const mid = bandOf({ source: "kaikki", freqRank: 4000, level: null }); const rare = bandOf({ source: "kaikki", freqRank: 12000, level: null }); expect(common).toBeLessThan(mid); expect(mid).toBeLessThan(rare); }); it("lets the curated level override raw frequency", () => { // Same rank, different graded level — the graded one lands later. const beginner = bandOf({ source: "krdict", freqRank: 900, level: "초급" }); const advanced = bandOf({ source: "krdict", freqRank: 900, level: "고급" }); expect(beginner).toBeLessThan(advanced); }); it("sends unranked and very rare words to the reference band", () => { expect(bandOf({ source: "kaikki", freqRank: null, level: null })).toBe(REFERENCE_BAND); expect(bandOf({ source: "kaikki", freqRank: 999_999, level: null })).toBe(REFERENCE_BAND); }); it("keeps the reference band above every gated band, so it can never leak", () => { for (const b of BANDS) expect(REFERENCE_BAND).toBeGreaterThan(b.band); }); }); describe("the phonological ladder", () => { it("agrees with validate.mjs — no curriculum word breaks its own unit", () => { const violations: string[] = []; units.forEach((u, i) => { const level = featureLevel(i, indexOf); for (const w of u.words ?? []) { const v = phonologyViolations(w, level); if (v.length) violations.push(`${u.id} ${w}: ${v.join(", ")}`); } }); expect(violations).toEqual([]); }); it("climbs monotonically through phase 1", () => { const levels = ["1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7", "1.8"].map((id) => featureLevel(indexOf(id), indexOf), ); for (let i = 1; i < levels.length; i++) { expect(levels[i]!, `unit 1.${i + 1}`).toBeGreaterThan(levels[i - 1]!); } }); it("blocks a sound the learner has not reached", () => { const at11 = featureLevel(indexOf("1.1"), indexOf); // Unit 1.1 is batchim-free and uses only the ten basic vowels. expect(isReadableAt("가", at11)).toBe(true); expect(isReadableAt("밥", at11)).toBe(false); // final consonant expect(isReadableAt("개", at11)).toBe(false); // compound vowel expect(isReadableAt("까", at11)).toBe(false); // tense consonant }); it("admits a double batchim only once 1.7 has been reached", () => { const before = featureLevel(indexOf("1.6"), indexOf); const after = featureLevel(indexOf("1.7"), indexOf); expect(isReadableAt("값", before)).toBe(false); expect(isReadableAt("값", after)).toBe(true); }); it("admits a liaison context only once 1.5 has been reached", () => { const before = featureLevel(indexOf("1.4"), indexOf); const after = featureLevel(indexOf("1.5"), indexOf); expect(phonologyViolations("음악", before)).toContain("liaison context"); expect(isReadableAt("음악", after)).toBe(true); }); it("is a no-op once the sound phase is over", () => { const at61 = featureLevel(indexOf("6.1"), indexOf); expect(at61).toBe(LADDER_COMPLETE); for (const w of ["괜찮다", "값", "읽었어", "많이"]) { expect(isReadableAt(w, at61), w).toBe(true); } }); it("ignores non-Hangul characters rather than choking on them", () => { expect(phonologyViolations("ABC 123", 0)).toEqual([]); expect(phonologyViolations("", 0)).toEqual([]); }); });