Files
Hankan/test/sync/resolve.test.ts
MechaCat02 57fd6e5087 feat(learn): the reference screens as the reworked artifact has them
읽기 연습. Each mode opens with what the round is and a Start button; the
round is timed, and a finished one reports accuracy and time, keeps the
best per mode and says when it was beaten. In sound mode the written
spelling is among the options — reading it as written is the habit the
drill exists to break, so it is the distractor that matters. Answers stay
marked 450ms when right, 900ms when a rule is shown, 1400ms when wrong.
The best rounds sync (trainer.drill), merged mode by mode: the higher
accuracy, then the faster time.

문법. An All chip, a count on every category, 반말 selected first — it is
what manhwa speech is made of — and each row names its category, so the
list still reads under All.

활용 연습. Words come in random order, not the pool's; the running score
comes back after a reload; a wrong answer shows what was typed beside what
it should have been, then the rule. The pool adds the roadmap's own verbs
and adjectives to the deck's. A form lib/conjugation.js cannot build no
longer leaves the Check button stuck.

문장. The artifact's lesson on the ending word and the 서술어 legend, a count
on each level, and every sentence's place in review — as far along as its
least-known chunk, since the chunks are the cards.

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

133 lines
4.8 KiB
TypeScript

/* Gate 3 — what happens when two copies of one row disagree.
The rule is PORT.md's: the copy that holds more wins, a tie goes to the
server so every device lands on one copy, and a deliberate delete is
obeyed. These pin each table's reading of "more". */
import { describe, it, expect } from "vitest";
import { resolve, type Resolution, type Side } from "@app/sync/resolve.js";
/** The merged meta value, parsed. */
const mergedValue = (r: Resolution): unknown => {
if (r.kind !== "merge") throw new Error(`expected a merge, got ${r.kind}`);
return JSON.parse(String(r.data.v));
};
const ctx = { unitIndex: (id: string) => ["1.1", "1.2", "1.3", "1.10", "2.1"].indexOf(id) };
const live = (data: Record<string, string | number | null>): Side => ({ deleted: false, data });
const gone: Side = { deleted: true, data: null };
describe("deletes", () => {
it("obeys a delete from the server over an edit made here", () => {
expect(resolve("card", live({ reps: 9 }), gone, ctx)).toEqual({ kind: "adopt" });
});
it("keeps a delete made here over an edit on the server", () => {
expect(resolve("card", gone, live({ reps: 9 }), ctx)).toEqual({ kind: "keep" });
});
});
describe("cards and evidence — more work wins", () => {
it("keeps the card with more reviews behind it", () => {
expect(resolve("card", live({ reps: 6, lapses: 1 }), live({ reps: 5, lapses: 0 }), ctx)).toEqual({ kind: "keep" });
expect(resolve("card", live({ reps: 2, lapses: 0 }), live({ reps: 5, lapses: 0 }), ctx)).toEqual({ kind: "adopt" });
});
it("gives a tie to the server", () => {
expect(resolve("card", live({ reps: 5, lapses: 0 }), live({ reps: 5, lapses: 0 }), ctx)).toEqual({ kind: "adopt" });
});
it("weighs evidence by everything observed, lookups included", () => {
expect(
resolve("evidence", live({ ok: 2, wrong: 1, lookups: 2 }), live({ ok: 3, wrong: 0, lookups: 0 }), ctx),
).toEqual({ kind: "keep" });
});
});
describe("progress — merged, never un-finished", () => {
it("keeps a unit finished if either copy finished it", () => {
const r = resolve(
"progress",
live({ unit_id: "1.1", done: 1, confidence: 60, answers: 4, note: "mine" }),
live({ unit_id: "1.1", done: 0, confidence: 90, answers: 7, note: "theirs" }),
ctx,
);
expect(r).toEqual({
kind: "merge",
data: { unit_id: "1.1", done: 1, confidence: 90, answers: 7, note: "theirs" },
});
});
it("takes confidence from the copy with more answers behind it", () => {
expect(
resolve(
"progress",
live({ unit_id: "1.1", done: 0, confidence: 95, answers: 9, note: "a" }),
live({ unit_id: "1.1", done: 0, confidence: 40, answers: 3, note: "b" }),
ctx,
),
).toEqual({ kind: "keep" });
});
});
describe("meta", () => {
it("puts him on the further unit, by roadmap order and not by string", () => {
// "1.10" sorts before "1.2" as text; on the roadmap it comes after.
expect(resolve("meta", live({ k: "road.unit", v: "1.10" }), live({ k: "road.unit", v: "1.2" }), ctx)).toEqual({
kind: "keep",
});
});
it("never moves a counter backwards", () => {
expect(resolve("meta", live({ k: "learner.round", v: "12" }), live({ k: "learner.round", v: "9" }), ctx)).toEqual({
kind: "keep",
});
expect(resolve("meta", live({ k: "reset.chat", v: "1" }), live({ k: "reset.chat", v: "2" }), ctx)).toEqual({
kind: "adopt",
});
});
it("unites grammar flags learned on two devices", () => {
const r = resolve(
"meta",
live({ k: "grammar.learned", v: JSON.stringify({ a: true }) }),
live({ k: "grammar.learned", v: JSON.stringify({ b: true }) }),
ctx,
);
expect(mergedValue(r)).toEqual({ a: true, b: true });
});
it("keeps the better reading-drill round for each mode", () => {
const r = resolve(
"meta",
live({ k: "trainer.drill", v: JSON.stringify({ sound: { acc: 92, sec: 40 }, speed: { acc: 75, sec: 30 } }) }),
live({ k: "trainer.drill", v: JSON.stringify({ sound: { acc: 92, sec: 55 }, sentence: { acc: 58, sec: 70 } }) }),
ctx,
);
expect(mergedValue(r)).toEqual({
sound: { acc: 92, sec: 40 }, // the same accuracy, faster
speed: { acc: 75, sec: 30 },
sentence: { acc: 58, sec: 70 },
});
});
it("keeps the longer of two versions of one note", () => {
const r = resolve(
"meta",
live({ k: "grammar.notes", v: JSON.stringify({ p1: "a long careful note" }) }),
live({ k: "grammar.notes", v: JSON.stringify({ p1: "short", p2: "other" }) }),
ctx,
);
expect(mergedValue(r)).toEqual({
p1: "a long careful note",
p2: "other",
});
});
it("takes the server's copy of a preference", () => {
expect(resolve("meta", live({ k: "prefs.goal", v: "40" }), live({ k: "prefs.goal", v: "20" }), ctx)).toEqual({
kind: "adopt",
});
});
});