chore: take in the 16 Sep bundle — lib, curriculum v5, prompt, gate audit

The artifact was reworked after real incidents: a week of lost data, a
student taught out of order, and spelling diagnoses the model invented.
This takes the new export in verbatim; the port catches up in the
commits that follow.

Copied byte-identical from the bundle:
  lib/        lexicon.js and sync.js are new; gate.js gains enforcement,
              hangul.js letter-level marking, srs.js recall evidence,
              conjugation.js deconjugate(); blocks.js now takes the last
              block, closes gloss at "=", and parses recall, ::result and
              ::confirmed
  data/       curriculum.json v5 — six 다지기 phase reviews; the 371
              roadmap words are unchanged and no band moves
  prompt/     English-only rule, recall, LETTER-LEVEL CHECK, marking
  audit-gate.mjs, run-checks.sh, fixtures/  — the word gate measured
              against 54 real tutor messages

CI runs run-checks.sh in place of validate.mjs alone, and `npm run check`
gains the audit. Baselines: validate PASS 0/0; audit 7 of 41 and 2 of 13.

types/lib/ declares the new API, and test/lib/ pins it: letterCheck on
the prompt's own 짧다/빫다 case, deconjugation, the roadmap-first order
that keeps 마셔 out of Phase 1, sync's three gates, and recall evidence —
including the two ways lib's evidence is looser than PORT.md, pinned as
they are so the call site that tightens them is visibly needed.

TaskHost gains a plain recall renderer so the tree typechecks against the
wider Task union.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 19:49:52 +02:00
parent 75dd699f3e
commit e72b77d6c2
34 changed files with 2201 additions and 89 deletions

View File

