Files
app/frontend/tests/panel.spec.ts
T
stroblmeandClaude Opus 5 518231aa39
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 1m9s
Playwright Tests / test-playwright (2, 2) (push) Failing after 11s
pre-commit / pre-commit (push) Failing after 1m59s
Test Backend / test-backend (push) Failing after 2m28s
Compose Smoke Test / test-compose (push) Failing after 11s
Playwright Tests / merge-reports (push) Failing after 2m19s
Touch is a panel setting, and the rail grows with it
It described the wrong object. A dashboard is a document that may hang on a
hallway tablet and in a desk browser at the same time, and only one of those
has fingers on it — so the flag moves off `DashboardDef.settings` and onto
`PanelDef` as a plain bool, ticked in the Panels dialog. `useCanvasRoot` takes
it as an argument rather than reading the document, and `/panel/{id}` is the
only surface with a panel to ask.

Dropping the message binding with it is deliberate: nothing drove it, and a
flow deciding whether a screen has fingers on it was never the point. A stored
`settings.touch` is inert rather than migrated, which `_check_settings`
skipping unknown names already guaranteed.

The rail was the other half. It had no touch behaviour at all and its 40px
buttons met neither branch of the 44/32 rule. `[data-touch] .dui-rail{-item}`
in `ui/core/core.css` spends the padding and the gap on the buttons instead,
so they reach the 44px target and the rail comes out taller at exactly the
same width — `RAIL_INSET` never moves, and the arrangement under it does not
either.

Also closes the panels-dialog icon gap: `DashboardSummary` carries the `icon`
now, so the dialog draws each assigned dashboard's rail glyph beside its
checkbox. `initials()` went from three identical copies in the looks to one in
`Dashboard/icons.ts`, so the dialog and the rail fall back the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va7ExQDtuwKN7kNpHhWWNQ
2026-08-31 19:04:20 +02:00

256 lines
8.3 KiB
TypeScript

