읽기 연습. 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>
133 lines
4.8 KiB
TypeScript
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",
|
|
});
|
|
});
|
|
});
|