- A tile no longer lifts under the pointer. A finger does not move away afterwards the way a cursor does, so whatever hover raised stayed raised until something else was touched: a tile stuck, not answering. - The ground's blobs wander a closed path on their own clock instead of sliding back and forth along one line, which read as things moving rather than as light in a room. - A bar is one grid now, its rows borrowing its columns, so names of different lengths no longer start and end their tracks in different places — two bars that share no baseline cannot be compared, which is the one thing a stack of them is for. The names read rightward into their tracks, with room either side. - A reading on its way somewhere is written to as many decimals as the value it is heading for. Without that a slider stepping in halves passed through 22.37460937 on its way to 24: a number nobody asked for, a different width every frame. - The brightness column is the same control as the slider widget's, stood on its end and thicker, and exactly as tall as the disc beside it. Getting there meant drawing a slider's rail, fill and handle rather than styling `::-webkit-slider-*`: those need one set of rules per orientation, each with its own centring quirk, and the handle landed off its track when the writing mode turned. The native input stays, laid transparent over the top, so the keyboard, the pointer and every `aria-` are still its.
669 lines
22 KiB
TypeScript
669 lines
22 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 = 9
|
|
|
|
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" },
|
|
{ name: "setpoint", dtype: "float" },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
// 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("setpoint"), 21.5)
|
|
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()
|
|
// Two rows taller than the default panel: the tiles below already fill it,
|
|
// and a widget past the last row is clipped rather than drawn.
|
|
dashboard.canvas_height = 1400
|
|
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: {},
|
|
},
|
|
{
|
|
// No precision configured, so what it is worth is what decides how it is
|
|
// written — including on the way there.
|
|
id: "aim",
|
|
type: "slider",
|
|
title: "Setpoint",
|
|
layout: { lg: { x: 0, y: 6, w: 4, h: 2 } },
|
|
config: {
|
|
target: w("setpoint"),
|
|
dtype: "float",
|
|
min: 16,
|
|
max: 24,
|
|
step: 0.5,
|
|
unit: "°C",
|
|
},
|
|
},
|
|
{
|
|
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("a reading is written to its own precision while it is moving", async ({
|
|
page,
|
|
}) => {
|
|
await openPanel(page)
|
|
const readout = page
|
|
.getByTestId("widget-frame")
|
|
.filter({ hasText: "Setpoint" })
|
|
.getByTestId("readout")
|
|
await expect(readout).toContainText("21.5")
|
|
|
|
// A value tweening toward 24 must pass through 22.0 and 23.5, not
|
|
// 22.37460937: a reading nobody asked for, a different width every frame.
|
|
// Started before the publish, not awaited: the frames worth looking at are
|
|
// the ones between the old reading and the new one.
|
|
const seen = page.evaluate(async () => {
|
|
const cell = [
|
|
...document.querySelectorAll("[data-testid=widget-frame]"),
|
|
].find((frame) => frame.textContent?.includes("Setpoint"))
|
|
const el = cell?.querySelector("[data-testid=readout]")
|
|
const samples: string[] = []
|
|
const until = performance.now() + 600
|
|
return new Promise<string[]>((resolve) => {
|
|
const step = () => {
|
|
samples.push(el?.textContent ?? "")
|
|
if (performance.now() < until) requestAnimationFrame(step)
|
|
else resolve(samples)
|
|
}
|
|
requestAnimationFrame(step)
|
|
})
|
|
})
|
|
await publish(page, w("setpoint"), 24)
|
|
const frames = await seen
|
|
|
|
for (const frame of frames) {
|
|
expect(frame, "a reading grew decimals on its way").toMatch(
|
|
/^-?\d+(\.\d)?\s*°C$/,
|
|
)
|
|
}
|
|
// Otherwise the frames above are all the reading standing still, and every
|
|
// one of them would pass whatever the tween was doing.
|
|
expect(
|
|
new Set(frames).size,
|
|
"the reading never moved, so nothing was watched",
|
|
).toBeGreaterThan(1)
|
|
await publish(page, w("setpoint"), 21.5)
|
|
})
|
|
|
|
test("a widget is drawn inside its tile rather than scrolled", async ({
|
|
page,
|
|
}) => {
|
|
await openPanel(page)
|
|
|
|
// A picture that overflows its tile is a picture nobody can see the rest of.
|
|
// Only the widgets that are text or a list may scroll, and this panel holds
|
|
// none of them — so nothing here should.
|
|
const overflowing = await page.evaluate(() =>
|
|
[...document.querySelectorAll(".dui-frame-body")]
|
|
.filter((body) => body.scrollHeight > body.clientHeight + 1)
|
|
.map((body) => body.closest("[data-testid=widget-frame]")?.textContent),
|
|
)
|
|
expect(overflowing, "these widgets overflow their tile").toEqual([])
|
|
})
|
|
|
|
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)
|
|
})
|
|
|
|
test("touch makes the controls bigger without changing what they do", async ({
|
|
page,
|
|
}) => {
|
|
await openPanel(page)
|
|
const control = page.getByTestId("widget-frame").filter({ hasText: "Mode" })
|
|
const pointer = (await control.getByRole("button").first().boundingBox())!
|
|
.height
|
|
|
|
await setLook(page, dashboardName, { touch: { value: true } })
|
|
await openPanel(page)
|
|
await expect(page.getByTestId("canvas-surface")).toHaveAttribute(
|
|
"data-touch",
|
|
"",
|
|
)
|
|
const touched = (await control.getByRole("button").first().boundingBox())!
|
|
.height
|
|
expect(
|
|
touched,
|
|
`a segment is ${touched.toFixed(1)} touched and ${pointer.toFixed(1)} pointed at`,
|
|
).toBeGreaterThan(pointer)
|
|
|
|
// The control is the same control: it still publishes what it always did.
|
|
await control.getByRole("button", { name: "Boost" }).click()
|
|
await expect(control.getByRole("button", { name: "Boost" })).toHaveAttribute(
|
|
"aria-pressed",
|
|
"true",
|
|
)
|
|
|
|
await setLook(page, dashboardName, { touch: { value: false } })
|
|
})
|
|
|
|
/**
|
|
* 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: [] },
|
|
})
|
|
})
|
|
})
|
|
}
|