pcbjam/tests/web/locks.spec.ts
Gergő Törcsvári c30bcd4531
feat(collab): selection soft-locks — remote-selected items can't be dragged locally (collab-presence 0007)
While a peer has an item selected, local users can still select it for
inspection but move/drag/rotate/delete skip it with an infobar naming the
holder (native locked-item UX; enforced via the fork's PCBJAM_REMOTE_LOCK
query — kicad 81f9cd80fd, the epic's first fork-touching phase). Overlapping
holds (both grabbed inside the awareness propagation window) tie-break
deterministically: lowest (user.id, clientID) keeps the item, every losing
client auto-releases it.

- lock-tiebreak.ts: pure policy — beats(), remoteLocks() (union of ALL other
  clients' selections incl. own user's other tabs, minus own-held-and-winning
  uuids so the winner isn't blocked mid-release), contestedReleases()
- presence.ts: clients() (per-client view, no user dedupe) + self(); FIX for
  a pre-existing flaky stack overflow — resolveCollision re-entered itself
  synchronously via its own patch's awareness 'change' and could ping-pong on
  stale same-user states (~1-in-3 unit runs); re-entrancy guard defers
  re-resolution to the next genuine delivery
- presence-kicad.ts: locks ride the kicadCollabSetRemote snapshot
  (`locks:[{uuid,name}]`); losing overlaps call kicadCollabReleaseSelection
- wasm bindings (both TUs + merged dispatch): g_locks map + fork query
  install; kicadCollabReleaseSelection (cancelInteractive only when a tool
  stack is live — bare ESC would clear the whole selection — then selective
  RemoveItemFromSel + infobar + forced re-emit); kicadCollabTestGetLocked
- tests: lock-tiebreak unit suite; presence-locks e2e for both editors (real
  move veto — pcbnew click+M hotkey since its default left-drag is
  rubber-band select, eeschema real drag — each with an unlocked control);
  two-tab tests/web/locks.spec.ts (lock propagation + deterministic tiebreak
  release + unlock on clear, passing vs real partykit)

Spec: docs/features/collab-presence/0007 (closed repo).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lg5jwWuhFH5dL8hEcBDuP2
2026-07-07 21:09:24 +02:00

111 lines
4.1 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test';
/**
* Selection soft-locks e2e (collab-presence 0007): two tabs of the real app
* on the SAME board share the per-file room — one tab's live selection must
* soft-lock those items for the other, and an overlapping hold (both grab the
* same item inside the propagation window — simulated with the programmatic
* select, which bypasses the acquisition veto exactly like a real race)
* resolves by the deterministic (user.id, clientID) tiebreak: alice keeps,
* bob's client releases.
*/
const SCOPE = 'default';
type Mod = {
kicadCollabGetSelection(): string;
kicadCollabTestGetLocked(): string;
kicadCollabTestSelectComponent(): string;
kicadCollabTestClearSelection(): boolean;
};
type W = { Module: Mod };
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}: board editor never reached the expected title`,
timeout: 120000,
intervals: [1000],
})
.toMatch(/demo — PCB Editor/i);
await page.waitForFunction(
() =>
typeof (window as unknown as Partial<W>).Module?.kicadCollabTestGetLocked ===
'function',
null,
{ timeout: 60000 },
);
}
const selection = (page: Page) =>
page.evaluate(() =>
JSON.parse((window as unknown as W).Module.kicadCollabGetSelection()),
);
const locked = (page: Page) =>
page.evaluate(() =>
JSON.parse((window as unknown as W).Module.kicadCollabTestGetLocked()),
);
test('a peer selection locks the item; overlapping holds tiebreak deterministically', async ({
page,
context,
}) => {
test.setTimeout(480000); // two full board boots
const alice = page;
await bootBoard(alice, 'alice');
const bob = await context.newPage();
await bootBoard(bob, 'bob');
// ── lock propagation ───────────────────────────────────────────────────────
const fpId = await alice.evaluate(() =>
(window as unknown as W).Module.kicadCollabTestSelectComponent(),
);
expect(fpId, 'demo board should contain a footprint').toBeTruthy();
await expect
.poll(() => locked(bob), {
timeout: 20000,
message: "bob never saw alice's selection as a lock",
})
.toEqual([{ uuid: fpId, name: 'alice' }]);
// The holder's own tab is NOT locked against itself.
await expect.poll(() => locked(alice), { timeout: 20000 }).toEqual([]);
// ── overlapping hold → tiebreak ────────────────────────────────────────────
// bob grabs the SAME footprint programmatically (the race-window simulation:
// AddItemToSel bypasses the acquisition veto, like two grabs inside the
// awareness propagation window).
const bobPick = await bob.evaluate(() =>
(window as unknown as W).Module.kicadCollabTestSelectComponent(),
);
expect(bobPick).toBe(fpId);
// alice ("alice" < "bob") keeps it; bob's client releases it.
await expect
.poll(() => selection(bob), {
timeout: 20000,
message: "bob's losing hold was never released",
})
.toEqual([]);
expect(await selection(alice)).toContain(fpId);
// ── unlock on clear ────────────────────────────────────────────────────────
await alice.evaluate(() =>
(window as unknown as W).Module.kicadCollabTestClearSelection(),
);
await expect.poll(() => locked(bob), { timeout: 20000 }).toEqual([]);
// Now bob's grab sticks, and it locks the item for alice.
await bob.evaluate(() =>
(window as unknown as W).Module.kicadCollabTestSelectComponent(),
);
await expect
.poll(() => selection(bob), { timeout: 20000 })
.toContain(fpId);
await expect
.poll(() => locked(alice), { timeout: 20000 })
.toEqual([{ uuid: fpId, name: 'bob' }]);
});