/* audit-gate.mjs, reproduced through the PORT's own code. run-checks.sh measures lib/ on its own. This measures what the app actually does: the allowed set built from its database, the resolver that falls back to its dictionary, parse() as the tutor tab sees it. If the two ever disagree, the port is not running the gate that was measured. Numbers from audit-gate.mjs, and why they are what they are, are in that file: 7 of 41 earlier messages, 2 of 13 from the live 1.10 review. */ import { describe, it, expect, beforeAll, afterAll, vi } from "vitest"; import { readFileSync } from "node:fs"; import { SqliteWasmDb } from "@app/db/sqlite-wasm-core.js"; import { migrate } from "@app/db/migrate.js"; import { editCard } from "@app/db/writes.js"; import type { Db } from "@app/db/types.js"; import { parse } from "@lib/blocks.js"; import { proseIsKorean, scanTask, taskMaterial } from "@lib/gate.js"; import { markKnown } from "@lib/srs.js"; import { allowedSet, courseScaffold, gateFor, metWords, UNITS, unitOf } from "@app/domain/gate.js"; import { headsFor } from "@app/domain/resolver.js"; const read = (f: string) => JSON.parse(readFileSync(new URL(`../../${f}`, import.meta.url), "utf8")); const DICT = new URL("../../app/public/dict/", import.meta.url); interface Snapshot { road: { done: Record; unit: string }; deckWordsMet: string[]; allowedWordsExpected: string[]; } const snapshot = read("fixtures/progress-snapshot.json") as Snapshot; let db: Db; beforeAll(async () => { vi.stubGlobal("fetch", async (input: string) => { const name = String(input).split("/dict/")[1] ?? ""; return new Response(readFileSync(new URL(name, DICT))); }); db = await SqliteWasmDb.open({ memory: true }); await migrate(db); const { ensureBands } = await import("@app/domain/dictionary.js"); await ensureBands(db, 6); // The student's state in the snapshot: the deck words he has met. for (const w of snapshot.deckWordsMet) { const lemma = await db.get<{ id: number }>( `SELECT id FROM lemma WHERE headword = ? ORDER BY unit_id IS NULL, source <> 'curated', id LIMIT 1`, [w], ); if (lemma) await editCard(db, lemma.id, markKnown(20_000)); } }, 60_000); afterAll(async () => { vi.unstubAllGlobals(); await db.close(); }); const progress = () => ({ current: snapshot.road.unit, done: Object.fromEntries(Object.keys(snapshot.road.done).map((k) => [k, true])), confidence: {}, }); describe("the allowed set", () => { it("is the one the shipped app computed from this state — 149 words", async () => { const met = await metWords(db); const done = new Set(Object.keys(snapshot.road.done)); const base = new Set([...UNITS.filter((u) => done.has(u.id)).flatMap((u) => u.words ?? []), ...met]); const expected = new Set(snapshot.allowedWordsExpected); expect([...expected].filter((w) => !base.has(w)), "missing here").toEqual([]); expect([...base].filter((w) => !expected.has(w)), "extra here").toEqual([]); }); }); async function audit(file: string): Promise { const gate = gateFor({ progress: progress(), met: await metWords(db) }); const allowed = allowedSet(gate); const scaffold = courseScaffold(); const { messages } = read(file) as { messages: string[] }; const flagged: string[] = []; for (const [i, m] of messages.entries()) { const parsed = parse(m); const tokens = taskMaterial(parsed.task).join(" ").match(/[가-힣]+/g) ?? []; const heads = await headsFor(db, tokens); const found = scanTask(parsed, { allowed, scaffold, heads, unitOf }).map((f) => f.word); if (proseIsKorean(m)) found.push("«KO»"); if (found.length) flagged.push(`#${i} ${found.join(" ")}`); } return flagged; } describe("the audit, through the port", () => { it("flags 7 of the 41 earlier messages — the shipped gate's count", async () => { expect(await audit("fixtures/tutor-messages.json")).toEqual([ "#10 마셔", "#14 «KO»", "#18 마셔", "#20 마셔", "#27 마셔", "#30 살 이야", "#32 살 이야", ]); }); it("flags 2 of the 13 from the live 1.10 review — both 아파, from unit 3.2", async () => { expect(await audit("fixtures/tutor-messages-live.json")).toEqual(["#1 아파", "#3 아파"]); }); });