Current section

Files

Jump to
visualize spec 12-testing-and-conformance.md
Raw

spec/12-testing-and-conformance.md

# Testing and Conformance

**Status:** Agreed

This document settles how conformance to the specification is demonstrated: the projection golden that already exists, and the surface gate, heading check, CI pipeline and formal model that are agreed but not yet built. Each section states whether it is implemented today.

## 1. The projection golden

**Implemented.** `test/support/projection_golden.txt` records `Visualize.Geo.Projection.project/3` and `invert/3` for all 38 projection types over a fixed 9 × 9 grid of longitudes `[-170, -120, -60, -15, 0, 15, 60, 120, 170]` and latitudes `[-80, -60, -30, -5, 0, 5, 30, 60, 80]`, with `scale(100)` and `translate(0, 0)`. Each line is `type lon lat -> x y | ilon ilat`, or `-> nil` when the forward projection clips, or `| nil` when the inverse does; the test renders the same text and asserts byte equality.

- Numbers are printed to nine decimals, far below the projections' own iteration tolerances, so a real change in the maths shows and float noise from reordering an expression does not.
- Negative zero is folded to positive zero before printing (D-6). A residual such as `-4e-10` still prints as `-0.000000000`; that is a property of the maths, not of the sign of zero, and is stable.
- The inverse of `:transverse_mercator` is recorded as `excluded` pending issue #9 (D-5).
- A second test asserts that the golden's set of type names equals the module's list, so adding a projection without regenerating fails.

The golden MUST be regenerated (`mix run -e 'Visualize.Geo.ProjectionGoldenTest.write!()'`) only when projection maths changes on purpose, and the regeneration MUST be committed with the change that caused it.

