import { expect, type Page, test } from "@playwright/test" import { api, apiPage, deleteAll } from "./utils/api" /** * A colour disc sends once, at the end of the gesture. * * Hue is the angle and saturation the radius, so setting a colour is one drag * across one picture — and the disc follows the pointer from the moment it * goes down. Only the release publishes: a value per pixel would flood * whatever is listening, and on a lamp it would strobe it. * * The arithmetic behind the wheel is checked in `color.check.ts`, which needs * no browser. What needs one is the gesture: how many messages a drag sends, * and whether the one it does send is where the pointer was let go. And the * wiring: a tile set to rgb or hex reads and sends that form, not the wheel's. */ const flowName = `test_color_${Date.now().toString(36)}` const dashboard = `${flowName}_d` const target = `${flowName}.tint` test.use({ storageState: "playwright/.auth/user.json" }) test.describe.configure({ mode: "serial" }) test.beforeAll(async ({ browser }) => { const page = await apiPage(browser) // A node that only declares: nothing consumes the message, so a publish // reaches the graph without waking an engine run. The widget is what is // under test, not a flow. const madeFlow = await api(page, `/flows/${flowName}`, { method: "PUT", data: { name: flowName, title: "Colour drag", version: 1, nodes: [ { id: "lamp", type: "python", provides: [ { name: "tint", dtype: "list", item: "float" }, { name: "rgb", dtype: "list", item: "int" }, { name: "hex", dtype: "str" }, ], }, ], }, }) if (!madeFlow.ok()) throw new Error(`flow PUT ${madeFlow.status()}: ${await madeFlow.text()}`) const flow = await (await api(page, `/flows/${flowName}?draft=true`)).json() await api(page, `/flows/${flowName}/publish`, { method: "POST", data: { version: flow.definition.version }, }) // The same amber in either form, as a flow speaking it would set it. for (const [name, value] of [ ["rgb", [204, 150, 57]], ["hex", "#cc9639"], ] as const) { await api(page, `/messages/${flowName}.${name}`, { method: "POST", data: { value }, }) } // A leftover from a run that failed before its teardown would answer the // create with a version conflict. await api(page, `/dashboards/${dashboard}`, { method: "DELETE" }) const madeBoard = await api(page, `/dashboards/${dashboard}`, { method: "PUT", data: { name: dashboard, title: "Colour drag", version: 0, widgets: [ { id: "tint", type: "color", title: "Tint", layout: { lg: { x: 0, y: 0, w: 6, h: 4 } }, config: { target, dtype: "list", format: "hsv" }, }, { id: "rgb", type: "color", title: "RGB", layout: { lg: { x: 6, y: 0, w: 3, h: 4 } }, config: { target: `${flowName}.rgb`, dtype: "list", format: "rgb" }, }, { id: "hex", type: "color", title: "Hex", layout: { lg: { x: 9, y: 0, w: 3, h: 4 } }, config: { target: `${flowName}.hex`, dtype: "str", format: "hex" }, }, ], }, }) if (!madeBoard.ok()) throw new Error( `dashboard PUT ${madeBoard.status()}: ${await madeBoard.text()}`, ) const board = await madeBoard.json() const shown = await api(page, `/dashboards/${dashboard}/publish`, { method: "POST", data: { version: board.version }, }) if (!shown.ok()) throw new Error(`publish ${shown.status()}: ${await shown.text()}`) await page.close() }) test.afterAll(async ({ browser }) => { await deleteAll(browser, [`/dashboards/${dashboard}`, `/flows/${flowName}`]) }) /** Open the panel, settled, and collect every publish made to `name`. */ async function openBoard( page: Page, name = target, ) { await page.goto(`/view/${dashboard}`) await page.getByTestId("color-wheel").first().waitFor({ timeout: 20000 }) // A tile fades and scales in, so where the disc is drawn is only settled // once that has finished — and the gestures below are aimed in pixels. await page.waitForFunction( () => [...document.querySelectorAll(".widget-cell")].every( (cell) => Number(getComputedStyle(cell).opacity) > 0.99, ), undefined, { timeout: 15000 }, ) // Every publish this panel makes, as the browser sends it. The setup above // talks to the API out of band, so nothing but the widget is counted here. const sent: Value[] = [] page.on("request", (request) => { if ( request.method() === "POST" && request.url().includes(`/messages/${name}`) ) sent.push(request.postDataJSON()?.value) }) return sent } test("a dragged disc publishes once, where it was let go", async ({ page }) => { const sent = await openBoard(page) const disc = page.getByRole("slider", { name: "Tint hue and saturation" }) const box = (await disc.boundingBox())! const middle = { x: box.x + box.width / 2, y: box.y + box.height / 2 } // Down in the white centre and out towards three o'clock, which the wheel's // own frame — zero degrees at twelve, running clockwise — reads as hue 90. await page.mouse.move(middle.x, middle.y) await page.mouse.down() for (let step = 1; step <= 8; step++) { await page.mouse.move(middle.x + (box.width * 0.4 * step) / 8, middle.y, { steps: 2, }) } // Long enough that a publish made mid-drag would have been seen by now. await page.waitForTimeout(500) expect(sent, "the disc published while it was still being dragged").toEqual( [], ) await page.mouse.up() await expect .poll(() => sent.length, { timeout: 10000, message: "letting go of the disc published nothing", }) .toBe(1) // And it carries the colour the gesture ended on rather than the one it // started from — otherwise a disc that ignored the drag would pass. // Loosely: a few degrees of pixel rounding is not the point, and the // colour it started on — white, hue 0 — is nowhere near either bound. const [hue, saturation] = sent[0] expect( Math.abs(hue - 90), `let go at three o'clock, published hue ${hue}`, ).toBeLessThan(10) expect( saturation, `let go four fifths out, published saturation ${saturation}`, ).toBeGreaterThan(60) // Nothing follows the release either: one gesture, one message. await page.waitForTimeout(1000) expect(sent, "the release published more than once").toHaveLength(1) // And that one message reached the graph rather than only the wire. await expect .poll( async () => { const rows = await (await api(page, "/messages/")).json() return (rows.data ?? rows).find( (message: { name: string }) => message.name === target, )?.value }, { timeout: 10000, message: "the release never reached the engine" }, ) .toEqual(sent[0]) }) test("a dragged brightness column publishes where it was let go", async ({ page, }) => { const sent = await openBoard(page) const column = page.getByRole("slider", { name: "Tint brightness" }) const before = Number(await column.inputValue()) const box = (await column.boundingBox())! const x = box.x + box.width / 2 const bottom = box.y + box.height - 2 const end = box.y + box.height * 0.3 // From the foot of the column up to three tenths from its top. The column // reports nothing while it moves, so the release alone decides what is // sent — and it has to send what the column was let go at. That is read // off the column rather than worked out from the pixels: Playwright's // WebKit reads a `writing-mode` range across, not along. await page.mouse.move(x, bottom) await page.mouse.down() for (let step = 1; step <= 8; step++) { await page.mouse.move(x, bottom - ((bottom - end) * step) / 8, { steps: 2, }) } const letGo = Number(await column.inputValue()) expect(letGo, "the drag never moved the column").not.toBe(before) await page.mouse.up() await expect .poll(() => sent.length, { timeout: 10000, message: "letting go of the column published nothing", }) .toBe(1) expect(sent[0][2], "the release sent another brightness").toBe(letGo) }) for (const { title, sends } of [ { title: "RGB", sends: [204, 162, 57] }, { title: "Hex", sends: "#cca239" }, ]) { test(`a key's release publishes the colour as ${title}`, async ({ page }) => { const sent = await openBoard( page, `${flowName}.${title.toLowerCase()}`, ) const disc = page.getByRole("slider", { name: `${title} hue and saturation`, }) // The amber the setup published, read back from the tile's own form. await expect(disc).toHaveAttribute( "aria-valuetext", "38 degrees, 72% saturated", ) // A key moves the hue five degrees on the way down and commits on the // way up, so one press is one message. await disc.press("ArrowRight") await expect .poll(() => sent.length, { timeout: 10000, message: "letting go of the key published nothing", }) .toBe(1) expect(sent[0], "the key sent another colour").toEqual(sends) }) }