diff --git a/frontend/tests/color-drag.spec.ts b/frontend/tests/color-drag.spec.ts new file mode 100644 index 0000000..ffd395c --- /dev/null +++ b/frontend/tests/color-drag.spec.ts @@ -0,0 +1,171 @@ +import { expect, 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. + */ +const flowName = `test_color_${Date.now().toString(36)}` +const dashboard = `${flowName}_d` +const target = `${flowName}.tint` + +test.use({ storageState: "playwright/.auth/user.json" }) + +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" }], + }, + ], + }, + }) + 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 }, + }) + + // 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" }, + }, + ], + }, + }) + 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}`]) +}) + +test("a dragged disc publishes once, where it was let go", async ({ page }) => { + await page.goto(`/view/${dashboard}`) + const disc = page.getByTestId("color-wheel") + await disc.waitFor({ timeout: 20000 }) + // A tile fades and scales in, so where the disc is drawn is only settled + // once that has finished — and the gesture below is 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: [number, number, number][] = [] + page.on("request", (request) => { + if ( + request.method() === "POST" && + request.url().includes(`/messages/${target}`) + ) + sent.push(request.postDataJSON()?.value) + }) + + 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]) +})