import { test, expect, type Locator, type Page } from "@playwright/test"; import { resetData, writeChannelConfig, writeSite } from "./helpers"; // THE RACK'S LAYER LADDER, asserted by hit-testing rather than by reading // classes: `document.elementFromPoint` answers "what would a click here land // on", which is the operator's complaint ("rows scroll OVER the group // controls") stated as a test. // // The order is popover > thead > group header > identity cells // (app/channels/components/rackLayout.ts). The regression this pins down was a // TIE: the pinned Slug cell and the group header both at z-20, broken by DOM // order — so scrolled down AND right, the slug band of every later row painted // over the group header and swallowed clicks on its station buttons. // Enough channels that the default group's rows outgrow the 1280x720 region, // so it scrolls vertically; the sixteen columns already make it scroll across. const EXTRA = Array.from( { length: 16 }, (_, i) => `rack-${String(i + 1).padStart(2, "0")}`, ); // Seeded, loaded and HYDRATED: the stations are disabled until mount, and the // measured --thead-h / --rack-w and the popover's onToggle exist only after it. async function open(page: Page) { await seed(); await page.goto("/channels?site=alpha"); await expect(page.getByLabel("sync group News")).toBeEnabled(); } async function seed() { await resetData("two-slow-channels"); for (const slug of EXTRA) await writeChannelConfig(slug); await writeSite("alpha", { siteTitle: "Alpha", groups: [ { id: "default", name: "All channels", selectedByDefault: true, order: 2 }, { id: "news", name: "News", selectedByDefault: false, order: 1 }, ], channels: [ { slug: "slow-b", groupId: "news" }, { slug: "slow-a" }, ...EXTRA.map((slug) => ({ slug })), ], }); } // Does a hit-test at (x, y) land inside `target`? async function hitsInside( page: Page, target: Locator, x: number, y: number, ): Promise { const handle = await target.elementHandle(); return page.evaluate( ([el, px, py]) => { const hit = document.elementFromPoint(px as number, py as number); return !!hit && (el as Element).contains(hit); }, [handle, x, y] as const, ); } test.use({ viewport: { width: 1280, height: 720 } }); test("scrolled to the bottom-right, the pinned group header paints over the pinned identity cells", async ({ page, }) => { await open(page); const region = page.getByTestId("channels-rack"); await expect(region).toBeVisible(); await expect(page.getByRole("link", { name: "rack-16" })).toBeVisible(); // The premise: the region really scrolls on both axes, or nothing below // tests anything. const extent = await region.evaluate((el) => ({ y: el.scrollHeight - el.clientHeight, x: el.scrollWidth - el.clientWidth, })); expect(extent.y).toBeGreaterThan(0); expect(extent.x).toBeGreaterThan(0); await region.evaluate((el) => { el.scrollTop = el.scrollHeight; el.scrollLeft = el.scrollWidth; }); const regionBox = (await region.boundingBox())!; const header = page.getByRole("rowheader", { name: "All channels" }); const headerBox = (await header.boundingBox())!; // The header pinned under the thead, inside the region's viewport. expect(headerBox.y).toBeGreaterThan(regionBox.y); expect(headerBox.y + headerBox.height).toBeLessThan( regionBox.y + regionBox.height, ); // (1) Inside the pinned Slug band (it starts at left-8 = 32px of the // region) and on the header's line: the header owns that point. The header // itself scrolled left with the table, so its own box.x is off to the left — // the band is measured from the region, which is what stays put. const x = regionBox.x + 40; const y = headerBox.y + headerBox.height / 2; expect(await hitsInside(page, header, x, y)).toBe(true); // (2) The same fact as numbers: the header's z sits strictly above the slug // cell's. A tie is what broke it. const slugCell = page.locator("td", { has: page.getByRole("link", { name: "rack-16", exact: true }), }); const zOf = (l: Locator) => l.evaluate((el) => Number(getComputedStyle(el).zIndex) || 0); expect(await zOf(header)).toBeGreaterThan(await zOf(slugCell)); // (2b) The group's controls stayed on screen: the header's content pins to // the region's left edge, so scrolled all the way across, its Sync station // is inside the region and a click on it lands on it. const sync = page.getByLabel("sync group All channels"); const syncBox = (await sync.boundingBox())!; expect(syncBox.x).toBeGreaterThanOrEqual(regionBox.x); expect(syncBox.x + syncBox.width).toBeLessThanOrEqual( regionBox.x + regionBox.width, ); expect( await hitsInside( page, sync, syncBox.x + syncBox.width / 2, syncBox.y + syncBox.height / 2, ), ).toBe(true); // (3) The column header still owns its own centre over everything scrolling // under it. const slugTh = page.locator("th", { has: page.getByRole("button", { name: "sort by Slug" }), }); const thBox = (await slugTh.boundingBox())!; expect( await hitsInside( page, slugTh, thBox.x + thBox.width / 2, thBox.y + thBox.height / 2, ), ).toBe(true); }); test("the Advanced priority popover paints over the rows, pins and headers around it", async ({ page, }) => { await open(page); // A row with rows under it: the panel overlays them, and their pinned // identity cells, which is the overlap the ladder orders. await page.getByLabel("advanced priority for rack-03").click(); const popover = page .getByLabel("advanced priority for rack-03") .locator("xpath=following-sibling::div[1]"); await expect(popover).toBeVisible(); const box = (await popover.boundingBox())!; expect( await hitsInside(page, popover, box.x + box.width / 2, box.y + box.height / 2), ).toBe(true); // And its bottom edge, which overlays the rows that follow it. expect( await hitsInside(page, popover, box.x + box.width / 2, box.y + box.height - 4), ).toBe(true); }); test("an Advanced popover opened on the last row scrolls itself into view", async ({ page, }) => { await open(page); const region = page.getByTestId("channels-rack"); // slow-a sorts last: its panel opens past the region's bottom edge. await page.getByLabel("advanced priority for slow-a").click(); const popover = page .getByLabel("advanced priority for slow-a") .locator("xpath=following-sibling::div[1]"); await expect(popover).toBeVisible(); await expect .poll(async () => { const r = (await region.boundingBox())!; const p = (await popover.boundingBox())!; return p.y + p.height <= r.y + r.height + 1; }) .toBe(true); const box = (await popover.boundingBox())!; expect( await hitsInside(page, popover, box.x + box.width / 2, box.y + box.height - 4), ).toBe(true); });