feat: ysync 0008 Stage D — wire the v2 items collab into the app + verify
- WasmTool: hard cutover to startKicadCollab (gates on kicadCollabSnapshotItems; scalar reconciler no longer runs in the app) - file-seed (ysync 0005 tie-in): empty rooms are seeded from the opened file via fileToDoc/docToY (meta+layout+items — file recoverable from the Y.Doc alone); populated rooms still adopt (seed-once unchanged) - binding: gate UP applies until seed() — the provider's initial state sync otherwise streams the full doc into an editor that already holds the file (trapped eeschema's paste path in the real app) - pcbnew blobForItem: footprints Format a uuid-corrected copy directly — SaveSelection's copy regenerated mandatory-field uuids (FOOTPRINT copy ctor assigns into fresh fields), breaking wire identity (rebuild) - roundtrip.spec on the v2 items wire; new passing pcbnew footprint-containment round trip (the 0004 containment win); full fixture stays fixme on the tracked envelope-parse limit - real-app two-tab emit verified for eeschema + pcbnew (see 0008 doc) Full kicad suite: 45 passed, 3 skipped (known), 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3a06a267b0
commit
07863da416
7 changed files with 273 additions and 46 deletions
|
|
@ -3,13 +3,14 @@ import { test, expect } from "./fixtures";
|
|||
import { sexprDiff } from "../../web/standalone/src/wasm/collab/sexpr-diff";
|
||||
|
||||
/**
|
||||
* Round-trip integration tests (feature 0004): for each collab-capable, file-
|
||||
* bearing tool prove the Y.Doc representation is lossless —
|
||||
* Round-trip integration tests (feature 0004; v2 items wire since ysync 0008
|
||||
* Stage D): for each collab-capable, file-bearing tool prove the Y.Doc
|
||||
* representation is lossless —
|
||||
*
|
||||
* load a fixture file → save it (ORIG, normalized through the serializer)
|
||||
* → kicadCollabSnapshot (the yjs item representation)
|
||||
* → kicadCollabSnapshotItems (per-item s-expr blobs, the Slot-model wire)
|
||||
* → RELOAD the page (fresh process-global wasm; boot.ts) → open an EMPTY doc
|
||||
* → kicadCollabApply(snapshot) (rebuild the model purely from the yjs items)
|
||||
* → kicadCollabApplyItems(snapshot) (rebuild the model purely from the blobs)
|
||||
* → save (REGEN)
|
||||
* → assert sexprDiff(ORIG, REGEN).equal
|
||||
*
|
||||
|
|
@ -40,8 +41,8 @@ interface ToolCfg {
|
|||
|
||||
type Mod = {
|
||||
kicadOpenFile(p: string): unknown;
|
||||
kicadCollabSnapshot(): string;
|
||||
kicadCollabApply(j: string): unknown;
|
||||
kicadCollabSnapshotItems(): string;
|
||||
kicadCollabApplyItems(j: string): unknown;
|
||||
} & Record<string, (p: string) => unknown>;
|
||||
type FS = {
|
||||
mkdirTree(p: string): void;
|
||||
|
|
@ -67,8 +68,8 @@ async function bootOpen(page: Page, cfg: ToolCfg, content: string, name: string)
|
|||
const m = (window as unknown as { Module?: Mod }).Module;
|
||||
return (
|
||||
typeof m?.kicadOpenFile === "function" &&
|
||||
typeof m?.kicadCollabSnapshot === "function" &&
|
||||
typeof m?.kicadCollabApply === "function" &&
|
||||
typeof m?.kicadCollabSnapshotItems === "function" &&
|
||||
typeof m?.kicadCollabApplyItems === "function" &&
|
||||
typeof m?.[saveFn] === "function"
|
||||
);
|
||||
},
|
||||
|
|
@ -131,16 +132,18 @@ async function roundTrip(
|
|||
const extract = await context.newPage();
|
||||
await bootOpen(extract, cfg, cfg.fixture, "rt");
|
||||
const orig = await saveRead(extract, cfg, "orig_dump");
|
||||
const snap = await extract.evaluate(() => window.Module.kicadCollabSnapshot());
|
||||
const snap = await extract.evaluate(() => window.Module.kicadCollabSnapshotItems());
|
||||
await extract.close();
|
||||
|
||||
const ids: string[] = JSON.parse(snap).added.map((i: { id: string }) => i.id);
|
||||
// v2 wire: { added: [{ sexpr, parent }] } — uuids live inside the blobs.
|
||||
const blobs: string[] = JSON.parse(snap).added.map((w: { sexpr: string }) => w.sexpr);
|
||||
const ids = [...new Set(blobs.flatMap((s) => [...s.matchAll(/\(uuid "([^"]+)"\)/g)].map((m) => m[1]!)))];
|
||||
expect(ids.length, "fixture must contain uuid items").toBeGreaterThan(0);
|
||||
|
||||
// 4–5: fresh page (fresh wasm), open an empty doc, rebuild the model from the snapshot.
|
||||
const rebuild = await context.newPage();
|
||||
await bootOpen(rebuild, cfg, cfg.empty, "rt");
|
||||
await rebuild.evaluate((s) => window.Module.kicadCollabApply(s), snap);
|
||||
await rebuild.evaluate((s) => window.Module.kicadCollabApplyItems(s), snap);
|
||||
|
||||
// apply() runs async for eeschema/pcbnew (CallAfter + coroutine). Best-effort wait
|
||||
// for the first applied item to materialize — but DON'T fail here: proceed to save
|
||||
|
|
@ -268,6 +271,46 @@ const PCB: ToolCfg = {
|
|||
ignoreTokens: ["generator_version"],
|
||||
};
|
||||
|
||||
// Footprint-only board: the v2 items wire's PROVEN pcbnew apply path (bare
|
||||
// footprint blob parse + children — the 0004 containment win). Kept separate
|
||||
// from PCB so the fragile-envelope types (via/text/segment, see the fixme
|
||||
// below) don't mask the passing footprint coverage.
|
||||
const PCB_FP: ToolCfg = {
|
||||
...PCB,
|
||||
fixture: `(kicad_pcb
|
||||
(version 20241229)
|
||||
(generator "pcbnew")
|
||||
(generator_version "9.0")
|
||||
(general (thickness 1.6))
|
||||
(paper "A4")
|
||||
(layers
|
||||
(0 "F.Cu" signal)
|
||||
(2 "B.Cu" signal)
|
||||
(37 "F.SilkS" user)
|
||||
(25 "Edge.Cuts" user)
|
||||
)
|
||||
(setup)
|
||||
(net 0 "")
|
||||
(footprint "TestLib:R"
|
||||
(layer "F.Cu")
|
||||
(uuid "66666666-0000-0000-0000-000000000001")
|
||||
(at 100 100)
|
||||
(attr smd)
|
||||
(property "Reference" "R1" (at 0 -4.2 0) (layer "F.SilkS") (uuid "66666666-0000-0000-0000-0000000000aa") (effects (font (size 1 1) (thickness 0.15))))
|
||||
(property "Value" "R" (at 0 4.6 0) (layer "F.Fab") (uuid "66666666-0000-0000-0000-0000000000bb") (effects (font (size 1 1) (thickness 0.15))))
|
||||
(fp_text user "HELLO" (at 0 0 0) (layer "F.SilkS") (uuid "66666666-0000-0000-0000-0000000000cc") (effects (font (size 1 1) (thickness 0.15))))
|
||||
)
|
||||
(footprint "TestLib:C"
|
||||
(layer "F.Cu")
|
||||
(uuid "99999999-0000-0000-0000-000000000009")
|
||||
(at 50 50)
|
||||
(attr smd)
|
||||
(property "Reference" "C1" (at 0 -2 0) (layer "F.SilkS") (uuid "99999999-0000-0000-0000-0000000000aa") (effects (font (size 1 1) (thickness 0.15))))
|
||||
)
|
||||
)
|
||||
`,
|
||||
};
|
||||
|
||||
test.beforeAll(() => {
|
||||
// No reconciler bundle needed — the round trip drives only the C++ Module.*
|
||||
// exports (open / snapshot / apply / save).
|
||||
|
|
@ -298,15 +341,29 @@ test.describe("round trip: file → yjs → file", () => {
|
|||
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
|
||||
});
|
||||
|
||||
// KNOWN BRIDGE GAPS (0004 phase-4 findings — tracked, not test bugs). The harness
|
||||
// boots, round-trips, and the comparator reports precisely what pcbnew's
|
||||
// kicadCollabApply does NOT yet reconstruct from a snapshot:
|
||||
// - footprints + their fields are not recreated (FP removed entirely);
|
||||
// - a footprint-child fp_text comes back as a board gr_text at absolute position;
|
||||
// - segment `width` and via `size` are lost (default 0);
|
||||
// - zones are not reconstructed.
|
||||
// Un-fixme as each apply converter lands. Run with --grep-invert skipped to see the
|
||||
// live diff.
|
||||
// THE 0004 CONTAINMENT WIN (ysync 0008 Stage C/D): footprints round-trip via the
|
||||
// items wire — the bare-footprint blob parse recreates the footprint WITH its
|
||||
// children (properties, fp_text) in place, which the scalar wire never could.
|
||||
test("pcbnew preserves footprint containment through a yjs round trip", async ({
|
||||
context,
|
||||
testLogger,
|
||||
}) => {
|
||||
const { orig, regen } = await roundTrip(context, PCB_FP);
|
||||
const diff = sexprDiff(orig, regen, { ignoreTokens: PCB_FP.ignoreTokens });
|
||||
expect(
|
||||
diff.equal,
|
||||
`round trip lost data:\n${JSON.stringify(diff, null, 2)}`,
|
||||
).toBe(true);
|
||||
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
|
||||
});
|
||||
|
||||
// REMAINING KNOWN GAP (ysync 0008 status, known limit 1 — tracked, not a test
|
||||
// bug): pcbnew track/via/zone/text APPLY rides the `(kicad_pcb …)` envelope
|
||||
// parse, the codebase's documented asyncify-fragile path (even a verbatim
|
||||
// SaveSelection envelope for a segment dies silently in the commit). The full
|
||||
// fixture (via + gr_text + segments) therefore still loses those items on the
|
||||
// rebuild side. Un-fixme when the envelope parse is solved. Run with
|
||||
// --grep-invert skipped to see the live diff.
|
||||
test.fixme(
|
||||
"pcbnew preserves items through a yjs round trip",
|
||||
async ({ context, testLogger }) => {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@
|
|||
#include <eda_text.h>
|
||||
#include <pcb_edit_frame.h>
|
||||
#include <kicad_clipboard.h>
|
||||
#include <io/kicad/kicad_io_utils.h>
|
||||
#include <richio.h>
|
||||
#include <tools/pcb_selection.h>
|
||||
#include <geometry/shape_poly_set.h>
|
||||
#include <geometry/shape_line_chain.h>
|
||||
|
|
@ -253,8 +255,52 @@ json itemToJson( BOARD_ITEM* aItem )
|
|||
|
||||
// Serialize one live board item to a clipboard blob (used only for `added` payloads — NOT the
|
||||
// diff unit, so `changed`/`removed` stay light and the blob never drives change detection).
|
||||
//
|
||||
// Footprints DON'T go through SaveSelection: its "make safe to transfer" step copies the
|
||||
// footprint, and FOOTPRINT's copy ctor ASSIGNS the mandatory fields into the new footprint's
|
||||
// freshly-constructed ones (`*existingField = *field`; EDA_ITEM::operator= keeps the target's
|
||||
// uuid) — so Reference/Value/Datasheet/Description would carry NEW uuids in every blob,
|
||||
// breaking the wire's identity-by-uuid (every emit would read as field remove+add, and round
|
||||
// trips lose the field uuids). Instead we make the same safety copy ourselves, RESTORE the
|
||||
// mandatory-field uuids from the source, and Format it directly — the same Format machinery
|
||||
// SaveSelection uses internally, so asyncify behavior is identical.
|
||||
std::string blobForItem( BOARD* aBoard, BOARD_ITEM* aItem )
|
||||
{
|
||||
if( aItem->Type() == PCB_FOOTPRINT_T )
|
||||
{
|
||||
const FOOTPRINT* src = static_cast<const FOOTPRINT*>( aItem );
|
||||
FOOTPRINT copy( *src );
|
||||
|
||||
for( PCB_FIELD* field : copy.GetFields() )
|
||||
{
|
||||
if( field->IsMandatory() )
|
||||
{
|
||||
if( const PCB_FIELD* srcField = src->GetField( field->GetId() ) )
|
||||
const_cast<KIID&>( field->m_Uuid ) = srcField->m_Uuid;
|
||||
}
|
||||
}
|
||||
|
||||
// The rest of SaveSelection's footprint safety steps, minus the refPoint move
|
||||
// (the wire carries absolute positions).
|
||||
for( PAD* pad : copy.Pads() )
|
||||
pad->SetNetCode( 0 );
|
||||
|
||||
copy.SetLocked( false );
|
||||
|
||||
CLIPBOARD_IO io;
|
||||
STRING_FORMATTER fmt;
|
||||
io.SetBoard( aBoard );
|
||||
io.SetOutputFormatter( &fmt );
|
||||
io.Format( © );
|
||||
|
||||
copy.SetParent( nullptr );
|
||||
copy.SetParentGroup( nullptr );
|
||||
|
||||
std::string out = fmt.GetString();
|
||||
KICAD_FORMAT::Prettify( out, KICAD_FORMAT::FORMAT_MODE::COMPACT_TEXT_PROPERTIES );
|
||||
return out;
|
||||
}
|
||||
|
||||
PCB_SELECTION sel;
|
||||
sel.Add( aItem ); // pointer-only insert; no mutation of the live item
|
||||
|
||||
|
|
|
|||
|
|
@ -3,20 +3,22 @@ import {
|
|||
collabRoomId,
|
||||
EXTENSION_TOOL,
|
||||
FILELESS_TOOLS,
|
||||
fileToDoc,
|
||||
toolSchema,
|
||||
type KicadDoc,
|
||||
type Tool,
|
||||
} from "@pcbjam/shared";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { WASM_ASSET_BASE_URL, yjsProviderConfig } from "@/lib/config";
|
||||
import { bootKicadTool } from "@/wasm/boot";
|
||||
import { memfsProjectDir } from "@/wasm/constants";
|
||||
import { memfsFilePath, memfsProjectDir } from "@/wasm/constants";
|
||||
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
|
||||
import type { CollabWindow } from "@/wasm/collab";
|
||||
import type { KicadItemsWindow } from "@/wasm/collab";
|
||||
import { clog, cwarn } from "@/wasm/collab/debug";
|
||||
import { createOomWatch, respawnInNewTab } from "@/recovery/oom-watch";
|
||||
import { MemoryExhaustedDialog } from "@/recovery/MemoryExhaustedDialog";
|
||||
|
||||
// Tools with a working collab bridge (kicadCollabSnapshot/Apply embind exports).
|
||||
// Tools with the v2 items bridge (kicadCollabSnapshotItems/ApplyItems embind exports).
|
||||
const COLLAB_TOOLS = new Set<Tool>(["pl_editor", "eeschema", "pcbnew"]);
|
||||
const LEGACY_EXTENSION_TOOL: Record<string, Tool> = {
|
||||
".sch": "eeschema",
|
||||
|
|
@ -170,10 +172,35 @@ function installToolNavigationHook(
|
|||
}
|
||||
|
||||
/**
|
||||
* Collaborative editing (features/yjs-bridge), ON BY DEFAULT for any tool that has the
|
||||
* collab bridge. Open the same project URL in two tabs to edit together: the channel is
|
||||
* keyed to project+file, so both tabs share one Y.Doc over BroadcastChannel. Editor edits
|
||||
* (add/move items) fire the tool's change hook → the bridge → the peer tab.
|
||||
* Read the opened file back from MEMFS (what the editor actually loaded) and
|
||||
* parse it into the full `KicadDoc` (ysync 0007 `fileToDoc`). Used to seed the
|
||||
* Y.Doc LOSSLESSLY when this client opens an empty room (ysync 0005): the doc
|
||||
* then carries meta + layout + items, so the file is recoverable from the Y.Doc
|
||||
* alone. Falls back to undefined (→ editor-snapshot seed, items only) when the
|
||||
* file is absent or doesn't parse as a KiCad s-expr document.
|
||||
*/
|
||||
function seedDocFromMemfs(
|
||||
win: ToolWindow,
|
||||
slug: string,
|
||||
targetPath?: string,
|
||||
): KicadDoc | undefined {
|
||||
if (!targetPath) return undefined;
|
||||
try {
|
||||
const text = win.FS?.readFile(memfsFilePath(slug, targetPath), { encoding: "utf8" });
|
||||
if (typeof text !== "string") return undefined;
|
||||
return fileToDoc(text);
|
||||
} catch (err) {
|
||||
cwarn("seed: fileToDoc failed — falling back to editor-snapshot seed", err);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collaborative editing (ysync 0008, Slot-model items wire), ON BY DEFAULT for any
|
||||
* tool that has the collab bridge. Open the same project URL in two tabs to edit
|
||||
* together: the channel is keyed to project+file, so both tabs share one Y.Doc over
|
||||
* BroadcastChannel. Editor edits (add/move items) fire the tool's change hook → the
|
||||
* bridge → the peer tab.
|
||||
*
|
||||
* Opt OUT with `?collab=0` (or `collab=false`). Tools without a bridge are skipped anyway.
|
||||
*/
|
||||
|
|
@ -181,6 +208,7 @@ async function maybeStartCollab(
|
|||
win: ToolWindow,
|
||||
opts: {
|
||||
tool: Tool;
|
||||
slug: string;
|
||||
projectId: string;
|
||||
targetPath?: string;
|
||||
log: (m: string) => void;
|
||||
|
|
@ -193,8 +221,8 @@ async function maybeStartCollab(
|
|||
collabParam,
|
||||
tool: opts.tool,
|
||||
hasModule: !!mod,
|
||||
hasSnapshot: typeof mod?.kicadCollabSnapshot,
|
||||
hasApply: typeof mod?.kicadCollabApply,
|
||||
hasSnapshotItems: typeof mod?.kicadCollabSnapshotItems,
|
||||
hasApplyItems: typeof mod?.kicadCollabApplyItems,
|
||||
url: win.location.href,
|
||||
});
|
||||
|
||||
|
|
@ -207,23 +235,28 @@ async function maybeStartCollab(
|
|||
clog(`tool ${opts.tool} has no collab bridge — skipping`);
|
||||
return;
|
||||
}
|
||||
if (typeof mod?.kicadCollabSnapshot !== "function") {
|
||||
if (typeof mod?.kicadCollabSnapshotItems !== "function") {
|
||||
cwarn(
|
||||
"BRIDGE NOT PRESENT: Module.kicadCollabSnapshot is",
|
||||
typeof mod?.kicadCollabSnapshot,
|
||||
`— the loaded ${opts.tool}.wasm predates the collab bridge. Rebuild + \`npm run setup:kicad\` and restart the dev server.`,
|
||||
"BRIDGE NOT PRESENT: Module.kicadCollabSnapshotItems is",
|
||||
typeof mod?.kicadCollabSnapshotItems,
|
||||
`— the loaded ${opts.tool}.wasm predates the v2 items bridge (ysync 0008 Stage C). Rebuild + \`npm run setup:kicad\` and restart the dev server.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { startCollab } = await import("@/wasm/collab");
|
||||
const { startKicadCollab } = await import("@/wasm/collab");
|
||||
const provider = yjsProviderConfig();
|
||||
// One room per (project, document). Two tabs of the same build compute the
|
||||
// same id, so cross-tab BroadcastChannel still works; network providers use it
|
||||
// verbatim to namespace + persist (see @pcbjam/shared collabRoomId).
|
||||
const room = collabRoomId(opts.projectId, opts.targetPath ?? opts.tool);
|
||||
clog("starting collab", provider.kind, "room", room);
|
||||
await startCollab(mod, win as unknown as CollabWindow, { provider, room });
|
||||
const seedDoc = seedDocFromMemfs(win, opts.slug, opts.targetPath);
|
||||
clog("starting collab", provider.kind, "room", room, "seedDoc:", !!seedDoc);
|
||||
await startKicadCollab(mod, win as unknown as KicadItemsWindow, {
|
||||
provider,
|
||||
room,
|
||||
seedDoc,
|
||||
});
|
||||
opts.log(`[collab] ${provider.kind} connected on ${room}`);
|
||||
opts.onStatus("Collab: connected");
|
||||
clog("connected ✓");
|
||||
|
|
@ -324,7 +357,7 @@ export function WasmTool({
|
|||
log: append,
|
||||
onStatus: setStatus,
|
||||
});
|
||||
await maybeStartCollab(win, { tool, projectId, targetPath, log: append, onStatus: setStatus });
|
||||
await maybeStartCollab(win, { tool, slug, projectId, targetPath, log: append, onStatus: setStatus });
|
||||
} catch (err) {
|
||||
append(`[fatal] ${String(err)}`);
|
||||
setStatus(`Error: ${String(err)}`);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import type { ProviderConfig, ProviderKind } from "@/wasm/collab";
|
|||
* Which Yjs collab provider this deployment uses (one active per env), and its
|
||||
* endpoint/token. Defaults to `broadcastchannel` so a vanilla checkout keeps
|
||||
* the cross-tab-only behavior with no backend. Built here at the composition
|
||||
* root and passed into `startCollab`, so `wasm/collab` stays env-agnostic.
|
||||
* root and passed into `startKicadCollab`, so `wasm/collab` stays env-agnostic.
|
||||
*/
|
||||
export function yjsProviderConfig(): ProviderConfig {
|
||||
const kind = (import.meta.env.VITE_YJS_PROVIDER ?? "broadcastchannel") as ProviderKind;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as Y from "yjs";
|
||||
import type { KicadDoc } from "@pcbjam/shared";
|
||||
import { clog } from "./debug";
|
||||
import {
|
||||
connectProvider,
|
||||
|
|
@ -55,6 +56,13 @@ export interface StartCollabOptions {
|
|||
provider: ProviderConfig;
|
||||
/** Room id — see @pcbjam/shared `collabRoomId`. Identifies one shared doc. */
|
||||
room: string;
|
||||
/**
|
||||
* The full `KicadDoc` parsed from the opened file (`fileToDoc`) — when the
|
||||
* room is empty, the Y.Doc is seeded from THIS (meta + layout + items, so the
|
||||
* file is recoverable from the doc alone — ysync 0005) instead of the editor
|
||||
* snapshot. Ignored by the legacy scalar `startCollab`.
|
||||
*/
|
||||
seedDoc?: KicadDoc;
|
||||
}
|
||||
|
||||
export interface CollabHandle {
|
||||
|
|
@ -127,7 +135,7 @@ export async function startKicadCollab(
|
|||
const provider = await connectProvider(doc, opts.provider, { room: opts.room });
|
||||
|
||||
await provider.whenSynced();
|
||||
binding.seed();
|
||||
binding.seed(opts.seedDoc);
|
||||
clog("startKicadCollab: ready; doc items =", binding.items.size);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import * as Y from "yjs";
|
||||
import {
|
||||
docToFile,
|
||||
fileToDoc,
|
||||
itemsWireToDelta,
|
||||
parseItemsWireDelta,
|
||||
renderItem,
|
||||
sexprToItems,
|
||||
yToDoc,
|
||||
type KicadItem,
|
||||
} from "@pcbjam/shared";
|
||||
import { bindKicadCollab, type KicadItemsBridge } from "./kicad-binding";
|
||||
|
|
@ -150,6 +153,61 @@ describe("bindKicadCollab — two editors over relayed Y.Docs", () => {
|
|||
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("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("destroy() detaches the editor from further remote changes", () => {
|
||||
const { edA, edB, bindA, bindB } = setup();
|
||||
seedEditor(edA, FP);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
applyDeltaToY,
|
||||
deltaFromYEvents,
|
||||
deltaToItemsWire,
|
||||
docToY,
|
||||
isEmptyItemsWireDelta,
|
||||
isEmptyKicadDelta,
|
||||
itemsWireToDelta,
|
||||
|
|
@ -11,6 +12,7 @@ import {
|
|||
renderItem,
|
||||
yToItem,
|
||||
type ItemsWireDelta,
|
||||
type KicadDoc,
|
||||
type KicadItem,
|
||||
type KicadYItems,
|
||||
} from "@pcbjam/shared";
|
||||
|
|
@ -46,12 +48,15 @@ export interface KicadItemsBridge {
|
|||
|
||||
export interface KicadBinding {
|
||||
/**
|
||||
* Seed-once join: if the shared doc holds no items this client seeds it from
|
||||
* the editor snapshot; otherwise the editor adopts the doc (doc authority —
|
||||
* local-only roots are removed, doc roots applied). Call once after the
|
||||
* doc/provider are connected.
|
||||
* Seed-once join: if the shared doc holds no items this client seeds it —
|
||||
* from `seedDoc` (the FULL `KicadDoc` parsed from the opened file via
|
||||
* `fileToDoc`; writes meta + layout + items so `docToFile` can regenerate the
|
||||
* file from the Y.Doc alone — ysync 0005/0007) when given, else from the
|
||||
* editor snapshot (items only). Otherwise the editor adopts the doc (doc
|
||||
* authority — local-only roots are removed, doc roots applied). Call once
|
||||
* after the doc/provider are connected.
|
||||
*/
|
||||
seed(): void;
|
||||
seed(seedDoc?: KicadDoc): void;
|
||||
destroy(): void;
|
||||
/** The underlying kdoc items map (exposed for tests/inspection). */
|
||||
readonly items: KicadYItems;
|
||||
|
|
@ -61,6 +66,12 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
|
|||
const items = kicadItemsMap(doc);
|
||||
// Opaque per-instance origin tag so we can distinguish our own writes from peers'.
|
||||
const ORIGIN = { local: true };
|
||||
// Remote events arriving BEFORE seed() (e.g. the provider's initial state sync)
|
||||
// must not stream into the editor item-by-item: the editor already holds the
|
||||
// opened file, so that would be a redundant full-document blob apply (observed
|
||||
// to trap eeschema's paste path in the real app). seed()'s adopt branch covers
|
||||
// everything those early events contained.
|
||||
let seeded = false;
|
||||
|
||||
/** Plain snapshot of the Y items (the `current`/`view` the conversions need). */
|
||||
const itemsView = (): Record<string, KicadItem> => {
|
||||
|
|
@ -94,6 +105,7 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
|
|||
// (the runtime); the event→delta computation is the shared default impl.
|
||||
const observer = (events: Y.YEvent<Y.Map<unknown>>[], txn: Y.Transaction) => {
|
||||
if (txn.origin === ORIGIN) return; // our own echo — ignore
|
||||
if (!seeded) return; // pre-seed state sync — seed()'s adopt covers it
|
||||
const delta = deltaFromYEvents(items, events);
|
||||
if (isEmptyKicadDelta(delta)) return;
|
||||
const wire = deltaToItemsWire(delta, itemsView());
|
||||
|
|
@ -107,7 +119,20 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
|
|||
};
|
||||
items.observeDeep(observer);
|
||||
|
||||
function seed(): void {
|
||||
function seed(seedDoc?: KicadDoc): void {
|
||||
seeded = true; // open the UP gate; everything below runs synchronously
|
||||
if (items.size === 0 && seedDoc) {
|
||||
// First tab, file-seeded: write the FULL doc (meta + layout + items) so
|
||||
// the Y.Doc — not the editor snapshot — is the lossless source of truth
|
||||
// (the file is recoverable via docToFile). The editor already opened the
|
||||
// same file, so no applyItems is needed.
|
||||
clog(
|
||||
`seed: doc empty → SEEDING from file (${Object.keys(seedDoc.items).length} item(s), root ${seedDoc.root})`,
|
||||
);
|
||||
docToY(seedDoc, doc, ORIGIN);
|
||||
return;
|
||||
}
|
||||
|
||||
let wire: ItemsWireDelta;
|
||||
try {
|
||||
wire = parseItemsWireDelta(bridge.snapshotItems());
|
||||
|
|
@ -123,7 +148,7 @@ export function bindKicadCollab(doc: Y.Doc, bridge: KicadItemsBridge): KicadBind
|
|||
);
|
||||
|
||||
if (items.size === 0) {
|
||||
// First tab: seed the shared doc from the editor model.
|
||||
// First tab, no file source: seed the shared doc from the editor model.
|
||||
applyDeltaToY(doc, local, ORIGIN);
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue