import { composeStories } from "@storybook/react" import { expect, userEvent, waitFor } from "storybook/test" import { test } from "vitest" import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts" import { mountStory } from "../mountStory.testHelpers.ts" import { expectAgentDrivable } from "../testing/index.ts" import * as stories from "./PortraitTiles.stories.tsx" const { AllStates, AllVariants, DataColours, Playground, Interactive, NamedHues, Responsive, WithPictures, } = composeStories(stories) test("it is a named group of pressable portraits", async () => { const { canvas, canvasElement } = await mountStory(Playground) const group = expectAgentDrivable(canvas, { name: "Who's shopping?", role: "group", }) await expect( group.querySelectorAll("button"), ).toHaveLength(4) // Nothing here is selected, and nothing claims to be — the same // call `ActionTiles` makes. A picker that keeps a value is // `RadioGroup itemShape="tile"`. await expect( group.querySelector( "[aria-checked], [aria-pressed], [role='radio']", ), ).toBeNull() await expectNoAxeViolations(canvasElement) }) /** * The number and its unit are both inside the tile, so both are * computed into the accessible name. "1,240" on its own tells a * screen-reader user nothing at all, and "Avery" alone loses the * thing the portrait was being chosen by. */ test("the name, the number and its unit are one accessible name", async () => { const { canvas } = await mountStory(Playground) expectAgentDrivable(canvas, { name: "Avery 1,240 points", role: "button", }) }) /** * The avatar is decoration: the name it sits beside is inside the * same tile, so an announced picture is the label read twice. This * holds for the initials fallback and for a real ``, which is * why both are checked. */ test("the face stays out of the accessible name", async () => { const { canvas, canvasElement } = await mountStory(WithPictures) const group = expectAgentDrivable(canvas, { name: "Who's shopping?", role: "group", }) for (const image of group.querySelectorAll("img")) { await expect(image).toHaveAttribute("alt", "") } // Named by its words alone. An initial that joined the name would // read "A Avery 1,240 points". expectAgentDrivable(canvas, { name: "Casey 2,015 points", role: "button", }) await expectNoAxeViolations(canvasElement) }) /** * A 404 is the case this shape cannot absorb: a torn hole where a * face was, beside three that loaded. `onError` is the only signal a * browser gives, and the fallback is the one the caller already * supplied for "no picture". * * The story points a portrait at a file that does not exist, so this * is the real failure rather than a mocked one. */ test("a picture that fails to load falls back to the initials", async () => { const { canvas } = await mountStory(WithPictures) const group = expectAgentDrivable(canvas, { name: "Who's shopping?", role: "group", }) // A real 404 over the network, not a mocked error event, so it gets // longer than `waitFor`'s 1 s default: with four browser windows // sharing a CI runner the failure arrived after it. await waitFor( async () => { const broken = Array.from( group.querySelectorAll("img"), ).find((one) => one.src.includes("charcuterie-missing-portrait"), ) // Gone from the DOM entirely, replaced by the letter — not left // in place with a broken-image glyph in it. await expect(broken).toBeUndefined() }, { timeout: 5000 }, ) const bailey = expectAgentDrivable(canvas, { name: "Bailey 860 points", role: "button", }) await expect(bailey.textContent).toContain("B") }) /** * `layout="auto"` reads the container and never the window. Every * panel in the board is the same window, so a difference between * them can only have come from the container. */ test("the arrangement comes from the container, not the window", async () => { const { canvas } = await mountStory(Responsive) const getTileDirection = (name: string) => { const tile = expectAgentDrivable(canvas, { name, role: "group", }).querySelector("button") if (!tile) { throw new Error(`${name} drew no portrait`) } return getComputedStyle(tile).flexDirection } // Below `cq-sm` there is no room to stack: a face beside a name is // what fits in one narrow column. await expect( getTileDirection("Who's shopping at 15rem"), ).toBe("row") await expect( getTileDirection("Who's shopping at 34rem"), ).toBe("column") }) test("a fixed layout skips the query", async () => { const { canvas, canvasElement } = await mountStory(AllVariants) const getTileDirection = (name: string) => { const tile = expectAgentDrivable(canvas, { name, role: "group", }).querySelector("button") if (!tile) { throw new Error(`${name} drew no portrait`) } return getComputedStyle(tile).flexDirection } // Both cells are the same width. Only the prop differs, which is // the point of having one. await expect( getTileDirection("Who's shopping, as rows"), ).toBe("row") await expect( getTileDirection("Who's shopping, as columns"), ).toBe("column") await expectNoAxeViolations(canvasElement) }) /** * The hue has to reach the paint, and the assertion is a computed * colour rather than a class name. A `--color-categorical-*` that * had never existed resolves to nothing, paints transparent, and * satisfies every check that the element rendered — which is exactly * how a token name that did not exist shipped in Docket. */ test("each portrait wears a different hue, and a named one holds it", async () => { const { canvas } = await mountStory(Playground) const faces = Array.from( expectAgentDrivable(canvas, { name: "Who's shopping?", role: "group", }).querySelectorAll( "button > *:first-child", ), ).map((face) => getComputedStyle(face).backgroundColor) await expect(faces).toHaveLength(4) for (const face of faces) { await expect(face).not.toBe("rgba(0, 0, 0, 0)") } await expect(new Set(faces).size).toBe(4) const { canvas: pinnedCanvas } = await mountStory(NamedHues) const pinned = Array.from( expectAgentDrivable(pinnedCanvas, { name: "Who's shopping, with pinned colours", role: "group", }).querySelectorAll( "button > *:first-child", ), ).map((face) => getComputedStyle(face).backgroundColor) // Avery is position 0 in both sets. Naming `categorical: 3` has to // change its colour, or the prop does nothing and every assertion // above still passes. await expect(pinned[0]).not.toBe(faces[0]) }) /** * The second colour arm, which no token covers: the hue arrives from * data, lands in an inline custom property, and three separate * classes read it back out. Every step of that is invisible to * typecheck, to lint and to axe — the CSS either generated or it did * not, and a portrait with no fill looks like a picture that failed * to load. */ test("a data colour reaches the fill exactly, and the initials flip to suit it", async () => { const { canvas, canvasElement } = await mountStory(DataColours) const faces = Array.from( expectAgentDrivable(canvas, { name: "Who's shopping, coloured by their cards", role: "group", }).querySelectorAll( "button > *:first-child", ), ) await expect(faces).toHaveLength(3) // Exactly the stated colour. A palette hue would be some other // triplet entirely, which is the whole point of the arm. await expect( faces.map( (face) => getComputedStyle(face).backgroundColor, ), ).toEqual([ "rgb(143, 211, 244)", "rgb(217, 210, 197)", "rgb(59, 74, 107)", ]) // The first two fills are pale and the third is dark, so the // letters have to go the other way on the third. Nothing in the // toolchain would have said otherwise. await expect( faces.map((face) => getComputedStyle(face).color), ).toEqual([ "rgb(0, 0, 0)", "rgb(0, 0, 0)", "rgb(255, 255, 255)", ]) await expectNoAxeViolations(canvasElement) }) /** * The number is the hue mixed toward the scheme's own text colour, * not the hue itself — a `#C0C4CC` number on a near-white surface is * the readability failure this arm gives up the palette's guarantee * to allow. */ test("the number is the data colour pulled toward readable, not the raw colour", async () => { const { canvas } = await mountStory(DataColours) const bailey = Array.from( expectAgentDrivable(canvas, { name: "Who's shopping, coloured by their cards", role: "group", }).querySelectorAll("button"), ).find((one) => one.textContent?.includes("Bailey")) const stat = Array.from( bailey?.querySelectorAll("span") ?? [], ).find((one) => one.textContent === "860") await expect(stat).toBeDefined() const statColour = getComputedStyle( stat as HTMLElement, ).color await expect(statColour).not.toBe("rgb(217, 210, 197)") await expect(statColour).not.toBe("rgba(0, 0, 0, 0)") }) test("every portrait is its own tab stop, and Enter and Space press it", async () => { const { canvas } = await mountStory(Interactive) const tiles = Array.from( expectAgentDrivable(canvas, { name: "Who's shopping?", role: "group", }).querySelectorAll("button"), ) await expect( tiles.filter((one) => one.tabIndex === 0), ).toHaveLength(2) tiles[0]?.focus() await userEvent.tab() await waitFor(() => { expect(tiles[1]).toHaveFocus() }) await userEvent.keyboard("{Enter}") await userEvent.keyboard(" ") }) test("a disabled portrait cannot be pressed", async () => { const { canvas, canvasElement } = await mountStory(AllStates) const bailey = Array.from( expectAgentDrivable(canvas, { name: "Who's shopping, with Bailey unavailable", role: "group", }).querySelectorAll("button"), ).find((one) => one.textContent?.includes("Bailey")) await expect(bailey).toBeDisabled() await expectNoAxeViolations(canvasElement) }) test("an href portrait is a real anchor", async () => { const { canvas, canvasElement } = await mountStory(AllStates) const avery = Array.from( expectAgentDrivable(canvas, { name: "Who's shopping, as links", role: "group", }).querySelectorAll("a"), ).find((one) => one.textContent?.includes("Avery")) // A real ``, so middle-click, ctrl-click, "open in a new // tab" and "copy link address" come from the element rather than // from the paint. await expect(avery).toHaveAttribute("href", "/avery") await expectNoAxeViolations(canvasElement) }) /** * The box is shared with the other tile shapes through * `tileStyles.ts`. Two tile sets on one page have to be the same * card, and "identical" spelled as two copies of one string is a * promise that survives exactly one edit. */ test("a portrait is the same box as an action tile", async () => { const { canvas } = await mountStory(Playground) const portrait = expectAgentDrivable(canvas, { name: "Avery 1,240 points", role: "button", }) const style = getComputedStyle(portrait) await expect(style.borderTopWidth).toBe("1px") await expect(style.borderTopLeftRadius).not.toBe("0px") await expect( Number.parseFloat(style.paddingTop), ).toBeGreaterThan(0) }) test("a bounded set keeps three portraits beside each other without an empty fourth track", async () => { const { BoundedSet } = composeStories(stories) const { canvasElement } = await mountStory(BoundedSet) const buttons = [ ...canvasElement.querySelectorAll("button"), ] await waitFor(() => expect(buttons).toHaveLength(3)) const rectangles = buttons.map((button) => button.getBoundingClientRect(), ) await expect(rectangles[0]?.top).toBe(rectangles[2]?.top) await expect(rectangles[0]?.width).toBeGreaterThan(240) }) test("a wrapping bounded set centers its final row and responds to container resizing", async () => { const { CenteredLastRow } = composeStories(stories) const { canvas, canvasElement } = await mountStory(CenteredLastRow) const group = expectAgentDrivable(canvas, { name: "People", role: "group", }) const tiles = Array.from(group.querySelectorAll("button")) const wrapper = group.parentElement?.parentElement if (!wrapper) throw new Error("Missing story wrapper") for (const width of [780, 540, 1100, 200, 780]) { wrapper.style.width = `${width}px` await waitFor(() => { const bounds = group.getBoundingClientRect() const rects = tiles.map((tile) => tile.getBoundingClientRect(), ) const lastTop = rects.at(-1)?.top const last = rects.filter( (rect) => rect.top === lastTop, ) expect( Math.abs( ((last[0]?.left ?? 0) + (last.at(-1)?.right ?? 0)) / 2 - (bounds.left + bounds.right) / 2, ), ).toBeLessThan(1) expect( Math.abs( (rects[0]?.width ?? 0) - (rects.at(-1)?.width ?? 0), ), ).toBeLessThan(1) expect(group.scrollWidth).toBeLessThanOrEqual( group.clientWidth, ) }) } await expectNoAxeViolations(canvasElement) })