import { expect, type Page, test } from "@playwright/test"
import type { PanelDef } from "../src/client"
import { api, apiPage, deleteAll } from "./utils/api"
/**
* A panel carrying more than one dashboard draws the way between them.
*
* The rail is part of what the screen shows rather than chrome beside it: it
* is inside the canvas, scaled with it, and wearing the dashboard's own look.
* Drawn outside, it was a strip of the app's design bolted to the edge of
* somebody's wall panel — and on a scaled canvas it did not even line up.
*
* It is also as tall as what it carries. Stretched end to end, a panel with
* two dashboards showed a pill nine tenths empty.
*/
const flowName = `test_panel_${Date.now().toString(36)}`
const first = `${flowName}_a`
const second = `${flowName}_b`
/** The instance's own panels, put back by the teardown. */
let panels: PanelDef[] | null = null
test.use({ storageState: "playwright/.auth/user.json" })
test.describe.configure({ mode: "serial" })
test.beforeAll(async ({ browser }) => {
const page = await apiPage(browser)
await api(page, `/flows/${flowName}`, {
method: "PUT",
data: {
name: flowName,
title: "Panel",
version: 1,
nodes: [
{
id: "emit",
type: "python",
provides: [
{ name: "level", dtype: "float" },
{ name: "mode", dtype: "str" },
],
},
],
},
})
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 api(page, `/messages/${flowName}.level`, {
method: "POST",
data: { value: 4 },
})
for (const name of [first, second]) {
await api(page, `/dashboards/${name}`, { method: "POST" })
const doc = await (await api(page, `/dashboards/${name}?draft=true`)).json()
doc.settings = { look: { value: "glass" } }
doc.widgets = [
{
id: "stat",
type: "stat",
title: "Level",
layout: { lg: { x: 0, y: 0, w: 3, h: 2 } },
config: { message: `${flowName}.level`, dtype: "float" },
},
// Something with a hit target on it, so the touch test can measure a
// control rather than only the rail.
{
id: "mode",
type: "dropdown",
title: "Mode",
layout: { lg: { x: 3, y: 0, w: 4, h: 2 } },
config: {
target: `${flowName}.mode`,
dtype: "str",
style: "segmented",
options: [
{ label: "Eco", value: "eco" },
{ label: "Boost", value: "boost" },
],
},
},
]
const put = await (
await api(page, `/dashboards/${name}`, { method: "PUT", data: doc })
).json()
await api(page, `/dashboards/${name}/publish`, {
method: "POST",
data: { version: put.version },
})
}
// Saving is a whole-list replace, and a panel that disappears takes the
// credential of the screen hanging on it. So append to what is there, and
// keep the list for the teardown to put back.
const config = await (await api(page, "/panels/")).json()
const existing: PanelDef[] = (config.panels ?? []).filter(
(p: PanelDef) => p.id !== flowName,
)
panels = existing
await api(page, "/panels/", {
method: "PUT",
data: {
panels: [
...existing,
{ id: flowName, title: "Hall", dashboards: [first, second] },
],
},
})
await page.close()
})
test.afterAll(async ({ browser }) => {
// Restoring the list is also what removes this spec's own panel.
if (panels) {
const page = await apiPage(browser)
await api(page, "/panels/", { method: "PUT", data: { panels } })
await page.close()
}
await deleteAll(browser, [
`/dashboards/${first}`,
`/dashboards/${second}`,
`/flows/${flowName}`,
])
})
test("the rail is drawn on the panel, in the panel's own look", async ({
page,
}) => {
await page.goto(`/panel/${flowName}?d=${first}`)
const rail = page.getByTestId("panel-rail")
await rail.waitFor({ timeout: 20000 })
const canvas = (await page.getByTestId("canvas-surface").boundingBox())!
const box = (await rail.boundingBox())!
expect(box.x, "the rail starts left of the panel").toBeGreaterThanOrEqual(
canvas.x - 1,
)
expect(box.y, "the rail starts above the panel").toBeGreaterThanOrEqual(
canvas.y - 1,
)
expect(
box.x + box.width,
"the rail runs off the right of the panel",
).toBeLessThanOrEqual(canvas.x + canvas.width + 1)
expect(
box.y + box.height,
"the rail runs off the bottom of the panel",
).toBeLessThanOrEqual(canvas.y + canvas.height + 1)
// Inside the canvas is also what makes it wear the look: the attributes and
// the palette are stated on the canvas, and inheritance does the rest.
const inside = await rail.evaluate(
(el) => el.closest("[data-testid=canvas-surface]") !== null,
)
expect(inside, "the rail is drawn outside the panel it belongs to").toBe(true)
// As tall as what it carries rather than as tall as the panel: a rail of
// two stretched end to end is mostly empty pill.
expect(
box.height,
"the rail is stretched to the height of the panel",
).toBeLessThan(canvas.height / 2)
const centres = Math.abs(
box.y + box.height / 2 - (canvas.y + canvas.height / 2),
)
expect(centres, "the rail is not centred in its column").toBeLessThan(2)
// And the arrangement keeps clear of it rather than sitting under it.
const tile = (await page.getByTestId("widget-frame").first().boundingBox())!
expect(tile.x, "a widget is drawn under the rail").toBeGreaterThanOrEqual(
box.x + box.width,
)
})
test("a touched panel grows its rail without widening it", async ({ page }) => {
const open = async () => {
await page.goto(`/panel/${flowName}?d=${first}`)
await page.getByTestId("panel-rail").waitFor({ timeout: 20000 })
}
const sizes = async () => ({
rail: (await page.getByTestId("panel-rail").boundingBox())!,
item: (await page.getByTestId(`panel-rail-${first}`).boundingBox())!,
segment: (await page
.getByTestId("widget-frame")
.filter({ hasText: "Mode" })
.getByRole("button")
.first()
.boundingBox())!,
})
await open()
const pointed = await sizes()
await setTouch(page, true)
await open()
await expect(page.getByTestId("canvas-surface")).toHaveAttribute(
"data-touch",
"",
)
const touched = await sizes()
// The whole point: the column the rail lives in is reserved by RAIL_INSET
// either way, so a wider rail would move the arrangement under it.
expect(
Math.abs(touched.rail.width - pointed.rail.width),
`the rail is ${touched.rail.width.toFixed(1)} touched and ${pointed.rail.width.toFixed(1)} pointed at`,
).toBeLessThan(1)
expect(touched.rail.height, "the rail did not grow taller").toBeGreaterThan(
pointed.rail.height,
)
expect(
touched.item.height,
`a rail button is ${touched.item.height.toFixed(1)} touched and ${pointed.item.height.toFixed(1)} pointed at`,
).toBeGreaterThan(pointed.item.height)
// The widgets are told by the same flag, which used to be the dashboard's.
expect(
touched.segment.height,
"a widget control did not grow with the panel",
).toBeGreaterThan(pointed.segment.height)
// And the arrangement still keeps clear of it.
const tile = (await page.getByTestId("widget-frame").first().boundingBox())!
expect(
tile.x,
"a widget is drawn under the touched rail",
).toBeGreaterThanOrEqual(touched.rail.x + touched.rail.width)
await setTouch(page, false)
})
/** What the Panels dialog writes, as the API sees it. */
async function setTouch(page: Page, touch: boolean) {
const config = await (await api(page, "/panels/")).json()
await api(page, "/panels/", {
method: "PUT",
data: {
panels: (config.panels ?? []).map((panel: PanelDef) =>
panel.id === flowName ? { ...panel, touch } : panel,
),
},
})
}
test("the rail switches the panel between its dashboards", async ({ page }) => {
await page.goto(`/panel/${flowName}?d=${first}`)
await page.getByTestId(`panel-rail-${second}`).click()
await expect(page).toHaveURL(new RegExp(`d=${second}`))
await expect(page.getByTestId(`panel-rail-${second}`)).toHaveAttribute(
"aria-current",
"page",
)
})