277 lines
9.2 KiB
TypeScript
277 lines
9.2 KiB
TypeScript
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<Value = [number, number, number]>(
|
|
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<unknown>(
|
|
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)
|
|
})
|
|
}
|