diff --git a/web/standalone/src/wasm/collab/ai-bridge.test.ts b/web/standalone/src/wasm/collab/ai-bridge.test.ts new file mode 100644 index 0000000..535a8da --- /dev/null +++ b/web/standalone/src/wasm/collab/ai-bridge.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import * as Y from "yjs"; +import { docToFile, docToY, kicadLibSymbolsMap, fileToDoc, yToDoc } from "@pcbjam/shared"; +import { applyToActiveBuffer, readActiveBuffer, registerActiveCollabDoc } from "./ai-bridge"; + +// A symbol instance is only renderable with its `(lib_symbols …)` definition. +// `docDelta` (the apply path's item differ) only diffs uuid-bearing items, and a +// `(symbol "Device:R" …)` lib def has no uuid — so an AI proposal that places a +// part the sheet has never carried used to land the instance with no definition, +// and KiCad drew nothing. `applyToActiveBuffer` now upserts the proposal's lib +// defs into `kdoc_libsymbols` alongside the item delta. + +const WIRE1 = "22222222-0000-0000-0000-000000000001"; +const SYM1 = "44444444-0000-0000-0000-000000000001"; + +const SCH_WITHOUT_SYMBOL = `(kicad_sch +\t(version 20250114) +\t(generator "eeschema") +\t(generator_version "9.0") +\t(uuid "11111111-1111-1111-1111-111111111111") +\t(paper "A4") +\t(lib_symbols) +\t(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "${WIRE1}")) +\t(sheet_instances (path "/" (page "1"))) +) +`; + +const SCH_WITH_SYMBOL = `(kicad_sch +\t(version 20250114) +\t(generator "eeschema") +\t(generator_version "9.0") +\t(uuid "11111111-1111-1111-1111-111111111111") +\t(paper "A4") +\t(lib_symbols +\t\t(symbol "Device:R" (pin_numbers (hide yes)) (pin_names (offset 0)) (exclude_from_sim no) (in_bom yes) (on_board yes) +\t\t\t(property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27)))) +\t\t\t(property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27)))) +\t\t\t(symbol "R_0_1" +\t\t\t\t(rectangle (start -1.016 -2.54) (end 1.016 2.54) (stroke (width 0.254) (type default)) (fill (type none))) +\t\t\t) +\t\t\t(symbol "R_1_1" +\t\t\t\t(pin passive line (at 0 3.81 270) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "1" (effects (font (size 1.27 1.27))))) +\t\t\t\t(pin passive line (at 0 -3.81 90) (length 1.27) (name "~" (effects (font (size 1.27 1.27)))) (number "2" (effects (font (size 1.27 1.27))))) +\t\t\t) +\t\t) +\t) +\t(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "${WIRE1}")) +\t(symbol (lib_id "Device:R") (at 63.5 63.5 0) (unit 1) (exclude_from_sim no) (in_bom yes) (on_board yes) (dnp no) +\t\t(uuid "${SYM1}") +\t\t(property "Reference" "R1" (at 66.04 62.23 0) (effects (font (size 1.27 1.27)) (justify left))) +\t\t(property "Value" "10k" (at 66.04 64.77 0) (effects (font (size 1.27 1.27)) (justify left))) +\t\t(pin "1" (uuid "44444444-0000-0000-0000-0000000000a1")) +\t\t(pin "2" (uuid "44444444-0000-0000-0000-0000000000a2")) +\t\t(instances (project "rt" (path "/11111111-1111-1111-1111-111111111111" (reference "R1") (unit 1)))) +\t) +\t(sheet_instances (path "/" (page "1"))) +) +`; + +function seed(src: string): Y.Doc { + const ydoc = new Y.Doc(); + docToY(fileToDoc(src), ydoc); + return ydoc; +} + +describe("ai-bridge applyToActiveBuffer", () => { + it("carries a newly-placed symbol's (lib_symbols) definition, not just the instance", () => { + const ydoc = seed(SCH_WITHOUT_SYMBOL); + registerActiveCollabDoc(ydoc, "board.kicad_sch"); + try { + expect(kicadLibSymbolsMap(ydoc).get("Device:R")).toBeUndefined(); + + applyToActiveBuffer(SCH_WITH_SYMBOL); + + const def = kicadLibSymbolsMap(ydoc).get("Device:R"); + expect(def).toBeDefined(); + expect(def).toContain("R_0_1"); + + // and the instance item itself landed via the normal item delta + const out = docToFile(yToDoc(ydoc)); + expect(out).toContain(SYM1); + expect(out).toContain('(lib_id "Device:R")'); + } finally { + registerActiveCollabDoc(null); + } + }); + + it("is a no-op on lib_symbols when the proposal adds no new definitions", () => { + const ydoc = seed(SCH_WITH_SYMBOL); + registerActiveCollabDoc(ydoc, "board.kicad_sch"); + try { + const before = kicadLibSymbolsMap(ydoc).get("Device:R"); + expect(before).toBeDefined(); + + // same doc, wire nudged — no new parts + applyToActiveBuffer(SCH_WITH_SYMBOL.replace("101.6 50.8", "127 50.8")); + + expect(kicadLibSymbolsMap(ydoc).get("Device:R")).toBe(before); + expect(readActiveBuffer()).toContain("127 50.8"); + } finally { + registerActiveCollabDoc(null); + } + }); +}); diff --git a/web/standalone/src/wasm/collab/ai-bridge.ts b/web/standalone/src/wasm/collab/ai-bridge.ts index 85a46cd..4040831 100644 --- a/web/standalone/src/wasm/collab/ai-bridge.ts +++ b/web/standalone/src/wasm/collab/ai-bridge.ts @@ -28,6 +28,8 @@ import { docDelta, docToFile, fileToDoc, + libSymbolsFromLayout, + upsertLibSymbolsToY, yToDoc, type KicadDoc, } from "@pcbjam/shared"; @@ -82,6 +84,20 @@ export function applyToActiveBuffer(newSource: string): void { const prev = yToDoc(activeDoc); const next = fileToDoc(newSource); const delta = docDelta(prev, next); + // `docDelta` only diffs uuid-bearing items — a `(lib_symbols …)` def has no + // uuid, so a proposal that places a symbol the sheet has never carried adds + // the instance item but drops its library definition, leaving KiCad an + // instance pointing at an undefined symbol (renders nothing). Push the + // proposal's lib defs into the kdoc_libsymbols map FIRST (upsert by lib id, + // never deletes — the server always splices into the existing block, so this + // is existing ∪ additions), so the items observer's `deltaToItemsWire` can + // prefix each new instance with its `(lib_symbols …)` on the way to the + // editor — exactly what `kicad-binding.ts`'s own DOWN path does with + // `upsertLibSymbolsToY(doc, defs, ORIGIN)`. Deliberately NOT `syncLayoutToY`: + // that also reconciles non-item layout heads (root `uuid`, `sheet_instances`, + // `paper`…) wholesale-LWW from the proposal, which would corrupt the sheet's + // identity if the server's spliced preamble deviates at all. + upsertLibSymbolsToY(activeDoc, libSymbolsFromLayout(next.layout, next.items), APPLY_ORIGIN); applyDeltaToY(activeDoc, delta, APPLY_ORIGIN); }