findings group P: liftBlob field gap (P-5), apply-slot hardening (P-1), chooser-held rotate (P-4)
- P-5: blobForItem returns "" for PCB_FIELD_T (the file writer cannot format a
field standalone → hollow (kicad_pcb …) envelope); liftBlob skips empty blobs
and STRUCT_DELETED roots with a console breadcrumb; the scalar added path and
the seed never attach an empty blob.
- P-1 (not reproduced, structural): the runOnCoroutine apply body runs in
try/catch(...) so an unwinding body still marks the slot done instead of
wedging fit/pan/flushDiff/remote applies forever; new probe
kicadCollabTestApplyQueueState() → {busy, queued}.
- P-4: wxwidgets 9666a743bc (TLW SetFocus delegates to a child,
menubar never takes focus); probe kicadTestFocusWindow() (wx FindFocus).
- Specs: tests/kicad/findings-p.spec.ts (P-1 ×3, P-2 probes as fixme, P-3 ×2,
P-4, P-5), tests/web/findings-p1.spec.ts (P-1 on the real standalone).
P-2 verdict: stock KiCad candidate priority, text scales normally; P-3 not
reproducible — see docs/features/findings/groups/P-editor-tools-embind-interaction.md.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WmnMPkjugyGqe4LvUsBB14
This commit is contained in:
parent
931935c70a
commit
3d19117d88
6 changed files with 861 additions and 5 deletions
678
tests/kicad/findings-p.spec.ts
Normal file
678
tests/kicad/findings-p.spec.ts
Normal file
|
|
@ -0,0 +1,678 @@
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
import { waitForEditorReady, clickByTooltip, findByTooltip, clickMenuBarItem, clickMenuItemByText, waitUntil } from "../e2e/utils/element-tracker";
|
||||||
|
import { waitForPcbnew } from "./utils/pcbnew-ready";
|
||||||
|
import { injectFromSubmodule } from "./utils/fs-inject";
|
||||||
|
import { waitForBoardLoaded } from "./utils/board-ready";
|
||||||
|
import { PROJECT_DIR_MEMFS } from "./utils/threed-viewer";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Findings group P — editor tools, selection & embind interaction
|
||||||
|
* (docs/features/findings/groups/P-editor-tools-embind-interaction.md).
|
||||||
|
*
|
||||||
|
* Repro probes for P-1 … P-5, ported from the demo-video ledger's
|
||||||
|
* `video/src/probes/*` (not in this tree) onto the kicad harness. Each test
|
||||||
|
* names the finding it gates; RED = bug present on the current line.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TRAP =
|
||||||
|
/Aborted\(|index out of bounds|unreachable executed|indirect call signature|null function|memory access out of bounds/;
|
||||||
|
|
||||||
|
const SEG1 = "44444444-0000-0000-0000-000000000001";
|
||||||
|
const FP1 = "66666666-0000-0000-0000-000000000001";
|
||||||
|
const FP1_REF = "66666666-0000-0000-0000-0000000000aa";
|
||||||
|
const FP1_VAL = "66666666-0000-0000-0000-0000000000bb";
|
||||||
|
const FP1_AT = { x: 100e6, y: 100e6 }; // IU (nm)
|
||||||
|
|
||||||
|
const SAMPLE_PCB = `(kicad_pcb
|
||||||
|
\t(version 20260206)
|
||||||
|
\t(generator "pcbnew")
|
||||||
|
\t(generator_version "9.0")
|
||||||
|
\t(general
|
||||||
|
\t\t(thickness 1.6)
|
||||||
|
\t)
|
||||||
|
\t(paper "A4")
|
||||||
|
\t(layers
|
||||||
|
\t\t(0 "F.Cu" signal)
|
||||||
|
\t\t(2 "B.Cu" signal)
|
||||||
|
\t\t(37 "F.SilkS" user)
|
||||||
|
\t\t(25 "Edge.Cuts" user)
|
||||||
|
\t)
|
||||||
|
\t(setup)
|
||||||
|
\t(net 0 "")
|
||||||
|
\t(footprint "TestLib:R"
|
||||||
|
\t\t(layer "F.Cu")
|
||||||
|
\t\t(uuid "${FP1}")
|
||||||
|
\t\t(at 100 100)
|
||||||
|
\t\t(attr smd)
|
||||||
|
\t\t(property "Reference" "R1"
|
||||||
|
\t\t\t(at 0 -4.2 0)
|
||||||
|
\t\t\t(layer "F.SilkS")
|
||||||
|
\t\t\t(uuid "${FP1_REF}")
|
||||||
|
\t\t\t(effects (font (size 1 1) (thickness 0.15)))
|
||||||
|
\t\t)
|
||||||
|
\t\t(property "Value" "R"
|
||||||
|
\t\t\t(at 0 4.6 0)
|
||||||
|
\t\t\t(layer "F.Fab")
|
||||||
|
\t\t\t(uuid "${FP1_VAL}")
|
||||||
|
\t\t\t(effects (font (size 1 1) (thickness 0.15)))
|
||||||
|
\t\t)
|
||||||
|
\t\t(pad "1" smd rect
|
||||||
|
\t\t\t(at -1 0)
|
||||||
|
\t\t\t(size 1 1)
|
||||||
|
\t\t\t(layers "F.Cu")
|
||||||
|
\t\t\t(uuid "66666666-0000-0000-0000-0000000000dd")
|
||||||
|
\t\t)
|
||||||
|
\t\t(pad "2" smd rect
|
||||||
|
\t\t\t(at 1 0)
|
||||||
|
\t\t\t(size 1 1)
|
||||||
|
\t\t\t(layers "F.Cu")
|
||||||
|
\t\t\t(uuid "66666666-0000-0000-0000-0000000000ee")
|
||||||
|
\t\t)
|
||||||
|
\t)
|
||||||
|
\t(segment (start 50.8 50.8) (end 101.6 50.8) (width 0.2) (layer "F.Cu") (net 0) (uuid "${SEG1}"))
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
type Vp = { cx: number; cy: number; scale: number; w: number; h: number };
|
||||||
|
type Mod = {
|
||||||
|
kicadOpenFile(p: string): unknown;
|
||||||
|
kicadCollabPresenceStart(): void;
|
||||||
|
kicadCollabGetViewport(): string;
|
||||||
|
kicadCollabFitViewport(cx: number, cy: number, hw: number, hh: number): void;
|
||||||
|
kicadCollabGetSelection(): string;
|
||||||
|
kicadCollabTestSelectByUuid(id: string): boolean;
|
||||||
|
kicadCollabTestClearSelection(): boolean;
|
||||||
|
kicadCollabTestRotateItem(id: string, deg: number): boolean;
|
||||||
|
kicadCollabTestItemBlob(id: string): string;
|
||||||
|
kicadCollabTestListItems(n: number): string;
|
||||||
|
kicadCollabSnapshot(): string;
|
||||||
|
kicadCollabGetPos(id: string): string;
|
||||||
|
};
|
||||||
|
type W = { FS: { mkdirTree(p: string): void; writeFile(p: string, d: string): void }; Module: Mod };
|
||||||
|
|
||||||
|
function hasTrap(l: { consoleLogs: string[]; errors: string[] }): string[] {
|
||||||
|
return [...l.consoleLogs, ...l.errors].filter((s) => TRAP.test(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bootCollab(page: Page, content: string = SAMPLE_PCB, firstId: string = FP1): Promise<void> {
|
||||||
|
await page.goto("/kicad/pcbnew-collab.html");
|
||||||
|
await expect(page.locator("#canvas")).toBeVisible({ timeout: 90000 });
|
||||||
|
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const m = (window as unknown as { Module?: Partial<Mod> }).Module;
|
||||||
|
return (
|
||||||
|
typeof m?.kicadOpenFile === "function" &&
|
||||||
|
typeof m?.kicadCollabFitViewport === "function" &&
|
||||||
|
typeof m?.kicadCollabTestRotateItem === "function"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{ timeout: 90000 },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
!!window.wxElementRegistry &&
|
||||||
|
window.wxElementRegistry
|
||||||
|
.findAll({ visible: true })
|
||||||
|
.some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")),
|
||||||
|
null,
|
||||||
|
{ timeout: 90000 },
|
||||||
|
);
|
||||||
|
await page.evaluate(({ content }) => {
|
||||||
|
const w = window as unknown as W;
|
||||||
|
const dir = "/home/kicad/documents";
|
||||||
|
try {
|
||||||
|
w.FS.mkdirTree(dir);
|
||||||
|
} catch {
|
||||||
|
/* exists */
|
||||||
|
}
|
||||||
|
const p = `${dir}/findings-p.kicad_pcb`;
|
||||||
|
w.FS.writeFile(p, content);
|
||||||
|
w.Module.kicadOpenFile(p);
|
||||||
|
}, { content });
|
||||||
|
await page.waitForFunction(
|
||||||
|
(id) => {
|
||||||
|
try {
|
||||||
|
const m = (window as unknown as W).Module;
|
||||||
|
return JSON.parse(m.kicadCollabTestListItems(1000)).some((i: { id?: string }) => (i.id ?? i) === id)
|
||||||
|
|| m.kicadCollabTestItemBlob(id).includes("(footprint");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
firstId,
|
||||||
|
{ timeout: 60000 },
|
||||||
|
);
|
||||||
|
await page.evaluate(() => (window as unknown as W).Module.kicadCollabPresenceStart());
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVp = (page: Page): Promise<Vp> =>
|
||||||
|
page.evaluate(() => JSON.parse((window as unknown as W).Module.kicadCollabGetViewport()));
|
||||||
|
|
||||||
|
async function fit(page: Page, t: { cx: number; cy: number; hw: number; hh: number }) {
|
||||||
|
await page.evaluate((t) => (window as unknown as W).Module.kicadCollabFitViewport(t.cx, t.cy, t.hw, t.hh), t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fit and wait for the camera to land; returns whether it did (P-1 oracle). */
|
||||||
|
async function fitLands(page: Page, t: { cx: number; cy: number; hw: number; hh: number }, timeout = 8000) {
|
||||||
|
await fit(page, t);
|
||||||
|
return expect
|
||||||
|
.poll(async () => {
|
||||||
|
const vp = await getVp(page);
|
||||||
|
return Math.abs(vp.cx - t.cx) < 1e6 && Math.abs(vp.cy - t.cy) < 1e6;
|
||||||
|
}, { timeout, intervals: [200] })
|
||||||
|
.toBe(true)
|
||||||
|
.then(() => true, () => false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fpRotation(page: Page, id: string): Promise<number | null> {
|
||||||
|
const blob = await page.evaluate((i) => (window as unknown as W).Module.kicadCollabTestItemBlob(i), id);
|
||||||
|
const m = blob.match(/\(footprint[^]*?\(at\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s+(-?[\d.]+))?\)/);
|
||||||
|
if (!m) return null;
|
||||||
|
return m[3] ? Number(m[3]) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function glCanvasBox(page: Page) {
|
||||||
|
const id = await page.evaluate(() => {
|
||||||
|
const c = Array.from(document.querySelectorAll('[id^="glcanvas-"]'))
|
||||||
|
.map((c) => c as HTMLCanvasElement)
|
||||||
|
.find((c) => {
|
||||||
|
const r = c.getBoundingClientRect();
|
||||||
|
return window.getComputedStyle(c).display !== "none" && r.width > 0 && r.height > 0;
|
||||||
|
});
|
||||||
|
return c?.id ?? null;
|
||||||
|
});
|
||||||
|
expect(id, "visible GL canvas").not.toBeNull();
|
||||||
|
const box = await page.locator(`#${id}`).boundingBox();
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
return box!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// P-1 · kicadCollabFitViewport wedges after the page's first committed edit
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
test.describe("P-1 FitViewport after first edit", () => {
|
||||||
|
test("fit still lands after a committed rotate (embind commit)", async ({ page, testLogger }) => {
|
||||||
|
await bootCollab(page);
|
||||||
|
|
||||||
|
const a = { cx: 120e6, cy: 90e6, hw: 40e6, hh: 30e6 };
|
||||||
|
const b = { cx: 60e6, cy: 140e6, hw: 20e6, hh: 15e6 };
|
||||||
|
expect(await fitLands(page, a), "fit BEFORE any edit lands").toBe(true);
|
||||||
|
|
||||||
|
const rot0 = await fpRotation(page, FP1);
|
||||||
|
await page.evaluate((id) => (window as unknown as W).Module.kicadCollabTestRotateItem(id, 90), FP1);
|
||||||
|
await expect.poll(() => fpRotation(page, FP1), { timeout: 10000 }).not.toBe(rot0);
|
||||||
|
console.log(`[PROBE P-1] rotation ${rot0} → ${await fpRotation(page, FP1)}`);
|
||||||
|
|
||||||
|
const landed = await fitLands(page, b);
|
||||||
|
console.log(`[PROBE P-1] fit AFTER embind-commit rotate landed: ${landed} vp=${JSON.stringify(await getVp(page))}`);
|
||||||
|
expect(hasTrap(testLogger)).toEqual([]);
|
||||||
|
expect(landed, "P-1: fit after the first committed edit must still move the camera").toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fit still lands after a keyboard rotate of a selected footprint", async ({ page, testLogger }) => {
|
||||||
|
await bootCollab(page);
|
||||||
|
|
||||||
|
const a = { cx: FP1_AT.x, cy: FP1_AT.y, hw: 20e6, hh: 15e6 };
|
||||||
|
expect(await fitLands(page, a), "fit BEFORE any edit lands").toBe(true);
|
||||||
|
|
||||||
|
// Click the part (real input, so keyboard focus is where a user's would be), then `r`.
|
||||||
|
const box = await glCanvasBox(page);
|
||||||
|
const vp = await getVp(page);
|
||||||
|
const sx = box.x + (FP1_AT.x - vp.cx) * vp.scale + vp.w / 2;
|
||||||
|
const sy = box.y + (FP1_AT.y - vp.cy) * vp.scale + vp.h / 2;
|
||||||
|
await page.mouse.move(sx, sy);
|
||||||
|
await page.waitForTimeout(300); // eslint-disable-line -- pointer-move dwell
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(300); // eslint-disable-line -- selection dwell
|
||||||
|
const sel = await page.evaluate(() => JSON.parse((window as unknown as W).Module.kicadCollabGetSelection()));
|
||||||
|
console.log(`[PROBE P-1] selection after click: ${JSON.stringify(sel)} activeElement=${await page.evaluate(() => document.activeElement?.tagName)}`);
|
||||||
|
if (!sel.length) {
|
||||||
|
await page.evaluate((id) => (window as unknown as W).Module.kicadCollabTestSelectByUuid(id), FP1);
|
||||||
|
}
|
||||||
|
const rot0 = await fpRotation(page, FP1);
|
||||||
|
await page.keyboard.press("r");
|
||||||
|
const rotated = await expect.poll(() => fpRotation(page, FP1), { timeout: 8000 }).not.toBe(rot0)
|
||||||
|
.then(() => true, () => false);
|
||||||
|
console.log(`[PROBE P-1] keyboard rotate applied: ${rotated} (${rot0} → ${await fpRotation(page, FP1)})`);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
|
const b = { cx: 60e6, cy: 140e6, hw: 20e6, hh: 15e6 };
|
||||||
|
const landed = await fitLands(page, b);
|
||||||
|
console.log(`[PROBE P-1] fit AFTER keyboard rotate landed: ${landed} vp=${JSON.stringify(await getVp(page))}`);
|
||||||
|
expect(hasTrap(testLogger)).toEqual([]);
|
||||||
|
expect(rotated, "keyboard `r` rotated the selected footprint (precondition)").toBe(true);
|
||||||
|
expect(landed, "P-1: fit after a keyboard rotate must still move the camera").toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("P-1 apply-queue poisoning", () => {
|
||||||
|
// Candidate mechanism: runOnCoroutine is a single-slot FIFO whose `done` flag is set
|
||||||
|
// only when the body returns normally. A JS exception thrown from a wire callback
|
||||||
|
// (EM_ASM inside flushDiff) unwinds the body → busy forever → every later fit no-ops.
|
||||||
|
test("fit still lands after a JS wire callback throws during flushDiff", async ({ page, testLogger }) => {
|
||||||
|
await bootCollab(page);
|
||||||
|
expect(await fitLands(page, { cx: 120e6, cy: 90e6, hw: 40e6, hh: 30e6 })).toBe(true);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const w = window as unknown as { kicadCollab?: Record<string, unknown> };
|
||||||
|
w.kicadCollab = { ...(w.kicadCollab ?? {}),
|
||||||
|
onItems: () => { throw new Error("P-1 probe: onItems throws"); },
|
||||||
|
onDelta: () => { throw new Error("P-1 probe: onDelta throws"); } };
|
||||||
|
});
|
||||||
|
const rot0 = await fpRotation(page, FP1);
|
||||||
|
await page.evaluate((id) => (window as unknown as W).Module.kicadCollabTestRotateItem(id, 90), FP1);
|
||||||
|
await expect.poll(() => fpRotation(page, FP1), { timeout: 10000 }).not.toBe(rot0);
|
||||||
|
await page.waitForTimeout(1000); // eslint-disable-line -- let the flush run
|
||||||
|
const landed = await fitLands(page, { cx: 60e6, cy: 140e6, hw: 20e6, hh: 15e6 });
|
||||||
|
const errs = testLogger.errors.filter((e) => e.includes("P-1 probe"));
|
||||||
|
// Apply-slot probe (added with the P-1 hardening): a wedge reads busy=true with a growing
|
||||||
|
// queue; a healthy slot is idle once the fit landed.
|
||||||
|
const state = await page.evaluate(() => {
|
||||||
|
const m = (window as unknown as { Module: { kicadCollabTestApplyQueueState?: () => string } }).Module;
|
||||||
|
return m.kicadCollabTestApplyQueueState ? JSON.parse(m.kicadCollabTestApplyQueueState()) : null;
|
||||||
|
});
|
||||||
|
console.log(`[PROBE P-1c] callback threw: ${errs.length > 0}; fit after landed: ${landed}; applyQueue=${JSON.stringify(state)}`);
|
||||||
|
expect(hasTrap(testLogger)).toEqual([]);
|
||||||
|
expect(landed, "P-1: a throwing JS wire callback must not wedge the apply queue").toBe(true);
|
||||||
|
if (state) expect(state.busy, "apply slot idle after the edit + fit").toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// P-2 · reference text steals the click at wide zoom
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
test.describe("P-2 click-to-select a footprint across zoom levels", () => {
|
||||||
|
test("clicking the part origin selects the footprint from 10 mm to 150 mm half-width", async ({ page, testLogger }) => {
|
||||||
|
await bootCollab(page);
|
||||||
|
const box = await glCanvasBox(page);
|
||||||
|
const results: Array<{ hw: number; sel: string[]; pxPerMm: number }> = [];
|
||||||
|
|
||||||
|
for (const hwMm of [10, 20, 40, 80, 150]) {
|
||||||
|
await page.evaluate(() => (window as unknown as W).Module.kicadCollabTestClearSelection());
|
||||||
|
const t = { cx: FP1_AT.x, cy: FP1_AT.y, hw: hwMm * 1e6, hh: hwMm * 0.75e6 };
|
||||||
|
expect(await fitLands(page, t), `fit ${hwMm}mm lands`).toBe(true);
|
||||||
|
const vp = await getVp(page);
|
||||||
|
const sx = box.x + (FP1_AT.x - vp.cx) * vp.scale + vp.w / 2;
|
||||||
|
const sy = box.y + (FP1_AT.y - vp.cy) * vp.scale + vp.h / 2;
|
||||||
|
await page.mouse.move(sx, sy);
|
||||||
|
await page.waitForTimeout(300); // eslint-disable-line -- pointer-move dwell
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(400); // eslint-disable-line -- selection dwell
|
||||||
|
const sel: string[] = await page.evaluate(() => JSON.parse((window as unknown as W).Module.kicadCollabGetSelection()));
|
||||||
|
results.push({ hw: hwMm, sel, pxPerMm: vp.scale * 1e6 });
|
||||||
|
console.log(`[PROBE P-2] hw=${hwMm}mm px/mm=${(vp.scale * 1e6).toFixed(2)} click=(${sx.toFixed(0)},${sy.toFixed(0)}) sel=${JSON.stringify(sel)}`);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(hasTrap(testLogger)).toEqual([]);
|
||||||
|
const wrong = results.filter((r) => !(r.sel.length === 1 && r.sel[0] === FP1));
|
||||||
|
expect(wrong, "P-2: every framing must select the footprint itself (not its text child)").toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// P-5 · a footprint FIELD blobbed standalone yields an item-less board envelope
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
test.describe("P-5 liftBlob gap", () => {
|
||||||
|
test("blobForItem on a PCB_FIELD must not emit a bare (kicad_pcb …) envelope", async ({ page, testLogger }) => {
|
||||||
|
await bootCollab(page);
|
||||||
|
const blob = await page.evaluate((id) => (window as unknown as W).Module.kicadCollabTestItemBlob(id), FP1_REF);
|
||||||
|
console.log(`[PROBE P-5] field blob (${blob.length} bytes): ${blob.slice(0, 200).replace(/\n/g, " ")}`);
|
||||||
|
expect(hasTrap(testLogger)).toEqual([]);
|
||||||
|
// Either lifted to the parent footprint (contains the property) or refused (empty) —
|
||||||
|
// never a hollow board envelope that carries no uuid-bearing item.
|
||||||
|
const hollow = blob.includes("(kicad_pcb") && !blob.includes("(footprint") && !blob.includes("(property");
|
||||||
|
expect(hollow, "P-5: hollow envelope emitted for a standalone field").toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// P-3 · `m` move wedges after its first use (pcbnew.html, real input)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
type SnapItem = { id: string; type: string; x: number; y: number };
|
||||||
|
|
||||||
|
async function snapshotItems(page: Page): Promise<SnapItem[]> {
|
||||||
|
return page.evaluate(() => JSON.parse((window as unknown as W).Module.kicadCollabSnapshot()).added);
|
||||||
|
}
|
||||||
|
async function getPos(page: Page, id: string) {
|
||||||
|
const raw = await page.evaluate((i) => (window as unknown as W).Module.kicadCollabGetPos(i), id);
|
||||||
|
const [x, y] = raw.split(",").map(Number);
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveWithM(page: Page, at: { x: number; y: number }, nudges: number) {
|
||||||
|
await page.mouse.move(at.x, at.y);
|
||||||
|
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell
|
||||||
|
await page.keyboard.press("m");
|
||||||
|
await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell
|
||||||
|
for (let i = 0; i < nudges; i++) {
|
||||||
|
await page.keyboard.press("ArrowRight");
|
||||||
|
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell
|
||||||
|
}
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("P-3 `m` move twice per page", () => {
|
||||||
|
test("the second `m` move works too", async ({ page, testLogger }) => {
|
||||||
|
await page.goto("/kicad/pcbnew.html");
|
||||||
|
await waitForEditorReady(page);
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const m = (window as unknown as { Module?: Partial<Mod> }).Module;
|
||||||
|
return typeof m?.kicadCollabSnapshot === "function" && typeof m?.kicadCollabGetPos === "function";
|
||||||
|
}, null, { timeout: 30000 });
|
||||||
|
await page.waitForFunction(() =>
|
||||||
|
!!window.wxElementRegistry?.findAllRendered
|
||||||
|
&& window.wxElementRegistry.findAllRendered({ elementType: "tool" }).some((t) => t.tooltip?.includes("Draw Lines")),
|
||||||
|
null, { timeout: 15000 });
|
||||||
|
|
||||||
|
const idsBefore = new Set((await snapshotItems(page)).map((i) => i.id));
|
||||||
|
expect(await clickByTooltip(page, "Draw Lines", { elementType: "tool" })).toBe(true);
|
||||||
|
await expect.poll(async () => ((await findByTooltip(page, "Draw Lines", { elementType: "tool" }))?.label ?? "").includes("[checked]"),
|
||||||
|
{ timeout: 5000 }).toBe(true);
|
||||||
|
|
||||||
|
const gl = await glCanvasBox(page);
|
||||||
|
const p0 = { x: Math.round(gl.x + gl.width * 0.35), y: Math.round(gl.y + gl.height * 0.45) };
|
||||||
|
const p1 = { x: Math.round(gl.x + gl.width * 0.55), y: Math.round(gl.y + gl.height * 0.45) };
|
||||||
|
const mid = { x: Math.round((p0.x + p1.x) / 2), y: p0.y };
|
||||||
|
for (const p of [p0, p1]) {
|
||||||
|
await page.mouse.move(p.x, p.y);
|
||||||
|
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect.poll(async () => (await snapshotItems(page)).filter((i) => !idsBefore.has(i.id)).length,
|
||||||
|
{ timeout: 8000, intervals: [200] }).toBe(1);
|
||||||
|
const id = (await snapshotItems(page)).find((i) => !idsBefore.has(i.id))!.id;
|
||||||
|
|
||||||
|
const pos0 = await getPos(page, id);
|
||||||
|
await moveWithM(page, mid, 10);
|
||||||
|
const pos1 = await getPos(page, id);
|
||||||
|
const dx1 = pos1.x - pos0.x;
|
||||||
|
// The item moved right; its midpoint on screen moved by dx1 world → recompute via the viewport.
|
||||||
|
const vp = await getVp(page);
|
||||||
|
const mid2 = { x: Math.round(mid.x + dx1 * vp.scale), y: mid.y };
|
||||||
|
await moveWithM(page, mid2, 10);
|
||||||
|
const pos2 = await getPos(page, id);
|
||||||
|
const dx2 = pos2.x - pos1.x;
|
||||||
|
console.log(`[PROBE P-3] first m: dx=${dx1}; second m: dx=${dx2} (pos0=${JSON.stringify(pos0)} pos1=${JSON.stringify(pos1)} pos2=${JSON.stringify(pos2)})`);
|
||||||
|
|
||||||
|
expect(hasTrap(testLogger)).toEqual([]);
|
||||||
|
expect(dx1, "first `m` move works (precondition, issue #9)").toBeGreaterThan(0);
|
||||||
|
expect(dx2, "P-3: the second `m` move on the same page must work too").toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("P-3 `m` move twice on a TRACK", () => {
|
||||||
|
test("the second `m` move of a track works too", async ({ page, testLogger }) => {
|
||||||
|
await bootCollab(page);
|
||||||
|
const t = { cx: FP1_AT.x, cy: FP1_AT.y, hw: 20e6, hh: 15e6 };
|
||||||
|
expect(await fitLands(page, t)).toBe(true);
|
||||||
|
const id: string = await page.evaluate(() => (window as unknown as { Module: { kicadCollabTestAddTrack(x1: number, y1: number, x2: number, y2: number, w: number, l: string): string } }).Module
|
||||||
|
.kicadCollabTestAddTrack(90e6, 110e6, 110e6, 110e6, 500000, "F.Cu"));
|
||||||
|
console.log(`[PROBE P-3b] added track ${id}`);
|
||||||
|
await expect.poll(() => getPos(page, id).then((p) => p.x), { timeout: 8000 }).toBeGreaterThan(0);
|
||||||
|
const box = await glCanvasBox(page);
|
||||||
|
const toScreen = async (wx: number, wy: number) => {
|
||||||
|
const vp = await getVp(page);
|
||||||
|
return { x: Math.round(box.x + (wx - vp.cx) * vp.scale + vp.w / 2), y: Math.round(box.y + (wy - vp.cy) * vp.scale + vp.h / 2) };
|
||||||
|
};
|
||||||
|
const pos0 = await getPos(page, id);
|
||||||
|
await moveWithM(page, await toScreen(100e6, 110e6), 10);
|
||||||
|
const pos1 = await getPos(page, id);
|
||||||
|
const dx1 = pos1.x - pos0.x;
|
||||||
|
await moveWithM(page, await toScreen(100e6 + dx1, 110e6), 10);
|
||||||
|
const pos2 = await getPos(page, id);
|
||||||
|
const dx2 = pos2.x - pos1.x;
|
||||||
|
console.log(`[PROBE P-3b] track first m: dx=${dx1}; second m: dx=${dx2}`);
|
||||||
|
expect(hasTrap(testLogger)).toEqual([]);
|
||||||
|
expect(dx1, "first `m` move of a track works (precondition)").toBeGreaterThan(0);
|
||||||
|
expect(dx2, "P-3: the second `m` move of a track must work too").toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// P-4 · `r` on a chooser-held footprint does nothing (placed at rotation 0)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
const DEMO_DIR = "kicad/demos/ecc83";
|
||||||
|
const STEM = "ecc83-pp";
|
||||||
|
const FILTER = "ECC-83-1";
|
||||||
|
|
||||||
|
async function synthClick(page: Page, x: number, y: number) {
|
||||||
|
await page.evaluate(([cx, cy]) => {
|
||||||
|
const c = document.querySelector("#canvas") as HTMLCanvasElement;
|
||||||
|
const opt = (b: number) => ({ clientX: cx, clientY: cy, bubbles: true, cancelable: true, view: window, button: 0, buttons: b });
|
||||||
|
c.dispatchEvent(new MouseEvent("mousemove", opt(0)));
|
||||||
|
c.dispatchEvent(new MouseEvent("mousedown", opt(1)));
|
||||||
|
c.dispatchEvent(new MouseEvent("mouseup", opt(0)));
|
||||||
|
c.dispatchEvent(new MouseEvent("click", opt(0)));
|
||||||
|
}, [x, y]);
|
||||||
|
}
|
||||||
|
const frameCount = (page: Page) => page.evaluate(() =>
|
||||||
|
(window.wxElementRegistry?.findAll({ visible: true }) ?? []).filter((e) => /Frame$/.test(e.typeName || "")).length);
|
||||||
|
const listIds = (page: Page): Promise<string[]> => page.evaluate(() => {
|
||||||
|
try { return JSON.parse((window as unknown as W).Module.kicadCollabTestListItems(100000)); } catch { return []; }
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("P-4 rotate a chooser-held footprint", () => {
|
||||||
|
test("`r` while holding the part from the chooser rotates it before placement", async ({ page, testLogger }) => {
|
||||||
|
test.setTimeout(300000);
|
||||||
|
await page.goto("/kicad/pcbnew.html");
|
||||||
|
await waitForPcbnew(page);
|
||||||
|
const root = path.resolve(__dirname, "..", "..");
|
||||||
|
for (const f of fs.readdirSync(path.join(root, DEMO_DIR, "footprints.pretty"))) {
|
||||||
|
await injectFromSubmodule(page, `${DEMO_DIR}/footprints.pretty/${f}`, `${PROJECT_DIR_MEMFS}/footprints.pretty/${f}`);
|
||||||
|
}
|
||||||
|
for (const f of ["fp-lib-table", `${STEM}.kicad_pcb`, `${STEM}.kicad_pro`]) {
|
||||||
|
await injectFromSubmodule(page, `${DEMO_DIR}/${f}`, `${PROJECT_DIR_MEMFS}/${f}`);
|
||||||
|
}
|
||||||
|
expect(await clickMenuBarItem(page, "File")).toBe(true);
|
||||||
|
await clickMenuItemByText(page, "Open");
|
||||||
|
await waitUntil(page, () => {
|
||||||
|
const r = window.wxElementRegistry;
|
||||||
|
return !!r && r.findAll({ visible: true }).some((el) => el.typeName === "wxTextCtrl" && el.name === "text");
|
||||||
|
}, "file dialog filename input");
|
||||||
|
const input = await page.evaluate(() => {
|
||||||
|
const t = window.wxElementRegistry!.findAll({ visible: true }).find((el) => el.typeName === "wxTextCtrl" && el.name === "text");
|
||||||
|
return t ? { x: t.centerX, y: t.centerY } : null;
|
||||||
|
});
|
||||||
|
await page.mouse.click(input!.x, input!.y);
|
||||||
|
await page.waitForTimeout(200); // eslint-disable-line -- dwell
|
||||||
|
await page.keyboard.type(`${STEM}.kicad_pcb`);
|
||||||
|
await page.waitForTimeout(300); // eslint-disable-line -- dwell
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await waitForBoardLoaded(page, testLogger, 60000);
|
||||||
|
|
||||||
|
const idsBefore = new Set(await listIds(page));
|
||||||
|
const framesBefore = await frameCount(page);
|
||||||
|
expect(await clickMenuBarItem(page, "Place")).toBe(true);
|
||||||
|
await clickMenuItemByText(page, "Place Footprints");
|
||||||
|
const canvas = (await page.locator("#canvas").boundingBox())!;
|
||||||
|
const target = { x: Math.round(canvas.width * 0.5), y: Math.round(canvas.height * 0.5) };
|
||||||
|
await synthClick(page, target.x, target.y);
|
||||||
|
await page.waitForFunction((n) => (window.wxElementRegistry?.findAll({ visible: true }) ?? [])
|
||||||
|
.filter((e) => /Frame$/.test(e.typeName || "")).length > n, framesBefore, { timeout: 60000 });
|
||||||
|
|
||||||
|
const inputsOf = () => page.evaluate(() =>
|
||||||
|
Array.from(document.querySelectorAll("input.wx-dom-control")).map((i) => i as HTMLInputElement)
|
||||||
|
.filter((i) => i.style.display !== "none" && i.getBoundingClientRect().width > 0)
|
||||||
|
.map((i) => { const r = i.getBoundingClientRect(); return { value: i.value, x: r.x, y: r.y, w: r.width, h: r.height }; }));
|
||||||
|
await expect.poll(async () => (await inputsOf()).length, { timeout: 30000 }).toBeGreaterThan(0);
|
||||||
|
const search = (await inputsOf()).reduce((a, b) => (b.y < a.y ? b : a));
|
||||||
|
await page.mouse.click(search.x + search.w / 2, search.y + search.h / 2);
|
||||||
|
await page.waitForTimeout(200); // eslint-disable-line -- focus dwell
|
||||||
|
await page.keyboard.type(FILTER, { delay: 30 });
|
||||||
|
await expect.poll(async () => (await inputsOf()).some((i) => i.value.includes(FILTER)), { timeout: 10000 }).toBe(true);
|
||||||
|
await page.waitForTimeout(1500); // eslint-disable-line -- filter timer dwell
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
const closed = (n: number) => page.waitForFunction((k) => (window.wxElementRegistry?.findAll({ visible: true }) ?? [])
|
||||||
|
.filter((e) => /Frame$/.test(e.typeName || "")).length <= k, n, { timeout: 8000 }).then(() => true, () => false);
|
||||||
|
let isClosed = await closed(framesBefore);
|
||||||
|
console.log(`[PROBE P-4] chooser closed by Enter: ${isClosed} (O-2)`);
|
||||||
|
if (!isClosed) {
|
||||||
|
const ok = await page.evaluate(() => {
|
||||||
|
const b = (window.wxElementRegistry?.findAll({ visible: true }) ?? []).find(
|
||||||
|
(e) => /Button/i.test(e.typeName || "") && /^&?ok$/i.test((e.label || "").trim()));
|
||||||
|
return b ? { x: b.centerX, y: b.centerY } : null;
|
||||||
|
});
|
||||||
|
if (!ok) {
|
||||||
|
const dump = await page.evaluate(() => (window.wxElementRegistry?.findAll({ visible: true }) ?? [])
|
||||||
|
.filter((e) => /Button|Dialog|Frame/i.test(e.typeName || "")).map((e) => `${e.typeName}:${e.name}:${(e.label || "").slice(0, 20)}`));
|
||||||
|
console.log(`[PROBE P-4] visible buttons/dialogs: ${JSON.stringify(dump)}`);
|
||||||
|
}
|
||||||
|
expect(ok, "chooser OK button").not.toBeNull();
|
||||||
|
await page.mouse.click(ok!.x, ok!.y);
|
||||||
|
isClosed = await closed(framesBefore);
|
||||||
|
console.log(`[PROBE P-4] chooser closed by OK: ${isClosed}`);
|
||||||
|
}
|
||||||
|
expect(isClosed, "chooser closed (precondition)").toBe(true);
|
||||||
|
await page.waitForTimeout(1000); // eslint-disable-line -- footprint load dwell
|
||||||
|
|
||||||
|
// Holding the part now. Move the (real) mouse onto the canvas, press `r`, then place.
|
||||||
|
const wxFocus = () => page.evaluate(() => {
|
||||||
|
const m = (window as unknown as { Module: { kicadTestFocusWindow?: () => string } }).Module;
|
||||||
|
return m.kicadTestFocusWindow ? m.kicadTestFocusWindow() : "n/a";
|
||||||
|
});
|
||||||
|
console.log(`[PROBE P-4] wx focus after chooser closed: ${await wxFocus()}`);
|
||||||
|
const active0 = await page.evaluate(() => `${document.activeElement?.tagName}.${(document.activeElement as HTMLElement)?.className}`);
|
||||||
|
await page.mouse.move(canvas.x + target.x + 40, canvas.y + target.y + 40);
|
||||||
|
await page.waitForTimeout(300); // eslint-disable-line -- pointer dwell
|
||||||
|
await page.keyboard.press("r");
|
||||||
|
await page.waitForTimeout(400); // eslint-disable-line -- key dwell
|
||||||
|
const active1 = await page.evaluate(() => `${document.activeElement?.tagName}.${(document.activeElement as HTMLElement)?.className}`);
|
||||||
|
console.log(`[PROBE P-4] activeElement before r: ${active0}; after r: ${active1}; wx focus after r: ${await wxFocus()}`);
|
||||||
|
await synthClick(page, target.x + 40, target.y + 40);
|
||||||
|
await expect.poll(async () => (await listIds(page)).filter((i) => !idsBefore.has(i)).length, { timeout: 15000 }).toBeGreaterThan(0);
|
||||||
|
const newId = (await listIds(page)).find((i) => !idsBefore.has(i))!;
|
||||||
|
const rot = await fpRotation(page, newId);
|
||||||
|
console.log(`[PROBE P-4] placed ${newId} rotation=${rot}`);
|
||||||
|
|
||||||
|
expect(hasTrap(testLogger)).toEqual([]);
|
||||||
|
expect(rot, "P-4: `r` while holding the part must rotate it (placed rotation ≠ 0)").not.toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// P-2b · real board (ecc83): click every footprint origin at a wide framing
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
test.describe("P-2b ecc83 origin clicks at wide zoom", () => {
|
||||||
|
test("clicking each footprint origin at 40/80/150 mm half-width selects the footprint", async ({ page, testLogger }) => {
|
||||||
|
test.setTimeout(300000);
|
||||||
|
// Informational probe: on a dense board tracks/zones under the origin legitimately win
|
||||||
|
// (stock GuessSelectionCandidates) — see group doc P-2, 2026-08-28. Not a gate.
|
||||||
|
test.fixme(true, "P-2 probe — stock KiCad candidate priority, informational");
|
||||||
|
await page.goto("/kicad/pcbnew.html");
|
||||||
|
await waitForPcbnew(page);
|
||||||
|
const root = path.resolve(__dirname, "..", "..");
|
||||||
|
for (const f of fs.readdirSync(path.join(root, DEMO_DIR, "footprints.pretty"))) {
|
||||||
|
await injectFromSubmodule(page, `${DEMO_DIR}/footprints.pretty/${f}`, `${PROJECT_DIR_MEMFS}/footprints.pretty/${f}`);
|
||||||
|
}
|
||||||
|
for (const f of ["fp-lib-table", `${STEM}.kicad_pcb`, `${STEM}.kicad_pro`]) {
|
||||||
|
await injectFromSubmodule(page, `${DEMO_DIR}/${f}`, `${PROJECT_DIR_MEMFS}/${f}`);
|
||||||
|
}
|
||||||
|
expect(await clickMenuBarItem(page, "File")).toBe(true);
|
||||||
|
await clickMenuItemByText(page, "Open");
|
||||||
|
await waitUntil(page, () => {
|
||||||
|
const r = window.wxElementRegistry;
|
||||||
|
return !!r && r.findAll({ visible: true }).some((el) => el.typeName === "wxTextCtrl" && el.name === "text");
|
||||||
|
}, "file dialog filename input");
|
||||||
|
const input = await page.evaluate(() => {
|
||||||
|
const t = window.wxElementRegistry!.findAll({ visible: true }).find((el) => el.typeName === "wxTextCtrl" && el.name === "text");
|
||||||
|
return t ? { x: t.centerX, y: t.centerY } : null;
|
||||||
|
});
|
||||||
|
await page.mouse.click(input!.x, input!.y);
|
||||||
|
await page.waitForTimeout(200); // eslint-disable-line -- dwell
|
||||||
|
await page.keyboard.type(`${STEM}.kicad_pcb`);
|
||||||
|
await page.waitForTimeout(300); // eslint-disable-line -- dwell
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await waitForBoardLoaded(page, testLogger, 60000);
|
||||||
|
await page.waitForFunction(() => typeof (window as unknown as { Module?: Partial<Mod> }).Module?.kicadCollabFitViewport === "function");
|
||||||
|
await page.evaluate(() => (window as unknown as W).Module.kicadCollabPresenceStart());
|
||||||
|
|
||||||
|
const all = await snapshotItems(page);
|
||||||
|
const byId = new Map(all.map((i) => [i.id, i as SnapItem & { parent?: string }]));
|
||||||
|
const fps: SnapItem[] = all.filter((i) => /footprint/i.test(i.type));
|
||||||
|
console.log(`[PROBE P-2b] ${fps.length} footprints: ${JSON.stringify(fps.slice(0, 3))}`);
|
||||||
|
const box = await glCanvasBox(page);
|
||||||
|
const wrong: string[] = [];
|
||||||
|
for (const hwMm of [40, 80, 150]) {
|
||||||
|
for (const fp of fps) {
|
||||||
|
await page.evaluate(() => (window as unknown as W).Module.kicadCollabTestClearSelection());
|
||||||
|
expect(await fitLands(page, { cx: fp.x, cy: fp.y, hw: hwMm * 1e6, hh: hwMm * 0.75e6 })).toBe(true);
|
||||||
|
const vp = await getVp(page);
|
||||||
|
const sx = box.x + (fp.x - vp.cx) * vp.scale + vp.w / 2;
|
||||||
|
const sy = box.y + (fp.y - vp.cy) * vp.scale + vp.h / 2;
|
||||||
|
await page.mouse.move(sx, sy);
|
||||||
|
await page.waitForTimeout(250); // eslint-disable-line -- pointer dwell
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(350); // eslint-disable-line -- selection dwell
|
||||||
|
const sel: string[] = await page.evaluate(() => JSON.parse((window as unknown as W).Module.kicadCollabGetSelection()));
|
||||||
|
const ok = sel.length === 1 && sel[0] === fp.id;
|
||||||
|
const desc = sel.map((s) => { const it = byId.get(s); return it ? `${it.type}${it.parent === fp.id ? "(own child)" : it.parent ? "(child of other)" : ""}` : `?${s.slice(-6)}`; });
|
||||||
|
console.log(`[PROBE P-2b] hw=${hwMm}mm fp=…${fp.id.slice(-6)} px/mm=${(vp.scale * 1e6).toFixed(2)} sel=${JSON.stringify(desc)} ${ok ? "OK" : "WRONG"}`);
|
||||||
|
if (!ok) wrong.push(`${hwMm}mm:…${fp.id.slice(-6)}→${desc.join(",")}`);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(hasTrap(testLogger)).toEqual([]);
|
||||||
|
expect(wrong, "P-2: every footprint origin click must select that footprint").toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// P-2c · the ledger's own board (tests/fixtures/demo): origin clicks, own-child hits
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
test.describe("P-2c demo fixture origin clicks", () => {
|
||||||
|
test("footprint origin clicks on demo.kicad_pcb at 20/40/80/150 mm", async ({ page, testLogger }) => {
|
||||||
|
test.setTimeout(300000);
|
||||||
|
// Informational probe (6/60 own-child hits, all stock hit-slop behaviour) — see group doc P-2.
|
||||||
|
test.fixme(true, "P-2 probe — stock KiCad candidate priority, informational");
|
||||||
|
const content = fs.readFileSync(path.resolve(__dirname, "..", "fixtures", "demo", "demo.kicad_pcb"), "utf-8");
|
||||||
|
const firstId = content.match(/\(footprint[^]*?\(uuid "([^"]+)"/)?.[1] ?? FP1;
|
||||||
|
await bootCollab(page, content, firstId);
|
||||||
|
const all = await snapshotItems(page);
|
||||||
|
const byId = new Map(all.map((i) => [i.id, i as SnapItem & { parent?: string }]));
|
||||||
|
const fps = all.filter((i) => /footprint/i.test(i.type));
|
||||||
|
console.log(`[PROBE P-2c] ${fps.length} footprints, ${all.length} items`);
|
||||||
|
const box = await glCanvasBox(page);
|
||||||
|
const tally: Record<string, number> = {};
|
||||||
|
const ownChild: string[] = [];
|
||||||
|
for (const hwMm of [20, 40, 80, 150]) {
|
||||||
|
for (const fp of fps) {
|
||||||
|
await page.evaluate(() => (window as unknown as W).Module.kicadCollabTestClearSelection());
|
||||||
|
expect(await fitLands(page, { cx: fp.x, cy: fp.y, hw: hwMm * 1e6, hh: hwMm * 0.75e6 })).toBe(true);
|
||||||
|
const vp = await getVp(page);
|
||||||
|
const sx = box.x + (fp.x - vp.cx) * vp.scale + vp.w / 2;
|
||||||
|
const sy = box.y + (fp.y - vp.cy) * vp.scale + vp.h / 2;
|
||||||
|
await page.mouse.move(sx, sy);
|
||||||
|
await page.waitForTimeout(250); // eslint-disable-line -- pointer dwell
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(350); // eslint-disable-line -- selection dwell
|
||||||
|
const sel: string[] = await page.evaluate(() => JSON.parse((window as unknown as W).Module.kicadCollabGetSelection()));
|
||||||
|
const kind = sel.length === 0 ? "none" : sel.length > 1 ? "multi" : sel[0] === fp.id ? "footprint" :
|
||||||
|
(byId.get(sel[0])?.parent === fp.id ? `own-child:${byId.get(sel[0])?.type}` : `other:${byId.get(sel[0])?.type ?? "?"}`);
|
||||||
|
tally[`${hwMm}mm:${kind}`] = (tally[`${hwMm}mm:${kind}`] ?? 0) + 1;
|
||||||
|
if (kind.startsWith("own-child")) ownChild.push(`${hwMm}mm:${fp.id.slice(-6)}`);
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[PROBE P-2c] tally=${JSON.stringify(tally)} ownChild=${JSON.stringify(ownChild)}`);
|
||||||
|
expect(hasTrap(testLogger)).toEqual([]);
|
||||||
|
expect(ownChild, "P-2: a footprint's own text child must not win the origin click").toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
102
tests/web/findings-p1.spec.ts
Normal file
102
tests/web/findings-p1.spec.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Findings P-1 on the REAL standalone (collab-bound): kicadCollabFitViewport
|
||||||
|
* must still move the camera after the page's first committed edit.
|
||||||
|
* docs/features/findings/groups/P-editor-tools-embind-interaction.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCOPE = 'default';
|
||||||
|
|
||||||
|
type Mod = {
|
||||||
|
kicadCollabGetViewport(): string;
|
||||||
|
kicadCollabFitViewport(cx: number, cy: number, hw: number, hh: number): void;
|
||||||
|
kicadCollabSnapshot(): string;
|
||||||
|
kicadCollabTestRotateItem(id: string, deg: number): boolean;
|
||||||
|
kicadCollabTestItemBlob(id: string): string;
|
||||||
|
kicadCollabGetSelection(): string;
|
||||||
|
};
|
||||||
|
type W = { Module: Mod };
|
||||||
|
type Vp = { cx: number; cy: number; scale: number; w: number; h: number };
|
||||||
|
|
||||||
|
async function bootBoard(page: Page, user: string): Promise<void> {
|
||||||
|
await page.goto(`/${SCOPE}/projects/demo/demo.kicad_pcb?user=${user}`);
|
||||||
|
await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
|
||||||
|
await expect.poll(() => page.title(), { timeout: 120000, intervals: [1000] }).toMatch(/demo — PCB Editor/i);
|
||||||
|
await page.waitForFunction(() => {
|
||||||
|
const m = (window as unknown as Partial<W>).Module;
|
||||||
|
return typeof m?.kicadCollabGetViewport === 'function' && typeof m?.kicadCollabFitViewport === 'function'
|
||||||
|
&& typeof m?.kicadCollabTestRotateItem === 'function';
|
||||||
|
}, null, { timeout: 60000 });
|
||||||
|
}
|
||||||
|
const viewport = (page: Page): Promise<Vp> =>
|
||||||
|
page.evaluate(() => JSON.parse((window as unknown as W).Module.kicadCollabGetViewport()));
|
||||||
|
async function fitLands(page: Page, t: { cx: number; cy: number; hw: number; hh: number }, timeout = 15000) {
|
||||||
|
await page.evaluate((t) => (window as unknown as W).Module.kicadCollabFitViewport(t.cx, t.cy, t.hw, t.hh), t);
|
||||||
|
return expect.poll(async () => {
|
||||||
|
const vp = await viewport(page);
|
||||||
|
return Math.abs(vp.cx - t.cx) < 1e6 && Math.abs(vp.cy - t.cy) < 1e6;
|
||||||
|
}, { timeout, intervals: [250] }).toBe(true).then(() => true, () => false);
|
||||||
|
}
|
||||||
|
async function rotation(page: Page, id: string): Promise<number | null> {
|
||||||
|
const blob = await page.evaluate((i) => (window as unknown as W).Module.kicadCollabTestItemBlob(i), id);
|
||||||
|
const m = blob.match(/\(footprint[^]*?\(at\s+(-?[\d.]+)\s+(-?[\d.]+)(?:\s+(-?[\d.]+))?\)/);
|
||||||
|
return m ? (m[3] ? Number(m[3]) : 0) : null;
|
||||||
|
}
|
||||||
|
async function firstFootprint(page: Page): Promise<{ id: string; x: number; y: number }> {
|
||||||
|
const items: Array<{ id: string; type: string; x: number; y: number }> = await page.evaluate(
|
||||||
|
() => JSON.parse((window as unknown as W).Module.kicadCollabSnapshot()).added);
|
||||||
|
const fp = items.find((i) => i.type === 'FOOTPRINT');
|
||||||
|
if (!fp) throw new Error('no footprint on demo board');
|
||||||
|
return fp;
|
||||||
|
}
|
||||||
|
async function glBox(page: Page) {
|
||||||
|
const id = await page.evaluate(() => Array.from(document.querySelectorAll('[id^="glcanvas-"]'))
|
||||||
|
.map((c) => c as HTMLCanvasElement).find((c) => c.getBoundingClientRect().width > 0)?.id ?? null);
|
||||||
|
const box = id ? await page.locator(`#${id}`).boundingBox() : await page.locator('#canvas').boundingBox();
|
||||||
|
if (!box) throw new Error('no canvas box');
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('P-1 standalone: fit lands after an embind-committed rotate', async ({ page }) => {
|
||||||
|
test.setTimeout(300000);
|
||||||
|
await bootBoard(page, 'alice');
|
||||||
|
const T1 = { cx: 120e6, cy: 90e6, hw: 40e6, hh: 30e6 };
|
||||||
|
const T2 = { cx: 180e6, cy: 120e6, hw: 25e6, hh: 20e6 };
|
||||||
|
expect(await fitLands(page, T1), 'fit before edit').toBe(true);
|
||||||
|
const fp = await firstFootprint(page);
|
||||||
|
const r0 = await rotation(page, fp.id);
|
||||||
|
await page.evaluate((id) => (window as unknown as W).Module.kicadCollabTestRotateItem(id, 90), fp.id);
|
||||||
|
await expect.poll(() => rotation(page, fp.id), { timeout: 15000 }).not.toBe(r0);
|
||||||
|
await page.waitForTimeout(1500); // eslint-disable-line -- let flushDiff + ysync run
|
||||||
|
const landed = await fitLands(page, T2);
|
||||||
|
console.log(`[PROBE P-1 web] embind rotate ${r0}→${await rotation(page, fp.id)}; fit after landed=${landed} vp=${JSON.stringify(await viewport(page))}`);
|
||||||
|
expect(landed, 'P-1: fit after first committed edit').toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('P-1 standalone: fit lands after a keyboard rotate of a clicked footprint', async ({ page }) => {
|
||||||
|
test.setTimeout(300000);
|
||||||
|
await bootBoard(page, 'alice');
|
||||||
|
const fp = await firstFootprint(page);
|
||||||
|
expect(await fitLands(page, { cx: fp.x, cy: fp.y, hw: 15e6, hh: 10e6 }), 'fit before edit').toBe(true);
|
||||||
|
const box = await glBox(page);
|
||||||
|
const vp = await viewport(page);
|
||||||
|
const sx = box.x + (fp.x - vp.cx) * vp.scale + vp.w / 2;
|
||||||
|
const sy = box.y + (fp.y - vp.cy) * vp.scale + vp.h / 2;
|
||||||
|
await page.mouse.move(sx, sy);
|
||||||
|
await page.waitForTimeout(300); // eslint-disable-line -- pointer dwell
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.up();
|
||||||
|
await page.waitForTimeout(400); // eslint-disable-line -- selection dwell
|
||||||
|
const sel = await page.evaluate(() => JSON.parse((window as unknown as W).Module.kicadCollabGetSelection()));
|
||||||
|
const r0 = await rotation(page, fp.id);
|
||||||
|
await page.keyboard.press('r');
|
||||||
|
const rotated = await expect.poll(() => rotation(page, fp.id), { timeout: 10000 }).not.toBe(r0).then(() => true, () => false);
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.waitForTimeout(1500); // eslint-disable-line -- let flushDiff + ysync run
|
||||||
|
const T2 = { cx: 180e6, cy: 120e6, hw: 25e6, hh: 20e6 };
|
||||||
|
const landed = await fitLands(page, T2);
|
||||||
|
console.log(`[PROBE P-1 web] click sel=${JSON.stringify(sel)} kb rotate applied=${rotated} (${r0}→${await rotation(page, fp.id)}); fit after landed=${landed} vp=${JSON.stringify(await viewport(page))}`);
|
||||||
|
expect(rotated, 'keyboard rotate applied (precondition)').toBe(true);
|
||||||
|
expect(landed, 'P-1: fit after keyboard rotate').toBe(true);
|
||||||
|
});
|
||||||
|
|
@ -124,7 +124,22 @@ inline void drainApplies()
|
||||||
slot.cor = new COROUTINE<int, int>( []( int ) -> int
|
slot.cor = new COROUTINE<int, int>( []( int ) -> int
|
||||||
{
|
{
|
||||||
ApplySlot& sl = activeApplySlot();
|
ApplySlot& sl = activeApplySlot();
|
||||||
( *sl.body )();
|
|
||||||
|
// P-1 hardening (findings group P): `done` is the ONLY completion signal, so a
|
||||||
|
// body that unwinds — a C++ exception, or a foreign (JS) exception thrown out of
|
||||||
|
// an EM_ASM wire callback inside flushDiff and surfacing here as `...` under
|
||||||
|
// wasm EH — would leave applyBusy() true for the page's life and silently
|
||||||
|
// queue every later job behind it: fit/pan from JS, remote applies AND the
|
||||||
|
// local-edit flushDiff (local edits stop syncing). Always mark the slot done.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
( *sl.body )();
|
||||||
|
}
|
||||||
|
catch( ... )
|
||||||
|
{
|
||||||
|
EM_ASM( { console.error( '[pcbjam collab] apply body threw — slot released' ); } );
|
||||||
|
}
|
||||||
|
|
||||||
sl.done = true;
|
sl.done = true;
|
||||||
|
|
||||||
// If we suspended, no drain is pending by the time the body
|
// If we suspended, no drain is pending by the time the body
|
||||||
|
|
@ -145,6 +160,14 @@ inline void drainApplies()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Test/diagnostic probe (P-1): is the apply slot busy and how many bodies wait behind it.
|
||||||
|
* A `busy` that never clears while `queued` grows is the wedge signature. */
|
||||||
|
inline std::string applyQueueStateJson()
|
||||||
|
{
|
||||||
|
return "{\"busy\":" + std::string( applyBusy() ? "true" : "false" )
|
||||||
|
+ ",\"queued\":" + std::to_string( applyQueue().size() ) + "}";
|
||||||
|
}
|
||||||
|
|
||||||
inline void runOnCoroutine( wxEvtHandler* aHandler, std::function<void()> aBody )
|
inline void runOnCoroutine( wxEvtHandler* aHandler, std::function<void()> aBody )
|
||||||
{
|
{
|
||||||
applyHandler() = aHandler;
|
applyHandler() = aHandler;
|
||||||
|
|
|
||||||
|
|
@ -502,6 +502,20 @@ static void collabSetViewport( double aCx, double aCy )
|
||||||
}
|
}
|
||||||
|
|
||||||
// Follow-user (collab-presence 0008).
|
// Follow-user (collab-presence 0008).
|
||||||
|
// Test probe (findings P-4): which wx window holds keyboard focus — the wx-side
|
||||||
|
// `FindFocus()`, not the DOM's activeElement. Keys are delivered there
|
||||||
|
// (wx wasm app.cpp HandleKeyEvent), so a frame or NULL here means hotkeys are lost.
|
||||||
|
static std::string kicadTestFocusWindow()
|
||||||
|
{
|
||||||
|
wxWindow* w = wxWindow::FindFocus();
|
||||||
|
|
||||||
|
if( !w )
|
||||||
|
return "null";
|
||||||
|
|
||||||
|
return std::string( wxString( w->GetClassInfo()->GetClassName() ).utf8_str().data() )
|
||||||
|
+ ":" + std::string( w->GetName().utf8_str().data() );
|
||||||
|
}
|
||||||
|
|
||||||
static void collabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH )
|
static void collabFitViewport( double aCx, double aCy, double aHalfW, double aHalfH )
|
||||||
{
|
{
|
||||||
pcbEditorActive() ? pcbCollabFitViewport( aCx, aCy, aHalfW, aHalfH )
|
pcbEditorActive() ? pcbCollabFitViewport( aCx, aCy, aHalfW, aHalfH )
|
||||||
|
|
@ -614,6 +628,8 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
|
||||||
// Programmatic file open (preferred over UI automation from the web app).
|
// Programmatic file open (preferred over UI automation from the web app).
|
||||||
function("kicadOpenFile", &kicadOpenFile PCBJAM_PARKER_POLICY);
|
function("kicadOpenFile", &kicadOpenFile PCBJAM_PARKER_POLICY);
|
||||||
function("kicadOpenFileBusy", &kicadOpenFileBusy);
|
function("kicadOpenFileBusy", &kicadOpenFileBusy);
|
||||||
|
function("kicadCollabTestApplyQueueState", &pcbjam_collab::applyQueueStateJson);
|
||||||
|
function("kicadTestFocusWindow", &kicadTestFocusWindow);
|
||||||
function("kicadTestSetOpenPark", &kicadTestSetOpenPark);
|
function("kicadTestSetOpenPark", &kicadTestSetOpenPark);
|
||||||
function("kicadTestArmTimerPark", &kicadTestArmTimerPark);
|
function("kicadTestArmTimerPark", &kicadTestArmTimerPark);
|
||||||
function("kicadTestTimerParkState", &kicadTestTimerParkState);
|
function("kicadTestTimerParkState", &kicadTestTimerParkState);
|
||||||
|
|
|
||||||
|
|
@ -407,6 +407,15 @@ std::string wrapInBoardEnvelope( BOARD& aBoard, const std::string& aItemSexpr );
|
||||||
|
|
||||||
std::string blobForItem( BOARD* aBoard, BOARD_ITEM* aItem )
|
std::string blobForItem( BOARD* aBoard, BOARD_ITEM* aItem )
|
||||||
{
|
{
|
||||||
|
// P-5 (findings group P): the FILE writer cannot format a footprint FIELD standalone —
|
||||||
|
// PCB_IO_KICAD_SEXPR::Format has an explicit `case PCB_FIELD_T: break;` (fields are
|
||||||
|
// written by the footprint formatter), so a field reaching here produced a bare
|
||||||
|
// `(kicad_pcb … (layers …))` envelope with no uuid-bearing item, which the JS wire
|
||||||
|
// (unwrapWireItem) could not resolve. Fields only ever travel inside their footprint's
|
||||||
|
// blob; refuse to blob one on its own — callers skip an empty blob.
|
||||||
|
if( aItem->Type() == PCB_FIELD_T )
|
||||||
|
return "";
|
||||||
|
|
||||||
if( aItem->Type() == PCB_FOOTPRINT_T )
|
if( aItem->Type() == PCB_FOOTPRINT_T )
|
||||||
{
|
{
|
||||||
const FOOTPRINT* src = static_cast<const FOOTPRINT*>( aItem );
|
const FOOTPRINT* src = static_cast<const FOOTPRINT*>( aItem );
|
||||||
|
|
@ -866,7 +875,27 @@ void flushDiff()
|
||||||
if( !wDone.insert( rootId ).second )
|
if( !wDone.insert( rootId ).second )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
json w = json{ { "sexpr", blobForItem( board, live ) }, { "parent", nullptr } };
|
std::string sexpr = blobForItem( board, live );
|
||||||
|
|
||||||
|
// P-5: a root the writer cannot serialize standalone (a footprint FIELD whose parent
|
||||||
|
// is not a footprint, or a deleted item still resolvable through the id cache) must
|
||||||
|
// not reach the wire as a hollow envelope. Skip it and leave a breadcrumb — the
|
||||||
|
// trigger that lets a field arrive here unlifted is still unknown
|
||||||
|
// (memory: items-wire-batch-loss); this line is the hunt.
|
||||||
|
if( sexpr.empty() || ( live->GetFlags() & STRUCT_DELETED ) )
|
||||||
|
{
|
||||||
|
std::string why = sexpr.empty() ? "empty blob" : "STRUCT_DELETED";
|
||||||
|
std::string type = toUtf8( live->GetClass() );
|
||||||
|
int parentNull = live->GetParent() == nullptr ? 1 : 0;
|
||||||
|
EM_ASM( {
|
||||||
|
console.warn( '[pcbjam collab] P-5: skipped un-serializable dirty root',
|
||||||
|
{ uuid: UTF8ToString( $0 ), type: UTF8ToString( $1 ),
|
||||||
|
why: UTF8ToString( $2 ), parentNull: !!$3 } );
|
||||||
|
}, rootId.c_str(), type.c_str(), why.c_str(), parentNull );
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
json w = json{ { "sexpr", sexpr }, { "parent", nullptr } };
|
||||||
|
|
||||||
// A lifted child means its (pre-existing) parent's CONTENT changed.
|
// A lifted child means its (pre-existing) parent's CONTENT changed.
|
||||||
( lifted ? wChanged : aArr ).push_back( w );
|
( lifted ? wChanged : aArr ).push_back( w );
|
||||||
|
|
@ -897,7 +926,12 @@ void flushDiff()
|
||||||
// wasted SaveSelection plus the envelope parse for those.
|
// wasted SaveSelection plus the envelope parse for those.
|
||||||
if( live && !isTrackType( live->Type() ) && live->Type() != PCB_ZONE_T
|
if( live && !isTrackType( live->Type() ) && live->Type() != PCB_ZONE_T
|
||||||
&& live->Type() != PCB_TEXT_T )
|
&& live->Type() != PCB_TEXT_T )
|
||||||
withBlob["sexpr"] = blobForItem( board, live );
|
{
|
||||||
|
std::string sexpr = blobForItem( board, live );
|
||||||
|
|
||||||
|
if( !sexpr.empty() ) // P-5: never attach a hollow envelope
|
||||||
|
withBlob["sexpr"] = sexpr;
|
||||||
|
}
|
||||||
|
|
||||||
added.push_back( withBlob );
|
added.push_back( withBlob );
|
||||||
}
|
}
|
||||||
|
|
@ -1520,7 +1554,10 @@ std::string pcbCollabSnapshotItems()
|
||||||
{
|
{
|
||||||
auto push = [&]( BOARD_ITEM* item )
|
auto push = [&]( BOARD_ITEM* item )
|
||||||
{
|
{
|
||||||
added.push_back( json{ { "sexpr", blobForItem( board, item ) }, { "parent", nullptr } } );
|
std::string sexpr = blobForItem( board, item );
|
||||||
|
|
||||||
|
if( !sexpr.empty() ) // P-5: never seed a hollow envelope
|
||||||
|
added.push_back( json{ { "sexpr", sexpr }, { "parent", nullptr } } );
|
||||||
};
|
};
|
||||||
|
|
||||||
for( FOOTPRINT* fp : board->Footprints() ) push( fp );
|
for( FOOTPRINT* fp : board->Footprints() ) push( fp );
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit 15e53152442a93340779ca5c4733976646ef6009
|
Subproject commit 9666a743bcd236cd33034c4cf75ecf3c6684c541
|
||||||
Loading…
Reference in a new issue