From 1f13db0d87554956655d1df924153e52cc95aeb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Mon, 17 Aug 2026 15:02:01 +0200 Subject: [PATCH] test(collab): A-4 deterministic held-relay same-item conflict reducer Unit-tier rebuild of drift-trio S4/S4b (findings A-4): two bound editors over a held Y relay, conflicts released in fixed order, asserting Y.Doc convergence FIRST then native projections against the doc. Pinned clientIDs make the LWW winner deterministic; reverse-order release must converge byte-identically. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UZJ1pUePb4W47hGoLMYTw4 --- .../src/wasm/collab/kicad-binding.test.ts | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/web/standalone/src/wasm/collab/kicad-binding.test.ts b/web/standalone/src/wasm/collab/kicad-binding.test.ts index 26b5e51..da1bf7c 100644 --- a/web/standalone/src/wasm/collab/kicad-binding.test.ts +++ b/web/standalone/src/wasm/collab/kicad-binding.test.ts @@ -517,3 +517,164 @@ describe("validity-revert marker → DOC_REVERTED_EVENT (kicad-validity 0001 B3) } }); }); + +/** + * A-4 deterministic same-item conflict reducer (findings group A; drift-trio + * S4/S4b divergence rebuilt at unit tier, per the recorded plan: hold two known + * same-item transactions, release them in a FIXED order, assert the Y.Docs + * converge FIRST, then assert every native projection matches the doc). + * + * Whole-item Yjs LWW may legitimately lose one concurrent value but must still + * converge — permanent editor divergence can only come from the bridge, and + * this harness removes every timing variable the e2e trio has. + */ +describe("A-4 reducer — held-relay same-item conflicts converge deterministically", () => { + /** Two Y.Docs whose relay is HELD: updates queue per direction and only + * cross on an explicit release. clientIDs are pinned so LWW arbitration — + * and therefore the winner — is deterministic run to run. */ + function heldPair(): { + a: Y.Doc; + b: Y.Doc; + releaseAtoB: () => void; + releaseBtoA: () => void; + drain: () => void; + } { + const a = new Y.Doc(); + const b = new Y.Doc(); + a.clientID = 1; + b.clientID = 2; + const aToB: Uint8Array[] = []; + const bToA: Uint8Array[] = []; + a.on("update", (u: Uint8Array) => aToB.push(u)); + b.on("update", (u: Uint8Array) => bToA.push(u)); + const releaseAtoB = (): void => { + while (aToB.length) Y.applyUpdate(b, aToB.shift()!, "relay"); + }; + const releaseBtoA = (): void => { + while (bToA.length) Y.applyUpdate(a, bToA.shift()!, "relay"); + }; + // Applying a release can enqueue echo updates on the other lane (Yjs emits + // "update" for applied remote content too); drain until both lanes are dry. + const drain = (): void => { + while (aToB.length || bToA.length) { + releaseAtoB(); + releaseBtoA(); + } + }; + return { a, b, releaseAtoB, releaseBtoA, drain }; + } + + const CONFLICT_FILE = `(kicad_wks (version 20220228) (generator "pl_editor") + (setup (textsize 1.5 1.5) (linewidth 0.15)) + (rect (uuid "r-1") (name "target") (start 0 0 ltcorner) (end 0 0 rbcorner)) + (rect (uuid "r-2") (name "bystander") (start 1 1 ltcorner) (end 2 2 rbcorner)) +) +`; + const rectSexpr = (name: string): string => + `(rect (uuid "r-1") (name "${name}") (start 0 0 ltcorner) (end 0 0 rbcorner))`; + + function setupConverged() { + const held = heldPair(); + const edA = new FakeEditor(); + const edB = new FakeEditor(); + const bindA = bindKicadCollab(held.a, edA); + const bindB = bindKicadCollab(held.b, edB); + const seedDoc = fileToDoc(CONFLICT_FILE); + Object.assign(edA.store, seedDoc.items); + bindA.seed(seedDoc); + held.drain(); // B's doc now holds the seed + bindB.seed(); // adopt + held.drain(); + // Precondition: fully converged before the conflict is staged. + expect(docToFile(yToDoc(held.a))).toBe(docToFile(yToDoc(held.b))); + expect(edB.store["r-1"]).toBeDefined(); + return { ...held, edA, edB, bindA, bindB }; + } + + /** Y-first convergence oracle, then native projections against the doc. */ + function assertConverged(t: ReturnType): string { + const fileA = docToFile(yToDoc(t.a)); + const fileB = docToFile(yToDoc(t.b)); + // 1. The CRDT layer itself converged. + expect(fileA).toBe(fileB); + // 2. Each editor's native projection matches ITS OWN doc (bridge applied + // everything), hence both editors match each other. + for (const [doc, ed] of [ + [t.a, t.edA], + [t.b, t.edB], + ] as const) { + const docItems = yToDoc(doc).items; + expect(Object.keys(ed.store).sort()).toEqual(Object.keys(docItems).sort()); + for (const uuid of Object.keys(docItems).filter((u) => docItems[u]!.parent === null)) { + expect(renderItem({ items: ed.store }, uuid)).toBe(renderItem({ items: docItems }, uuid)); + } + } + return fileA; + } + + it("value-vs-value: A→B then B→A release converges everywhere, one value wins", () => { + const t = setupConverged(); + // Both actors commit a conflicting edit to the SAME item while the relay + // is held — the exact S4b shape (two clients racing setValue). + t.edA.localUpsert(rectSexpr("from-A")); + t.edB.localUpsert(rectSexpr("from-B")); + t.releaseAtoB(); + t.releaseBtoA(); + t.drain(); + const file = assertConverged(t); + expect(/from-[AB]/.test(file)).toBe(true); // exactly one value survived + expect(file.includes("from-A") && file.includes("from-B")).toBe(false); + }); + + it("value-vs-value: the REVERSE release order converges to the SAME winner", () => { + const first = (() => { + const t = setupConverged(); + t.edA.localUpsert(rectSexpr("from-A")); + t.edB.localUpsert(rectSexpr("from-B")); + t.releaseAtoB(); + t.releaseBtoA(); + t.drain(); + return assertConverged(t); + })(); + const t = setupConverged(); + t.edA.localUpsert(rectSexpr("from-A")); + t.edB.localUpsert(rectSexpr("from-B")); + t.releaseBtoA(); // reversed + t.releaseAtoB(); + t.drain(); + // Delivery order must not change the arbitration (CRDT order-independence + // + pinned clientIDs): byte-identical converged file. + expect(assertConverged(t)).toBe(first); + }); + + it("move-vs-delete on the same item converges everywhere", () => { + const t = setupConverged(); + t.edA.localUpsert(rectSexpr("moved-by-A")); // stand-in for a move commit + t.edB.localRemove("r-1"); + t.releaseAtoB(); + t.releaseBtoA(); + t.drain(); + const file = assertConverged(t); + // Winner is CRDT policy, not asserted — but the outcome must be one of the + // two staged states on every replica, never a mix. + const present = file.includes("moved-by-A"); + const absent = !file.includes('"r-1"'); + expect(present || absent).toBe(true); + expect(file.includes("bystander")).toBe(true); // the other item survives + }); + + it("conflict staged DURING a partially-released exchange still converges", () => { + const t = setupConverged(); + // A's edit crosses to B first; B then edits the already-updated item while + // its own answer is still held — the "edit arrived mid-apply" interleave + // (bug-05 family) reduced to a deterministic schedule. + t.edA.localUpsert(rectSexpr("from-A")); + t.releaseAtoB(); + t.edB.localUpsert(rectSexpr("from-B-after-seeing-A")); + t.releaseBtoA(); + t.drain(); + const file = assertConverged(t); + // B's edit is causally AFTER A's — it must win outright on both replicas. + expect(file.includes("from-B-after-seeing-A")).toBe(true); + }); +});