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", ) })