/** * T-167 (P5) — whiteboard 2,000-OBJECT PERFORMANCE GATE. * * The spec requires "a rendering approach suitable for large numbers of * objects." The hand-rolled SVG renderer achieves this via VIEWPORT CULLING: * it only mounts a DOM node for elements whose display bounds intersect the * visible world rect (packages/whiteboard `viewportToWorldRect` + * `displayBounds` + `rectsIntersect`), so an infinite canvas with thousands * of elements never mounts a node per element. * * This is the measurable gate. We seed a board with 2,000 elements — a small * cluster near the origin (always in view) plus 1,990 in a far region a * browser window cannot reach — and assert the renderer mounts only a small * fraction of DOM nodes (≪ 2,000). Without culling every element would be a * mounted SVG node; with it, the DOM size is independent of the total count. */ import { test, expect } from "../fixtures"; interface WhiteboardPageDto { id: string; } interface CanvasElement { id: string; type: string; } const TOTAL = 2_000; const NEAR = 10; /** Build a 2,000-element canvas: 10 rects near the origin (in view) + 1,990 * far to the right (x ≥ 10,000 — unreachable by any browser window). */ function buildCanvas() { const elements: Record = {}; for (let i = 0; i < NEAR; i++) { elements[`r${i}`] = { id: `r${i}`, type: "rect", x: (i % 2) * 80, y: Math.floor(i / 2) * 60, w: 60, h: 40, z: i, fill: null, stroke: null }; } for (let i = NEAR; i < TOTAL; i++) { const j = i - NEAR; elements[`r${i}`] = { id: `r${i}`, type: "rect", x: 10_000 + (j % 20) * 100, y: Math.floor(j / 20) * 100, w: 60, h: 40, z: i, fill: null, stroke: null }; } return { version: 1, elements }; } test.describe("Whiteboard — 2,000-object performance gate (T-167/P5)", () => { test("renders a 2,000-element board with viewport culling (DOM ≪ total)", async ({ authPage, api, freshWorkspace }) => { const ws = await freshWorkspace("Perf Gate Space"); const createRes = await api.post(`/api/v1/workspaces/${ws.id}/pages`, { data: { title: "Perf Wall", type: "whiteboard" } }); expect(createRes.status(), await createRes.text()).toBe(201); const page = (await createRes.json()) as WhiteboardPageDto; // Seed the 2,000-element document (validateCanvas accepts it — no // per-document element cap). const canvas = buildCanvas(); const put = await api.put(`/api/v1/pages/${page.id}/whiteboard`, { data: canvas }); expect(put.status(), await put.text()).toBe(200); // Sanity: the server stored all 2,000 elements. const get = await api.get(`/api/v1/pages/${page.id}/whiteboard`); expect(get.status()).toBe(200); const body = (await get.json()) as { canvas?: { elements?: Record } }; expect(Object.keys(body.canvas?.elements ?? {}).length).toBe(TOTAL); await authPage.goto(`/w/${ws.slug}/page/${page.id}`); const root = authPage.getByTestId("wb-root"); await expect(root).toBeVisible({ timeout: 20_000 }); await expect(authPage.getByTestId("wb-canvas")).toBeVisible(); // THE GATE: the renderer mounts a DOM node only for the on-viewport // elements. Culling engages on the ResizeObserver's first async fire (the // container reports its real size → the visible world rect is known), so // we POLL for the culled count to settle. The 1,990 far elements must NOT // be mounted — the mounted count is a small fraction of the total // (independent of 2,000). await expect .poll( async () => authPage.getByTestId("wb-rect").count(), { timeout: 15_000, message: "expected the renderer to cull off-viewport elements (mount ≪ total)" } ) .toBeLessThan(100); // The near cluster IS visible (culling doesn't drop on-screen elements). const mounted = await authPage.getByTestId("wb-rect").count(); expect(mounted).toBeGreaterThan(0); // < 5% of the total — the proof that a node is NOT mounted per element. expect(mounted).toBeLessThan(TOTAL / 10); }); });