A dashboard is a wall panel somebody hangs in their own hallway, so it now wears what they choose: a look, and a palette of their own colours. Two complete component sets live under `Dashboard/ui/` — `glass` (translucent panes over a slowly moving ground) and `material` (Material 3 tonal cards) — behind one prop contract. Every control's state, keyboard and `aria-` live in `ui/core` and are shared, so the two sets are the same dashboard drawn twice rather than two products: a set only decides what a control looks like while doing it. Four settings join the channel, each drivable by a flow like any other: `look`, `palette`, `background` and `touch`. A palette is an ordered list of hex colours — background, surface, primary, accent, text, then more chart colours — pasted from a coolors.co link or typed, written onto the canvas as the token variables everything already reads. Trailing roles are derived, so three colours are a whole dashboard, and derived text is held to AA rather than trusted (`theme.check.ts` measures it). A palette also decides light or dark, since its first colour is the ground. Widgets are measured against their own tile with container queries rather than against the viewport, animate through `motion`, and can be drawn without their title. The three reworks: - a bar draws a row per reading, up to eight, each in the dashboard's own data colours and each able to carry its own scale — replacing readings nested in one fill, which could only ever share one colour and stop at three. Documents written the old way are read as rows. - a chart's range picker moved to a column down its right-hand edge, which gives the plot back a whole row of a short tile. - the colour wheel became a disc: hue is the angle and saturation the distance from the middle, so a colour is one gesture rather than three, with brightness on a slider beside it. `index.css` and `lib/motion.ts` are untouched — the dashboard overrides token *values* on its canvas, never the blocks the two repos share.
554 lines
18 KiB
TypeScript
554 lines
18 KiB
TypeScript
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 bar of three rows needs a tile to itself. */
|
|
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.
|
|
*
|
|
* Widgets arrive as a page rather than all at once, so "drawn" means settled:
|
|
* the first frame exists a moment before the last one has faded in, and a
|
|
* screenshot taken between the two shows half a dashboard.
|
|
*/
|
|
async function openPanel(page: Page, name = dashboardName) {
|
|
await page.goto(`/view/${name}`)
|
|
await page.waitForSelector("[data-testid=widget-frame]", { timeout: 15000 })
|
|
await page.waitForFunction(
|
|
() =>
|
|
[...document.querySelectorAll(".widget-cell")].every(
|
|
(cell) => Number(getComputedStyle(cell).opacity) > 0.99,
|
|
),
|
|
undefined,
|
|
{ timeout: 15000 },
|
|
)
|
|
}
|
|
|
|
test("a bar draws a row per reading", async ({ page }) => {
|
|
await openPanel(page)
|
|
|
|
// The panel's bar is stored the way a bar was written before it had rows —
|
|
// one reading with a second nested in it — so drawing two rows is also what
|
|
// proves that shape is still read.
|
|
const rows = page.getByTestId("bar-row")
|
|
await expect(rows).toHaveCount(2)
|
|
|
|
const fills = page.getByTestId("bar-fill")
|
|
const outer = await fills.nth(0).boundingBox()
|
|
const inner = await fills.nth(1).boundingBox()
|
|
expect(outer).not.toBeNull()
|
|
expect(inner).not.toBeNull()
|
|
expect(
|
|
inner!.width,
|
|
"the smaller reading is not drawn as the shorter bar",
|
|
).toBeLessThan(outer!.width)
|
|
|
|
// Told apart by colour rather than by nesting, which is what lets a row
|
|
// carry a scale of its own.
|
|
const paint = (index: number) =>
|
|
fills.nth(index).evaluate((el) => getComputedStyle(el).backgroundColor)
|
|
expect(await paint(0), "two rows are drawn in the same colour").not.toEqual(
|
|
await paint(1),
|
|
)
|
|
})
|
|
|
|
test("a reading over its scale fills the track and no more", async ({
|
|
page,
|
|
}) => {
|
|
await openPanel(page)
|
|
|
|
await publish(page, w("pv"), 120)
|
|
await expect(page.getByText(/120\.00? kW/)).toBeVisible()
|
|
|
|
const fill = await page.getByTestId("bar-fill").nth(1).boundingBox()
|
|
const track = await page
|
|
.getByTestId("bar-row")
|
|
.nth(1)
|
|
.locator(".dui-bar-track")
|
|
.boundingBox()
|
|
expect(
|
|
fill!.width,
|
|
"a value over the scale runs off the end of its track",
|
|
).toBeLessThanOrEqual(track!.width + 1)
|
|
|
|
await publish(page, w("pv"), 30)
|
|
})
|
|
|
|
test("rows are drawn in the order they were configured", async ({ page }) => {
|
|
await openPanel(page, stackName)
|
|
|
|
const rows = page.getByTestId("bar-row")
|
|
await expect(rows).toHaveCount(3)
|
|
const first = await rows.nth(0).boundingBox()
|
|
const second = await rows.nth(1).boundingBox()
|
|
expect(second!.y, "the second row is not below the first").toBeGreaterThan(
|
|
first!.y,
|
|
)
|
|
|
|
// Each row names what it reads, so a stack of three is legible without the
|
|
// widget's title having to list them.
|
|
await expect(rows.nth(1)).toContainText(/pv/i)
|
|
await expect(rows.nth(2)).toContainText(/grid/i)
|
|
})
|
|
|
|
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")
|
|
})
|
|
|
|
/**
|
|
* The cursor has to land under the pointer.
|
|
*
|
|
* A panel is drawn at its own pixel size and CSS-scaled to fit the screen it
|
|
* landed on, while uPlot maps the pointer against its own unscaled plot width
|
|
* — so without a correction the cursor lags further behind the further into
|
|
* the chart it is. The cursor line's box is in screen pixels, which is the
|
|
* same space the mouse was moved in, so the two are directly comparable.
|
|
*
|
|
* The readings are published with the panel already open: nothing keeps a ring
|
|
* for this tile, so the live tail is what puts a line on it.
|
|
*/
|
|
test("a chart's cursor follows the pointer", async ({ page }) => {
|
|
await openPanel(page)
|
|
// The socket carries the tail, so it has to be listening first.
|
|
await page.waitForTimeout(1000)
|
|
for (const value of [40, 60, 50, 70]) {
|
|
await publish(page, w("level"), value)
|
|
await page.waitForTimeout(250)
|
|
}
|
|
|
|
const chart = page.getByTestId("widget-frame").filter({ hasText: "Trend" })
|
|
const over = chart.locator(".u-over")
|
|
await over.waitFor({ timeout: 15000 })
|
|
const box = (await over.boundingBox()) as {
|
|
x: number
|
|
y: number
|
|
width: number
|
|
height: number
|
|
}
|
|
|
|
// Worth asserting only while the panel really is scaled, which is what the
|
|
// correction is for.
|
|
const drawnAt = await over.evaluate(
|
|
(el) => el.getBoundingClientRect().width / el.clientWidth,
|
|
)
|
|
expect(drawnAt, "a panel is scaled to fit the screen").toBeLessThan(0.95)
|
|
|
|
// Well inside: uPlot snaps the last pixel at either edge to the edge itself.
|
|
const x = box.x + box.width * 0.6
|
|
await page.mouse.move(x, box.y + box.height / 2)
|
|
|
|
const cursor = chart.locator(".u-cursor-x")
|
|
await expect(cursor).toBeVisible()
|
|
const at = async () => ((await cursor.boundingBox()) as { x: number }).x
|
|
const line = await at()
|
|
expect(
|
|
Math.abs(line - x),
|
|
`the cursor is drawn at ${line.toFixed(1)}, the pointer is at ${x.toFixed(1)}`,
|
|
).toBeLessThan(3)
|
|
|
|
// And it stays there. The chart sets its data on every render, which makes
|
|
// uPlot recompute the cursor from the position it already holds — so a
|
|
// correction applied twice would walk the line left while nothing moved.
|
|
await publish(page, w("level"), 55)
|
|
await page.waitForTimeout(1500)
|
|
const settled = await at()
|
|
expect(
|
|
Math.abs(settled - x),
|
|
`after a redraw the cursor is at ${settled.toFixed(1)}, the pointer at ${x.toFixed(1)}`,
|
|
).toBeLessThan(3)
|
|
})
|
|
|
|
/**
|
|
* What a dashboard was told to wear, as a wall panel would be told.
|
|
*
|
|
* The page has to be on the app first: the token these calls carry is read out
|
|
* of its local storage, and `about:blank` has none to read.
|
|
*/
|
|
async function setLook(
|
|
page: Page,
|
|
name: string,
|
|
settings: Record<string, unknown>,
|
|
) {
|
|
const current = await (
|
|
await api(page, `/dashboards/${name}?draft=true`)
|
|
).json()
|
|
const next = await (
|
|
await api(page, `/dashboards/${name}`, {
|
|
method: "PUT",
|
|
data: { ...current, settings: { ...current.settings, ...settings } },
|
|
})
|
|
).json()
|
|
await api(page, `/dashboards/${name}/publish`, {
|
|
method: "POST",
|
|
data: { version: next.version },
|
|
})
|
|
}
|
|
|
|
// Both looks, in both themes. A look is mostly colour and depth, so the four
|
|
// screenshots are the assertion that neither collapses — and the checks around
|
|
// them are that a look changes how the panel is drawn and nothing else.
|
|
for (const scheme of ["light", "dark"] as const) {
|
|
test.describe(`${scheme} theme`, () => {
|
|
test.use({ colorScheme: scheme })
|
|
|
|
for (const look of ["material", "glass"] as const) {
|
|
test(`the panel reads in ${scheme} ${look}`, async ({ page }) => {
|
|
await openPanel(page)
|
|
await setLook(page, dashboardName, { look: { value: look } })
|
|
await openPanel(page)
|
|
|
|
await expect(page.getByTestId("canvas-surface")).toHaveAttribute(
|
|
"data-look",
|
|
look,
|
|
)
|
|
// Every widget still draws, whichever set is asked to draw it.
|
|
await expect(page.getByTestId("widget-frame")).toHaveCount(TILES)
|
|
await expect(page.getByTestId("bar-row")).toHaveCount(2)
|
|
|
|
await page.screenshot({
|
|
path: `screenshots/widgets/${scheme}-${look}.png`,
|
|
})
|
|
})
|
|
}
|
|
|
|
test(`a palette recolours the panel in ${scheme}`, async ({ page }) => {
|
|
await openPanel(page)
|
|
await setLook(page, dashboardName, {
|
|
look: { value: "glass" },
|
|
// Ground, surface, primary, accent — the roles, in order.
|
|
palette: {
|
|
value: ["#264653", "#2a9d8f", "#e9c46a", "#f4a261"],
|
|
},
|
|
})
|
|
await openPanel(page)
|
|
|
|
const surface = page.getByTestId("canvas-surface")
|
|
// The ground decides light or dark, so a dark palette reads dark
|
|
// whatever the device asked for.
|
|
await expect(surface).toHaveClass(/dark/)
|
|
await expect(surface).toHaveAttribute("data-palette", "")
|
|
await expect(page.getByTestId("canvas-ground")).toBeVisible()
|
|
|
|
const primary = await page
|
|
.getByTestId("bar-fill")
|
|
.first()
|
|
.evaluate((el) => getComputedStyle(el).backgroundColor)
|
|
expect(
|
|
primary,
|
|
"the first row is not drawn in the palette's primary",
|
|
).toBe("rgb(233, 196, 106)")
|
|
|
|
await page.screenshot({
|
|
path: `screenshots/widgets/${scheme}-palette.png`,
|
|
})
|
|
await setLook(page, dashboardName, {
|
|
look: { value: "material" },
|
|
palette: { value: [] },
|
|
})
|
|
})
|
|
})
|
|
}
|