The reworked app's learner model hangs off cards: recall evidence, the phase-review checklist, the practice set, and the gate's "words he has met". So every word a unit introduces has to be studiable — and 16 of the 371 were not. Eleven existed only as sentence chunks or dictionary rows outside the review deck, and five (봐 읽어 갔어 봤어 먹었어) nowhere at all. The build now marks exactly one reviewable lemma per roadmap word with the unit that introduces it. Where the deck has the word, its row is chosen deterministically (deck order, then source, then part of speech) — the artifact tagged whichever card came last, which put the evidence for 이, 눈 and 저 on the wrong meaning. The sixteen get a `curriculum` lemma of their own, glossed from the curated verb they conjugate (자 is "sleep", the 반말 of 자다 — not the dictionary's "ruler"), else from the sentence that uses them, else the dictionary. Lemmas also carry their topic, which the vocabulary filters need. dict:assert gains the guarantee: 371/371 roadmap words as one card each. Migration 7 adds the two columns; the rows arrive with the dictionary reload a changed build now triggers on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
116 lines
4.6 KiB
TypeScript
116 lines
4.6 KiB
TypeScript
/* Loading the shipped dictionary, and reloading it when it changes.
|
|
|
|
Loaded bands used to be recorded by number alone, so a rebuilt dictionary
|
|
never reached an existing install. Reloading only became safe once an id
|
|
named a word instead of a position — these pin both halves. */
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, 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, editAddCustomWord } from "@app/db/writes.js";
|
|
import { markKnown } from "@lib/srs.js";
|
|
import { lemmaId } from "@shared/lemma-id.mjs";
|
|
import type { Db } from "@app/db/types.js";
|
|
|
|
const DICT = new URL("../../app/public/dict/", import.meta.url);
|
|
const shipped = JSON.parse(readFileSync(new URL("manifest.json", DICT), "utf8")) as {
|
|
bands: { band: number; file: string; sha256: string; lemmas: number }[];
|
|
};
|
|
|
|
/** Serve the committed files; `manifest` lets a test pretend to be a rebuild. */
|
|
function serve(manifest: unknown) {
|
|
vi.stubGlobal("fetch", async (input: string) => {
|
|
const name = String(input).split("/dict/")[1] ?? "";
|
|
if (name === "manifest.json") return new Response(JSON.stringify(manifest));
|
|
return new Response(readFileSync(new URL(name, DICT)));
|
|
});
|
|
}
|
|
|
|
async function loader() {
|
|
vi.resetModules(); // the manifest is cached per module instance
|
|
return import("@app/domain/dictionary.js");
|
|
}
|
|
|
|
let db: Db;
|
|
beforeEach(async () => {
|
|
db = await SqliteWasmDb.open({ memory: true });
|
|
await migrate(db);
|
|
});
|
|
afterEach(async () => {
|
|
vi.unstubAllGlobals();
|
|
await db.close();
|
|
});
|
|
|
|
const count = async (sql: string, params: (string | number)[] = []) =>
|
|
(await db.get<{ n: number }>(sql, params))!.n;
|
|
|
|
describe("loading a band", () => {
|
|
it("derives every id from the word, so surfaces join their lemmas", async () => {
|
|
serve(shipped);
|
|
const { ensureBands } = await loader();
|
|
await ensureBands(db, 0);
|
|
|
|
expect(await count("SELECT count(*) AS n FROM lemma")).toBe(shipped.bands[0]!.lemmas);
|
|
const rice = await db.get<{ id: number }>("SELECT id FROM lemma WHERE headword = '밥' AND pos = 'noun'");
|
|
expect(rice?.id).toBe(lemmaId("밥", "noun"));
|
|
expect(await count("SELECT count(*) AS n FROM surface s LEFT JOIN lemma l ON l.id = s.lemma_id WHERE l.id IS NULL")).toBe(0);
|
|
});
|
|
|
|
it("does not load a band twice", async () => {
|
|
serve(shipped);
|
|
const { ensureBands } = await loader();
|
|
expect(await ensureBands(db, 0)).toEqual([0]);
|
|
expect(await ensureBands(db, 0)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("curriculum words", () => {
|
|
it("arrive as studiable cards, each tagged with the unit that introduces it", async () => {
|
|
serve(shipped);
|
|
const { ensureBands } = await loader();
|
|
await ensureBands(db, 1);
|
|
const { deck } = await import("@app/domain/cards.js");
|
|
const entries = await deck(db);
|
|
|
|
const eat = entries.find((e) => e.headword === "먹어");
|
|
expect(eat).toMatchObject({ source: "curriculum", unitId: "2.3", glossEn: "eat" });
|
|
expect(eat!.lemmaId).toBe(lemmaId("먹어", "form"));
|
|
|
|
const tagged = entries.filter((e) => e.unitId?.startsWith("1."));
|
|
const phase1 = (await import("@app/domain/gate.js")).UNITS.filter((u) => u.phase === 1).flatMap((u) => u.words ?? []);
|
|
expect(tagged.map((e) => e.headword).sort()).toEqual([...new Set(phase1)].sort());
|
|
});
|
|
});
|
|
|
|
describe("a rebuilt dictionary", () => {
|
|
it("reloads, and every card and custom word still names its word", async () => {
|
|
serve(shipped);
|
|
let dict = await loader();
|
|
await dict.ensureBands(db, 0);
|
|
const rice = lemmaId("밥", "noun");
|
|
await editCard(db, rice, markKnown(100));
|
|
const custom = await editAddCustomWord(db, { headword: "던전돌", gloss: "dungeon stone", pos: "noun" });
|
|
|
|
// A rebuild: band 0's file hash changes.
|
|
const rebuilt = structuredClone(shipped);
|
|
rebuilt.bands[0]!.sha256 = "f".repeat(64);
|
|
serve(rebuilt);
|
|
dict = await loader();
|
|
await db.run("DELETE FROM lemma WHERE headword = '물'"); // prove the reload really re-inserts
|
|
expect(await dict.ensureBands(db, 0)).toEqual([0]);
|
|
|
|
expect(await count("SELECT count(*) AS n FROM lemma WHERE headword = '물'")).toBeGreaterThan(0);
|
|
const card = await db.get<{ headword: string }>(
|
|
"SELECT l.headword FROM card c JOIN lemma l ON l.id = c.lemma_id WHERE c.lemma_id = ?",
|
|
[rice],
|
|
);
|
|
expect(card?.headword).toBe("밥");
|
|
const mine = await db.get<{ headword: string; source: string }>(
|
|
"SELECT headword, source FROM lemma WHERE id = ?",
|
|
[custom.lemmaId],
|
|
);
|
|
expect(mine).toEqual({ headword: "던전돌", source: "custom" });
|
|
});
|
|
});
|