One circular FAB (peer-count badge) replaces the top-right overlay row: draggable anywhere (pointer capture, 4px click-vs-drag threshold — the comment-pin pattern), position persisted in localStorage, panel opens toward screen center and stacks composable sections — roster, source chip, view-only pill, follow row, comments, chrome toggle. Renders at z-50 above everything (decided: covers wx dialogs; trivially dismissed via click-away/ Esc/drag). CommentLayer portals its bar + list panel into the menu's comments slot; pins/popovers/composer/click-catcher stay canvas-anchored. The FAB is the chrome-hidden survivor. e2e specs updated with an openOverlayMenu helper (panel is closed by default, click-away closes it). NOTE: Playwright suites updated but not executed here (no local WASM artifacts) — verify on CI / next session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A42xfPFNdfsUt9eowkC9eM
128 lines
4.7 KiB
TypeScript
128 lines
4.7 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test';
|
|
import { openOverlayMenu } from './overlay-menu';
|
|
|
|
/**
|
|
* 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 (inside the overlay menu, 0010) and clicks
|
|
// her avatar → follow.
|
|
await openOverlayMenu(b);
|
|
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);
|
|
});
|