Files
Hankan/test/lib/sync.test.ts
MechaCat02 e72b77d6c2 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>
2026-09-16 19:49:52 +02:00

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");
});
});