# Testing the kit

First, what a unit runner has to be told before it can load a kit module at all.
Then three kit anatomies where the accessibility tree says one thing and an
automated driver has to do another. Each of those is **by design** — the shape
that makes the component correct for a keyboard and a screen reader is the shape
that defeats a naive `click()` — so each will read as a bug the first time.

Everything here is true wherever the kit renders. The iframe that a Lotics app
runs inside adds one more rule on top — `lotics docs building_an_app` § 8.

---

## A unit runner must transform the kit, not hand it to Node

Every component module carries the literal `import "./button.css"` that delivers
its own sheet. A runner that externalizes `node_modules` gives that import to
Node's ESM loader, which has no loader for `.css`, and the suite dies before its
first assertion:

```
TypeError: Unknown file extension ".css" for …/node_modules/@lotics/ui/dist/button.css
```

It is a whole-FILE load failure, so it hides every test in the file and names a
stylesheet rather than a cause. Route the package through the transform instead
(vitest), or map the sheet to a stub (jest):

```ts
// vitest — the `test` block of vite.config.ts / vitest.config.ts
server: { deps: { inline: [/@lotics\/ui/] } },
```

```js
// jest — jest.config.js. `identity-obj-proxy` is a package: npm i -D identity-obj-proxy
moduleNameMapper: { "\\.css$": "identity-obj-proxy" },
transformIgnorePatterns: ["node_modules/(?!@lotics/ui)"],
```

Any package whose modules import CSS needs the same line. A Lotics app scaffold
ships it wired, and `lotics app codegen` warns when an app's config has lost it.

**No sheet is APPLIED under either runner** — jsdom fetches no `<link>`, and
vitest makes an inlined CSS import inert. The kit's missing-tokens check knows
it: it reads a token only once the document HAS a stylesheet, so a unit run is
silent rather than reporting its own environment once per suite. What that leaves
testable is what does not depend on paint: structure, roles, accessible names,
`data-*` state, and what an update KEEPS (below). Anything that depends on a
computed style — a width, a contrast, a focus ring — is measured in a browser,
against the probes in `reviewing.md`.

## A pressable row: click the surface, not the named button

A row that presses open **and** carries its own controls is a role-less
`PressableRow` with a `RowFocusEntry` sibling (`composition.md` § Row actions —
*never nested*). The door is what a keyboard and a screen reader use: it holds
the tab stop, the accessible name and the focus ring, and it is an **empty
absolutely-positioned element** under the cells.

So the a11y tree shows `button "Open ACME-1042"`, and clicking it fails:

```
subtree intercepts pointer events
```

Drive it the way a mouse user does:

- **click the row container** — the `generic [cursor=pointer]` wrapping the door;
  the click bubbles to `PressableRow`
- or `dispatchEvent('click')` on the door directly
- or exercise the keyboard path, which is the one the door exists for: focus the
  door and press `Enter`

**Never reach for `force: true`.** It suppresses the actionability check that is
telling you the truth, and the click then lands somewhere you did not choose.

## Overlays render at the top of the DOM, not inside their trigger

Popovers, tooltips and dialogs go through a portal (`Portal`), so their
content is a sibling near the end of the document rather than a descendant of
the control that opened it. Looking for it under the trigger finds nothing.

Assert two things, not one: that the content appeared, and that it **dismisses**
— outside-click and `Escape` both. An open overlay usually has a click-catching
backdrop, so clicking the trigger a second time is often intercepted; click the
backdrop or press `Escape`.

**Press `Escape` at the FOCUSED element, never at `document`.** A nested layer's
dismissal is decided on the way UP from where the key started, so dispatching on
the document passes whether or not the layer between swallowed it — which is how
a picker inside a dialog shipped closing on neither press.

Two overlays open at once stack by the order they were OPENED, so the newest one
takes the press. If a driver reports `subtree intercepts pointer events` on a
control of the overlay you just opened, that is a real defect: `elementFromPoint`
at the control's centre names whatever is actually on top.

## Custom pointer drag is not `dragTo`

The calendar and gantt drags are built on `use_pointer_drag`, which listens for
real `pointerdown` / `pointermove` / `pointerup`. Playwright's `dragTo` and mouse
emulation do not drive them — the sequence simply does nothing, with no error.

Dispatch the events yourself, **in the frame's own context** so the coordinates
and the window are the right ones. From the dragged element,
`el.ownerDocument.defaultView` is that window:

1. `pointerdown` on the element
2. `pointermove` on the window, past the drag threshold (a few pixels — a smaller
   move is treated as a click, deliberately)
3. `pointerup` on the window, with `clientX`/`clientY` at the drop target

Then check **both** halves: re-snapshot for the optimistic move, and re-read the
record to confirm the mutation actually persisted. An optimistic move that never
reached the server looks identical on screen.

## Assert what an update KEEPS, not only what it renders

A test that reads the output passes either way when a list rebuilds itself: the same rows are on
screen, so nothing about the text is wrong. What changed is that every node was replaced — and a
replaced `Image` paints empty before it repaints.

Hold the node and compare it after the update:

```tsx
const { rerender, getByTestId } = render(board(["a", "b"]));
const before = getByTestId("b");
rerender(board(["new", "a", "b"]));
expect(getByTestId("b")).toBe(before); // MOVED, not rebuilt
```

State the budget as DOM WRITES, never as a render count. Re-running a component is close to free —
React's diff absorbs it — so counting renders fails a healthy tree and pushes the next author into
memoising leaves that cost nothing. A `MutationObserver` around a `rerender` with equal data should
see zero. And assert the opposite too: an insert MUST write, or a green suite only proves the
observer was blind.

→ [composition.md](./composition.md) §"Lists that stay fast".
