/* 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): 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 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", }); }); });