feat(collab): cross-app selection — eeschema symbol ⇄ pcbnew footprint ghost highlight (collab-presence 0006)
Selecting a symbol in eeschema ghost-highlights the linked footprint(s) in
every pcbnew tab of the project, and vice versa — across users AND one
user's own two tabs. Native KIWAY cross-probe is inert in WASM (one frame
per page); this rides the presence layer instead.
- cross-app.ts: project-wide awareness-only room (presenceRoomId), publishes
full PresenceState at selection rate (cursor always null); peers() = other-
TOOL clients incl. own user's other tabs; window.__pcbjamCrossApp test handle
- presence-kicad.ts: parseSelectionEmit (bare array | {uuids,fpPaths}),
xselFromPeerState (pcbnew paths → symbol uuids; eeschema uuids verbatim),
cross peers appended to the kicadCollabSetRemote snapshot as
{id "<user>#x<client>", name "<user> · sch|pcb", xsel}
- C++ (zero fork changes): pcbnew emits {uuids, fpPaths} (FOOTPRINT::GetPath)
and ghost-renders xsel via path-tail suffix scan; eeschema resolves xsel via
ResolveItem gated to the CURRENT sheet (xsel arrives project-wide, unlike
room-scoped selections); ghostStyle = alphas × xselAlphaScale (0.55, tuner-
patchable); new exports kicadCollabGetSelectionFull / TestGetCrossMapped /
TestSelectComponent (skips power symbols — PWR_FLAG has no footprint) +
merged-image dispatch
- tests: presence suites extended (payload shape, ghost render pixel tests,
13/13) + new two-tab tests/web/cross-probe.spec.ts (passing vs real
partykit); eeschema pixel compares now target the #glcanvas-* GAL panel
(the whole-window #canvas compare flaked on the auto-dismissing version
infobar — also fixes the long-known presence-eeschema restore flake);
cross-app + presence-kicad vitest suites
Spec: docs/features/collab-presence/0006 (closed repo).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lg5jwWuhFH5dL8hEcBDuP2
This commit is contained in:
parent
157d60f6b8
commit
3186986a0a
14 changed files with 1276 additions and 48 deletions
128
tests/web/cross-probe.spec.ts
Normal file
128
tests/web/cross-probe.spec.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Cross-app selection e2e (collab-presence 0006): one tab on the demo
|
||||
* SCHEMATIC and one on the demo BOARD of the same project share the
|
||||
* project-wide presence room over the default BroadcastChannel provider.
|
||||
* Selecting a symbol in eeschema must ghost-highlight the linked footprint in
|
||||
* pcbnew (footprint `(path …)` tail = symbol uuid — the demo fixtures are
|
||||
* linked), and vice versa. Assertions use the deterministic
|
||||
* `kicadCollabTestGetCrossMapped()` probe (what the ghost render draws from)
|
||||
* rather than pixel diffs — the render path itself is covered by the
|
||||
* tests/kicad specs.
|
||||
*
|
||||
* This is also the single-user cross-probe test by construction: both tabs
|
||||
* run as the same `?user=` in one browser context and still map each other
|
||||
* (cross-app peers are keyed by client, not user).
|
||||
*/
|
||||
|
||||
const SCOPE = 'default';
|
||||
|
||||
type Mod = {
|
||||
kicadCollabTestSelectComponent(): string;
|
||||
kicadCollabTestClearSelection(): boolean;
|
||||
kicadCollabTestGetCrossMapped(): string;
|
||||
kicadCollabGetSelectionFull(): string;
|
||||
};
|
||||
type W = { Module: Mod };
|
||||
|
||||
async function bootTool(
|
||||
page: Page,
|
||||
route: string,
|
||||
titleRe: RegExp,
|
||||
user: string,
|
||||
): Promise<void> {
|
||||
await page.goto(`/${SCOPE}/projects/demo/${route}?user=${user}`);
|
||||
await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 });
|
||||
await expect
|
||||
.poll(() => page.title(), {
|
||||
message: `${route}: editor never reached the expected title`,
|
||||
timeout: 120000,
|
||||
intervals: [1000],
|
||||
})
|
||||
.toMatch(titleRe);
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const m = (window as unknown as Partial<W>).Module;
|
||||
return (
|
||||
typeof m?.kicadCollabTestSelectComponent === 'function' &&
|
||||
typeof m?.kicadCollabTestGetCrossMapped === 'function'
|
||||
);
|
||||
},
|
||||
null,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
}
|
||||
|
||||
const crossMapped = (page: Page) =>
|
||||
page.evaluate(() =>
|
||||
JSON.parse((window as unknown as W).Module.kicadCollabTestGetCrossMapped()),
|
||||
);
|
||||
|
||||
test('selecting a symbol in eeschema ghost-highlights the footprint in pcbnew, and back', async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
test.setTimeout(480000); // two full editor boots (eeschema + pcbnew wasm)
|
||||
|
||||
const sch = page;
|
||||
await bootTool(sch, 'demo.kicad_sch', /demo — Schematic Editor/i, 'alice');
|
||||
const pcb = await context.newPage();
|
||||
await bootTool(pcb, 'demo.kicad_pcb', /demo — PCB Editor/i, 'bob');
|
||||
|
||||
// ── eeschema → pcbnew ──────────────────────────────────────────────────────
|
||||
const symId = await sch.evaluate(() =>
|
||||
(window as unknown as W).Module.kicadCollabTestSelectComponent(),
|
||||
);
|
||||
expect(symId, 'demo schematic should contain a symbol').toBeTruthy();
|
||||
|
||||
// The board tab maps the symbol to ≥1 footprint (reused sheets map to more).
|
||||
await expect
|
||||
.poll(() => crossMapped(pcb), {
|
||||
timeout: 20000,
|
||||
message: 'pcbnew never received/mapped the eeschema selection',
|
||||
})
|
||||
.not.toEqual([]);
|
||||
|
||||
// The selection emit carried the symbol uuid across (sanity on the wire).
|
||||
const schSel = await sch.evaluate(() =>
|
||||
JSON.parse((window as unknown as W).Module.kicadCollabGetSelectionFull()),
|
||||
);
|
||||
expect(schSel.uuids).toContain(symId);
|
||||
|
||||
// Clearing the schematic selection clears the board ghosts.
|
||||
await sch.evaluate(() =>
|
||||
(window as unknown as W).Module.kicadCollabTestClearSelection(),
|
||||
);
|
||||
await expect
|
||||
.poll(() => crossMapped(pcb), { timeout: 20000 })
|
||||
.toEqual([]);
|
||||
|
||||
// ── pcbnew → eeschema ──────────────────────────────────────────────────────
|
||||
const fpId = await pcb.evaluate(() =>
|
||||
(window as unknown as W).Module.kicadCollabTestSelectComponent(),
|
||||
);
|
||||
expect(fpId, 'demo board should contain a footprint').toBeTruthy();
|
||||
|
||||
// The footprint's path tail resolves to a symbol on the schematic's sheet.
|
||||
await expect
|
||||
.poll(() => crossMapped(sch), {
|
||||
timeout: 20000,
|
||||
message: 'eeschema never received/mapped the pcbnew selection',
|
||||
})
|
||||
.not.toEqual([]);
|
||||
|
||||
// …and the emit carried the footprint's schematic path.
|
||||
const pcbSel = await pcb.evaluate(() =>
|
||||
JSON.parse((window as unknown as W).Module.kicadCollabGetSelectionFull()),
|
||||
);
|
||||
expect(pcbSel.uuids).toContain(fpId);
|
||||
expect(pcbSel.fpPaths.length).toBeGreaterThan(0);
|
||||
|
||||
await pcb.evaluate(() =>
|
||||
(window as unknown as W).Module.kicadCollabTestClearSelection(),
|
||||
);
|
||||
await expect
|
||||
.poll(() => crossMapped(sch), { timeout: 20000 })
|
||||
.toEqual([]);
|
||||
});
|
||||
Loading…
Reference in a new issue