# Testing the kit

First, what a unit runner has to be told before it can load a kit module at all.
Then the 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. 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.

**A register row is neither `<tr>` nor `[role="row"]`** — select it by
`[class*="table__row"]`. The obvious selectors return 0, and a shot script reads
that as a register with no records rather than as an error.

## 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 that it appears AND that it **dismisses** — outside-click and `Escape`
both; a backdrop usually intercepts a second click on the trigger.

**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.

Two overlays stack by the order they were OPENED; `subtree intercepts pointer
events` on a control of the one you just opened is a real defect —
`elementFromPoint` at its centre names what is on top.

**While an overlay is open, seek the scroller INSIDE the topmost one.** A `Drawer`
portals to the END of the document, so "the first element with scroll overflow"
is the register behind it, and scrolling that yields identical frames that read
as "the panel does not scroll".

## 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 — a render is close to free, and counting
them pushes the next author into memoising leaves that cost nothing. A `MutationObserver` around a
`rerender` with equal data sees zero, and an insert MUST write, or the suite proves only that the
observer was blind.

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