/* 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" }); }); });