import { expect, test, type Page } from "@playwright/test"; const MAP = { knifeX: 600, parallelTop: 90, parallelBottom: 420, chainKnifeX: 430, chainTop: 390, chainBottom: 485, mathAHeader: { x: 850, y: 90 }, transformHeader: { x: 440, y: 560 }, noiseHeader: { x: 850, y: 560 }, } as const; async function open(page: Page) { await page.goto("/examples/blender/link-tools-test/"); await page.evaluate(() => window.linkToolsTest.ready); await page.evaluate(() => { const events = { mutations: [] as number[], snapshots: [] as number[] }; Object.assign(window, { linkToolEvents: events }); window.linkToolsTest.root!.onMutations((e) => events.mutations.push(e.version)); window.linkToolsTest.root!.onSnapshots((e) => events.snapshots.push(e.version)); }); return page.locator("#link-tools"); } async function publicState(page: Page) { return page.evaluate(async () => ({ save: await window.linkToolsTest.root!.save(), snapshot: await window.linkToolsTest.root!.getState(), events: structuredClone( (window as typeof window & { linkToolEvents: { mutations: number[]; snapshots: number[] } }).linkToolEvents, ), })); } async function knife( page: Page, canvas: ReturnType, from: { x: number; y: number }, to: { x: number; y: number }, alt = false, release = true, ) { await canvas.hover({ position: from }); await page.keyboard.down("Control"); if (alt) await page.keyboard.down("Alt"); await page.mouse.down({ button: "right" }); const box = await canvas.boundingBox(); if (!box) throw new Error("canvas bounds missing"); await page.mouse.move(box.x + to.x, box.y + to.y, { steps: 12 }); if (release) await page.mouse.up({ button: "right" }); if (alt) await page.keyboard.up("Alt"); await page.keyboard.up("Control"); } test("Ctrl-RMB knife is transient then removes every crossed link atomically", async ({ page }) => { const canvas = await open(page), before = await publicState(page); await knife( page, canvas, { x: MAP.knifeX, y: MAP.parallelTop }, { x: MAP.knifeX, y: MAP.parallelBottom }, false, false, ); const during = await publicState(page); expect(during).toEqual(before); // The actual canvas must contain the white knife/highlight, not merely worker state. expect( await page.evaluate( ({ x, top, bottom }) => { const c = document.querySelector("#link-tools")!, d = c.getContext("2d")!.getImageData(x - 3, top, 7, bottom - top).data; let n = 0; for (let i = 0; i < d.length; i += 4) if (d[i]! > 220 && d[i + 1]! > 220 && d[i + 2]! > 220) n++; return n; }, { x: MAP.knifeX, top: MAP.parallelTop, bottom: MAP.parallelBottom }, ), ).toBeGreaterThan(150); await page.mouse.up({ button: "right" }); await page.keyboard.up("Control"); const removed = await publicState(page); expect(removed.save.links.map((l) => l.id).sort()).toEqual(["chain-in", "chain-out"]); expect(removed.snapshot.version).toBe(before.snapshot.version + 1); expect(removed.events).toEqual({ mutations: [before.snapshot.version + 1], snapshots: [before.snapshot.version + 1], }); await page.keyboard.press("Control+z"); const undone = await publicState(page); expect(undone.save.links).toEqual(before.save.links); expect(undone.snapshot.version).toBe(before.snapshot.version + 2); }); test("Escape and pointer cancellation discard knife and context menu stays suppressed", async ({ page }) => { const canvas = await open(page); await page.evaluate(() => { (window as any).unblockedMenus = 0; document.addEventListener("contextmenu", (event) => { if (!event.defaultPrevented) (window as any).unblockedMenus++; }); }); await knife(page, canvas, { x: 600, y: 55 }, { x: 600, y: 390 }, false, false); await page.keyboard.press("Escape"); await page.mouse.up({ button: "right" }); await page.keyboard.up("Control"); expect((await publicState(page)).events).toEqual({ mutations: [], snapshots: [] }); await knife(page, canvas, { x: 600, y: 55 }, { x: 600, y: 390 }, false, false); await canvas.dispatchEvent("pointercancel", { pointerId: 1, pointerType: "mouse", button: 2, buttons: 0, clientX: 600, clientY: 390, }); await page.mouse.up({ button: "right" }); await page.keyboard.up("Control"); expect((await publicState(page)).save.links).toHaveLength(5); expect(await page.evaluate(() => (window as any).unblockedMenus)).toBe(0); }); test("Ctrl-Alt-RMB mutes a link, restores its default editor, and undo is atomic", async ({ page }) => { const canvas = await open(page), before = await publicState(page); await knife(page, canvas, { x: 600, y: 100 }, { x: 600, y: 205 }, true); const muted = await publicState(page), link = muted.save.links.find((l) => l.id === "parallel-a"); expect(link?.muted).toBe(true); expect(muted.events).toEqual({ mutations: [before.snapshot.version + 1], snapshots: [before.snapshot.version + 1] }); // Red pixels on the formerly grey link and a clickable default-value row prove rendering/hit behavior. expect( await page.evaluate(() => { const c = document.querySelector("#link-tools")!, d = c.getContext("2d")!.getImageData(500, 50, 200, 130).data; let n = 0; for (let i = 0; i < d.length; i += 4) if (d[i]! > 150 && d[i + 1]! < 110) n++; return n; }), ).toBeGreaterThan(5); await canvas.click({ position: { x: 850, y: 125 } }); expect((await publicState(page)).snapshot.version).toBe(before.snapshot.version + 1); await knife(page, canvas, { x: 600, y: 100 }, { x: 600, y: 205 }, true); expect((await publicState(page)).save.links.find((l) => l.id === "parallel-a")?.muted).toBe(false); await page.keyboard.press("Control+z"); expect((await publicState(page)).save.links.find((l) => l.id === "parallel-a")?.muted).toBe(true); }); test("reroute effective mute is red without changing downstream authored flags", async ({ page }) => { const canvas = await open(page); await knife(page, canvas, { x: MAP.chainKnifeX, y: MAP.chainTop }, { x: MAP.chainKnifeX, y: MAP.chainBottom }, true); const state = await publicState(page); expect(state.save.links.find((l) => l.id === "chain-in")?.muted).toBe(true); expect(state.save.links.find((l) => l.id === "chain-out")?.muted).toBe(false); await page.evaluate(() => window.linkToolsTest.view!.whenRendered()); expect( await page.evaluate(() => { const c = document.querySelector("#link-tools")!, d = c.getContext("2d")!.getImageData(590, 420, 250, 100).data; let n = 0; for (let i = 0; i < d.length; i += 4) if (d[i]! > 150 && d[i + 1]! < 110) n++; return n; }), ).toBeGreaterThan(5); }); test("M mutes operators with bypasses and visibly grays generators", async ({ page }) => { const canvas = await open(page); for (const [id, point] of [ ["math-a", MAP.mathAHeader], ["transform", MAP.transformHeader], ] as const) { await canvas.click({ position: point }); const prior = await publicState(page), before = prior.snapshot.version; await canvas.press("m"); const muted = await publicState(page); expect(muted.save.nodes.find((n) => n.id === id)?.muted).toBe(true); expect(muted.snapshot.version).toBe(before + 1); expect(muted.events.mutations).toHaveLength(prior.events.mutations.length + 1); expect(muted.events.snapshots).toHaveLength(prior.events.snapshots.length + 1); await page.evaluate(() => window.linkToolsTest.view!.whenRendered()); expect( await page.evaluate(({ x, y }) => { const c = document.querySelector("#link-tools")!, d = c.getContext("2d")!.getImageData(x - 100, y, 200, 100).data; let n = 0; for (let i = 0; i < d.length; i += 4) if (d[i]! > 150 && d[i + 1]! < 110) n++; return n; }, point), ).toBeGreaterThan(5); await page.keyboard.press("Control+z"); expect((await publicState(page)).save.nodes.find((n) => n.id === id)?.muted).toBe(false); } await canvas.click({ position: MAP.noiseHeader }); const before = await publicState(page), beforeLight = await page.evaluate(() => { const c = document.querySelector("#link-tools")!, d = c.getContext("2d")!.getImageData(780, 550, 165, 85).data; let n = 0; for (let i = 0; i < d.length; i += 4) n += d[i]! + d[i + 1]! + d[i + 2]!; return n; }); await canvas.press("m"); await page.evaluate(() => window.linkToolsTest.view!.whenRendered()); const muted = await publicState(page), afterLight = await page.evaluate(() => { const c = document.querySelector("#link-tools")!, d = c.getContext("2d")!.getImageData(780, 550, 165, 85).data; let n = 0; for (let i = 0; i < d.length; i += 4) n += d[i]! + d[i + 1]! + d[i + 2]!; return n; }); expect(muted.save.nodes.find((n) => n.id === "noise")?.muted).toBe(true); expect(muted.snapshot.version).toBe(before.snapshot.version + 1); expect(afterLight).toBeLessThan(beforeLight * 0.8); await page.keyboard.press("Control+z"); expect((await publicState(page)).save.nodes.find((n) => n.id === "noise")?.muted).toBe(false); });