Files
Hankan/test/db/migration-8.test.ts
MechaCat02 a1c86d9550 feat(tutor): the turn enforced — retries, evidence, the 다지기 checklist, earned progress
"The client enforces; the prompt only explains." Every rule the artifact's
tutor was merely asked to follow, it broke: it certified words on one
correct answer, scored a unit before anything was answered, used a word
from three phases ahead, answered in Korean, and invented spelling
diagnoses. The reworked app fixed each by making the client refuse. This
ports those refusals; domain/turn.ts holds the turn, testable without React.

The gate. A reply is scanned before he sees it — the side of the exercise
he must decode, through the one resolver, and its prose for Korean. A
refused draft is never stored, shown or applied: the tutor is asked again
and told exactly why. After two retries the reply is shown with its words
flagged, and the next turn names them. (The artifact's follow-up told the
tutor it could declare such a word in ::words; that contradicts the gate
and is left out.)

Marking. ::result feeds recall evidence per word. lib/srs.js is looser
than PORT.md, so the call site tightens it: one outcome per word per round,
and "learned" also needs five rounds between the first and last CORRECT
answer — lib alone counted a wrong answer as the start of the span. A
lookup is never recall. What he mistook a word for is kept. The schedule
takes at most one good grade a day from marking; in the artifact five good
rounds in one afternoon made a word "secure" by interval alone.

Phase reviews. The client holds the 다지기 checklist — each unit's rule and
every word the phase introduced, 132 items for Phase 1 — worked in batches
of ten. ::confirmed ticks a rule on the tutor's word but a word only on
evidence; "-item" puts one back; anything off the list is ignored.

Progress is earned: ignored until the unit has an answer, +25 at most per
message, a fall honoured in full, and the next unit only at 85% with three
answers — plus, in a review, nothing open. advanceUnit() enforces it too,
not only the banner.

The prompt gains a per-round tail after the shipped prompt — the practice
set (scored on the evidence, round-robin by word class, each word with the
words one letter away), the checklist, retry notes — sent as a second,
uncached system block so the stable prefix still caches.

Also: recall answers carry the letter-level jamo comparison (kept out of
his own bubble, since it is written to the model); match chips are keyed by
pair index, the bug PORT.md names; and the stand-in tutor exercises every
path offline — recall, ::result, ::confirmed, progress only after answers.

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

101 lines
4.2 KiB
TypeScript

/* Migration 8 — the roadmap loses its 'now' rows; chat turns get ids that
two devices can share.
A database as the previous build left it, including the state an earlier
sync could produce: two units both marked 'now'. */
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 { readProgress } from "@app/domain/progress.js";
import type { Db } from "@app/db/types.js";
let db: Db;
beforeEach(async () => {
db = await SqliteWasmDb.open({ memory: true });
await migrate(db, 7);
await db.exec(`
INSERT INTO progress (unit_id, state, confidence, updated_at) VALUES
('1.1', 'done', 87, 100),
('1.2', 'now', 40, 300),
('1.3', 'now', 10, 200),
('1.4', 'todo', 0, 0);
INSERT INTO chat (id, role, body, created_at, updated_at) VALUES
(1, 'user', 'Start unit 1.1.', 0, 0),
(2, 'assistant', 'first', 0, 0),
(10, 'assistant', 'tenth', 0, 0),
(11, 'user', 'later', 500, 500);
INSERT INTO tombstone (tbl, pk, updated_at) VALUES ('chat', '9', 400);
`);
await migrate(db);
});
afterEach(async () => {
await db.close();
});
describe("migration 8", () => {
it("keeps what is true of each unit, and nothing about being current", async () => {
expect(await db.all("SELECT unit_id, done, confidence, updated_at FROM progress ORDER BY unit_id")).toEqual([
{ unit_id: "1.1", done: 1, confidence: 87, updated_at: 100 },
{ unit_id: "1.2", done: 0, confidence: 40, updated_at: 300 },
{ unit_id: "1.3", done: 0, confidence: 10, updated_at: 200 },
{ unit_id: "1.4", done: 0, confidence: 0, updated_at: 0 },
]);
});
it("puts him where he most recently was, as exactly as new as that was", async () => {
expect(await db.get("SELECT v, updated_at FROM meta WHERE k = 'road.unit'")).toEqual({
v: "1.2",
updated_at: 300,
});
expect((await readProgress(db)).current).toBe("1.2");
});
it("gives every turn a text id and keeps the transcript in the order it was written", async () => {
const turns = await db.all<{ id: string; body: string }>("SELECT id, body FROM chat ORDER BY created_at, id");
expect(turns.map((t) => t.body)).toEqual(["Start unit 1.1.", "first", "tenth", "later"]);
for (const t of turns) expect(t.id).toMatch(/^legacy:[0-9a-f-]{36}:\d{10}$/);
});
it("renames a chat tombstone the same way", async () => {
const device = (await db.get<{ v: string }>("SELECT v FROM meta WHERE k = 'sync.device'"))!.v;
expect(await db.all("SELECT pk, updated_at FROM tombstone WHERE tbl = 'chat'")).toEqual([
// As JSON, once migration 9 has written keys in protocol 2's form.
{ pk: JSON.stringify([`legacy:${device}:0000000009`]), updated_at: 400 },
]);
});
it("names this install without stamping it", async () => {
expect(await db.get("SELECT updated_at FROM meta WHERE k = 'sync.device'")).toEqual({ updated_at: 0 });
});
it("creates the learner-model tables empty", async () => {
for (const tbl of ["evidence", "confusion", "phase_ledger"]) {
expect(await db.get(`SELECT count(*) AS n FROM ${tbl}`), tbl).toEqual({ n: 0 });
}
});
});
describe("the new roadmap model", () => {
it("does not un-finish a unit that is revisited", async () => {
const { goToUnit } = await import("@app/domain/progress.js");
await goToUnit(db, await readProgress(db), "1.1");
const p = await readProgress(db);
expect(p.current).toBe("1.1");
expect(p.done["1.1"]).toBe(true);
});
it("finishes the current unit and moves on in one step — once it is earned", async () => {
const { advanceUnit, noteAnswer, applyProgressReport } = await import("@app/domain/progress.js");
expect(await advanceUnit(db, await readProgress(db)), "40% and no answers").toBeNull();
for (let i = 0; i < 3; i++) await noteAnswer(db, await readProgress(db), `answer ${i}`);
for (const n of [65, 90]) await applyProgressReport(db, await readProgress(db), n, "");
const next = await advanceUnit(db, await readProgress(db));
const p = await readProgress(db);
expect(next).toBe("1.3");
expect(p.current).toBe("1.3");
expect(p.done["1.2"]).toBe(true);
});
});