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>
97 lines
3.5 KiB
TypeScript
97 lines
3.5 KiB
TypeScript
/* 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");
|
|
});
|
|
});
|