Three storage changes the reworked app needs, as migration 8. Progress. "Which unit is current" was a 'now' state on each unit's row. Two devices that advanced could leave two of them, and leaving a finished unit through the roadmap panel wrote it back to 'todo' — goToUnit un-finished work. Where he is now lives in one place, meta road.unit; a unit's row records only what is true of that unit: done, confidence, and room for the answer count and the tutor's note that earned progress needs. The migration carries the most recently written 'now' row across with its own stamp. Chat. Turn ids were INTEGER PRIMARY KEY — max+1 on whichever device wrote them, restarting at 1 after a clear — so two devices continuing a lesson both wrote turn 201 and sync treated two different turns as one row. Ids are UUIDv7 now, and the transcript is ordered by (created_at, id). Existing turns become legacy:<device>:<n>, zero-padded so turns sharing a timestamp keep the order they were written in; their tombstones are renamed with them. The learner model gets its tables: evidence (lib/srs.js's record, plus the rounds of the first and last CORRECT answer, which PORT.md measures and lib does not), confusion, and phase_ledger for the 다지기 checklist, with a confirmed flag so putting an item back is an edit rather than a delete. A roadmap reset now clears the ledger and road.*; a full wipe also clears evidence, confusions and learner.* — the artifact's wipe left its round counter and confusion list behind. What a learner knows about words survives a roadmap-only reset, as it should. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
172 lines
6.6 KiB
TypeScript
172 lines
6.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 editMeta(db, "road.unit", "1.10");
|
|
await editMeta(db, "learner.round", "12");
|
|
await db.run("INSERT INTO progress (unit_id, confidence, updated_at) VALUES ('1.1', 40, 1)");
|
|
await db.exec(`
|
|
INSERT INTO evidence (word, ok, rounds, updated_at) VALUES ('물', 2, 2, 1);
|
|
INSERT INTO confusion (word, mistook, updated_at) VALUES ('물', '불', 1);
|
|
INSERT INTO phase_ledger (phase, kind, item, updated_at) VALUES (1, 'rule', '연음', 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", "evidence", "confusion", "phase_ledger"]) {
|
|
expect(await count(tbl), `${tbl} should be empty after a full wipe`).toBe(0);
|
|
}
|
|
});
|
|
|
|
it("forgets where he was and how far the rounds had got", async () => {
|
|
await aLearnerWithHistory();
|
|
await editReset(db, "everything");
|
|
expect(await db.all("SELECT k FROM meta WHERE k LIKE 'road.%' OR k LIKE 'learner.%'")).toEqual([]);
|
|
});
|
|
|
|
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("phase_ledger")).toBe(0);
|
|
expect(await db.get("SELECT v FROM meta WHERE k = 'road.unit'")).toBeUndefined();
|
|
expect(await count("card")).toBe(2);
|
|
expect(await count("study_log")).toBe(1);
|
|
// What he knows about words is not the roadmap.
|
|
expect(await count("evidence")).toBe(1);
|
|
expect(await db.get("SELECT v FROM meta WHERE k = 'learner.round'")).toEqual({ v: "12" });
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|