Files
Hankan/test/domain/reset.test.ts
MechaCat02 f8183d786f feat(dict): stable lemma ids — a card names its word, not a build position
Cards point at lemmas by id, and an id was the entry's position in the
sorted build. One word added near the top of the dictionary would have
moved every card below it onto a different word — silently, because loaded
bands were recorded by number and a rebuilt dictionary never reached an
existing install anyway. A custom word took max(id)+1 on whichever device
added it, so the same id meant different words on a phone and a laptop.

An id is now lemmaId(headword, pos), a 53-bit hash defined once in
shared/ and used by the build, the loader, custom words and the migration.
The build asserts all 30,520 entries are collision-free, and a test pins the
function itself, since changing it re-keys every card.

Band files are format 2: they carry no ids at all. The loader derives each
id from the word, and a surface names its lemma by row index in the same
file. Writing hashed ids out cost 0.5 MB of incompressible digits; leaving
them out makes the files smaller than before (1.1 MB -> 1.0 MB).

The loaded dictionary is now versioned by its band hashes, so a rebuild
reloads on the next boot — safe only now that a reload cannot move a card.

Migration 6 re-keys an existing install without stamping anything: cards
and their tombstones move through the lemma rows still loaded, custom words
become custom_word rows (the learner's data, which can travel) carrying the
time their card was made, and the dictionary is dropped to reload.

Sync is paused until the protocol that replaces it lands: the server still
holds rows under the old ids, and exchanging them would plant cards that
name no word.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 19:59:14 +02:00

154 lines
5.6 KiB
TypeScript

/* What a full wipe has to leave behind: nothing the learner did.
The reported failure was that "Reset everything" did not clear learned
words. It did delete them — and then also deleted the `seed.known` guard,
so the next boot re-seeded ~47 cards from the known-words list and the
deck looked untouched. The seed is gone now; these tests pin the wipe so
a future seed cannot reintroduce the same shape. */
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { SqliteWasmDb } from "@app/db/sqlite-wasm-core.js";
import { migrate } from "@app/db/migrate.js";
import {
editAddCustomWord,
editCard,
editChatTurn,
editMeta,
editReset,
editStudyLog,
insertBand,
seedCard,
type LemmaRow,
} from "@app/db/writes.js";
import { newCard, markKnown } from "@lib/srs.js";
import type { Db } from "@app/db/types.js";
let db: Db;
beforeEach(async () => {
db = await SqliteWasmDb.open({ memory: true });
await migrate(db);
});
afterEach(async () => {
await db.close();
});
const lemma = (id: number, headword: string): LemmaRow => ({
id,
headword,
pos: "noun",
freq_rank: id,
level: null,
gloss_en: `gloss ${id}`,
gloss_ko: "",
unit_band: 0,
source: "curated",
});
const count = async (tbl: string): Promise<number> =>
(await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM ${tbl}`))!.n;
async function aLearnerWithHistory(): Promise<void> {
await insertBand(db, [lemma(1, "물"), lemma(2, "책"), lemma(3, "가다")], []);
await editCard(db, 1, markKnown(10));
await editCard(db, 2, newCard());
await editStudyLog(db, 10, { reviews: 4, correct: 3 });
await editChatTurn(db, "user", "Start unit 1.1.");
await editChatTurn(db, "assistant", "좋아요.");
await editMeta(db, "prefs.focus", "particles");
await editMeta(db, "grammar.learned", '["p1"]');
await db.run("INSERT INTO progress (unit_id, confidence, updated_at) VALUES ('1.1', 40, 1)");
}
describe("editReset('everything')", () => {
it("leaves no cards, no study log, no transcript, no progress", async () => {
await aLearnerWithHistory();
await editReset(db, "everything");
for (const tbl of ["card", "study_log", "chat", "progress", "peek"]) {
expect(await count(tbl), `${tbl} should be empty after a full wipe`).toBe(0);
}
});
it("clears the learner's preferences, grammar flags and notes", async () => {
await aLearnerWithHistory();
await editReset(db, "everything");
const left = await db.all<{ k: string }>(
"SELECT k FROM meta WHERE k LIKE 'prefs.%' OR k LIKE 'grammar.%' OR k LIKE 'trainer.%'",
);
expect(left).toEqual([]);
});
it("keeps the dictionary, which is reference data and not the learner's", async () => {
await aLearnerWithHistory();
await editReset(db, "everything");
expect(await count("lemma")).toBe(3);
});
it("tombstones what it deleted, so a sync cannot restore it", async () => {
await aLearnerWithHistory();
await editReset(db, "everything");
const graves = await db.all<{ tbl: string }>("SELECT DISTINCT tbl FROM tombstone");
const tables = graves.map((g) => g.tbl).sort();
expect(tables).toContain("card");
expect(tables).toContain("chat");
expect(tables).toContain("progress");
expect(tables).toContain("study_log");
});
it("removes the learner's own words, lemma and all, and tombstones them", async () => {
await aLearnerWithHistory();
const { lemmaId } = await editAddCustomWord(db, { headword: "던전", gloss: "dungeon", pos: "noun" });
await editReset(db, "everything");
expect(await count("custom_word")).toBe(0);
expect(await db.get("SELECT 1 FROM lemma WHERE id = ?", [lemmaId])).toBeUndefined();
expect(await db.get("SELECT 1 FROM surface WHERE lemma_id = ?", [lemmaId])).toBeUndefined();
expect(await db.get("SELECT pk FROM tombstone WHERE tbl = 'custom_word'")).toEqual({
pk: JSON.stringify(["던전", "noun"]),
});
expect(await count("lemma")).toBe(3); // the shipped words stay
});
it("does not survive a reboot: nothing reseeds the deck", async () => {
await aLearnerWithHistory();
await editReset(db, "everything");
// Re-running the migrations is what a restart does.
await migrate(db);
expect(await count("card")).toBe(0);
});
});
describe("editReset('roadmap')", () => {
it("clears the roadmap and transcript but keeps the deck and history", async () => {
await aLearnerWithHistory();
await editReset(db, "roadmap");
expect(await count("progress")).toBe(0);
expect(await count("chat")).toBe(0);
expect(await count("card")).toBe(2);
expect(await count("study_log")).toBe(1);
});
});
describe("migration 5 — the known-words seed is gone", () => {
it("removes an existing install's seeded cards and keeps graded ones", async () => {
await insertBand(db, [lemma(1, "물"), lemma(2, "책")], []);
await seedCard(db, 1, markKnown(10)); // what the seed used to write
await editCard(db, 2, newCard()); // something the learner actually did
await db.run("INSERT INTO meta (k, v, updated_at) VALUES ('seed.known', '47', 0)");
// Wind the recorded version back to 4 so migrate() really runs 5 — and
// only 5: migration 6 re-keys cards, which is not what this pins.
await db.run("INSERT OR REPLACE INTO meta (k, v, updated_at) VALUES ('schema_version','4',0)");
const { from, to } = await migrate(db, 5);
expect(from).toBe(4);
expect(to).toBe(5);
const rows = await db.all<{ lemma_id: number }>("SELECT lemma_id FROM card");
expect(rows.map((r) => r.lemma_id)).toEqual([2]);
expect(await db.get("SELECT v FROM meta WHERE k = 'seed.known'")).toBeUndefined();
});
});