**The clip on the sphere is held to d3-geo** (#509, #510). `test/support/geo/clip_golden.jsonl` is d3-geo's own output — one JSON case per line, the projection in the library's terms (with its `clip_angle` where the case sets one; without one the type's default, so the defaults are held to d3's too), the GeoJSON input and d3's stream of subpaths, a ring inside a polygon and a line otherwise — for the cases of [07-geo](07-geo.md) §2.2.1: lines across the seam and over a pole, polygons across it, with a hole, around a pole and around all but one; lines and polygons across small circles of 60°, 120° and the orthographic's `90 + 10⁻⁶`, through one from outside, around its center and around the antipode, wound the other way and with a hole; and Natural Earth 1:110m land under rotated equirectangular and Natural Earth projections and under rotated, tilted and rolled orthographic, stereographic and azimuthal-equidistant ones, every projection at `precision(0)`; and **the rotation itself** (#511): points projected under several `[lambda, phi, gamma]` through the equirectangular, Mercator, Natural Earth, Equal Earth and the five azimuthal types, with d3's projected point — `null` where d3's stream drops it — and d3's inverse of it. The projection is passed to d3 as `[-lambda, phi, gamma]`: `phi` and `gamma` are d3's own and only `lambda` keeps the library's opposite sign (07 §1.4, D-44, D-129). Its header names the d3-geo version and the command. It is written by `test/support/geo/clip_golden.mjs`, a one-off node script and, with the circle's oracle below, the suite's only JavaScript, kept because d3-geo itself is the reference: it runs by hand, against a d3-geo installed outside the repository, and the test never runs node. `test/visualize/geo/clip_test.exs` projects each case at precision 0 and compares every subpath with d3's point by point within `1e-6`, and each rotation case's points — `Visualize.Geo.Projection.project/3`, `nil` where d3 drops the point, and, for every type but the Natural Earth and Equal Earth, whose inverses iterate to tolerances of their own, `invert/3` of d3's projected point — within `1e-9`; a ring without a closing vertex that repeats its first (d3 drops it where the clip leaves the ring whole, the library keeps it). It is regenerated only when the clip changes on purpose or d3-geo is moved to a later release.

**The geo circle is held to d3-geo** (#525). `test/support/geo/circle_golden.jsonl` is d3-geo's `geoCircle` itself — one JSON case per line, the circle's `center`, `radius` and `precision` and d3's ring, every number as `JSON.stringify` writes it, the shortest that reads back as the same double — for the centers, radii and precisions of [07-geo](07-geo.md) §2.5, and its header names the d3-geo version and the command. It is written by `test/support/geo/circle_golden.mjs`, the clip oracle's one-off companion, run by hand against the same d3-geo installed outside the repository; the test never runs node. `test/visualize/geo/circle_test.exs` builds each case with `Visualize.Geo.Circle.polygon/1` and compares the ring with d3's position by position within `1e-9` degrees, the lengths equal. It is regenerated only when the port changes on purpose or d3-geo is moved to a later release.

## 2. The API surface golden

**Implemented.** `API_SURFACE.md` at the project root is generated by `mix vis.surface --write` and verified by `mix vis.surface --check`. It joins two sets: every `` `Module.function/arity` `` found in the first cell of a table row under `spec/`, and every public (non-`@doc false`) function defined in `lib/`, excluding `Visualize.MixProject`. A function a module injects through `use` is not in the source the scanner reads, so a module that injects functions declares what it injects through a zero-arity, undocumented export and the scanner asks it: the builder's schema-generated functions ([14-declarative-chart](14-declarative-chart.md) §16.6) are rows of the golden like any other.

Each row is `{Function, Section, Status, Version, Locus}`:

- **Function** — the fully qualified `Module.function/arity`.
- **Section** — the `NN-slug §n.m` heading that declares it, or empty.
- **Status** — `specified` (declared and defined), `unspecified` (defined in `lib/`, no row in `spec/`), `unimplemented` (row in `spec/`, no definition in `lib/`).
- **Version** — a content hash of the function's definition, all clauses together, so a row changes only when the definition changes and unrelated rows never conflict on merge. It is empty for an `unimplemented` row, which has no definition to hash.
- **Locus** — the source file that defines it, relative to the project root, and empty for an `unimplemented` row.

`--check` MUST fail when the regenerated file differs from the committed one or when any row is not `specified`, and MUST name the rows that are not, with the remedy for each: write the row for an `unspecified` function, build it or delete the row for an `unimplemented` one. It is the HARD gate `api-surface-drift`: it cannot be waived per-merge, only changed by editing the specification. `mix vis.surface` with no flag reports the same two backlogs without failing, which is what makes it the form to run while the work is in hand.

## 3. Heading numbering, citations and spelling

**Implemented.** `mix vis.headings` reads every `spec/*.md` and fails when: a file has more than one `#` title; line 3 is not a `**Status:**` line with one of the three vocabulary words; any `##`, `###` or `####` heading lacks a decimal number; numbers at any level are not gap-free from 1; or a number is duplicated. It is what makes `§n.m` references in `API_SURFACE.md` stable.

**Every citation names a heading** (#648). `mix vis.citations` reads every file under `spec/`, `lib/`, `test/` and `specs/` and fails, naming the file, the line and the citation, when a citation names a section its document has no heading for. A citation is `spec/NN §id`, its long form `spec/NN-slug.md §id`, or a specification document's own link form `[NN-slug](NN-slug.md) §id`; a list continues one — `spec/08 §1.9, §7.3` cites both sections of spec/08, as does the range `spec/09 §4.3.8–4.3.9` both of its ends — so a list names one document, and a section of another is cited with its own `spec/NN`. An id is a heading's decimal number, and in spec/13 its `D-N` too, so `spec/13 §180` and `spec/13 §D-180` are one heading. A heading inside a code fence is an example, not a section, and resolves nothing. A bare `§id` with no document, the document's own sections, is not checked. Renumbering a chapter therefore fails the gate at every citation it leaves pointing at a heading that moved, which is how the force simulation's timer discipline was cited as spec/06's section 9.4, a Hexbin number, for as long as §8.5 held it.

**Everything this library names and writes is spelled American** (#642, D-183). `mix vis.spelling` reads `lib/`, `test/`, `specs/`, `spec/`, `guides/`, `README.md`, the CHANGELOG up to its first released entry, and the examples app's `lib/` and `test/` (`.ex`, `.exs`, `.md`, `.cheatmd`, `.heex`, `.js`, `.css`), and fails, naming the file, the line and the word, on a British form inside an identifier as well as in prose. What stays is an allowlist, not a judgment call: Elixir's own word for a module contract (`@behaviour`, a `Behaviour` module, prose about one), quoted third-party text (CSS's color keywords in their other spelling, d3's scheme names, ARIA's `aria-labelledby`, a unit alias a host may type, the metresis project's name), the CHANGELOG's released entries, a line marked `spelling: allow` (a comment line so marked allows the line after it too, an old name a migration reads), and a Markdown region between lines that are `<!-- spelling: allow -->` and `<!-- spelling: end -->`.

**No atom from untrusted input** (#778, #779, #780, D-195). `mix vis.atoms` reads every `.ex` file under `lib/` and fails, naming the file, the line and the text, on a call that makes an atom from a string — `String.to_atom`, `:erlang.binary_to_atom`, `List.to_atom`, `:erlang.list_to_atom` — and on an interpolated atom literal (`:"…#{…}…"`), outside the allowlist `audit/atoms_allowlist.exs`. Each allowlist entry names a file, a fragment of the line it allows, its class of D-195 — (a) a closed library vocabulary, (a-host) a vocabulary host code registers, (d) bounded by the library — and its reason, which for (a) and (d) states the bound. An entry of class (b), a name a document or a client declares, or (c), a name derived from those, fails the task: such a name is a string (spec/14 §9.4), never an allowed atom. An entry that matches no line, or a different number of lines, fails the task too, so the list shrinks with the code. `String.to_existing_atom` is not matched: it makes no atom, and the behavioral tests are what hold the readers to it — `test/visualize/chart/no_atoms_test.exs` counts the node's atoms (`:erlang.system_info(:atom_count)`) around reading N fresh documents of every kind (a design, a fragment, a board, a snapshot, a virtual source, a stored function) and N builder events naming fresh names, and holds the count unchanged.

## 4. The CI pipeline

**Implemented** (`.gitlab-ci.yml`). Three stages, each blocking the next:

| Stage | Jobs |
|---|---|
| `check` | `mix format --check-formatted`, in the root project and then in `examples/` against the examples app's own `.formatter.exs` (below); `mix compile --warnings-as-errors`; `mix test --warnings-as-errors --cover`, with the pinned resvg binary downloaded first (below) |
| `verify` | `mix vis.surface --check`; `mix vis.headings`, `mix vis.citations`, `mix vis.spelling` and `mix vis.atoms` (§3); `mix vis.verify` over the models in §5; the consumer compile: `mix compile --force --warnings-as-errors` in `MIX_ENV=prod`, then `elixir scripts/consumer_check.exs`, which generates a throwaway project depending on `visualize` by path and on nothing else, compiles it in `MIX_ENV=prod` and asserts that `Phoenix.Component`, `Phoenix.HTML.Safe`, `Nx`, `Table.Reader`, `Explorer.DataFrame`, `Tz.TimeZoneDatabase` (the test-only time zone database, D-115), `Resvg` and `RustlerPrecompiled` (the NIF rasterizer D-121 replaced and its loader), `Makeup` (the examples' code highlighter, #506), `:telemetry` and `Telemetry.Metrics` (optional and test-only for the telemetry feed, D-140), `Visualize.Components`, `Visualize.Components.Tree`, `Visualize.Audit`, `ExTLA.Spec` and `Surfex.Golden` cannot be loaded while `Visualize`, `Visualize.Scale`, `Visualize.Hooks`, `Visualize.Backend.CanvasBinary`, `Visualize.Feed.Monitor`, `Visualize.Feed.Telemetry` and `Visualize.Feed.Conformance` (the feed conformance kit, which references no ExUnit function, D-145) can, and that `Visualize.Render.to_png/2` returns `{:error, :no_rasterizer}` with no `:resvg` setting and the probe's `PATH` set to an empty directory, so no host's `resvg` is found — the proof that the optional dependencies (D-45) and the `elixirc_paths/1` split reach no consumer, and the run of `to_png/2` on a host without the binary; the gallery under `examples/` compiled and its suite run, both with `--warnings-as-errors` (#344): the gallery is held to the library's own gate, so a warning in a LiveView module is a red pipeline and not a note in a log; `verify:hooks`, the executed hook tests of §6 (#458): `mix test --only js --include js_resvg` in the Elixir image with Debian's `nodejs` package installed by its script and the pinned resvg downloaded as the `test` job downloads it (below, #679, D-179), since ExUnit drives the JavaScript engine and the job needs both |
| `quality` | `mix credo --strict`; `mix docs --warnings-as-errors` |

The two warning gates cover disjoint code and both are required (D-91). `mix compile --warnings-as-errors` compiles what `elixirc_paths/1` lists — `lib`, `audit`, `specs` and `test/support` — and never reads a `*_test.exs` file, so a warning in a test is invisible to it; `mix test --warnings-as-errors` is the one that fails on a warning raised while compiling the suite itself, and it exits non-zero after the tests have run rather than before them, so a warning is reported beside the results and not in place of them.

**Both projects are format-checked** (#702). The root `.formatter.exs` does not list `examples/`, and the examples app's own one imports Phoenix's formatter settings and runs the `Phoenix.LiveView.HTMLFormatter` plugin over its `~H` templates, so the check has to run there, with that app's dependencies on disk for the plugin to load. The `format` job therefore pulls the examples member's dependencies (the same depdep line `examples:test` uses, without the push) and runs `mix format --check-formatted` in `examples/` after the root check. Before it did, nothing checked that directory: by October 2026 fourteen of its files had drifted from the formatter — most of them templates written with `<%= %>` interpolation, which the HEEx formatter rewrites to `{}` — so a bare `mix format` there rewrote unrelated files, and changes to the app had to format by hand only the files they touched. With the check in the gate, `mix format` in `examples/` is safe to run again.

**An optional dependency runs both ways.** The `check` stage has every optional dependency, so the suite runs each feature with its package; the `consumer-compile` job builds a consumer that lists none of them, so it runs each feature's absent branch.

**The resvg binary runs both ways too** (D-121). PNG output needs a binary on the host, not a package ([09-rendering-backends](09-rendering-backends.md) §9.4):

- **The `test` job downloads a pinned release.** Its script installs `curl`, downloads `https://github.com/linebender/resvg/releases/download/v0.48.1/resvg-linux-x86_64.tar.gz`, checks it against the SHA-256 written in `.gitlab-ci.yml` (`fa8c26495a187e592c501db15bf9e8a9fdc051d4b2b336b39703d5b59f912b9d`) with `sha256sum -c`, extracts the one file `resvg` into `.resvg/`, and runs the suite with `VISUALIZE_RESVG` set to it. A tarball that does not match fails the job before any test runs. Moving to another resvg release is a change to the URL and the checksum together, and the raster goldens are regenerated with it when its pixels move beyond the tolerance (§6).
- **`test/test_helper.exs` reads `VISUALIZE_RESVG`.** When it is set, the helper configures `config :visualize, :resvg` with it, and a value that names no executable file stops the suite with an error rather than skipping: a pipeline that meant to rasterize never silently does not. When it is unset, the library's own lookup applies, `resvg` on the `PATH`. When no binary is found either way, the tests tagged `:resvg` (every test that runs the binary) are excluded, and the helper says so in one line, as it does for `:js` without node. A test that needs both — a hook replayed under node and compared with resvg's raster (#652) — is tagged `:js_resvg` and neither of the two: `--only js` includes a `:js` test even when `:resvg` is excluded, so the helper excludes `:js_resvg` unless node and the binary are both present. **`verify:hooks` has both** (#679, D-179): it downloads the same pinned release as `test`, from one hidden job `.resvg` both extend, sets `VISUALIZE_RESVG` and runs `mix test --only js --include js_resvg`, so the pixel-parity tests run in the pipeline and not only under `mix vis.check`.
- **The absent case runs everywhere.** The `{:error, :no_rasterizer}` branch of both `to_png/3` and `to_png!/3` is reached through the availability argument, untagged, so it runs with or without a binary; and the consumer check runs `to_png/2` on a host with none.
- **The CI image has no system fonts,** so every raster test that draws text renders with the bundled test font and `system_fonts: false`: the raster goldens of §6 are the same on every host.

`mix vis.check` (`scripts/check.exs`) runs the same jobs locally in the same order and MUST be the last thing run before submitting a change for review. Its `verify:hooks` runs `mix test --only js --include js_resvg` when `node` is on the path and otherwise prints `verify:hooks: skipped: no node` and goes on, so a workstation without node stays green and the pipeline is where the hooks are always executed. The raster tests follow the same rule through the `:resvg` tag: `mix vis.check` uses the binary `VISUALIZE_RESVG` names or the `PATH` holds, and without one the `test` job prints the helper's line and stays green.

**Compiled dependencies come from the depdep store** (`scripts/depdep.exs`, pinned to a depdep tag; #342, #343). Every job's `before_script` pulls the root project's dependencies in one line — `--pull --mix-get --compile-deps --exclude examples`: the pull, `mix deps.get` inside it, a second decision once the source is on disk (so a git dependency and its cone are keyed in one invocation), and a compile of exactly what the store lacked — falling back to a bare `mix deps.get` when the store cannot be reached; the `compile` job pushes what was missing after a green build, the examples member excluded since those jobs never build it. The `examples:test` job is the one that builds `examples/`, so it pulls and pushes that member itself, from its own directory, the same way. Without the `DEPDEP_*` variables depdep is a no-op that exits 0, so a pipeline never depends on the store.

## 5. The ExTLA model of the force simulation

**Implemented.** `Visualize.Specs.ForceSimulation` (`specs/force_simulation_spec.ex`, compiled in dev and test only) models the timer discipline of `Visualize.Layout.Force.Simulation` ([06-layouts](06-layouts.md) §8.5). Nodes, links and forces are abstracted away; what is kept exactly is every message the server handles, the deferred `:start_simulation` that `init/1` enqueues, and the two kinds of tick message: a *live* tick from the timer the server is waiting on, and a *stale* tick from a timer canceled after its message had already reached the mailbox.

The checker is extla, a dev and test dependency **pinned to a release tag** with its `mix.lock` entry committed (#441), so an extla change reaches this project only by a deliberate bump. CI and every workstation build the same extla. Building against a local checkout is an explicit opt-in, `EXTLA_PATH=../extla`, never inferred from a sibling directory: tracking an unlocked branch is how extla v0.4.0's rename of `TLA.*` to `ExTLA.*` reached a pipeline unannounced.

State: `running`, `live` (armed timers the server would act on, `0..2`), `stale` (canceled-too-late ticks in flight, bounded by `max_stale = 2`), `heat` (`0` = alpha below `alpha_min`; `1`, `2` above), `target_hot` (`alpha_target ≥ alpha_min`), `pending_start`, and the history variable `restart_started_stopped`. Actions: `deferred_start`, `start`, `stop(canceled)`, `restart`, `live_tick`, `stale_tick`, `manual_tick`, `set_alpha(h)`, `set_alpha_target(t)`.

Invariants (checked over all 72 reachable states):

- `running_iff_live_timer` — `running == (live == 1)`.
- `at_most_one_live_timer` — `live <= 1`.
- `restart_never_starts` — a `:restart` has never started a stopped simulation.

Liveness (`cools_down`, checked with the client quiet, `env_active = false`, under weak fairness on the tick and deferred-start actions): a target below `alpha_min` leads to `not running`.

Three constants select the intended behavior (default `true`) or the defect the code shipped with, and `test/specs/force_simulation_spec_test.exs` proves the checker finds each when its switch is flipped: `guarded_deferred_start` (a `:start_simulation` after a `:start` must not arm a second timer), `restart_keeps_stopped`, and `tagged_ticks` (a tick carries its timer's reference, so a stale tick after a stop-then-start cannot start a second chain — a defect the review had not seen; the checker did). `mix vis.verify` runs `mix extla.check` on every model in `@models` and is the CI `verify` job; the code fixes are the second work item of proposal #5, after which §8.5's `> Decision` note is retired. `ExTLA.GenServer` conformance (Layer 3) is not attempted until a sibling project has proven the path.

## 6. Unit and regression tests

**Implemented.** `mix test` runs the ExUnit suite. `test/visualize/defects_test.exs` holds one regression per recorded defect fix (D-1, D-3, D-8 and the `Data.range/3` step handling), and each future decision that changes behavior MUST add a regression there or in the module's own test file.

The LiveView components have render goldens of the same kind as §1: `test/visualize/components_test.exs` renders every chart component of [10-liveview-integration](10-liveview-integration.md) §2 through `Phoenix.LiveViewTest.render_component/2` on a small fixed dataset and on empty data, and asserts byte equality with `test/support/components/<name>.svg` and `<name>_empty.svg` (D-45). They are regenerated with `mix run -e 'Visualize.ComponentsTest.write!()'` only when a component's output changes on purpose, and the regeneration is committed with the change. `test/visualize/components/tree_test.exs` holds the hierarchy components of §3 the same way (`mix run -e 'Visualize.Components.TreeTest.write!()'`), and holds every `d` attribute of every golden under `test/support/components/` to the form `Visualize.IR.Path.to_string/1` prints — no space inside a `d` — so a component cannot reacquire a serializer of its own (D-71).

**The gallery has raster goldens** (#474). `test/visualize/render/raster_golden_test.exs` rasterizes every design of `Visualize.Designs.names/0` — the thirteen the library's tests render — with `Visualize.Render.to_png/2`, from `Visualize.Chart.generate(applied, root: true, resolve: :literal)` at `scale: 1` and `background: "#ffffff"`, with the font configuration `font_dirs: ["test/support/fonts"]`, `system_fonts: false` and `generic_families: [sans_serif: "DejaVu Sans"]`, and compares each with `test/support/raster/<name>.png`:

- **The bundled font.** `test/support/fonts/` holds one face, DejaVu Sans (`DejaVuSans.ttf`, DejaVu 2.37), under the Bitstream Vera license with DejaVu's changes in the public domain, which permits redistribution with the notice; the notice is `test/support/fonts/LICENSE`. It is the only font the goldens see, so they do not depend on the host's fonts, and the CI image has none.
- **No warnings.** Each design returns `{:ok, png, []}`: resvg reports no family it could not resolve against the bundled font, and nothing else ([09-rendering-backends](09-rendering-backends.md) §9.7). A design that gained a family the font configuration lacks fails here, not in a host's alert.
- **Within a per-pixel tolerance.** Both PNGs are decoded to RGBA pixels (`Visualize.PNGPixels`, test support: inflate the `IDAT` data and undo the five scanline filters of PNG §9). They MUST have the same width and height, and a pixel matches when each of its four channels is within **8** of the golden's; a design matches when at most **0.1 %** of its pixels do not. The tolerance absorbs anti-aliasing that moves by a step between builds of the rasterizer, and a mark that moves, recolors or disappears exceeds it. The goldens are rendered by resvg CLI 0.48.1, the CI's pinned release (§4), and hold on 0.45.1, the supported minimum, with no pixel outside the tolerance (D-121).
- **Drift.** The set of files under `test/support/raster/` MUST equal the set of design names, so a design added without a golden, or a golden left behind by a removed design, fails.
- **Size.** Thirteen 600 × 400 PNGs, about 130 KB in all; the font is 760 KB. Both are committed.

The goldens are regenerated with `VISUALIZE_RESVG=/path/to/resvg MIX_ENV=test mix run -e 'Application.put_env(:visualize, :resvg, System.fetch_env!("VISUALIZE_RESVG")); ExUnit.start(autorun: false); Code.require_file("test/visualize/render/raster_golden_test.exs"); Visualize.Render.RasterGoldenTest.write!()'` only when a design's picture changes on purpose — its SVG golden (`test/support/designs_golden.txt`) changes with it — or when a new resvg release moves pixels beyond the tolerance, with `VISUALIZE_RESVG` naming that release's binary (`mix run` does not read `test/test_helper.exs`, so the command sets the `:resvg` setting itself), and the regeneration is committed with the change. The regeneration command is in the test module's documentation, as for the other goldens.

**A raster test decodes each image once** (#732). A test that samples pixels reads them from the decoded image: `Visualize.PNGPixels.pixel/3` takes the `{width, height, pixels}` that `decode/1` returns, and never the PNG. It had taken the PNG and decoded the whole image on every read, so a test cost its sample count in whole-image decodes: `Visualize.Backend.PaintTest`'s hatch test reads about 3,400 pixels of two 220 × 100 images and took 24–30 s run alone, half its 60 s timeout, and it timed out once under load. Decoding once per image, it takes 0.7 s. Every raster test now decodes once — the gradient and hatch tests, the translucent band, the scoped symbols, the swatch, and the status cue's masks, which had decoded the gauge once per zone — and the tests that read pixel bytes by hand read them through `pixel/3`. Passing a PNG to `pixel/3` is a type warning, and the suite compiles with warnings as errors, so the per-read decode cannot come back. `test/visualize/png_pixels_test.exs` holds the decoder to a two-by-two image built by hand, one row unfiltered and one Sub-filtered, and `pixel/3` to refusing a point off the image.

**The port is held to what it promises** (#481, D-121), in `test/visualize/render/raster_test.exs` and, for the tests that change global state, `test/visualize/render/raster_port_test.exs` (`async: false`, so no other test runs beside them):

- **No scheduler held.** A peer node is started with one normal scheduler (`:peer`, `+S 1:1`, the suite's code paths). On it a ticker process loops on `receive after 1`, counting, while another process renders a design at a scale whose render takes far longer than a few dozen ticks. The test asserts a **count**: the ticker completed 20 ticks while the render was still in flight. A render that held the only scheduler — D-118's NIF — would let no tick run until it returned. No clock is read (§6, D-114).
- **The timeout kills the processes.** A render with `timeout: 1` returns `{:error, :timeout}`, and so does `Raster.run/4` over a stand-in program (`/bin/sh -c 'sleep 60'` through the same wrapper); for each, the test then signals the process group the `os_pid` led with signal 0 (`kill -0 -- -<os_pid>`), which fails once no process of the group exists.
- **No temp file.** With `TMPDIR` pointed at a fresh empty directory for the test (so `System.tmp_dir!/0` and the child's environment both see it), a render leaves that directory empty and the working directory's listing unchanged.
- **The binary and its version.** A configured path naming nothing returns `{:error, :no_rasterizer}` even with a `resvg` on the `PATH`; a stand-in binary printing `0.44.9` is refused with `{:error, {:rasterizer_version, "0.44.9", "0.45.0"}}`, and one printing no version likewise.
- **The warning text.** A missing family is rendered through the real binary and read back as `{:missing_family, list}`, which pins `Raster.warnings/1` to the CI's resvg; malformed SVG returns `{:error, {:rasterizer, message}}` with resvg's message.

**Every color position takes every color** (#710, D-185). `test/visualize/chart/color_positions_test.exs` holds one list of the design's color positions — each style part's color (a paint's `color` and `background`, a stroke's, a gradient stop's, an effect's), a frame's `background`, a scale's list range on an ordinal and on a sequential scale and its map of colors, a theme's `colors` and its `ramps` — and puts a typed color and a transform in each: the design validates, `Visualize.Chart.Migration.canonical/1` keeps both typed and types a bare atom there, and the render draws the color the theme resolves, on SVG and on the canvas commands. The list is checked against the schema: every key whose type holds a `:color`, a `:theme_color`, a `:color_map` or a `:range` (`Visualize.Chart.Schema.describe/1` over `Visualize.Chart.Schema.kinds/0`) MUST be in it, so a color position added to the schema without a row fails by name — the gap that let the scale range be typed as a domain and refuse `color(:red)`.

**Every design round-trips through the builder's sites** (#713, D-187). `test/visualize/chart/composite_round_trip_test.exs` decomposes every design of `Visualize.Designs.names/0` into sites with `Visualize.Chart.Composite.decompose/1` and flattens them again, and asserts the result is the canonical design; the examples app's `test/examples/composite_round_trip_test.exs` does the same for every gallery design, Composition included, which is why no gallery design is left out of the builder's templates.

`test/visualize/theme_contrast_test.exs` holds both built-in themes and every color scheme to the contrast floors of [08-utilities](08-utilities.md) §7.4.

`test/support/contour_golden.txt` records `Visualize.Contour.compute/2`, `Visualize.Contour.render/2`, `Visualize.Contour.Density.compute/2` and `Visualize.Contour.Density.render/2` over fixed grids and points, recorded from the implementation D-72 replaced, so the indexed grid and the linear tracing of [07-geo](07-geo.md) §6.3 are held to the rings the list-walking code traced; `test/visualize/contour_golden_test.exs` asserts byte equality and regenerates it (`Visualize.ContourGoldenTest.write!/0`) only when the tracing changes on purpose. `test/visualize/contour_test.exs` holds `Visualize.Contour.Density.compute/2` on its default grid to a time bound and the cost of `Visualize.Contour.compute/2` by a **count, not a clock** (#271): `Visualize.Contour.cost/2` reports the work the tracing does — the cells read, the segments built and the hops the ring-following takes — and the test holds the hops to the segment count and the reads to the padded cells for 101² and 201² grids, which is D-72's claim stated as what it is; a wall-clock ratio, which the test asserted before, moved by a third between runs on a shared box and failed at 6.0–6.8× on a 6× bound eight times in one day without a defect. The density default is held the same way (#462): its reductions stay within a budget per padded cell and threshold, which is *usable* stated as work — the list-walking grid D-72 replaced spent hundreds of millions of calls on a 51 × 51 grid — and ten times the points costs under one and a half times the work, since the estimate's cost is the grid's and not the points times the grid; the time it takes is a `:benchmark` test.

**No default-suite test asserts on wall-clock time; a timing is a `:benchmark` test** (#462, #271, D-114). A cost claim — a scroll tick costs its strip and not its window, a curve is built in work linear in its points, a fixed-domain frame does not walk its rows — is a claim about the algorithm, and a wall-clock reading of it is a claim about the machine: a scheduler preemption during the cheaper of two timed runs flips a ratio, and an absolute bound moves with the box and with whatever else runs on it. Such a claim is asserted by the **work done**: what the code under test exposes as a count (`Visualize.Contour.cost/2`, the rows a strip carries, the path points a payload encodes), or the reductions a run of a function spends, which `Visualize.Work.reductions/1` (`test/support/work.ex`) reads: the BEAM's own count of the work executed, unmoved by load. **The count is the run's execution in a process of its own, with no garbage collection in it** (#703). The function is run once in a spawned process. That run loads any module it calls first, and its traced collections give the words it allocates. It is then counted in a second spawned process with a minimum heap of those words and a margin, and a binary heap no run reaches. That process collects nothing, and the count is traced to prove it: a collection in the counted run is an error, not a count. The result comes back as a message, and a raise is raised again in the caller with its stack trace. Reductions include the garbage collections the run pays for, and how many it takes depends on the heap the process brings in, not only on the code. The helper first counted in the calling process, after a warm run there. Two consecutive counts of the same render in one process then took different paths: in CI, `day_night_gps` took 407 minor collections one time and 479 the next, and counted 2.74 to 2.98 million reductions; `world_map` took 766 and 899. In the gallery's single 45-chart test, each chart inherited the heap of the chart before it, and `day_night_gps` once counted 4.39 million against 3.15 million alone (pipeline 6073). Fresh processes alone were not enough. A process with the default heap took the same collections on every run, but the charge for them moved with the load of the machine. A function that does little but allocate counted from 233,000 to 528,000 reductions over the same 43 collections, idle and loaded. In CI, two counts of `day_night_gps` in fresh processes were 3,084,576 and 2,842,672, 8.5 % apart (pipeline 6230, job 44895). With the heap sized so that nothing is collected, the same function counts 200,007 every time, idle or with its CPU shared with four busy loops. Across four CI jobs (pipelines 6232 and 6233, every gallery chart counted six times each), `day_night_gps` counted 2,546,498 to 2,546,545 and `downsampling` 28,961,269 to 28,961,302. The widest spread of any chart was 0.18 % (`incremental_line`), and that residue is not a path through the code. A call-count trace of two runs about a thousand reductions apart showed every call into the library and the examples made the same number of times. It is the charge of a BIF that yields, which depends on where in its time slice the call lands. So the count leaves out what a run allocates and keeps what it executes. A cost claim about allocation is asserted as a count the code exposes. A cost test holds two counts to agree within a tolerance above that residue (spec/14 §12.6 states the gallery's). A measurement worth keeping is a test tagged `@tag :benchmark` (or a module tagged `@moduletag :benchmark`), which `test/test_helper.exs` excludes from the default run and `mix test --only benchmark` runs. `test/visualize/no_wall_clock_test.exs` enforces it: it scans `test/**/*.exs` and `examples/test/**/*.exs` for a clock read — `:timer.tc`, a monotonic, system or OS time, `:os.timestamp`, `:erlang.statistics` of wall clock or runtime — and fails on one that is not inside a test carrying `@tag :benchmark` in the attributes directly above it, nor in a module tagged `@moduletag :benchmark`. A helper that reads a clock outside any test fails it too: a benchmark times inline. The rule came from `Visualize.Chart.CompiledTest`'s #404 scroll test, which compared two `:timer.tc` readings and failed under load (a 17 ms scroll against a 48 ms redraw on a one-third bound) while its algorithm was sound; #271 was the same defect in the contour test. The gallery under `examples/` reads the same helper — `examples/test/test_helper.exs` requires `test/support/work.ex` — and holds every gallery chart's still render to a reduction budget by its declared cost class (spec/14 §12.6, #469). **The builder's editing pass is held the same way** (#704, D-184): `test/visualize/chart/builder_cost_test.exs` counts `Visualize.Chart.Builder.Editing.editing/1` — what every builder event and render derives first — on the fixture's design with a color field selected under the default theme, and asserts the second of two counts is within its budget, since a first run may fill what the second only reads (#703). A table rebuilt per name or per field returns as a named failure at that test, not as a timeout of some other test that happens to call the pass many times. A second budget counts the pass in **board mode** (#714, D-189), over a board whose cells bind a catalog and a pool of many sources, so typing the pool or the catalog on every pass returns there by name — and, since #723 (D-191), drawing every cell's preview or validating the board on a pass that changed neither. A third counts **an event that touches one cell** with the pass after it, held under the cost of drawing every cell again, and the test asserts that one preview was drawn.

**No test waits on the clock** (#520, #567, D-148). D-114 stops a test from measuring time; a test that *waits* on time fails the same way, whenever the runner is starved past its bound. Every such flake on record was one: #483's 500 ms wait for a force tick that arrived up to 2.86 s late, #512's and #567's ticks that read the real clock, #495's raster teardown and #469's comparison near its timeout. A test therefore waits on a message or a count, never on the clock:
- **`assert_receive` names no timeout; the test's own bounds it** (#736). A message a test waits for must arrive, so its wait has one bound, the test's timeout, whose report names the waiting line, as a `receive` with no `after` has (below). Both test helpers pass `ExUnit.start/1` `timeout: 60_000` and `assert_receive_timeout: 60_000`, so an `assert_receive` with no timeout of its own waits as long as the test may run, where ExUnit's default would give up after 100 ms. The floor this replaced, at least 5 s written at each site (D-148), said when a defect was given up on and not how fast the box was, but a busy runner can exceed any such bound, as MotionHookTest's `after 5000` showed (#715): twenty-four sites wrote one — eleven of exactly 5 s, the rest 10 or 30 s or an attribute of 10 or 30 s — and every one waited for a message that must arrive: a `:DOWN` after a kill or a stop, a run's frame or tick, a task's reply, a force tick, a board's view. Each now names no timeout. A test that needs a wait longer than the suite's default raises its own timeout and names the wait, on the check's allow-list, as the whole comparison of every light chart does (`@compare_wait`, 90 s under a 120 s test timeout, #469).
- **A `receive` has no `after` but `0`** (#715). A helper that waits for a message, such as a board cell's next view, waits on the message alone. A defect that never sends it is bounded by ExUnit's per-test timeout, and the timeout's stack trace names the helper. A `receive … after N` is a second, shorter clock under that one, and passing the floor did not make it safe. MotionHookTest's `after 5000` (#678) was exactly at the floor, so the check passed it. It failed in `verify:hooks` on a starved runner (pipeline 6139, job 44164: the suite ran 75.8 s there against 53.2 s on the retry): the cell's first draw had not arrived within 5 s while node scenarios ran beside it. `after 0`, which drains or reads the mailbox as it stands, waits for nothing and stays. Where the messages come in a known order, the test asserts each one as it is taken (`view = next_view(cell)`, then `assert view.state == :live`) rather than skipping until a predicate holds. Then a wrong message fails at once, with what it was, instead of at the timeout.
- **`refute_receive` does not wait.** Absence is asserted after a synchronizing call — a call answered, a task awaited, a monitor read — with `refute_received`, which reads the mailbox as it stands. A waiting refute passes when the message is merely late, so under load it proves nothing.
- **Nothing sleeps, and no loop polls the clock,** unless no message reports the condition — an OS process reaped, a message arriving in a suspended process's queue — and then the poll is bounded by a count long enough that load lengthens it and never fails it.
- **Where code decides by time, the test drives the time**: a clock passed in (`Visualize.Feed.Telemetry`'s `clock:`, the chart page's `run_clock`, spec/14 §12.6), or subscribers registered before the first tick (#483).

`test/visualize/no_wall_clock_test.exs` enforces it beside D-114's check. It parses every file of `test/**/*.exs`, `test/support/**/*.ex` and `examples/test/**/*.exs` — the clock scan now reads the support modules too — and walks the code, so a wait named in a string or a comment is not one. It fails on an `assert_receive` that names a timeout, however long — an integer literal, a module attribute, or an expression it cannot read (#736) — and checks that both test helpers pass `ExUnit.start/1` a literal `:timeout` and an `:assert_receive_timeout` no shorter, and that the running suite has them; on a `refute_receive` with any timeout but `0`; on a `receive … after` with any timeout but `0`, however long; and on `Process.sleep` and `:timer.sleep`. A site may stay only on the check's `@allowed` list, which names its file, the code on its line and its reason, and an entry that matches no site fails, so the list cannot outlive its sites. Four are listed: the performance page's whole comparison, which waits `@compare_wait` under a raised test timeout (above); the force test's 200 ms sleep, a delay the test imposes to show the tick count does not depend on it (#483); the raster test's poll of an OS process group, bounded by 3,000 tries ten milliseconds apart, where 100 had bounded it at a second (#495); and the telemetry test's poll of a suspended collector's queue for the call it waits to kill. Its own tests hold fixtures it must reject — an `assert_receive` naming a short timeout and a long one, each as a literal and as an attribute, and an expression, a waiting refute, a short `after` in a helper, and a long one, as a literal and as an attribute (#715), both sleeps — and fixtures it must pass: an `assert_receive` that names no timeout, `refute_received`, `after 0`, and waits quoted in strings and comments.

**The second audit** (#715) found eleven `receive … after` sites, all helpers that wait for a board's messages with a bound of 5 or 10 s: in MotionHookTest, `Visualize.Board.ExportTest`, `InteractionTest` (three), `RuntimeTest`, `Visualize.Components.BoardTest`, `Visualize.Chart.AxisDisplayTicksTest`, and the examples' `BoardLiveTest` (two) and `TenantsLiveTest`. Each now waits on its message alone. MotionHookTest also asserts the two views it takes, the first on subscribing and the next after the synchronous `Server.window/2`, instead of looping until a predicate holds.

**The audit** (#520) found the waits the check now rejects and fixed each: the builder's first play tick (1 s, now 5 s) and a telemetry subscriber's `:DOWN` (the 100 ms default) wait on their message with the floor; the drawer's wait for the run's next `:run_tick` likewise; the gallery's 200 ms `refute_receive`, which was to show that a warm mount starts no preview task, asserts it directly — the mount adds no monitor to the calling process, which `Task.Supervisor.async_nolink/2` would — with nothing to wait for. Code under test that is *given* a short timeout to prove it fires (`Visualize.Render.Raster.run/4` at 50 ms on a 60 s `sleep`, `to_png/2` at 1 ms) is not a wait: load only makes the timeout surer. **A test near its own timeout is a wait on the clock too.** `examples/test/examples_web/chart_live_size_test.exs` held every gallery chart on every backend to its design size in one test of 86 cases, each mounting a disconnected page and rendering its still frame three times in the test's process — on mount, on the backend and on the resize — for 51.6 s of a 60 s timeout run alone. It is one test per chart now, and each — like the multi-frame test beside it, which had rendered three designs at two widths three times each — mounts a connected page, where a still frame renders in a task and each change terminates the render before it, so the test awaits the one render at the final size, bounded by the test's timeout (#736): a third of the work, spread over async cases rather than one loop.

**No test walks the whole gallery, and the suites run a case a scheduler** (#735). Three `examples:test` timeouts in the 0.7.0 run — `charts_test.exs`'s "no chart animates on one backend and stands still on another", `builder_live_test.exs` before #704, and the cost test before #731 — were contention, not cost: a CI runner runs several jobs at once, each `examples:test` ran ExUnit's default of 32 cases on 16 cores, and a test that carried the whole gallery in one body took seconds alone and crossed its 60 s timeout beside the rest. Measured run serially, the longest such tests were `highlight_test.exs`'s every-chart-page-on-every-backend (17.2 s), the #424 animation test (10.7 s), the walk of Next across the gallery keeping Canvas (7.5 s), the site map (7.3 s), the router's pages (6.5 s), a chart page for every chart (6.7 s), the drawer's opening backend (4.8 s) and Next's visit of the index in order (4.0 s). Each is now **one test a chart**, generated at the module's top — `for {slug, module} <- ChartLive.charts() do test slug, do: assert_…(unquote(slug), unquote(module)) end` — inside a `describe` that carries the claim, so a failure names the chart and no test carries more than one chart's work. The body is a helper the generated tests call, never the generated test's own body: compiled once per chart, a body of assertions made `charts_test.exs` take 40.6 s where it had taken 22.6 s, in compiling alone; called, it takes 23.8 s. The walk of Next became the step it repeats: from each chart, Next is the next chart in the index's order, the last wrapping to the first, and keeps a chosen Canvas — which, with the index's order asserted beside it, is the walk. The examples suite went from 410 tests to 1,278. The examples suite takes 35 s at 32 cases, as before (35.0–46.1 s before the split, 35.2–35.5 s after, locally). Both test helpers now pass `max_cases: System.schedulers_online()`, half ExUnit's default of twice the schedulers, so a job beside others oversubscribes its runner half as much. Measured locally before choosing: the examples suite took 35.2–35.5 s at 32 cases, 34.7–35.0 s at 16 and 36.0–37.6 s at 8; the library suite 36.6–37.5 s, 36.4–37.8 s and 33.6–37.2 s. Half the schedulers again, 8, cost the examples 3–7 %, where the scheduler count cost nothing. `examples/test/examples/no_gallery_loop_test.exs` holds the rule. It parses every test file of the examples and fails on a loop inside a test's body over the gallery — a comprehension's generator, or the subject of an `Enum` or `Stream` function given a function, over `Examples.Gallery.charts/0`, `Examples.Gallery.by_slug/0`, `ExamplesWeb.ChartLive.charts/0` or `Examples.Charts.Templates.gallery_modules/0`, over a function of the same file that returns one of them however deep, over a list the file names as the gallery (`charts_test.exs`'s `charts/0` and `@charts`, `gallery_live_test.exs`'s `charts/0`), or over a variable bound to one in the test. Sorting, counting or comparing the list does no work per chart and is not a loop here. A test that walks the gallery doing no work per chart, or whose subject is the whole gallery, stays on its `@allowed` list with its reason — eight do: five read a slug, a title, a link or `design?/1` for each chart or compare the list with another, and three are the index's own, whose one mount renders every preview — and an entry that matches no test fails. It also asserts the four listers exist. Its own tests hold a comprehension, an `Enum` call, a pipe, a variable and a helper over the gallery, which it must reject, and tests generated per chart, a lookup and a loop outside any test, which it must pass. The scan reads loops in a test's body only: a test that calls a helper which itself renders every chart, as `charts_test.exs`'s golden comparison does (1.0 s), is not one, and stays.

**No test file reaches into another** (#733). A test file is an `.exs` that `mix test` requires in an order it does not promise, and ExUnit starts an async module's tests as soon as its file has loaded, before the others have. A call from one test file into a module another defines therefore works only when the other file happens to load first. `test/visualize/geo/clip_test.exs` read the projection types from `Visualize.Geo.ProjectionGoldenTest.types/0`: in the full suite the golden test usually loaded first, so the call passed,, and it failed, intermittently, when the clip test's file loaded first — and every time the clip test was run alone, with `UndefinedFunctionError`. Two more files, the projection edge and rotation tests, read the same function and had worked around the order with a `Code.require_file/1` of the golden test's file, which loads that file outside `mix test`'s order. The list is now test support, `Visualize.Geo.ProjectionTypes.all/0` (`test/support/geo/projection_types.ex`), compiled with the suite before any test file loads, and all four files read it. `test/visualize/no_cross_test_calls_test.exs` holds the rule. It parses every file of `test/**/*.exs` and `examples/test/**/*.exs`, collects the modules each defines (a nested module by its full name), and fails on a remote call from one file into a module another file defines — resolved through the file's aliases, `alias … as:`, `alias A.{B, C}`, `__MODULE__` and a nested module's short name — and on a `Code.require_file`, `Code.compile_file` or `Code.eval_file` of a path ending `_test.exs`. A library's module whose name ends in `Test` (`Phoenix.LiveViewTest`) is no test file's, and test support is what the files share. Its own tests hold a call by full name, by an alias and by `as:`, and a require of a test file, which it must reject, and a file's own and nested modules, a support module and `Phoenix.LiveViewTest`, which it must pass. The scan found the three calls into `ProjectionGoldenTest` and the two requires, and nothing else.

**The canvas hooks are executed, not only read** (#458). `test/visualize/hooks/executed_hooks_test.exs` runs the bundle `Visualize.Hooks.js_code/0` returns in node against a recording DOM stub, `test/support/js/dom_stub.js`, written for this and dependency-free: no npm, no jsdom, no build step. The stub gives a hook an element with `dataset`, `querySelector('canvas')`, `appendChild` and the attribute calls; a canvas whose `getContext('2d')` records every method call with its arguments and every property assignment, and whose `width`/`height` setters record the assignment and a `clear`, as setting either clears a canvas; an offscreen `document.createElement('canvas')` of the same kind; for `BuilderHook`, `querySelector`, `querySelectorAll` and `closest` over tag, class and attribute selectors, `classList`, `contains` and a `getBoundingClientRect` the scenario sets, and `Stub.markup(tag, attributes, children)`, an element built from its HTML attribute names, so a scenario lays out the rows the builder rendered; `pushEvent`, which records, and `handleEvent`, which registers so a scenario delivers a payload; and `setTimeout`/`clearTimeout`/`performance.now()` on a manual clock the scenario advances. `Visualize.JsHooks` (`test/support/js_hooks.ex`) is the driver: it writes the bundle, the stub and one scenario to a temporary `.mjs` file, runs `node` on it with `System.cmd/3`, and decodes the recorded log the scenario prints as JSON with Jason; the assertions are ordinary ExUnit. The tests are tagged `:js`, and `test/test_helper.exs` excludes the tag, saying so in one line, when no `node` is on the path, so `mix test` never needs node; `mix test --only js` runs them, and is the `verify:hooks` job of §4. The first scenarios pin the defects that shipped green because nothing executed the hooks: a `CanvasBinaryChart` mounted at 600×400 whose `data-width`/`data-height` change to 1200×800 is resized on `updated()` and redraws its unchanged `data-binary` onto the cleared canvas (#456); a `CanvasIncrementalChart` resized the same way resizes its offscreen buffer and draws no scroll payload until a `"full"` one (#456); a stream of the current record types — `path32`, `path_cubic32`, `cubic_run` and their f64 forms, encoded by `Visualize.Backend.CanvasBinary` — replays without throwing and issues the `moveTo`, `lineTo` and `bezierCurveTo` it encodes (#270); a payload naming another `data-frame` draws nothing (#432); and payloads carrying a `seq` are acknowledged with `pushEvent(ack, {seq, drawn, at})` at most once per `data-ack-interval` (D-107); and `BuilderHook`, over the stack panel's rows as the builder renders them with a group closed, sends the server's flat position for a drop below the closed group — the row's `data-builder-layer`, which `Visualize.Chart.Builder.Stack.all/1` gives the row after it — and pushes no `reorder` for a group dropped directly below its own last row (#468). **The sync group is executed too** (#466, spec/10 §4.3). For it the stub also models `document` as an event target, with `addEventListener`, `removeEventListener` and `dispatchEvent`, each dispatch recorded, and a count of listeners per event name. It provides `CustomEvent`, `document.body`, `createElementNS`, and elements that take listeners, answer `contains`, `closest` and attribute selectors, and return a bounding rectangle the scenario sets. Three crosshairs of different widths in one group put their rules on the same domain x when one is hovered. A tooltip in the group shows the nearest datum. A chart in another group is untouched, a member whose domain does not hold the value draws nothing, a pointer leaving clears every member, and the publisher never handles its own echo. `destroyed()` leaves no listener, and a remounted member handles one event once. **The shared brush is executed the same way** (#467, spec/10 §6.4): the stub's elements also take `insertBefore`, and an `<svg>` answers `createSVGPoint` and `getScreenCTM` from its `viewBox` attribute and the bounding rectangle the scenario sets, as a browser maps a client point into user units. Brushing one of three charts of different widths draws the band on all three at the same domain extent and at each one's own pixels, clips it on a member whose domain holds part of it, shows nothing on one whose domain is disjoint, leaves another group untouched, and pushes exactly one `brush_select`, from the chart brushed, with the window's `domain`; a clear propagates with one `brush_clear`; and teardown and remount leave one listener. Removing `sizeToElement()` from either hook's draw path fails the resize scenarios. **The crosshair is held to the drawing, not only to its arrays** (#507, spec/10 §13.3): the hook tests had checked the hook against the arrays it was given and never the arrays against the chart, so a page that drew at one size and wrote its arrays at another shipped green. A container a few pixels taller than its `<svg>`, as an inline element's baseline leaves it, and one holding a CSS-scaled `<canvas>`, each put the marker on the vertex under the pointer within a pixel, in client pixels through the overlay's `viewBox`. **The legend toggle is executed over real markup** (#508, spec/10 §14.3). `Visualize.JsHooks.markup/1` reads the SVG `Visualize.Chart.render/2` returned and builds the same element tree out of `Stub.markup` calls, so the scenario runs over the chart as rendered and not over a hand-written imitation of it. For it the stub's selectors also take the descendant combinator, a space between compounds, which is how the hook writes `.mark [data-series]`. `test/visualize/hooks/executed_legend_hook_test.exs` renders a chart with a `:line` and a `:circle` mark, both with `series`, and mounts `LegendHook` over it. Clicking one entry gives `display="none"` to every element of that series: its path, each of its points and each of its labels. It gives it to no element of another series. A second click removes it from all of them, and `updated()` keeps the series hidden. **The source-shape tests stay where they hold what execution does not:** that the bundle defines and exports every hook once and the decoder precedes its callers (spec/10 §7), that the decoder has a `case` for every byte of `Visualize.Backend.CanvasBinary.Opcodes` and every sub-opcode — an exhaustiveness claim no finite stream proves — and the listener discipline of the pointer hooks (D-47), whose DOM the stub does not model. **A paint looks the same on every backend** (#652, D-175): `test/visualize/backend/paint_test.exs` replays one gradient through `CanvasChart` from `:json` and from `:js` and through `CanvasBinaryChart`, holds the gradients the three build to one another — the stub returns a gradient object that records its constructor's arguments and its stops — and evaluates the binary hook's gradient at pixel centers against resvg's raster of the same SVG, a linear gradient skewed by a 4:1 box and an elliptical radial one, within 8 per channel. **Every paint key reaches every canvas** (#675, D-180). `test/visualize/backend/paint_opacity_test.exs` takes the element keys the fill and stroke parts project to from the grammar's one table (`Visualize.Chart.Style.Parts.paint_keys/0`) and, for every one SVG writes, asserts that the `:json` commands and the binary stream change with it, so a paint key added later fails until a canvas draws it; a translucent fill and stroke, under a translucent group and as a gradient, draw at the element's alpha times the paint's on `:json`, `:js` and binary; and under `:js_resvg` a translucent band composited by the binary hook is resvg's within 3 per channel. It also puts an element's `opacity` together with each paint's opacity, each paint alone and again under a translucent group, and asserts every encoder's stream changes with either, so an encoder that lets one replace the other, or keeps the element's from a paint, fails; the SVG-element encoder's records are held to the products, and under `:js_resvg` an SVG-element band and line drawn at a paint's, the element's and the group's opacity at once are resvg's within 3 per channel (#742). **A leaf's transform and state are its own** (#679, D-179). The stub's context scopes its assigned properties with `save` and `restore`, as a browser's does, and records every draw call — `fill`, `stroke`, `fillText`, `drawImage` and the rect calls — with the transform and the `globalAlpha`, `fillStyle` and `strokeStyle` it ran in, which `Stub.done/1` prints as `draws`. `test/visualize/backend/canvas_scope_test.exs` draws two siblings under a blue group, the first turned, moved, red and at alpha 0.5, the second with nothing of its own, through the `:json` and `:js` commands and the binary stream: on all three the second draws at the identity, at alpha 1 and in blue. **Every encoder runs the sibling cases for every state key** (#744, D-193). The canvas encoders are one list, `Visualize.CanvasEncoders` (`test/support/canvas_encoders.ex`), which both this test and `paint_opacity_test.exs` read, and every module under `Visualize.Backend.Canvas*` is either in it or named as no encoder (#693); each encoder carries the hook that replays its stream on the stub — the `:json` commands on `CanvasChart`, the IR and the SVG-element binary streams on `CanvasBinaryChart`, a full redraw on `CanvasIncrementalChart`. The stub also records, with each draw, the `lineWidth`, `lineCap`, `lineJoin` and line dash it ran in, all scoped by `save` and `restore`. For each encoder and each state key — the fill, its opacity, the stroke, its opacity, the width, the dash, the cap, the join, the element's opacity and a transform — two siblings, the first setting that key and the second setting none of it, stand at the root, under a group that sets nothing and under a group with a fill and a stroke; the second's draws are the draws it makes in the same place alone. Two root-level SVG `<line>`s, the first 6 wide and the second with no width, are held to resvg's raster under `:js_resvg`: the second is SVG's 1-pixel line. Under `:js_resvg` a symbol scatter replayed by `CanvasBinaryChart` has ink in resvg's raster of its SVG at the center of every symbol the hook drew. **Motion is executed on the stub's clock** (#653, D-176): the stub's `requestAnimationFrame` is a timer of 16 ms on the manual clock, handed the clock's time, and `cancelAnimationFrame` clears it, so a scenario advances a canvas transition frame by frame and reads every value it was shown at; a scenario that sets `window.matchMedia` answers the reduced-motion query (spec/10 §4.5).

`test/visualize/documented_examples_test.exs` holds every module the library ships whose moduledoc **or function documentation** carries an `iex>` example to being named by a `doctest` in some test file, without `except: [:moduledoc]` (D-90, D-93): a documented example that no test compiles is prose, and `Visualize.IR.Element`'s had not been Elixir since it was written. The scan resolves each `doctest` through its file's aliases, so `doctest Element` beside `alias Visualize.IR.Element` counts. An example that cannot be deterministic — a random seed, a timestamp, a process identifier — is named in the guard's own exclusion list with the reason it is excluded, so a skipped example is data a reader can audit rather than an absence; the list is empty.

`test/visualize/readme_example_test.exs` runs the `elixir` block under the README's `## Example` heading (#454): it reads `README.md`, takes the block by section rather than by position — the `## Installation` block is a `deps` fragment and is not evaluable — evaluates it, and asserts what it promises: an SVG string from `Visualize.Chart.render/1` and canvas commands from `Visualize.Chart.render/2` with `backend: :canvas`. The README is the first code a reader runs, and until the guides' test below nothing else executed it: `mix docs --warnings-as-errors` checks its links, and doctests (D-90) cover documentation attributes, not a Markdown extra. Its example stopped compiling when #427 removed the size from `cartesian/1` and stayed broken until #453; the test is what makes such a change a red pipeline. It is the same pattern as `test/visualize/chart/example_test.exs`, which reads [14-declarative-chart](14-declarative-chart.md) §13's worked example from the document itself. `usage-rules.md` carries no code blocks, so there is nothing of its own to run.

`test/visualize/chart/spec_examples_test.exs` does the same for every other `elixir` block of [14-declarative-chart](14-declarative-chart.md) (#460). It reads the document, pairs each block with the heading it sits under, and generates one test per block named by that heading, so a failure names the section and never a position. A block is evaluated with the context the section's prose assumes: `import Visualize.Chart.Build`, `import Visualize.Chart, only: [var: 1]` and `alias Visualize.Chart.Use`. A **pipeline** — a block whose value is `Visualize.Chart.compose/1`'s, §16.2 and the second block of §16.3 — is asserted to return `{:ok, design}` and the design to validate (§10); a **fragment** — §3.6, the first block of §16.3, §16.4, §16.5, §19.3 and §19.6, which illustrate a shape rather than build a design — is asserted to evaluate. §13's two blocks are run by `example_test.exs` and are not run again; a `heex` block (§18.1) is not Elixir and is not read. Every other `elixir` block is run: the test fails on a block that is neither evaluated, nor §13's, nor marked, and holds the evaluated sections to an exact list, so a block added to the document is noticed rather than silently covered or silently skipped.

**A block that is deliberately not runnable carries a marker on the line directly above its fence:** `<!-- not run: <reason> -->`, an HTML comment that does not render, with a reason that is not empty. The test reads it, skips that block and that block only, and fails on a marker with no reason or with no `elixir` fence below it. A skip is therefore written in the document beside the code it excuses, where an editor of the section sees it, and a reader can audit every skip with one search. No block of spec/14 carries one today: all ten run.

**Every guide's code is run** (#518, #515). The guides under `guides/` — `getting_started.md`, `liveview.md`, `designing_charts.md` and `cheatsheet.cheatmd` — ship in the package (`files:`) and are hexdocs extras in a *Guides* group placed after the README, so a reader meets them first; ExDoc lists ungrouped extras before every group, so the changelog forms a *Reference* group after it and the README alone is ungrouped. The README, the *Guides* and the changelog are the whole of the hexdocs sidebar (D-133): this specification, `API_SURFACE.md` and `usage-rules.md` ship in the package (`files:`) but are not extras, and a reader finds them in the GitHub repository. `test/visualize/guides_test.exs` runs every `elixir` block of every file matching `guides/*.md` or `guides/*.cheatmd`, and of `README.md`; the glob is read when the test compiles, so a new guide is covered with no edit to the test. There is one test per document. Its blocks run in document order in one evaluation environment — the binding and the imports and aliases an earlier block made — so a later block uses what an earlier one defined, as a reader typing them into IEx would. Each block is evaluated with the document as its file and its own first line as its line, so an error, a stack trace and a failure all name `guides/<file>.md:<line>`. A block MUST NOT warn: its diagnostics are collected and a warning fails the test. A line `#=> <value>` after an expression is the guide's claim about what that expression returns; the block is cut there, and the value evaluated so far MUST equal (`==`) the value the line evaluates to, so a guide cannot show a result the code no longer gives. `getting_started.md`, `liveview.md` and the README MUST each hold more than one `elixir` block, so a fence the reader stopped recognizing fails rather than passing vacuously.

**A block that cannot run outside a host carries a marker on the line directly above its fence:** `<!-- compile only: <reason> -->`, an HTML comment that does not render, with a reason that is not empty — a LiveView needs an endpoint and a socket, a `deps/0` fragment a `mix.exs`. It is the guides' counterpart of the `not run:` marker of spec/14 above, and a fence info string cannot carry it, since the Markdown parser `ex_doc` uses does not read a fence whose info string has a second word as a fence at all. A marked block is parsed with `Code.string_to_quoted!/2`; when every top-level form is a `defmodule`, it is also compiled, without warnings, and each module it defined is purged, so a guide's LiveView is held to the real names and arities of the library and of Phoenix. The test fails on a marker with no reason or with no `elixir` fence directly below it. The README's `## Installation` block carries one; its `## Example` block is run here as well as by the test above, which asserts more of it.

**A shipped document links only to what the docs carry** (#529, D-133). A relative Markdown link resolves in the published docs only when its target is an extra too, and ExDoc rewrites a link to an extra's file into that extra's page; a link to anything else — a specification document, `API_SURFACE.md`, `usage-rules.md`, a file of the repository — is a dead link on hexdocs. `test/visualize/doc_links_test.exs` reads the extras from `Mix.Project.config()[:docs][:extras]`, so the list it checks is the list the docs are built from. It fails on every inline link (`[text](target)`) and reference definition (`[label]: target`) outside code in an extra, and in the `@moduledoc` of every module of the application, whose target is relative — not a URL with a scheme, not `mailto:`, not a bare `#anchor` — and does not resolve to an extra, against the linking file's directory or, for a moduledoc, the project root; the failure names the file and the link. An absolute URL is always allowed. A link to the specification is `https://github.com/dcoai/Visualize/blob/main/spec/<file>.md`, and to the conformance record or the usage rules `https://github.com/dcoai/Visualize/blob/main/<file>.md`. A section anchor on such a link is GitHub's heading slug, which is not ExDoc's: the heading lowercased, every character that is not a letter, a digit, a space, a hyphen or an underscore dropped (so `.` and `§` go), each space turned into `-`, and a repeated slug suffixed `-1`, `-2`.

## 7. The virtual sources' sandbox

**Implemented** (#643, work item #748, D-194). A stored virtual-source function is untrusted text (spec/08 §10.5), so the sandbox is held by an **attack suite**, `test/visualize/virtual_source/sandbox_attack_test.exs`, modeled on the list of attacks Dune's test suite defends against (#643's prior-art review): each attack is a stored function, and each test asserts the fault the checker answers by reason, or the fault kind the runner reports, never only that something failed.

- **Atoms.** A name is read without an atom made (spec/08 §10.5.1, D-195): a variable is one of the library's pool, an atom literal, a map key and a field a word of the library or a string, whatever the node has seen; `String.to_atom/1` and `String.to_existing_atom/1` are refused by name; an atom made by interpolation (`:"a#{x}"`) is a call of `Kernel.to_string/1`, allowed since #769, and of `:erlang.binary_to_existing_atom/2`, refused; and the suite counts the node's atoms (`:erlang.system_info(:atom_count)`) around a second checking and compiling of every attack and a call of a checked function, after a first that loads every module they reach, and holds the count unchanged. A raise is not in the counted run: the VM's report of a crashed process is formatted by the logger in its own time, and the modules that formatting loads make atoms whenever it runs.
- **Calls the checker never sees.** A call whose receiver is a variable (`m.fun()`), a map read on a variable bound to an atom (`m = :erlang; m.halt`, which the rewrite reads as a map and the runner reports `:raised`), `apply/2,3`, an Erlang module (`:erlang`, `:os`, `:ets`, `:code`), `Code`, `System`, `File`, `Process`, `Node`, `Port`, `IO`, a capture of a function off the list (`&String.to_atom/1`, `&:os.cmd/1`), `Kernel.apply` by its module, and `then/2` each fail by name.
- **Processes and control flow.** `send`, `receive`, `spawn`, `self`, `try`, `raise`, `throw` and `exit` are refused.
- **Code as data.** `quote`, `unquote`, `import`, `require`, `alias`, `defmodule`, `binding/0`, a module attribute, every sigil (`~r`, `~w`, `~s`, `~c`) and `__ENV__` are refused.
- **Structs and protocols.** A struct literal, an alias as a value, `:"Elixir.File.Stream"` and `:__struct__` are refused, and a map rebuilt from a row's `DateTime` with another module under `:__struct__` raises when `Enum` reads it rather than calling anything.
- **Collectables.** `for … into:` and `Enum.into/2,3` with a variable, a call or a non-empty literal target are refused; the empty literals are allowed.
- **The decided exclusions.** Every excluded `{function, arity}` of `Visualize.VirtualSource.Sandbox.Allowlist.excluded/1` is refused when called, captured or piped into.
- **Resources.** An unbounded allocation (`Enum.to_list(1..100_000_000_000)`) ends `:memory`; a loop that allocates nothing (`Enum.reduce(1..100_000_000_000_000, 0, fn x, acc -> rem(acc + x, 7) end)`, a range reduced without a list) ends `:timeout` under a short `timeout:` the test gives the code under test, which §6's rule on waits permits (#715: a timeout given to code to prove it fires is not a wait); `1 / 0` ends `:raised` with `ArithmeticError`. The runner waits for a killed process's `:DOWN` before it answers, so no call returns while its process still runs.
- **Fields.** A map read of a name outside `fields:` is `{:unknown_field, name}`, and either spelling of a schema's field passes.

**The allowlist is held to the exports** (#643's second decision). `test/visualize/virtual_source/allowlist_test.exs` holds, for each of the eight modules (`Visualize.Data` among them, #769), its exports — functions and macros, without `module_info` and `__info__` — to its allowed and excluded lists together, exactly, on Elixir 1.20.4 and OTP 29: a function an Elixir release adds fails it until the function is reviewed and listed. The `Kernel` list is held to `Kernel`'s exports too, so a name on it that `Kernel` does not define, or an `is_*` guard it adds, fails.