# Testing

What is tested, what each test proves, and the philosophy behind coverage.
The gate is `bun validate` **from `apps/app`** (format + lint + test); the
scripts there mirror the ones the monorepo root used to hold, so the app sits
on its own. `bun test` runs with coverage reporting and **randomized order**,
so a test that depends on another test's leftovers fails loudly.

## The coverage philosophy

Coverage is a by-product, not a goal. The aim is **real coverage in real use
cases**: every test below exercises a behaviour a player, an author or a
maintainer actually depends on. Line and function coverage is the hint that
keeps us honest ("is there a branch no real case reaches?"), and the small set
of lines that stay dark is listed in
[Known issues and gaps](./21-known-issues-and-gaps.md).

The app has a real DOM harness now (happy-dom through a Bun preload) and no
Testing Library: components are mounted with `hono/jsx`'s own `render`, driven
with real events, and asserted on real DOM, real timers and real
`localStorage`.

There is deliberately **no `coverageThreshold`**. A handful of lines are
redundant re-checks that no input can reach (see the known-gaps page), so a
hard per-file 100% gate would force either synthetic tests or deleting still
useful defensive code. The report is read, not wired to fail.

## The tests, file by file

### `schemas/level.test.ts` - every level is real and winnable

The strongest test in the app. For each of the 20 `Level*.json` files it:

- parses the level and asserts the schema checks pass;
- converts the authored `optimalSolution` into a runnable sequence and
  **simulates it on the real map**;
- asserts the run wins, uses exactly `optimalActionCount` blocks, and costs
  exactly `optimalIterations` cycles.

Effect: no level can ship with a solution that does not actually win, and no
map edit can silently invalidate the stated optimum. This is how the app is
"played" before anyone opens it.

### `domain/simulation.test.ts` - the rules of the world

The engine's real behaviour, not just winning runs: `stepAction` (turns,
walls, bounds, ice slides that stop on the goal), `getMoveTrail`,
`evaluateCondition` at the board edge, `isTrapped`, `countBlocks` (which
includes the `start` head), the outcome paths `won` / `blocked` /
`not-reached` / `trapped` / `infinite-loop`, traps and infinite loops inside
`for` / `while` / `switch` / `if`, the animation helpers
(`collectExecutedActions` stops at the target), the cycle models
(`countExecutedCycles` charges every written repetition, a `while` stops on
the target), `formatCycles`, the loop threshold, and `detectExcessRepetitions`
for the excess-`for` hint.

### `schemas/level.schema.test.ts` and `schemas/sequence.schema.test.ts` - the boundaries

The rejection contract, which the happy-path suites cannot show: map
dimensions, palette rules, title and slug rules, the authored count bounds,
look defaults (traps inherit the ground theme, ice uses `ice`), and the
container shape rules for the optimal solution and the UI sequence, including
the fields that must not appear on a `for`, `while` or `if`. `parseLevel`,
`parseSequence`, `parseCount` and `parseOptimalSolution` are exercised
directly.

### `features/builder/level-codec.test.ts` - the share link codec

Round trips at every scale: a minimal level, every block kind, every
container shape, unicode titles and authors, and a 16x16 level with a deep
solution that must still land far under the URL budget. The rejection suite
pins garbage, truncation, a flipped bit (the checksum catches it), shapes the
encoder refuses (sizes, over-long strings, a container body holding the
implicit `start`), and a battery of **crafted, checksum-valid byte bodies**
that hit every decode rejection: a wrong version, bad sizes, an unknown
terrain or direction, a bad cell kind, missing strings, and every broken
solution item. `fnv1a16` and `toBase64Url` are exported so the tests can build
those bodies; the checksum itself is never weakened.

### `schemas/shared-level.schema.test.ts` - the share boundary

The decoded payload contract: sizes, title and author caps, map dimensions,
player and target inside the board, and a non-empty, well-formed authored
solution.

### `features/builder/shared-level.test.ts` - the builder bridge

Resizing with the off-map buffer, the floor rule under the player and the
target (ice may stay), painting and the player/target swap, the draft payload
round trip, the derived block and cycle counts, the too-heavy guard, the try
preparation (unnamed drafts and losing programs still run), every validation
status from empty to valid, and `levelFromPayload` rejecting a decodable
payload with an invalid shape or a program too heavy to build.

### `features/builder/level-identity.test.ts` - the content identity

The board-only content hash is stable, ignores title/author/solution, and
changes with the board or the action palette; the fingerprint normalises
container solutions and changes with the title or the program.

### `features/builder/builder-session.test.ts` - the publish rules

The dirty check against the one saved level, Share enabled only for a valid
level, and the reason carried when it is not.

### `domain/level.test.ts` - the registry

All 20 levels load, ids and slugs are unique, resolution works by slug, UUID
and padded number, the picker's previous/next walk terminates at the ends, and
the exported validators reject an unparseable entry and duplicate ids/slugs.

### `server.test.ts` - the routes

The SPA shell answers for level paths, `/` and unknown client routes, a
missing asset stays a 404 instead of quietly returning HTML, and
`startServer` binds a real port and stops again.

### `domain/sequence-tree.test.ts` - the program tree

Every mutation helper with its invariants: zone addressing
(`root` / `<id>` / `<id>:else` / `<id>:branch-<n>`), a malformed branch
marker, insert clamping past locked items, reorder clamping (including an
out-of-range source), removal, `updateItem`/`updateZone` reaching a target
inside an else or a switch branch, and `sortSwitchBranches` keeping `default`
last.

### `features/level/stored-sequence.test.ts` - storage round-trips