@@ -4,12 +4,12 @@
sentences appeared as prose above the exercise those same lines had
already been rendered into.
It is lib/blocks.js, not the model. parse() removes ::words by
truncating the body at its index, then removes ::task by substring
but RE.task's terminator (?:\n::|$) is part of the match, so t[0] ends
with the "\n::" of the following ::words that the truncation just cut
off. The substring no longer occurs, replace() is a no-op, and the whole
task block stays in the body.
It is lib/blocks.js, not the model. parse() deletes each block with a
regex whose terminator (?:\n::|$) is part of the match, so deleting one
block also deletes the "::" that opens the next. That block is then no
longer a block, and stays in the body as a bare word plus its rows. The
16 Sep lib changed which block survives — it now strips every kind in
turn instead of truncating at ::words — but not the defect.
Order is what decides it. The stub tutor emits ::words before ::task and
is therefore fine; a real model emitted ::task first. Nothing in the
@@ -33,15 +33,17 @@ function translateItems(task: ParsedMessage["task"]): { q: string }[] {
const CAPTURED = "::task translate\n\ub098 \uac00\ub2e4 \n\ub108 \uba39\ub2e4 \n\uc6b0\ub9ac \ub9c8\uc2dc\ub2e4 \n\uc774 \uc790\ub2e4 \n\uadf8 \ub098\ubb34 \uc11c\ub2e4 \n\n::words\n\ub098 | I, me (casual) | pron \n\uac00\ub2e4 | to go | verb (plain) \n\ub108 | you (casual) | pron \n\uba39\ub2e4 | to eat | verb (plain) \n\uc6b0\ub9ac | we, our | pron \n\ub9c8\uc2dc\ub2e4 | to drink | verb (plain) \n\uc774 | this | det \n\uc790\ub2e4 | to sleep | verb (plain) \n\uadf8 | that (near you) | det \n\ub098\ubb34 | tree | noun \n\uc11c\ub2e4 | to stand | verb (plain) \n\n::progress 20";
describe("lib/blocks.js — the leak, pinned as it is", () => {
it("leaves the whole ::task block in body when ::task precedes ::words", () => {
it("leaves the de-coloned ::words block in body when ::task precedes ::words", () => {
const r = parse(CAPTURED);
// The blocks themselves parse correctly...
expect(r.task?.type).toBe("translate");
expect(translateItems(r.task)).toHaveLength(5);
expect(r.words).toHaveLength(11);
// ...and the body still carries the markup that produced them.
expect(r.body).toContain("::task translate");
expect(r.body).toContain("나 가다");
// ...and the body carries the words block, its "::" eaten by the task's
// terminator: not even recognisable as markup any more.
expect(r.body).toMatch(/^words\n/);
expect(r.body).toContain("나 | I, me (casual) | pron");
expect(r.body).not.toContain("::");
});
it("is fine in the other order, which is why the stub never showed it", () => {

View File

@@ -15,22 +15,42 @@ describe("parse — ::words", () => {
]);
});
it("truncates the body at ::words — the block is contractually last", () => {
it("strips a closed block without truncating what follows it", () => {
// The old parse() cut the body at ::words; every block is now removed
// on its own, so prose after a properly closed block survives.
const p = parse("Read this.\n::words\n밥 | rice\n::\ntrailing junk");
expect(p.body).toBe("Read this.");
expect(p.body).toBe("Read this.\n\ntrailing junk");
});
});
describe("parse — the four task types", () => {
it("translate", () => {
describe("parse — the five task types", () => {
it("translate — and every task carries its raw rows and a retraction count", () => {
const p = parse("Try these.\n::task translate\n우리 밥 먹어\n학교 작아\n::");
expect(p.task).toEqual({ type: "translate", items: [{ q: "우리 밥 먹어" }, { q: "학교 작아" }] });
expect(p.task).toEqual({
type: "translate",
items: [{ q: "우리 밥 먹어" }, { q: "학교 작아" }],
retracted: 0,
rows: ["우리 밥 먹어", "학교 작아"],
});
expect(p.body).toBe("Try these.");
});
it("recall — English prompt, optional hint in the second field", () => {
const p = parse("::task recall\nchicken | double batchim\nthe sea\n::");
expect(p.task).toEqual({
type: "recall",
items: [
{ q: "chicken", hint: "double batchim" },
{ q: "the sea", hint: "" },
],
retracted: 0,
rows: ["chicken | double batchim", "the sea"],
});
});
it("match", () => {
const p = parse("::task match\n친구 | friend\n물 | water\n::");
expect(p.task).toEqual({
expect(p.task).toMatchObject({
type: "match",
pairs: [
{ ko: "친구", gloss: "friend" },
@@ -41,7 +61,7 @@ describe("parse — the four task types", () => {
it("build — first field is the English, the rest are chips in order", () => {
const p = parse("::task build\nWe eat rice. | 우리 | 밥 | 먹어\n::");
expect(p.task).toEqual({
expect(p.task).toMatchObject({
type: "build",
items: [{ en: "We eat rice.", chips: ["우리", "밥", "먹어"] }],
});
@@ -49,16 +69,22 @@ describe("parse — the four task types", () => {
it("choice", () => {
const p = parse("::task choice\n나 학교 ___ 가 | 에 | 에서 | 을\n::");
expect(p.task).toEqual({
expect(p.task).toMatchObject({
type: "choice",
items: [{ q: "나 학교 ___ 가", options: ["에", "에서", "을"] }],
});
});
it("drops malformed rows rather than emitting half a task", () => {
// A choice needs more than one option; a match needs both sides.
expect(parse("::task choice\nonly a question\n::").task).toEqual({ type: "choice", items: [] });
expect(parse("::task match\n친구\n::").task).toEqual({ type: "match", pairs: [] });
// A choice needs more than one option; a match needs both sides. The raw
// rows are kept regardless — the gate reads those, not the parsed items.
expect(parse("::task choice\nonly a question\n::").task).toEqual({
type: "choice",
items: [],
retracted: 0,
rows: ["only a question"],
});
expect(parse("::task match\n친구\n::").task).toMatchObject({ type: "match", pairs: [] });
});
it("is null when there is no task block", () => {
@@ -66,6 +92,51 @@ describe("parse — the four task types", () => {
});
});
/* The tutor sometimes writes an exercise, notices it broke the gate, and
writes a corrected one below. Matching the FIRST block handed the student
the draft that had just been withdrawn. */
describe("parse — several blocks of one kind", () => {
const reply = [
"Draft:",
"::task translate",
"원 없어",
"::",
"Sorry, 원 is not his yet. Here:",
"::task translate",
"물 없어",
"::",
"::words",
"물 | water",
"::",
"::words",
"물 | WATER",
"없어 | there is none",
"::",
"::progress 30 | a",
"::progress 40 | b",
].join("\n");
it("takes the LAST task and counts the drafts it replaced", () => {
const p = parse(reply);
expect(p.task).toMatchObject({ type: "translate", items: [{ q: "물 없어" }], retracted: 1 });
});
it("accumulates words, the first gloss of a term winning", () => {
expect(parse(reply).words).toEqual([
{ ko: "물", gloss: "water", note: "" },
{ ko: "없어", gloss: "there is none", note: "" },
]);
});
it("takes the last progress line", () => {
expect(parse(reply).progress).toEqual({ score: 40, note: "b" });
});
it("strips every block from the body, not only the one it used", () => {
expect(parse(reply).body).toBe("Draft:\n\nSorry, 원 is not his yet. Here:");
});
});
describe("parse — ::gloss", () => {
it("reads parts and the = line, defaulting the role to N", () => {
const p = parse(
@@ -83,23 +154,17 @@ describe("parse — ::gloss", () => {
]);
});
/* KNOWN LIMITATION, pinned deliberately.
The system prompt tells the tutor it may put several sentences in one
::gloss block, separated by their = lines. parse() sets `en` on the
current block when it meets "=", but never closes the block, so the
parts of every sentence pile into one run-on line and only the last
translation survives.
lib/ ships unchanged, so this is not fixed here. The app splits a
::gloss block on its = lines and calls parse() once per sentence —
see app/src/domain/gloss.ts. If lib/blocks.js is ever revised, the
one-line fix is `cur = null` after setting `en`, and this test and
that workaround both go away. */
it("does NOT close a block on the = line (see gloss.ts for the workaround)", () => {
it("closes a sentence at its = line, so one block can gloss several", () => {
const p = parse("::gloss\n나 | S | I\n가 | V | go\n= I go.\n밥 | O | rice\n= Rice.\n::");
expect(p.gloss).toHaveLength(1);
expect(p.gloss![0]!.parts.map((x) => x.ko)).toEqual(["나", "가", "밥"]);
expect(p.gloss![0]!.en).toBe("Rice."); // "I go." is lost
expect(p.gloss).toHaveLength(2);
expect(p.gloss![0]!.parts.map((x) => x.ko)).toEqual(["나", "가"]);
expect(p.gloss![0]!.en).toBe("I go.");
expect(p.gloss![1]!.en).toBe("Rice.");
});
it("accumulates separate gloss blocks", () => {
const p = parse("::gloss\n나 | S | I\n= I.\n::\nand\n::gloss\n밥 | O | rice\n= Rice.\n::");
expect(p.gloss!.map((g) => g.en)).toEqual(["I.", "Rice."]);
});
it("defaults a missing role to N and an absent highlight to empty", () => {
@@ -108,6 +173,41 @@ describe("parse — ::gloss", () => {
});
});
describe("parse — ::result", () => {
it("reads item | outcome | mistaken-for", () => {
expect(parse("::result\n닭 | ok\n여덟 | wrong | 여덜\n::").results).toEqual([
{ item: "닭", ok: true, mistakenFor: "" },
{ item: "여덟", ok: false, mistakenFor: "여덜" },
]);
});
it("counts only a literal ok as correct", () => {
// The prompt asks for "ok or wrong". Anything else is not a pass.
expect(parse("::result\n값 | right\n::").results![0]!.ok).toBe(false);
expect(parse("::result\n값 | OK\n::").results![0]!.ok).toBe(true);
});
it("takes the last block", () => {
expect(parse("::result\n닭 | wrong\n::\n::result\n닭 | ok\n::").results).toEqual([
{ item: "닭", ok: true, mistakenFor: "" },
]);
});
it("is null when there is no result block", () => {
expect(parse("Nice.").results).toBeNull();
});
});
describe("parse — ::confirmed", () => {
it("reads the first column, keeping a leading minus", () => {
expect(parse("::confirmed\n연음\n-닭\n값 | extra\n::").confirmed).toEqual(["연음", "-닭", "값"]);
});
it("takes the last block", () => {
expect(parse("::confirmed\n연음\n::\n::confirmed\n닭\n::").confirmed).toEqual(["닭"]);
});
});
describe("parse — ::progress", () => {
it("reads the score and the note", () => {
const p = parse("Nice work.\n::progress 72 | particles are landing");
@@ -142,6 +242,9 @@ describe("parse — a full reply", () => {
"밥 | rice",
"먹어 | eat | from 먹다",
"::",
"::result",
"밥 | ok",
"::",
"::progress 64 | word order is solid",
].join("\n");
@@ -151,6 +254,7 @@ describe("parse — a full reply", () => {
expect(p.task!.type).toBe("translate");
expect(p.words).toHaveLength(2);
expect(p.gloss).toHaveLength(1);
expect(p.results).toHaveLength(1);
expect(p.progress!.score).toBe(64);
});
});
@@ -173,6 +277,16 @@ describe("answerText — the message the student sends back", () => {
);
});
it("recall — the letter-level block rides between the answers and the lookups", () => {
const task = parse("::task recall\nchicken | double batchim\nthe sea\n::").task!;
expect(answerText(task, ["닭", ""], ["닭"], "BLOCK")).toBe(
"My written answers:\nchicken → 닭\nthe sea → (not sure)\n\nBLOCK\n\n(I had to look up: 닭)",
);
expect(answerText(task, ["닭", "바다"], [])).toBe(
"My written answers:\nchicken → 닭\nthe sea → 바다\n\n(No lookups.)",
);
});
it("match", () => {
const task = parse("::task match\n친구 | friend\n물 | water\n::").task!;
const out = answerText(task, { pairs: [{ ko: "친구", gloss: "friend" }] }, []);
@@ -201,6 +315,7 @@ describe("answerText — the message the student sends back", () => {
it("round-trips: every task type parses and answers without throwing", () => {
const blocks: [string, TaskState][] = [
["::task translate\n밥\n::", ["rice"]],
["::task recall\nrice\n::", ["밥"]],
["::task match\n밥 | rice\n::", { pairs: [] }],
["::task build\nRice. | 밥\n::", [["밥"]]],
["::task choice\n___ | 밥 | 물\n::", [1]],

View File

@@ -12,6 +12,8 @@ import {
explain,
surfaceForms,
IRREGULAR_FORMS,
deconjugate,
deconjugateCandidates,
} from "@lib/conjugation.js";
describe("haeche — the 아/어 rule", () => {
@@ -142,3 +144,35 @@ describe("surfaceForms — the build-time index generator", () => {
expect(surfaceForms("학교", "school")).toEqual([]);
});
});
/* Reading an inflected form back to its dictionary entry. Without it every
one of 660 realistic inflections of the curriculum's verbs failed to
resolve, and the student was told a taught word was not in the list. */
describe("deconjugate", () => {
const verbs = new Set(["가다", "앉다", "먹다", "마시다", "좋다"]);
const isVerb = (d: string) => verbs.has(d);
it.each([
["갑니다", "가다"],
["먹습니다", "먹다"],
["앉으면", "앉다"],
["가고", "가다"],
["가면", "가다"],
["가네", "가다"],
["간다", "가다"],
["먹었어요", "먹다"],
["좋아서", "좋다"],
])("%s → %s", (form, dict) => {
expect(deconjugate(form, isVerb)).toBe(dict);
});
it("proposes every plausible dictionary form, unguarded", () => {
expect(deconjugateCandidates("갑니다")).toEqual(["가다", "갑니다"]);
expect(deconjugateCandidates("앉으면")).toEqual(["앉다", "앉으다"]);
});
it("accepts nothing the lexicon does not hold as a verb — 가지 is an eggplant", () => {
expect(deconjugateCandidates("가지")).toContain("가다");
expect(deconjugate("가지", () => false)).toBeNull();
});
});

View File

@@ -3,7 +3,19 @@
The nine cases named in the export README are all here. */
import { describe, it, expect } from "vitest";
import { Composer, compose, decompose, isJamo, CHO, JUNG, JONG, KEYBOARD } from "@lib/hangul.js";
import {
Composer,
compose,
decompose,
isJamo,
CHO,
JUNG,
JONG,
KEYBOARD,
spellOut,
letterDiff,
letterCheck,
} from "@lib/hangul.js";
/** Type a sequence of jamo into a fresh composer, returning the final value. */
function type(jamo: string[]): string {
@@ -132,3 +144,55 @@ describe("KEYBOARD", () => {
for (const base of Object.keys(KEYBOARD.shift)) expect(flatKeys.has(base), base).toBe(true);
});
});
/* A model cannot see the letters inside a syllable. Asked about 빫다 for
짧다 the artifact's tutor blamed the ㄼ — identical in both — when the slip
was the initial ㅉ→ㅃ. These are the strings the tutor is handed instead. */
describe("letter-level marking", () => {
it("spells a word out jamo by jamo, unpacking a double batchim", () => {
expect(spellOut("짧다")).toBe("짧=ㅉ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ");
});
it("names the wrong slot AND the right ones — the prompt's own example", () => {
expect(letterDiff("짧다", "빫다")).toBe(
"syllable 1 (빫 for 짧) — WRONG: first consonant: wrote ㅃ, should be ㅉ — " +
"CORRECT, do not call these mistakes: vowel ㅏ, batchim ㄼ (ㄹ+ㅂ)",
);
});
it("catches a dropped half of a double batchim", () => {
expect(letterDiff("닭", "닥")).toBe(
"syllable 1 (닥 for 닭) — WRONG: batchim: wrote ㄱ, should be ㄺ (ㄹ+ㄱ) — " +
"CORRECT, do not call these mistakes: first consonant ㄷ, vowel ㅏ",
);
});
it("reports identity, a length mismatch, and says nothing about an empty side", () => {
expect(letterDiff("학교", "학교")).toBe("identical");
expect(letterDiff("바다", "바")).toBe("length differs: 2 syllables expected, 1 written");
expect(letterDiff("", "x")).toBe("");
});
it("builds the block a marking message carries, skipping correct answers", () => {
const block = letterCheck([
{ prompt: "to be short (dictionary form)", expected: "짧다", written: "빫다" },
{ prompt: "the sea", expected: "바다", written: "바다" },
]);
expect(block).toBe(
[
"════ LETTER-LEVEL CHECK — computed by the app ════",
"This is the actual jamo comparison. Use it exactly. Do NOT work out for yourself " +
"which letter was wrong, and never call a letter wrong that is listed as correct.",
"• to be short (dictionary form)",
" wanted 짧다 [짧=ㅉ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ]",
" wrote 빫다 [빫=ㅃ+ㅏ+ㄼ(ㄹ+ㅂ) · 다=ㄷ+ㅏ]",
" syllable 1 (빫 for 짧) — WRONG: first consonant: wrote ㅃ, should be ㅉ — " +
"CORRECT, do not call these mistakes: vowel ㅏ, batchim ㄼ (ㄹ+ㅂ)",
].join("\n"),
);
});
it("is empty when nothing differs, so no block is sent at all", () => {
expect(letterCheck([{ prompt: "the sea", expected: "바다", written: "바다" }])).toBe("");
});
});

123
test/lib/lexicon.test.ts Normal file
View File

@@ -0,0 +1,123 @@
/* Golden tests pinning lib/lexicon.js — the one resolver shared by word
lookup and the gate. It ships unchanged. */
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { buildLexicon, Lexicon, PARTICLES } from "@lib/lexicon.js";
import { haeche, past } from "@lib/conjugation.js";
import { flatten } from "@lib/gate.js";
import type { Curriculum } from "@lib/gate.js";
const conj = { haeche, past };
describe("ORDER MATTERS — roadmap words are entered first", () => {
/* 마셔 belongs to unit 2.3. Expanded from the deck first, it becomes a form
of 마시다 and inherits that stem's permission, so a unit-2.3 word turns
legal in Phase 1. In the fixture corpus that is four messages of
violations that stop being reported. */
it("keeps a scheduled form its own head", () => {
const lex = buildLexicon(
{ roadmapWords: ["마셔"], deck: [{ ko: "마시다", en: "to drink", pos: "verb" }] },
conj,
);
expect(lex.heads("마셔")).toEqual(["마셔"]);
expect(lex.get("마셔")).toMatchObject({ src: "roadmap", base: "" });
});
it("links it to the stem when the deck got there first", () => {
const lex = buildLexicon({ deck: [{ ko: "마시다", en: "to drink", pos: "verb" }] }, conj);
expect(lex.heads("마셔")).toEqual(["마셔", "마시다"]);
});
});
describe("heads — every route, best first", () => {
const lex = buildLexicon(
{
roadmapWords: ["닭"],
deck: [
{ ko: "마시다", en: "to drink", pos: "verb" },
{ ko: "친구", en: "friend", pos: "noun" },
],
glossExtra: [["이야", "it is"]],
sfx: [["쿵", "thud"]],
},
conj,
);
it("strips a particle", () => {
expect(lex.heads("닭이")).toEqual(["닭"]);
expect(lex.heads("친구가")).toEqual(["친구"]);
});
it("follows a generated form back to its stem", () => {
expect(lex.heads("마셨어")).toEqual(["마셨어", "마시다"]);
});
it("deconjugates a form nobody generated, but only onto a known verb", () => {
expect(lex.heads("마시면")).toEqual(["마시다"]);
});
it("returns nothing for a word it cannot place", () => {
expect(lex.heads("없는말")).toEqual([]);
});
});
describe("lookup — what the student sees on a tap", () => {
const lex = buildLexicon(
{ deck: [{ ko: "마시다", en: "to drink", pos: "verb" }, { ko: "친구", en: "friend", pos: "noun" }] },
conj,
);
it("notes the particle", () => {
expect(lex.lookup("친구가")).toMatchObject({ ko: "친구가", gloss: "friend", note: "with 조사 가" });
});
it("notes the dictionary form", () => {
expect(lex.lookup("마시면")).toMatchObject({ gloss: "to drink", note: "a form of 마시다" });
});
it("is null for an unknown word", () => {
expect(lex.lookup("없는말")).toBeNull();
});
});
describe("Lexicon", () => {
it("never replaces an existing entry — the first writer wins", () => {
const lex = new Lexicon();
lex.add("밥", "rice");
lex.add("밥", "meal");
expect(lex.get("밥")!.gloss).toBe("rice");
});
it("strips the longer particle forms", () => {
expect(PARTICLES).toEqual(expect.arrayContaining(["에서", "한테", "까지", "처럼"]));
});
});
describe("the shipped data", () => {
const read = (f: string) => JSON.parse(readFileSync(new URL(`../../data/${f}`, import.meta.url), "utf8"));
const curriculum = read("curriculum.json") as Curriculum;
const deck = read("deck.json") as { topics: Record<string, [string, string, string, string][]> };
const glossExtra = read("gloss-extra.json") as { entries: { ko: string; en: string; note?: string }[] };
const sentences = read("sentences.json") as { sentences: { parts: [string, string][] }[] };
const sfx = read("sfx.json") as { items: { ko: string; en: string }[] };
const units = flatten(curriculum);
const lex = buildLexicon(
{
roadmapWords: units.flatMap((u) => u.words ?? []),
deck: Object.values(deck.topics)
.flat()
.map(([ko, , en, pos]) => ({ ko, en, pos })),
glossExtra: glossExtra.entries.map((g) => [g.ko, g.en, g.note] as const),
sentences: sentences.sentences,
sfx: sfx.items.map((i) => [i.ko, i.en] as const),
},
conj,
);
it("resolves every roadmap word", () => {
const missing = units.flatMap((u) => u.words ?? []).filter((w) => !lex.lookup(w));
expect(missing).toEqual([]);
});
});

View File

@@ -19,6 +19,13 @@ import {
statusOf,
preview,
dayNumber,
LEARNED_OK,
LEARNED_STREAK,
LEARNED_SPAN,
newEvidence,
noteOutcome,
isLearned,
acceptConfirmation,
} from "@lib/srs.js";
const TODAY = 20_000;
@@ -168,3 +175,74 @@ describe("dayNumber", () => {
expect(c - b).toBe(1);
});
});
/* Recall evidence decides whether a word is KNOWN; the schedule above only
decides when to show it. The client refuses a ::confirmed it does not
support, because the tutor certified words on a single correct answer. */
describe("recall evidence", () => {
const run = (steps: [outcome: "ok" | "wrong", round: number, lookedUp?: boolean][]) =>
steps.reduce((e, [o, r, l]) => noteOutcome(e, o, r, l), newEvidence());
it("starts empty", () => {
expect(newEvidence()).toEqual({
ok: 0,
wrong: 0,
lookups: 0,
streak: 0,
firstRound: 0,
lastRound: 0,
lastSeen: 0,
rounds: 0,
});
expect([LEARNED_OK, LEARNED_STREAK, LEARNED_SPAN]).toEqual([3, 2, 5]);
});
it("is pure — the record passed in is not modified", () => {
const e = newEvidence();
noteOutcome(e, "ok", 1);
expect(e.ok).toBe(0);
});
it("never counts a lookup as recall, and a lookup resets the streak", () => {
const e = run([
["ok", 1],
["ok", 2],
["ok", 3, true],
]);
expect(e).toMatchObject({ ok: 2, lookups: 1, streak: 0, rounds: 3 });
});
it("learns three corrects in three rounds spanning five", () => {
const e = run([
["ok", 1],
["ok", 4],
["ok", 7],
]);
expect(isLearned(e)).toBe(true);
expect(acceptConfirmation(e)).toBe(true);
});
it("refuses three corrects crammed into consecutive rounds", () => {
expect(isLearned(run([["ok", 1], ["ok", 2], ["ok", 3]]))).toBe(false);
});
/* PINNED AS IT IS — lib is looser than PORT.md in two ways, and the app
enforces PORT.md's version at the call site (domain/turn.ts):
· several corrects in ONE round each count;
· the span runs from the first outcome of any kind, not the first
correct, so a wrong answer in round 1 lengthens it. */
it("counts every correct within a round", () => {
expect(run([["ok", 1], ["ok", 1]])).toMatchObject({ ok: 2, streak: 2, rounds: 1 });
});
it("measures the span from the first outcome, correct or not", () => {
const e = run([
["wrong", 1],
["ok", 4],
["ok", 5],
["ok", 6],
]);
expect(e).toMatchObject({ firstRound: 1, lastRound: 6 });
expect(isLearned(e)).toBe(true);
});
});

96
test/lib/sync.test.ts Normal file
View File

@@ -0,0 +1,96 @@
/* Golden tests pinning lib/sync.js — the three gates, as the artifact
applies them to four whole documents. The port syncs rows instead and
applies the same rules row by row; these pin the reference behaviour. */
import { describe, it, expect } from "vitest";
import { weigh, isLater, reconcile, makeWriter } from "@lib/sync.js";
import type { RemoteDoc } from "@lib/sync.js";
describe("weigh — how much a copy holds", () => {
it("counts turns, finished units, cards and days", () => {
expect(weigh("chat", { turns: [1, 2, 3] })).toBe(3);
expect(weigh("meta", { road: { done: { "1.1": 1 } } })).toBe(1);
expect(weigh("srs", { cards: { a: 1, b: 2 } })).toBe(2);
expect(weigh("log", { days: {} })).toBe(0);
expect(weigh("chat", null)).toBe(0);
});
});
describe("isLater — a counter, not a clock", () => {
it("compares counters when both sides have one, whatever the clocks say", () => {
expect(isLater({ v: 3, u: 1 }, 2, 99)).toBe(true);
expect(isLater({ v: 1, u: 999 }, 2, 1)).toBe(false);
});
it("breaks a counter tie on the stamp", () => {
expect(isLater({ v: 2, u: 5 }, 2, 4)).toBe(true);
expect(isLater({ v: 2, u: 4 }, 2, 4)).toBe(false);
});
it("falls back to the clock only when one side has no counter", () => {
expect(isLater({ u: 5 }, 0, 4)).toBe(true);
expect(isLater({ v: 9 }, 0, 4)).toBe(false);
});
});
describe("reconcile — no silent shrinking", () => {
const local = { version: 3, stamp: 100, data: { turns: [1, 2, 3] } };
it("reasserts ours when a later copy holds less and nobody said so", () => {
expect(reconcile("chat", { v: 4, u: 200, d: { turns: [1] } }, local)).toBe("reassert");
});
it("obeys a deliberate shrink", () => {
expect(reconcile("chat", { v: 4, u: 200, d: { turns: [1] }, x: 1 }, local)).toBe("adopt");
});
it("adopts a later copy that holds at least as much", () => {
expect(reconcile("chat", { v: 4, u: 200, d: { turns: [1, 2, 3, 4] } }, local)).toBe("adopt");
});
it("ignores an older copy, and a missing one", () => {
expect(reconcile("chat", { v: 2, u: 900, d: { turns: [] } }, local)).toBe("ignore");
expect(reconcile("chat", null, local)).toBe("ignore");
});
});
describe("makeWriter — hydration", () => {
function writer() {
let t = 1000;
const pushed: [string, RemoteDoc][] = [];
const w = makeWriter({
push: (name, body) => {
pushed.push([name, body]);
return "sent";
},
now: () => ++t,
});
return { w, pushed };
}
it("holds an edit made before hydration, unstamped and unpushed", () => {
const { w, pushed } = writer();
expect(w.touch("chat")).toMatchObject({ version: 0, stamp: 0, dirty: true, hydrated: false });
expect(w.flush("chat", { turns: [] })).toBeNull();
expect(pushed).toEqual([]);
});
it("stamps the held edit when hydration finds nothing newer, then pushes it", () => {
const { w, pushed } = writer();
w.touch("chat");
expect(w.hydrate("chat")).toMatchObject({ version: 1, stamp: 1001, hydrated: true });
expect(w.flush("chat", { turns: [1] })).toBe("sent");
expect(pushed).toEqual([["chat", { u: 1001, v: 1, d: { turns: [1] } }]]);
});
it("carries a deliberate shrink as x on exactly one push", () => {
const { w, pushed } = writer();
w.touch("meta", true);
w.hydrate("meta");
w.flush("meta", {});
expect(pushed[0]![1]).toMatchObject({ x: 1 });
w.touch("meta");
w.flush("meta", {});
expect(pushed[1]![1]).not.toHaveProperty("x");
});
});