pcbjam/tests/web/follow.spec.ts
Gergő Törcsvári 1f85e13acf
feat(collab): follow-user (collab-presence 0008) + chip depth-layer fix
Follow-user: click a peer's roster avatar to mirror their viewport until
local input breaks it.
- collab_presence_core.h: CORE::fitViewport(cx, cy, halfW, halfH) — fit the
  leader's world rect with CONTAIN semantics (zoom derived from the
  follower's own canvas via the ToScreen ratio; GetScale is the zoom, not
  px/IU). Exported as kicadCollabFitViewport from both editor TUs + the
  merged dispatcher.
- presence-kicad.ts: publish the visible world rect (viewportRect) into
  awareness, 100 ms trailing throttle; guarded for pre-0008 handles.
- follow-user.ts: createFollow — follows an awareness CLIENT (a tab, not a
  user); applies leader rect changes via FitViewport, dedupes unchanged
  republishes; break-on-interact compares local onViewport echoes against
  the last applied rect (2% rel tolerance, echo-grace before the first fit
  lands); unfollows on leader-left; pauses on eeschema sheet mismatch.
- PresenceRoster: avatars are follow toggles (ring on the followed peer);
  WasmTool renders the "Following <name> — move to stop" banner.
- tests: 7 controller units (85/85 collab), fitViewport round-trip e2e in
  both kicad presence specs (20/20), two-tab tests/web/follow.spec.ts
  (converge → track → wheel-zoom breaks → subsequent moves ignored).

Chip depth-layer fix (user-reported): name chips washed out inside
low-alpha selection fills — chip rects shared the shapes overlay's single
depth, and same-depth fragments drawn LATER lose the depth test, so an
earlier-painted fill rejected the chip's pixels. Now three layers via the
fork's VIEW_OVERLAY::SetDepthOffset: text (0) < chips + pin dots (1) <
selection shapes (2). drawLabel/drawCursor/drawSelectionBox take the chip
overlay explicitly; comment-pin dots move to the chip layer too (the 0005
"drawn last so pins sit above" comment had the rule backwards). Verified
with a chip-inside-30%-fill pixel repro + the full presence suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013u9h8fkQktH7KRECaHJmUG
2026-07-10 09:25:13 +02:00

125 lines
4.6 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test';
/**
* Follow-user e2e (collab-presence 0008): two tabs on the demo BOARD share
* the doc room's awareness. Tab B clicks tab A's roster avatar → B's viewport
* continuously mirrors A's published world rect (contain-fit); B's own canvas
* input breaks the follow.
*
* Assertions use the deterministic viewport transforms
* (`kicadCollabGetViewport` / `kicadCollabFitViewport`) — the fit math itself
* is covered per-editor in tests/kicad; this spec covers the wire + controller
* loop: publish (100 ms trailing) → awareness → follow apply → echo
* suppression → break-on-interact.
*/
const SCOPE = 'default';
type Mod = {
kicadCollabGetViewport(): string;
kicadCollabFitViewport(cx: number, cy: number, hw: number, hh: number): void;
};
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(), {
message: `${user}: editor never reached the expected 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'
);
},
null,
{ timeout: 60000 },
);
}
const viewport = (page: Page): Promise<Vp> =>
page.evaluate(() =>
JSON.parse((window as unknown as W).Module.kicadCollabGetViewport()),
);
const fit = (page: Page, cx: number, cy: number, hw: number, hh: number) =>
page.evaluate(
(t) => (window as unknown as W).Module.kicadCollabFitViewport(t.cx, t.cy, t.hw, t.hh),
{ cx, cy, hw, hh },
);
/** Center-convergence check: within 2 % of the target half-width. */
const near = (vp: Vp, cx: number, cy: number, tol: number) =>
Math.abs(vp.cx - cx) < tol && Math.abs(vp.cy - cy) < tol;
test('B follows A: viewport mirrors, then local input breaks the follow', async ({
page,
context,
}) => {
test.setTimeout(480000); // two full pcbnew wasm boots
const a = page;
await bootBoard(a, 'alice');
const b = await context.newPage();
await bootBoard(b, 'bob');
// A frames a distinctive region (world IU; demo board is an A4 sheet).
const T1 = { cx: 120e6, cy: 90e6, hw: 40e6, hh: 30e6 };
await fit(a, T1.cx, T1.cy, T1.hw, T1.hh);
await expect
.poll(async () => near(await viewport(a), T1.cx, T1.cy, 1e6), {
timeout: 20000,
message: 'A never landed on its own fit target',
})
.toBe(true);
// B sees alice in the roster and clicks her avatar → follow.
const avatar = b.locator('[data-presence-user="alice"]');
await expect(avatar).toBeVisible({ timeout: 30000 });
await avatar.click();
await expect(b.getByTestId('follow-banner')).toBeVisible({ timeout: 10000 });
// B's viewport converges on A's rect (publish 100 ms + awareness relay).
await expect
.poll(async () => near(await viewport(b), T1.cx, T1.cy, T1.hw * 0.02), {
timeout: 30000,
message: 'B never converged on the followed viewport',
})
.toBe(true);
// A moves again → B tracks.
const T2 = { cx: 180e6, cy: 120e6, hw: 25e6, hh: 20e6 };
await fit(a, T2.cx, T2.cy, T2.hw, T2.hh);
await expect
.poll(async () => near(await viewport(b), T2.cx, T2.cy, T2.hw * 0.02), {
timeout: 30000,
message: 'B never tracked the second viewport move',
})
.toBe(true);
// B interacts (real wheel zoom on the canvas) → the follow breaks.
const canvasBox = await b.locator('#canvas').boundingBox();
if (!canvasBox) throw new Error('no canvas box');
await b.mouse.move(canvasBox.x + canvasBox.width / 2, canvasBox.y + canvasBox.height / 2);
await b.mouse.wheel(0, -240);
await expect(b.getByTestId('follow-banner')).toBeHidden({ timeout: 15000 });
// A moves once more; B (unfollowed) must NOT track it. A landing on its own
// target is the synchronization point — by then A's rect went out and B
// demonstrably ignored it (its center is still near T2, zoom aside).
const T3 = { cx: 80e6, cy: 60e6, hw: 30e6, hh: 25e6 };
await fit(a, T3.cx, T3.cy, T3.hw, T3.hh);
await expect
.poll(async () => near(await viewport(a), T3.cx, T3.cy, 1e6), { timeout: 20000 })
.toBe(true);
const bVp = await viewport(b);
expect(near(bVp, T3.cx, T3.cy, T3.hw * 0.05), 'B tracked A after unfollow').toBe(false);
});