# Testing

Récit is tested on four levels. Each one answers a different question, and a change is tested at the
lowest level that can see it.

| Level                  | What it checks                                              | Where                                                      | Command                       |
| ---------------------- | ----------------------------------------------------------- | ---------------------------------------------------------- | ----------------------------- |
| Unit tests             | Logic without a DOM: parsers, schemas, config, SSR          | `*.test.ts`, `*.spec.ts`, `__tests__/` next to the code    | `pnpm test:related <files…>`  |
| Stories and `play`     | A component rendered in Chromium: states, interactions, axe | `X.stories.tsx` next to `X.tsx`                            | `pnpm test:stories [files…]`  |
| Behavioural end-to-end | A built site or deck, navigated as a reader does            | `e2e/*-behaviour.spec.ts`, `e2e/site-*.spec.ts`            | `pnpm test:e2e:host <spec>`   |
| Screenshots            | What every page and every story looks like, light and dark  | `e2e/__screenshots__/…-linux.png`, in the Docker container | `pnpm test:e2e:linux [specs]` |

Everything at once, as CI runs it: `pnpm verify` (lint, build, typecheck, unit tests and stories),
then `pnpm test:e2e:linux`.

## Unit tests

Vitest runs one project per package (`vitest.config.ts` at the root lists them) plus `scripts` and
the `storybook` project. A unit test stays for what a story cannot show: a function without a DOM, a
schema, a server render through `renderToString`. A test that needs a mounted component reuses its
story with `composeStories` instead of building another harness.

```bash
pnpm test:related packages/core/src/schema/assemble.ts   # the tests that import these files
pnpm exec vitest run packages/core/src/__tests__/source-conversion.spec.ts
pnpm test                                                # every project, stories included
pnpm test:coverage                                       # with the coverage thresholds
```

The coverage thresholds sit in the root `vitest.config.ts`, one entry per package, at the level last
measured (rounded down) with `autoUpdate`: coverage can only go up.

## Stories

Every component of the framework (`packages/site`), of the slides (`packages/components`), of the
deck, of the motion blocks, of the site kit and of the sites has a story next to it, and every
interactive component a `play` function. `node scripts/check-stories.js` counts what is missing and
fails `pnpm check` on the first finding. The workshop lives in `sites/storybook`; the title of a
story picks its decorators:

| Title                   | Source                                        | What it gets                                                 |
| ----------------------- | --------------------------------------------- | ------------------------------------------------------------ |
| `Site/UI/<Component>`   | `packages/site/src/lib/ui`                    | Mantine, the color mode                                      |
| `Site/Components/…`     | `packages/site/src/lib/components`, `plugins` | plus a site: router, query client, site context, slots, head |
| `Slides/<Component>`    | `packages/components/src`                     | a slide frame                                                |
| `Deck/<Component>`      | `packages/formats/deck/src`                   | a compiled deck                                              |
| `Motion/Blocks/<Block>` | `packages/formats/motion-blocks/src`          | a still film frame and a motion theme                        |
| `Kit/<Component>`       | `packages/tools/site-kit/src/components`      | the site, as for `Site/`                                     |

### Writing a story

```tsx title="packages/site/src/lib/components/Banner.stories.tsx"
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { Banner } from "./Banner.js";

/** One sentence: what the component is, what it does. */
const meta = {
  title: "Site/Components/Banner",
  component: Banner,
  args: { message: "Lorem ipsum dolor sit amet.", onDismiss: fn() },
  parameters: { site: { path: "/dolor" } },
} satisfies Meta<typeof Banner>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
  play: async ({ args, canvasElement }) => {
    const canvas = within(canvasElement);
    await expect(canvas.getByRole("status")).toHaveTextContent("Lorem ipsum");
    await userEvent.click(canvas.getByRole("button", { name: "Dismiss" }));
    await expect(args.onDismiss).toHaveBeenCalledOnce();
  },
};

export const Hidden: Story = { args: { message: undefined } };
```

- One file per component, one export per state, `args` complete on `meta`.
- Text in Lorem Ipsum, titles and comments in English.
- No class and no `style` in a story: lay variants out with Mantine's `Stack` or `Group`, or with
  `parameters.grid` (`"stack"` or `"row"`).
- A site story sets its route with `parameters.site.path` and overrides the fixture config with
  `parameters.site.options`.

### Writing a `play` function

A `play` function asserts what a reader sees and does, by **role and name**:

- `getByRole("button", { name: /copy/i })`, never a class, a CSS selector or a `data-testid`
  (allowed only on an element without role and text, listed in `scripts/check-stories.js`).
- After an interaction, `findBy*` or `waitFor`: the built Storybook renders without `act()`.
- A portal (dialog, menu, popover) is outside the canvas:
  `within(canvasElement.ownerDocument.body)`.
- A callback is a `fn()` of `args`, asserted with `toHaveBeenCalledWith`.
- No snapshot of HTML: the screenshot is the picture, the `play` is the behaviour.
- Name a step by the behaviour: `opens on click and closes on Escape`.

Axe runs on every story in error. A violation is fixed, or marked
`parameters: { a11y: { test: "todo" } }` with the reason on the line above.

### Running the stories

```bash
pnpm test:stories                                       # every story, in Chromium
pnpm test:stories packages/site/src/lib/ui/Button.stories.tsx  # one file
BROWSER=none pnpm -F recit-storybook dev                # the workshop, to browse
pnpm -F recit-storybook build                           # the build the screenshots read
```

A missing Chromium stops the run with the command that installs it:
`pnpm exec playwright install chromium`.

## End-to-end tests

The e2e suite serves the **built** sites, decks and Storybook on the ports of `e2e/ports.ts`: build
before running it. Pass the specs you need, and only their projects are built and served.

```bash
pnpm test:e2e:host e2e/site-behaviour.spec.ts     # behaviour on this machine, no screenshot
pnpm test:e2e:linux e2e/site-docs.spec.ts         # in Docker, screenshots compared, like CI
pnpm test:e2e:update:linux e2e/site-docs.spec.ts  # write the -linux.png references
```

The behavioural specs (`site-behaviour`, `deck-behaviour`, `deck-features`, `accessibility`)
navigate, type and click; they run anywhere. The screenshots are compared only in the Linux
container: its `-linux.png` files are the reference, and only `pnpm test:e2e:update:linux` writes
them.

The stories are screenshotted in light and dark by one spec per surface, so a change to one surface
replays only its own:

| Spec                            | Stories                                                 |
| ------------------------------- | ------------------------------------------------------- |
| `e2e/storybook-site-ui.spec.ts` | `Site/UI/`                                              |
| `e2e/storybook-site.spec.ts`    | `Site/Components/`, `Site/Navigation/`, `Site/Plugins/` |
| `e2e/storybook-slides.spec.ts`  | `Slides/`                                               |
| `e2e/storybook-deck.spec.ts`    | `Deck/`                                                 |
| `e2e/storybook-motion.spec.ts`  | `Motion/`                                               |
| `e2e/storybook-kit.spec.ts`     | `Kit/`, `Sites/`                                        |

A new story gets its two screenshots without touching a spec; its references are written in the
container at the next gate.

## Reading the gallery

After a container run, `node scripts/screenshot-gallery.js` builds
`test-results/gallery/index.html`: one row per screenshot that changed, with the reference, the new
image and their difference, a filter by spec, and an "accepted" button. Add `--serve <port>` to open
it from another machine. A change is accepted only when the page changed on purpose; the references
are then regenerated with `pnpm test:e2e:update:linux <specs>`.
