Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013MnySXALJiYsRQ5mCrxgwX
779 lines
31 KiB
TypeScript
779 lines
31 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import * as Y from "yjs";
|
|
import {
|
|
docToFile,
|
|
duplicateSingletonHeadIndices,
|
|
fileToDoc,
|
|
itemsWireToDelta,
|
|
kicadLibSymbolsMap,
|
|
parseItemsWireDelta,
|
|
renderItem,
|
|
SEXPR_VERSION_CURRENT,
|
|
sexprToItems,
|
|
syncLayoutToY,
|
|
ydocHasState,
|
|
ydocIsHollow,
|
|
ydocSexprVersion,
|
|
yToDoc,
|
|
type KicadItem,
|
|
} from "@pcbjam/shared";
|
|
import { bindKicadCollab, DOC_REVERTED_EVENT, SexprVersionError, type KicadItemsBridge } from "./kicad-binding";
|
|
|
|
/**
|
|
* A fake editor implementing the v2 items bridge over an in-memory flattened
|
|
* item store — the same semantics the Stage C C++ side will have: apply() is an
|
|
* idempotent per-item upsert/remove that does NOT re-emit (s_applyingRemote
|
|
* analogue); local edits mutate the store AND emit the items wire.
|
|
*/
|
|
class FakeEditor implements KicadItemsBridge {
|
|
store: Record<string, KicadItem> = {};
|
|
applied: string[] = []; // raw JSON of every applyItems call (echo assertions)
|
|
private emit: ((json: string) => void) | null = null;
|
|
|
|
snapshotItems(): string {
|
|
const roots = Object.entries(this.store)
|
|
.filter(([, it]) => it.parent === null)
|
|
.map(([uuid]) => ({
|
|
sexpr: renderItem({ items: this.store }, uuid),
|
|
parent: null,
|
|
}));
|
|
return JSON.stringify({ added: roots, changed: [], removed: [] });
|
|
}
|
|
|
|
applyItems(json: string): void {
|
|
this.applied.push(json);
|
|
this.applyToStore(json); // no emit — remote applies must not echo
|
|
}
|
|
|
|
onItems(cb: (json: string) => void): void {
|
|
this.emit = cb;
|
|
}
|
|
|
|
/** A local user edit: mutate the store, then emit (like OnModify → Format). */
|
|
localUpsert(sexpr: string, parent: string | null = null, kind: "added" | "changed" = "changed"): void {
|
|
const json = JSON.stringify({ [kind]: [{ sexpr, parent }] });
|
|
this.applyToStore(json);
|
|
this.emit?.(json);
|
|
}
|
|
|
|
localRemove(uuid: string): void {
|
|
const json = JSON.stringify({ removed: [uuid] });
|
|
this.applyToStore(json);
|
|
this.emit?.(json);
|
|
}
|
|
|
|
private applyToStore(json: string): void {
|
|
const delta = itemsWireToDelta(parseItemsWireDelta(json), this.store);
|
|
for (const it of [...delta.added, ...delta.updated]) {
|
|
const { uuid, ...item } = it;
|
|
this.store[uuid] = item;
|
|
}
|
|
for (const uuid of delta.removed) delete this.store[uuid];
|
|
}
|
|
}
|
|
|
|
/** Two Y.Docs joined by relaying updates (stand-in for any provider). */
|
|
function pair(): { a: Y.Doc; b: Y.Doc } {
|
|
const a = new Y.Doc();
|
|
const b = new Y.Doc();
|
|
a.on("update", (u: Uint8Array) => Y.applyUpdate(b, u, "relay"));
|
|
b.on("update", (u: Uint8Array) => Y.applyUpdate(a, u, "relay"));
|
|
return { a, b };
|
|
}
|
|
|
|
const FP = `(footprint "lib:R" (layer "F.Cu") (uuid "fp-1") (at 10 10)
|
|
(property "Reference" "R1" (at 0 -2) (uuid "fld-1"))
|
|
(pad "1" smd (at 0 0) (uuid "pad-1")))`;
|
|
|
|
function seedEditor(ed: FakeEditor, sexpr: string): void {
|
|
const { uuid, items } = sexprToItems(sexpr);
|
|
void uuid;
|
|
Object.assign(ed.store, items);
|
|
}
|
|
|
|
describe("bindKicadCollab — two editors over relayed Y.Docs", () => {
|
|
function setup() {
|
|
const { a, b } = pair();
|
|
const edA = new FakeEditor();
|
|
const edB = new FakeEditor();
|
|
const bindA = bindKicadCollab(a, edA);
|
|
const bindB = bindKicadCollab(b, edB);
|
|
return { a, b, edA, edB, bindA, bindB };
|
|
}
|
|
|
|
it("seed → add → edit → remove propagates both ways; no self-echo", () => {
|
|
const { edA, edB, bindA, bindB } = setup();
|
|
seedEditor(edA, FP);
|
|
bindA.seed(); // A is first: seeds the doc
|
|
bindB.seed(); // B joins: adopts the doc
|
|
|
|
// B's editor received the footprint subtree via adopt.
|
|
expect(Object.keys(edB.store).sort()).toEqual(["fld-1", "fp-1", "pad-1"]);
|
|
|
|
// B edits the pad locally → A's editor sees it.
|
|
edB.localUpsert(`(pad "1" smd (at 7 7) (uuid "pad-1"))`, "fp-1");
|
|
expect(edA.store["pad-1"]!.body).toEqual(
|
|
sexprToItems(`(pad "1" smd (at 7 7) (uuid "pad-1"))`, "fp-1").items["pad-1"]!.body,
|
|
);
|
|
|
|
// A adds a free segment → B gets it.
|
|
edA.localUpsert(`(segment (start 0 0) (end 1 1) (uuid "seg-1"))`, null, "added");
|
|
expect(edB.store["seg-1"]).toBeDefined();
|
|
|
|
// A removes the footprint → cascades to B's whole subtree.
|
|
edA.localRemove("fp-1");
|
|
expect(Object.keys(edB.store).sort()).toEqual(["seg-1"]);
|
|
|
|
// Echo suppression: every applyItems an editor received came from the PEER's
|
|
// edits (adopt + peer changes), never from its own emits bouncing back.
|
|
for (const json of edA.applied) {
|
|
const wire = parseItemsWireDelta(json);
|
|
// A's own edits were seg-1 add + fp-1 remove; they must not appear.
|
|
expect(wire.added.map((w) => w.sexpr).join()).not.toContain("seg-1");
|
|
expect(wire.removed).not.toContain("fp-1");
|
|
}
|
|
});
|
|
|
|
it("stamps every apply envelope (adopt + remote change) with the binding's sheetPath", () => {
|
|
const { a, b } = pair();
|
|
const edA = new FakeEditor();
|
|
const edB = new FakeEditor();
|
|
const bindA = bindKicadCollab(a, edA, { sheetPath: "Arduino Mega 2560/root.kicad_sch" });
|
|
const bindB = bindKicadCollab(b, edB, { sheetPath: "Arduino Mega 2560/root.kicad_sch" });
|
|
seedEditor(edA, FP);
|
|
bindA.seed();
|
|
bindB.seed(); // adopt apply
|
|
edA.localUpsert(`(segment (start 0 0) (end 1 1) (uuid "seg-1"))`, null, "added"); // remote apply
|
|
expect(edB.applied.length).toBeGreaterThanOrEqual(2);
|
|
for (const json of edB.applied) {
|
|
expect((JSON.parse(json) as { sheet?: string }).sheet).toBe("Arduino Mega 2560/root.kicad_sch");
|
|
}
|
|
// Untagged binding (single-file tools) leaves the envelope alone.
|
|
const { a: c, b: d } = pair();
|
|
const edC = new FakeEditor();
|
|
const edD = new FakeEditor();
|
|
bindKicadCollab(c, edC).seed();
|
|
seedEditor(edC, FP);
|
|
bindKicadCollab(d, edD).seed();
|
|
edC.localUpsert(`(segment (start 0 0) (end 1 1) (uuid "seg-2"))`, null, "added");
|
|
for (const json of edD.applied) {
|
|
expect("sheet" in (JSON.parse(json) as object)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("a remote apply does not bounce back to the originator", () => {
|
|
const { edA, edB, bindA, bindB } = setup();
|
|
seedEditor(edA, FP);
|
|
bindA.seed();
|
|
bindB.seed();
|
|
const appliedOnB = edB.applied.length;
|
|
|
|
edB.localUpsert(`(pad "1" smd (at 3 3) (uuid "pad-1"))`, "fp-1");
|
|
// B's own edit: nothing new applied on B (only A receives an apply).
|
|
expect(edB.applied.length).toBe(appliedOnB);
|
|
expect(edA.applied.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("adopt removes divergent local-only roots (doc authority)", () => {
|
|
const { edA, edB, bindA, bindB } = setup();
|
|
seedEditor(edA, FP);
|
|
bindA.seed();
|
|
|
|
// B cold-opened the same file unsaved → its local model has a DIFFERENT uuid.
|
|
seedEditor(edB, `(footprint "lib:R" (layer "F.Cu") (uuid "fp-DIVERGENT") (at 10 10))`);
|
|
bindB.seed();
|
|
|
|
expect(edB.store["fp-DIVERGENT"]).toBeUndefined(); // dropped
|
|
expect(edB.store["fp-1"]).toBeDefined(); // adopted
|
|
});
|
|
|
|
it("seed(seedDoc) writes the FULL doc — file recoverable from the Y.Doc; peer adopts", () => {
|
|
const { a, edA, edB, bindA, bindB } = setup();
|
|
const file = `(kicad_wks (version 20220228) (generator "pl_editor")
|
|
(setup (textsize 1.5 1.5) (linewidth 0.15))
|
|
(rect (uuid "r-1") (name "border") (start 0 0 ltcorner) (end 0 0 rbcorner))
|
|
)
|
|
`;
|
|
const seedDoc = fileToDoc(file);
|
|
// Editor A opened the same file: its model holds the same flattened items.
|
|
Object.assign(edA.store, seedDoc.items);
|
|
|
|
bindA.seed(seedDoc); // empty room → file-seeds meta + layout + items
|
|
bindB.seed(); // joins → adopts
|
|
|
|
// The peer's editor received the items via adopt.
|
|
expect(edB.store["r-1"]).toBeDefined();
|
|
// Lossless: the file is recoverable from the Y.Doc ALONE (ysync 0005/0007),
|
|
// which the editor-snapshot seed (items only, no layout/meta) cannot do.
|
|
expect(docToFile(yToDoc(a))).toBe(docToFile(seedDoc));
|
|
// The file-seed must not have echoed an apply into the seeding editor.
|
|
expect(edA.applied.length).toBe(0);
|
|
});
|
|
|
|
it("seed(seedDoc) on a populated room still ADOPTS (doc authority wins over the file)", () => {
|
|
const { edA, edB, bindA, bindB } = setup();
|
|
seedEditor(edA, FP);
|
|
bindA.seed(); // A seeds the room from its editor
|
|
|
|
// B cold-opens a divergent copy of the file and offers it as seedDoc.
|
|
const fileB = `(kicad_wks (version 20220228)
|
|
(rect (uuid "fp-DIVERGENT") (name "border"))
|
|
)
|
|
`;
|
|
const seedDocB = fileToDoc(fileB);
|
|
Object.assign(edB.store, seedDocB.items);
|
|
bindB.seed(seedDocB);
|
|
|
|
expect(edB.store["fp-DIVERGENT"]).toBeUndefined(); // dropped
|
|
expect(edB.store["fp-1"]).toBeDefined(); // adopted
|
|
});
|
|
|
|
it("a HOLLOW room (layout only from a save-all sync, never seeded) is file-seeded, NOT adopted", () => {
|
|
// Save-all fired the layout sync into a room nobody had entered: the doc got
|
|
// meta/layout but zero items and no seedNonce. Adopting it removed every
|
|
// item on the subsheet the editor had just shown (arduino subsheet blank bug).
|
|
const { a, edA, edB, bindA, bindB } = setup();
|
|
const file = `(kicad_wks (version 20220228) (generator "pl_editor")
|
|
(setup (textsize 1.5 1.5) (linewidth 0.15))
|
|
(rect (uuid "r-1") (name "border") (start 0 0 ltcorner) (end 0 0 rbcorner))
|
|
)
|
|
`;
|
|
const seedDoc = fileToDoc(file);
|
|
syncLayoutToY(seedDoc, a, "layout-save"); // the hollow footprint
|
|
expect(ydocHasState(a)).toBe(true);
|
|
expect(ydocIsHollow(a)).toBe(true);
|
|
|
|
Object.assign(edA.store, seedDoc.items); // editor shows the file
|
|
bindA.seed(seedDoc);
|
|
|
|
expect(edA.applied.length).toBe(0); // nothing removed from the editor
|
|
expect(edA.store["r-1"]).toBeDefined();
|
|
expect(ydocIsHollow(a)).toBe(false); // healed: items + seed marker in the doc
|
|
expect(docToFile(yToDoc(a))).toBe(docToFile(seedDoc));
|
|
bindB.seed(); // a peer joining now adopts the real content
|
|
expect(edB.store["r-1"]).toBeDefined();
|
|
});
|
|
|
|
it("a layout-only write racing a file seed converges to ONE header block (no double version)", () => {
|
|
// Session 1 (old client) save-all wrote a layout-only doc; session 2 file-seeded
|
|
// BEFORE that state arrived. Merged, the layout carried the header twice and
|
|
// KiCad rejected the materialized sheet ("Expecting … Got version").
|
|
const { a, b, edA, edB, bindA, bindB } = setup();
|
|
const file = `(kicad_wks (version 20220228) (generator "pl_editor")
|
|
(setup (textsize 1.5 1.5) (linewidth 0.15))
|
|
(rect (uuid "r-1") (name "border") (start 0 0 ltcorner) (end 0 0 rbcorner))
|
|
)
|
|
`;
|
|
const seedDoc = fileToDoc(file);
|
|
// Hold the relay: build the two histories independently, then merge.
|
|
const hollow = new Y.Doc();
|
|
syncLayoutToY(seedDoc, hollow, "layout-save");
|
|
Object.assign(edA.store, seedDoc.items);
|
|
bindA.seed(seedDoc); // A file-seeds an (apparently) empty room
|
|
Y.applyUpdate(a, Y.encodeStateAsUpdate(hollow), "relay"); // the stale layout lands
|
|
const text = docToFile(yToDoc(a));
|
|
expect((text.match(/\(version /g) ?? []).length).toBe(1);
|
|
expect(text).toBe(docToFile(seedDoc));
|
|
// The doc itself was repaired (not just the render): B sees a single header.
|
|
expect(duplicateSingletonHeadIndices(yToDoc(b).layout)).toEqual([]);
|
|
bindB.seed();
|
|
expect(edB.store["r-1"]).toBeDefined();
|
|
});
|
|
|
|
it("a seeded room that was legitimately emptied is still adopted (not mistaken for hollow)", () => {
|
|
const { a, edA, edB, bindA, bindB } = setup();
|
|
const file = `(kicad_wks (version 20220228) (generator "pl_editor")
|
|
(rect (uuid "r-1") (name "border") (start 0 0 ltcorner) (end 0 0 rbcorner))
|
|
)
|
|
`;
|
|
const seedDoc = fileToDoc(file);
|
|
Object.assign(edA.store, seedDoc.items);
|
|
bindA.seed(seedDoc); // file-seeded → seed marker present
|
|
edA.localRemove("r-1"); // the peer deletes everything on the sheet
|
|
expect(ydocHasState(a)).toBe(true);
|
|
expect(ydocIsHollow(a)).toBe(false); // seeded, merely empty
|
|
Object.assign(edB.store, seedDoc.items); // B cold-opens the stale file
|
|
bindB.seed(seedDoc);
|
|
expect(edB.store["r-1"]).toBeUndefined(); // doc authority: the deletion wins
|
|
});
|
|
|
|
it("pre-seed remote state does NOT stream into the editor (adopt covers it)", () => {
|
|
const { edA, edB, bindA, bindB } = setup();
|
|
seedEditor(edA, FP);
|
|
bindA.seed(); // relays the full state into B's Y.Doc immediately
|
|
|
|
// B has NOT seeded yet: the initial state sync must not have been applied
|
|
// item-by-item (in the real app that's a redundant full blob apply into an
|
|
// editor that already opened the file).
|
|
expect(edB.applied.length).toBe(0);
|
|
|
|
bindB.seed(); // the adopt delivers the same state, once
|
|
expect(Object.keys(edB.store).sort()).toEqual(["fld-1", "fp-1", "pad-1"]);
|
|
});
|
|
|
|
it("seed(editorMatchesDoc) skips the adopt apply but still binds both ways", () => {
|
|
const { edA, edB, bindA, bindB } = setup();
|
|
seedEditor(edA, FP);
|
|
bindA.seed(); // A seeds the room
|
|
|
|
// B is the Y.Doc-load path: its editor opened the file materialized from
|
|
// the doc, so its store ALREADY matches — no adopt apply must happen.
|
|
seedEditor(edB, FP);
|
|
bindB.seed(undefined, { editorMatchesDoc: true });
|
|
expect(edB.applied.length).toBe(0);
|
|
|
|
// The binding is still live both ways after the apply-less seed.
|
|
edA.localUpsert(`(pad "1" smd (at 5 5) (uuid "pad-1"))`, "fp-1");
|
|
expect(edB.store["pad-1"]!.body).toEqual(
|
|
sexprToItems(`(pad "1" smd (at 5 5) (uuid "pad-1"))`, "fp-1").items["pad-1"]!.body,
|
|
);
|
|
edB.localUpsert(`(pad "1" smd (at 6 6) (uuid "pad-1"))`, "fp-1");
|
|
expect(edA.store["pad-1"]!.body).toEqual(
|
|
sexprToItems(`(pad "1" smd (at 6 6) (uuid "pad-1"))`, "fp-1").items["pad-1"]!.body,
|
|
);
|
|
});
|
|
|
|
it("destroy() detaches the editor from further remote changes", () => {
|
|
const { edA, edB, bindA, bindB } = setup();
|
|
seedEditor(edA, FP);
|
|
bindA.seed();
|
|
bindB.seed();
|
|
|
|
bindB.destroy();
|
|
edA.localUpsert(`(pad "1" smd (at 9 9) (uuid "pad-1"))`, "fp-1");
|
|
|
|
// B's Y.Doc still received the update (provider-level), but its editor didn't.
|
|
expect(edB.store["pad-1"]!.body).toEqual(
|
|
sexprToItems(`(pad "1" smd (at 0 0) (uuid "pad-1"))`, "fp-1").items["pad-1"]!.body,
|
|
);
|
|
});
|
|
|
|
it("adopt applies only the DIFFERENCE (opt 13) — identical items cost nothing", () => {
|
|
const { edA, edB, bindA, bindB } = setup();
|
|
const SEG = `(segment (start 0 0) (end 1 1) (uuid "seg-1"))`;
|
|
seedEditor(edA, FP);
|
|
seedEditor(edA, SEG);
|
|
bindA.seed();
|
|
|
|
// B's editor already holds the IDENTICAL footprint but not the segment.
|
|
seedEditor(edB, FP);
|
|
bindB.seed();
|
|
|
|
expect(edB.store["seg-1"]).toBeDefined(); // caught up
|
|
expect(edB.applied).toHaveLength(1);
|
|
const wire = parseItemsWireDelta(edB.applied[0]!);
|
|
// Only the missing segment travelled — the matching footprint did not.
|
|
expect(wire.added).toHaveLength(1);
|
|
expect(wire.added[0]!.sexpr).toContain("seg-1");
|
|
expect(wire.changed).toHaveLength(0);
|
|
expect(wire.removed).toHaveLength(0);
|
|
});
|
|
|
|
it("adopt with a fully matching editor applies NOTHING (clean rebind)", () => {
|
|
const { edA, edB, bindA, bindB } = setup();
|
|
seedEditor(edA, FP);
|
|
bindA.seed();
|
|
seedEditor(edB, FP);
|
|
bindB.seed();
|
|
expect(edB.applied).toHaveLength(0);
|
|
});
|
|
|
|
it("adopt re-applies a differing item's DOC version, lifted to its root", () => {
|
|
const { edA, edB, bindA, bindB } = setup();
|
|
seedEditor(edA, FP);
|
|
bindA.seed();
|
|
|
|
// B holds the same footprint but its pad drifted (never-synced local state).
|
|
seedEditor(edB, FP.replace(`(pad "1" smd (at 0 0)`, `(pad "1" smd (at 9 9)`));
|
|
bindB.seed();
|
|
|
|
// Doc authority: B's editor converges on the doc's pad, via ONE root re-apply.
|
|
expect(edB.store["pad-1"]!.body).toEqual(
|
|
sexprToItems(`(pad "1" smd (at 0 0) (uuid "pad-1"))`, "fp-1").items["pad-1"]!.body,
|
|
);
|
|
expect(edB.applied).toHaveLength(1);
|
|
const wire = parseItemsWireDelta(edB.applied[0]!);
|
|
expect(wire.changed).toHaveLength(1);
|
|
expect(wire.changed[0]!.sexpr).toContain(`(uuid "fp-1")`); // the root, not the bare pad
|
|
});
|
|
});
|
|
|
|
describe("lib_symbols flow through the binding (miss 08A)", () => {
|
|
const DEF = `(symbol "Device:R" (property "Reference" "R" (at 2 0 90)))`;
|
|
const INSTANCE = `(symbol (lib_id "Device:R") (at 100 50 0) (uuid "sym-1"))`;
|
|
|
|
it("an emitted placement's definition is stored and re-rendered for the peer", () => {
|
|
const { a, b } = pair();
|
|
const edA = new FakeEditor();
|
|
const edB = new FakeEditor();
|
|
bindKicadCollab(a, edA).seed();
|
|
bindKicadCollab(b, edB).seed();
|
|
|
|
// A places a symbol: the eeschema blob is multi-form (definition + instance).
|
|
edA.localUpsert(`(lib_symbols ${DEF}) ${INSTANCE}`, null, "added");
|
|
|
|
// B's editor received the instance WITH its definition prefixed (findLib's
|
|
// first branch), even though B has never seen this symbol.
|
|
expect(edB.store["sym-1"]).toBeDefined();
|
|
const applied = edB.applied.map((j) => parseItemsWireDelta(j));
|
|
const symWire = applied
|
|
.flatMap((w) => [...w.added, ...w.changed])
|
|
.find((w) => w.sexpr.includes("sym-1"));
|
|
expect(symWire, "the symbol reached B").toBeTruthy();
|
|
expect(symWire!.sexpr).toMatch(/^\(lib_symbols \(symbol "Device:R"/);
|
|
|
|
// And the definition landed in the room's defs map on BOTH sides
|
|
// (materialization injection is covered by the shared-lib tests — this
|
|
// room was editor-snapshot-seeded, which carries no layout/meta).
|
|
expect(kicadLibSymbolsMap(b).get("Device:R")).toContain(`"Device:R"`);
|
|
expect(kicadLibSymbolsMap(a).get("Device:R")).toContain(`"Device:R"`);
|
|
});
|
|
|
|
it("adopt of a doc holding a symbol carries the definition too", () => {
|
|
const { a, b } = pair();
|
|
const edA = new FakeEditor();
|
|
const edB = new FakeEditor();
|
|
bindKicadCollab(a, edA).seed();
|
|
edA.localUpsert(`(lib_symbols ${DEF}) ${INSTANCE}`, null, "added");
|
|
|
|
// B joins with an empty editor → adopts the doc.
|
|
bindKicadCollab(b, edB).seed();
|
|
expect(edB.store["sym-1"]).toBeDefined();
|
|
const wire = parseItemsWireDelta(edB.applied[0]!);
|
|
const symWire = [...wire.added, ...wire.changed].find((w) => w.sexpr.includes("sym-1"));
|
|
expect(symWire!.sexpr).toMatch(/^\(lib_symbols \(symbol "Device:R"/);
|
|
});
|
|
});
|
|
describe("sexprVersion skew guard (ysync 0009 §5)", () => {
|
|
it("binds a fresh (empty) room and a current-version doc", () => {
|
|
const { a, b } = pair();
|
|
const edA = new FakeEditor();
|
|
seedEditor(edA, FP);
|
|
bindKicadCollab(a, edA).seed(); // empty room: reads as v1, stamped CURRENT on write
|
|
expect(ydocSexprVersion(a)).toBe(SEXPR_VERSION_CURRENT);
|
|
expect(() => bindKicadCollab(b, new FakeEditor())).not.toThrow(); // peer joins the v2 doc
|
|
});
|
|
|
|
it("refuses to bind a doc written by a newer encoding (update required)", () => {
|
|
const doc = new Y.Doc();
|
|
doc.getMap("kdoc_meta").set("sexprVersion", SEXPR_VERSION_CURRENT + 1);
|
|
expect(() => bindKicadCollab(doc, new FakeEditor())).toThrow(SexprVersionError);
|
|
expect(() => bindKicadCollab(doc, new FakeEditor())).toThrow(/update required/);
|
|
});
|
|
});
|
|
|
|
describe("bindKicadCollab — read-only viewer (read-only-viewer)", () => {
|
|
const WKS = `(kicad_wks (version 20220228) (generator "pl_editor")
|
|
(setup (textsize 1.5 1.5) (linewidth 0.15))
|
|
(rect (uuid "r-1") (name "border") (start 0 0 ltcorner) (end 0 0 rbcorner))
|
|
)
|
|
`;
|
|
|
|
it("never seeds an empty room — neither from the file nor the snapshot", () => {
|
|
const { a } = pair();
|
|
const viewer = new FakeEditor();
|
|
const seedDoc = fileToDoc(WKS);
|
|
// The viewer opened the file via the API fallback (room empty).
|
|
Object.assign(viewer.store, seedDoc.items);
|
|
|
|
bindKicadCollab(a, viewer, { readOnly: true }).seed(seedDoc);
|
|
|
|
// A writable binding would have file-seeded here; the viewer must not.
|
|
expect(ydocHasState(a)).toBe(false);
|
|
// And without a seedDoc, the editor-snapshot seed is skipped too.
|
|
const { b } = pair();
|
|
const viewer2 = new FakeEditor();
|
|
seedEditor(viewer2, FP);
|
|
bindKicadCollab(b, viewer2, { readOnly: true }).seed();
|
|
expect(ydocHasState(b)).toBe(false);
|
|
});
|
|
|
|
it("local edits never reach the doc (inert DOWN hook)", () => {
|
|
const { a, b } = pair();
|
|
const writer = new FakeEditor();
|
|
const viewer = new FakeEditor();
|
|
seedEditor(writer, FP);
|
|
bindKicadCollab(a, writer).seed();
|
|
|
|
const bindViewer = bindKicadCollab(b, viewer, { readOnly: true });
|
|
bindViewer.seed(); // adopts the writer's state
|
|
expect(viewer.store["fp-1"]).toBeDefined();
|
|
|
|
const appliedOnWriter = writer.applied.length;
|
|
viewer.localUpsert(`(pad "1" smd (at 9 9) (uuid "pad-1"))`, "fp-1");
|
|
// Nothing crossed: the writer's editor received no apply, and the shared
|
|
// doc still holds the writer's pad geometry.
|
|
expect(writer.applied.length).toBe(appliedOnWriter);
|
|
expect(writer.store["pad-1"]!.body).toEqual(
|
|
sexprToItems(`(pad "1" smd (at 0 0) (uuid "pad-1"))`, "fp-1").items["pad-1"]!.body,
|
|
);
|
|
});
|
|
|
|
it("remote edits still stream into the viewer (UP observer live)", () => {
|
|
const { a, b } = pair();
|
|
const writer = new FakeEditor();
|
|
const viewer = new FakeEditor();
|
|
seedEditor(writer, FP);
|
|
bindKicadCollab(a, writer).seed();
|
|
bindKicadCollab(b, viewer, { readOnly: true }).seed();
|
|
|
|
writer.localUpsert(`(pad "1" smd (at 5 5) (uuid "pad-1"))`, "fp-1");
|
|
expect(viewer.store["pad-1"]!.body).toEqual(
|
|
sexprToItems(`(pad "1" smd (at 5 5) (uuid "pad-1"))`, "fp-1").items["pad-1"]!.body,
|
|
);
|
|
});
|
|
|
|
it("a viewer parked on an empty room streams a late writer's seed in", () => {
|
|
const { a, b } = pair();
|
|
const viewer = new FakeEditor();
|
|
const writer = new FakeEditor();
|
|
const seedDoc = fileToDoc(WKS);
|
|
Object.assign(viewer.store, seedDoc.items);
|
|
Object.assign(writer.store, seedDoc.items);
|
|
|
|
// Viewer first (empty room, no seed), writer arrives later and file-seeds.
|
|
bindKicadCollab(a, viewer, { readOnly: true }).seed(seedDoc);
|
|
bindKicadCollab(b, writer).seed(seedDoc);
|
|
|
|
// The writer's seed reached the viewer's doc; the room is authored by the
|
|
// writer alone and stays file-recoverable.
|
|
expect(ydocHasState(a)).toBe(true);
|
|
expect(docToFile(yToDoc(a))).toBe(docToFile(seedDoc));
|
|
});
|
|
});
|
|
|
|
describe("validity-revert marker → DOC_REVERTED_EVENT (kicad-validity 0001 B3)", () => {
|
|
/** Stub the browser window just enough for the binding's dispatch. */
|
|
function withWindowSpy(): { events: CustomEvent[]; restore: () => void } {
|
|
const events: CustomEvent[] = [];
|
|
const prev = (globalThis as { window?: unknown }).window;
|
|
(globalThis as { window?: unknown }).window = {
|
|
dispatchEvent: (e: Event) => {
|
|
events.push(e as CustomEvent);
|
|
return true;
|
|
},
|
|
};
|
|
return {
|
|
events,
|
|
restore: () => {
|
|
(globalThis as { window?: unknown }).window = prev;
|
|
},
|
|
};
|
|
}
|
|
|
|
it("dispatches once per nonce when the backend stamps a revert", () => {
|
|
const spy = withWindowSpy();
|
|
try {
|
|
const { a, b } = pair();
|
|
const edA = new FakeEditor();
|
|
seedEditor(edA, FP);
|
|
bindKicadCollab(a, edA).seed();
|
|
|
|
// The "backend" writes the marker on the peer doc; it relays over.
|
|
const meta = b.getMap("kdoc_meta");
|
|
b.transact(() => {
|
|
meta.set("revertNonce", "job-1");
|
|
meta.set("revertReason", "unbalanced (");
|
|
meta.set("revertedAt", "2026-07-14T00:00:00Z");
|
|
});
|
|
|
|
expect(spy.events).toHaveLength(1);
|
|
expect(spy.events[0]!.type).toBe(DOC_REVERTED_EVENT);
|
|
expect(spy.events[0]!.detail).toMatchObject({
|
|
reason: "unbalanced (",
|
|
at: "2026-07-14T00:00:00Z",
|
|
});
|
|
|
|
// Same nonce again (e.g. a reconnect replay) → silent.
|
|
b.transact(() => meta.set("revertReason", "unbalanced ( again"));
|
|
expect(spy.events).toHaveLength(1);
|
|
|
|
// A NEW nonce → a second toast.
|
|
b.transact(() => meta.set("revertNonce", "job-2"));
|
|
expect(spy.events).toHaveLength(2);
|
|
} finally {
|
|
spy.restore();
|
|
}
|
|
});
|
|
|
|
it("a nonce present BEFORE binding does not fire (stale marker on open)", () => {
|
|
const spy = withWindowSpy();
|
|
try {
|
|
const doc = new Y.Doc();
|
|
doc.getMap("kdoc_meta").set("revertNonce", "old-job");
|
|
const ed = new FakeEditor();
|
|
seedEditor(ed, FP);
|
|
const binding = bindKicadCollab(doc, ed);
|
|
binding.seed();
|
|
expect(spy.events).toHaveLength(0);
|
|
|
|
// …and after destroy() the observer is gone entirely.
|
|
binding.destroy();
|
|
doc.getMap("kdoc_meta").set("revertNonce", "post-destroy");
|
|
expect(spy.events).toHaveLength(0);
|
|
} finally {
|
|
spy.restore();
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 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<typeof setupConverged>): 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);
|
|
});
|
|
});
|