Saving drops internal fields and keeps the `default` flag; loading merges
the level's defaults back (ids, movable, modifiable); several defaults of
one action match in traversal order; and the invalidation rules all ways: a
program that uses an action the level no longer offers (top level or nested)
is discarded, a flag with no matching default is discarded, a program that
omits a level default's flag is discarded, and an empty stored program on a
level with defaults is discarded. A flagged default plus user blocks after it
survives and comes back correct.

### `lib/persistence.test.ts` - the storage layer

The keys read and write through a fake storage object: valid shapes
round-trip, malformed JSON and wrong shapes fall back to empty, corrupt JSON
strings are ignored, the builder draft and the one saved builder level
round-trip, writes are immediate and non-throwing for a blocked or absent
storage, and the real `localStorage` is used when none is injected, including
when reading the storage property itself throws.

### `lib/i18n.test.ts`, `lib/cn.test.ts`, `lib/column.test.ts`, `lib/skin.test.ts`

The locale fallback (`localized`), class-name joining, column letters (and
their range rejection), and the skin storage round trip, malformed stored
values, and the document attribute mirror.

### `schemas/skin.schema.test.ts`, `schemas/persistence.schema.test.ts`

The skin value boundary, and the stored-sequence, last-level and
completed-level parsers including nested children, else children and branches.

### `features/level/status.test.ts`, `features/builder/draft-message.test.ts`

Every run status and every builder problem has its own non-empty sentence, and
a valid level has nothing to say.

### `features/level/completion.test.ts` - a level is only won by winning

`recordLevelOutcome` marks completion only for `won`, never for failures or
unfinished statuses, and only once.

### `features/level/export-algorithm.test.ts` - the export contract

The generated C, Python and JavaScript for real programs: symbol casing per
language, the `for`/`while`/`if`/`switch` translations, `pass` for empty
Python bodies, a switch that only has a `default` branch (unwrapped, no
chain), a switch with no branches (exported as nothing), and the blank-line
formatting contract (containers separated, leaves adjacent, bodies unpadded,
nested-first/flush rule).

### `features/builder/solution-sequence.test.ts`

The authored-solution ⇄ UI-sequence conversion for every container shape,
round-tripping back to the same solution.

### `components/Playground/textureVariant.test.ts`

Kept as an empty placeholder after the per-tile texture variation was
removed; see [Known issues and gaps](./21-known-issues-and-gaps.md).

## The DOM harness and the component tests

`happydom.ts` registers happy-dom before the run, through the `[test] preload`
entry in `bunfig.toml` and again when `src/dom-harness.tsx` is imported, so
the suites also work when the app is tested from a different working
directory. The same preload clears the document, storage and the player-skin
attribute after every test, so nothing leaks between files.

`src/dom-harness.tsx` mounts a real component into a fresh container, settles
its effects and state, and offers a real-timer wait. There is no Testing
Library and no mocking: the tests drive the real DOM, the real timers and the
real `localStorage`.

- `lib/toast.test.tsx` - a toast appears, closes on its button, keeps only the
  five newest, dismisses itself after its duration, clears its timers when the
  provider is replaced, and explains the misuse outside a provider.
- `features/skin/SkinProvider.test.tsx` - choosing a skin stores it and sets
  `data-player-skin`, a skin stored earlier comes back on mount, an unknown
  skin is ignored, clearing removes it from both the document and storage, and
  `useSkin` outside a provider explains the misuse.
- `components/Playground/Playground.test.tsx` - the grid renders a track per
  cell plus the row and column labels, every painted kind lands in its own
  cell, the player and the target are drawn where they belong, and the step
  animation is marked when the mouse walks in from another cell.
- `features/builder/BuilderActions.test.tsx` - the publish button reads Save
  or Share from the dirty state, Save runs its handler, Share is disabled with
  its reason until the level validates, and the two keep their colours.
- `components/Playground/cheeseHoles.test.ts` - the generated cheese holes
  keep their count, shape and non-overlap invariants.

## The documentation has its own tests

`features/documentation/docs.test.ts` and `documentation/documentation.test.ts`
cover the docs pipeline with real use cases:

- every page renders, titles are unique, and the catalog order is stable;
- every internal link and anchor in the rendered output resolves, and heading
  self-links point at real ids;
- the raw markdown route returns exactly the page source;
- the static-build render loop produces a complete page for each entry;
- the server answers `/docs`, `/docs/<slug>` and `/docs/<slug>.md`, and
  unknown slugs 404.

Details on [This documentation](./20-documentation-guide.md).

## What is not covered, and why

Two areas stay outside the tests on purpose:

- **The interactive layers that need layout or timing.** `features/level/dnd/`
  (pointer geometry), the run animation in `useSimulation`, the router and the
  keyboard path. happy-dom has no layout engine, so tests there would assert
  on geometry that does not exist; the logic those pieces delegate to
  (schemas, the sequence tree, the simulator, the storage merge, the export
  generator) is covered directly instead.
- **A few defensive or redundant branches.** The remaining dark lines are the
  simulator's redundant flag re-checks, the docs index comparator, the
  `switch` tail in the export generator, and the nested rebuild-failure
  propagation in stored-sequence. They are listed with their reasons on
  [Known issues and gaps](./21-known-issues-and-gaps.md).

## How tests run in the gate

`bun validate` (run in `apps/app`) = `bun format` + `bun lint` + `bun test`.
Tests run in randomized order with coverage on; a new test that fails alone, or
breaks another test's assumptions, surfaces immediately. Adding a test is
ordinary work, not ceremony: a new helper in `domain/sequence-tree.ts` gets
its cases in `sequence-tree.test.ts`, a new level gets caught by
`level.test.ts` automatically.

Next: [This documentation](./20-documentation-guide.md).
