import { expect, type Page, test } from "@playwright/test" import { api, apiPage, deleteAll } from "./utils/api" /** * The wall-panel widgets, on the page a panel actually opens. * * Each one is a picture rather than a number, so what is worth asserting is * the picture: a nested bar sits inside its outer fill, a condition picks the * glyph the mapping names, a forecast fades outwards, and a control that * latches reads back what it published. The two screenshots are the other * half — a colour that collapses in one theme is invisible to every assertion * above. */ const flowName = `test_widgets_${Date.now().toString(36)}` const dashboardName = `${flowName}_panel` /** A panel of its own: a stacked bar needs the only `bar-inner` on the page. */ const stackName = `${flowName}_stack` /** A message of the flow under test, qualified the way the engine names it. */ const w = (name: string) => `${flowName}.${name}` /** Every tile the dashboard carries, including the deliberately unbound one. */ const TILES = 8 test.use({ storageState: "playwright/.auth/user.json" }) test.describe.configure({ mode: "serial" }) /** Put a value into the graph, as a flow answering would. */ const publish = (page: Page, name: string, value: unknown) => api(page, `/messages/${name}`, { method: "POST", data: { value } }) test.beforeAll(async ({ browser }) => { const page = await apiPage(browser) // One node that only declares: nothing consumes these, so publishing a value // fills the panel without waking an engine run. await api(page, `/flows/${flowName}`, { method: "PUT", data: { name: flowName, title: "Widgets", version: 1, nodes: [ { id: "emit", type: "python", provides: [ { name: "level", dtype: "float" }, { name: "pv", dtype: "float" }, { name: "grid", dtype: "float" }, { name: "condition", dtype: "str" }, { name: "days", dtype: "list", item: "record" }, { name: "mode", dtype: "str" }, { name: "lamp", dtype: "bool" }, ], }, ], }, }) // Saving writes a draft and moves the version on, so publish what is // actually there rather than what it was a moment ago. const flow = await (await api(page, `/flows/${flowName}?draft=true`)).json() await api(page, `/flows/${flowName}/publish`, { method: "POST", data: { version: flow.definition.version }, }) await publish(page, w("level"), 80) await publish(page, w("pv"), 30) await publish(page, w("grid"), 20) await publish(page, w("condition"), "sun") await publish(page, w("days"), [ { label: "Mon", icon: "sun", value: "21°" }, { label: "Tue", icon: "cloudy", value: "18°" }, { label: "Wed", icon: "cloud-rain", value: "15°", color: "primary" }, { label: "Thu", icon: "wind", value: "16°" }, { label: "Fri", icon: "snowflake", value: "2°" }, // A sixth, so "Items shown" is what decides there are five columns. { label: "Sat", icon: "sun", value: "9°" }, ]) const dashboard = await ( await api(page, `/dashboards/${dashboardName}`, { method: "POST" }) ).json() dashboard.pages[0].sections[0].widgets = [ { id: "load", type: "bar", title: "Load", layout: { lg: { x: 0, y: 0, w: 4, h: 2 } }, config: { message: w("level"), dtype: "float", inner: w("pv"), inner_dtype: "float", min: 0, max: 100, unit: " kW", }, }, { id: "sky", type: "icon", title: "Sky", layout: { lg: { x: 4, y: 0, w: 2, h: 2 } }, config: { message: w("condition"), dtype: "str", rules: [ { at: "rain", icon: "cloud-rain", color: "primary" }, { at: "sun", icon: "sun", color: "default" }, ], }, }, { id: "wall", type: "clock", title: "Now", layout: { lg: { x: 6, y: 0, w: 3, h: 2 } }, config: {}, }, { id: "week", type: "forecast", title: "Week", layout: { lg: { x: 0, y: 2, w: 6, h: 2 } }, config: { message: w("days"), dtype: "list", count: 5 }, }, { id: "mode", type: "dropdown", title: "Mode", layout: { lg: { x: 6, y: 2, w: 4, h: 2 } }, config: { target: w("mode"), dtype: "str", style: "segmented", options: [ { label: "Eco", value: "eco" }, { label: "Boost", value: "boost" }, ], }, }, { id: "lamp", type: "switch", title: "Lamp", layout: { lg: { x: 0, y: 4, w: 3, h: 2 } }, config: { target: w("lamp"), dtype: "bool", style: "button" }, }, { // Bound to nothing on purpose: a half-configured tile has to say so // rather than take the page down with it. id: "spare", type: "bar", title: "Spare", layout: { lg: { x: 3, y: 4, w: 3, h: 2 } }, config: {}, }, { id: "trend", type: "chart", title: "Trend", layout: { lg: { x: 6, y: 4, w: 6, h: 3 } }, config: { series: [{ message: w("level"), dtype: "float" }] }, }, ] const draft = await ( await api(page, `/dashboards/${dashboardName}`, { method: "PUT", data: dashboard, }) ).json() await api(page, `/dashboards/${dashboardName}/publish`, { method: "POST", data: { version: draft.version }, }) const stack = await ( await api(page, `/dashboards/${stackName}`, { method: "POST" }) ).json() stack.pages[0].sections[0].widgets = [ { id: "split", type: "bar", title: "Split", layout: { lg: { x: 0, y: 0, w: 6, h: 2 } }, config: { message: w("level"), dtype: "float", // The list shape. The panel above keeps the single binding a bar was // written with, which is what proves both are still read. inner: [ { message: w("pv"), dtype: "float" }, { message: w("grid"), dtype: "float" }, ], min: 0, max: 100, unit: " kW", }, }, ] const stacked = await ( await api(page, `/dashboards/${stackName}`, { method: "PUT", data: stack }) ).json() await api(page, `/dashboards/${stackName}/publish`, { method: "POST", data: { version: stacked.version }, }) await page.close() }) test.afterAll(async ({ browser }) => { await deleteAll(browser, [ `/dashboards/${dashboardName}`, `/dashboards/${stackName}`, `/flows/${flowName}`, ]) }) /** The panel as a wall panel opens it, once the tiles are drawn. */ async function openPanel(page: Page, name = dashboardName) { await page.goto(`/view/${name}`) await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 }) } /** * WCAG contrast of two `rgb(...)` paints, so a fill can be held to the 3:1 * guideline for non-text rather than eyeballed on a screenshot. */ function contrast(first: string, second: string) { const luminance = (paint: string) => { const channel = (value: number) => { const scaled = value / 255 return scaled <= 0.03928 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4 } const [r, g, b] = (paint.match(/[\d.]+/g) ?? []).slice(0, 3).map(Number) return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) } const [dark, light] = [luminance(first), luminance(second)].sort( (a, b) => a - b, ) return (light + 0.05) / (dark + 0.05) } test("a nested bar is drawn inside its outer fill", async ({ page }) => { await openPanel(page) const fill = page.getByTestId("bar-fill") const inner = page.getByTestId("bar-inner") await expect(inner).toBeVisible() const outerBox = await fill.boundingBox() const innerBox = await inner.boundingBox() expect(outerBox).not.toBeNull() expect(innerBox).not.toBeNull() expect( innerBox!.width, "the nested share is not drawn inside the reading it is part of", ).toBeLessThan(outerBox!.width) }) test("a nested reading larger than the outer one is clamped to it", async ({ page, }) => { await openPanel(page) await publish(page, w("pv"), 120) // Written from the same reading, so the caption says when it landed. await expect(page.getByText(/120\.0 kW/)).toBeVisible() const outerBox = await page.getByTestId("bar-fill").boundingBox() const innerBox = await page.getByTestId("bar-inner").boundingBox() expect( innerBox!.width, "a nested value over the reading spills onto the track", ).toBeLessThanOrEqual(outerBox!.width) await publish(page, w("pv"), 30) }) test("a second nested reading starts where the first ends", async ({ page, }) => { await openPanel(page, stackName) const segments = page.getByTestId("bar-inner") await expect(segments).toHaveCount(2) const first = await segments.nth(0).boundingBox() const second = await segments.nth(1).boundingBox() const outerBox = await page.getByTestId("bar-fill").boundingBox() // Stacked rather than drawn over one another: the only gap between them is // the gutter that tells them apart, and neither leaves the fill. const gap = second!.x - (first!.x + first!.width) expect(gap, "the segments are drawn over one another").toBeGreaterThanOrEqual( 0, ) expect( gap, "the second segment does not follow the first", ).toBeLessThanOrEqual(3) expect(second!.x + second!.width).toBeLessThanOrEqual( outerBox!.x + outerBox!.width + 1, ) }) test("the icon follows what the message says", async ({ page }) => { await openPanel(page) const glyph = page.getByTestId("icon-glyph") await expect(glyph).toBeVisible() await publish(page, w("condition"), "rain") // Colour is never the only signal: the glyph names itself. await expect(glyph).toHaveAttribute("aria-label", /rain/i) }) test("a forecast shows the days asked for, fading outwards", async ({ page, }) => { await openPanel(page) const columns = page.getByTestId("forecast-column") await expect(columns).toHaveCount(5) const opacity = (index: number) => columns.nth(index).evaluate((el) => Number(getComputedStyle(el).opacity)) expect( await opacity(4), "the far end of the forecast reads as certain as the near end", ).toBeLessThan(await opacity(0)) }) test("the clock reads the wall and is not mis-wired", async ({ page }) => { await openPanel(page) await expect(page.getByTestId("clock-time")).toHaveText(/\d{1,2}:\d{2}/) // Bound to nothing by design, so it must not be flagged as unbound. const tile = page .getByTestId("widget-frame") .filter({ has: page.getByTestId("clock-time") }) await expect(tile.getByTestId("widget-issue")).toHaveCount(0) }) test("a control reads back what it published", async ({ page }) => { await openPanel(page) const boost = page.getByRole("button", { name: "Boost" }) await boost.click() await expect(boost).toHaveAttribute("aria-pressed", "true") // The latching button names its state; two presses are a round trip. const lamp = page.getByRole("button", { name: "Lamp" }) await expect(lamp).toHaveText("Off") await lamp.click() await expect(lamp).toHaveText("On") await lamp.click() await expect(lamp).toHaveText("Off") }) test("an unbound widget says so and takes nothing down", async ({ page }) => { const crashes: string[] = [] page.on("pageerror", (error) => crashes.push(error.message)) await openPanel(page) await expect(page.getByText("Pick a message.")).toBeVisible() await expect(page.getByTestId("widget-frame")).toHaveCount(TILES) expect(crashes, "the panel threw").toEqual([]) }) /** * uPlot draws the axis title into the canvas, so there is nothing in the DOM * to assert. What can be checked is that the setting survives: the panel is * where it is written, and a reload is what proves it was stored. */ test("a chart's axis title is kept", async ({ page }) => { await page.goto(`/dashboards/${dashboardName}?edit=true`) const chart = page .getByTestId("widget-frame") .filter({ hasText: "Trend" }) .first() await chart.waitFor({ timeout: 15000 }) await chart.click() const field = page.locator('div:has(> label:text-is("Y axis title")) > input') await expect(field).toBeVisible() await field.fill("kW") // Longer than the editor sits on an edit before saving it. await page.waitForTimeout(2000) await page.reload() await chart.waitFor({ timeout: 15000 }) await chart.click() await expect(field).toHaveValue("kW") }) for (const scheme of ["light", "dark"] as const) { test.describe(`${scheme} theme`, () => { test.use({ colorScheme: scheme }) test(`the panel reads in ${scheme}`, async ({ page }) => { await openPanel(page) await expect(page.getByTestId("bar-inner")).toBeVisible() // The nested fill is a picture, so it owes the 3:1 guideline for // non-text against the fill it sits on — measured, not eyeballed. const paint = (testId: string) => page .getByTestId(testId) .evaluate((el) => getComputedStyle(el).backgroundColor) const ratio = contrast(await paint("bar-fill"), await paint("bar-inner")) expect( ratio, `the nested fill measures ${ratio.toFixed(2)}:1 on the outer one`, ).toBeGreaterThanOrEqual(3) await page.screenshot({ path: `screenshots/widgets/${scheme}.png` }) }) }) }