# CreatorStudio Suite — Test Report

**Scope:** the Node/TypeScript announcement engine (`src/**`) **and** the two Angular front-ends
(`web/` — the Engine dashboard and the CreatorStudio authoring app).
**Engine suite:** 83 files, 750 cases (Node's built-in test runner + coverage).
**Front-end suite:** 76 files, 948 cases (Vitest + jsdom via Angular's `@angular/build:unit-test` —
dashboard 225, creator 723).
**Total: 1698 tests — 1698 passed · 0 failed · 0 skipped.**
**Coverage:** engine **92.5 % lines · 84.2 % branches · 72.7 % functions** (all loaded files, incl.
the Creator-side mirrors the parity tests pull in — see §3); front-end **92.6 % lines (dashboard)
/ 90.7 % (creator)**, **74.9 % / 72.2 % functions**.
**Generated:** 2026-08-07 (measured on this commit, not carried forward — after the Supabase→Azure
migration replaced `supabaseConfigSource`/`assistantPromptParity` with `pgConfigSource`/`creatorApi`).

> Reproduce:
> ```bash
> npm test                                  # pass/fail only
> NODE_OPTIONS="--import tsx" node --test --experimental-test-coverage test/*.test.ts
> npm --prefix web run test:coverage        # both front-ends, with coverage
> ```
> The suite needs no network, no broker and no cloud — every external (MQTT, TTS supplier,
> the database, HTTP audio fetch) is faked in-process, so the run is deterministic and offline. The two
> exceptions run a **real in-memory broker** (aedes) in-process: `mqttBusIntegration` and `engineBoot`.

---

## 1. Result summary (engine)

| | |
|---|---|
| Test files | 83 |
| Test cases | 750 |
| Passed | **750 (100 %)** |
| Failed | 0 |
| Skipped / todo | 0 |
| Duration | ~10 s (~26 s with coverage) |
| Runner | `node --test` (built-in TAP), TS via `tsx` |
| Flakes observed | one timing-sensitive case (`cache` disk-prune) can overrun its window when the whole suite runs under coverage on a loaded machine; it passes standalone and in the plain run |

There are no failing, skipped or quarantined tests. Nothing is xfail'd.

---

## 2. Results by suite

Every suite below passed in full. Case counts are exact (they sum to 750).

### Triggers & journey state

| Suite (`test/…`) | Cases | Area under test |
|---|--:|---|
| `ptEngine.test.ts` | 34 | PIS-PT → trigger firing: arrival/departure/passage, doors, trip-start, approaching, detour, situation alerts, geofence, stop-skip/-request, exit-side, alarm, occupancy, last-stop exclusivity |
| `customTriggers.test.ts` | 18 | Fact-based custom triggers: comparators, `changed`, groups (all/any/not), geofence in/out, rising-edge vs while-true, per-vehicle cooldown & edge state, stop-relative sign facts against the first stop still ahead |
| `geofencePolygon.test.ts` | 17 | Polygon geofence editing: vertex move/insert/delete, degenerate-ring refusal, and that a reshape actually changes what is inside |
| `ptContext.test.ts` | 11 | PIS-PT context: topic parse, tracked-vehicle gating, next-stop/destination/phase, route stops, connection enrichment, countdown |
| `triggerHardening.test.ts` | 10 | Retained-burst settle window, gate-hold (door), name-hold (late stop list), strict-stops distrust |
| `prerequisites.test.ts` | 8 | Universal prerequisite gates: door-open/closed, stop-button, velocity window, ungated |
| `priority.test.ts` | 8 | Priority tiers: default levels, interrupt vs ambient, interrupt aborts superseded render, slot release |
| `manifestSync.test.ts` | 3 | Engine ↔ CreatorStudio trigger-type parity (33 types), removed `service-disruption` |
| `variableParity.test.ts` | 3 | Every `{variable}` the Creator offers is one the resolver can fill (and the mirror matches exactly) |
| `triggerJobsParity.test.ts` | 2 | The Creator's audio preview resolves a trigger to the same jobs the engine renders (split/sequence/repeat) |
| `scheduler.test.ts` | 2 | Per-vehicle scheduler: priority interrupt/queue/drop, expired-entry purge |

### Render, speech & audio

| Suite (`test/…`) | Cases | Area under test |
|---|--:|---|
| `ttsClients.test.ts` | 24 | The three original supplier HTTP clients: request build (auth, voice, format), success → buffer, error-status mapping, ElevenLabs' proxy-block branch, Acapela's login flow |
| `lexicon.test.ts` | 18 | Pronunciation lexicon respelling + SSML (`<phoneme>`, say-as), language scoping, XML escaping, the Unicode-boundary fix that lets non-ASCII names (Malmö/Örebro/Arabic) match, `validateIpa` degradation |
| `ttsNewClients.test.ts` | 17 | The 2026 supplier additions behind stub fetch: Google/Polly/ReadSpeaker request shapes (SigV4, form POST), the OpenAI-compatible self-hosted trio, the generic HTTP adapter's raw/base64 envelopes and voice-catalogue endpoints |
| `pipeline.test.ts` | 12 | End-to-end render→publish: ADT audio, speaker routing, LED+FF sign, the Hanover twin frame pixel-identical to the published sign, metrics, proof record, skip-incomplete, abort-on-supersede, TTS-failure containment |
| `multilingual.test.ts` | 10 | Language-variant stop/destination resolution, `{currentStop}`/`{countdown}`/`{geofenceName}`, empty-payload → empty (never a literal token) |
| `perElementVoice.test.ts` | 9 | Per-element voice/volume run-splitting, run merging, pause folding, capability-gated lexicon SSML, volume dropped (runs stay merged) on a supplier without prosody-volume |
| `ttsSsml.test.ts` | 8 | SSML prosody (rate/volume), pitch never rendered & not in cache key, escaping / no SSML-injection, break tags |
| `resilientTts.test.ts` | 6 | Resilient TTS wrapper: retry, fallback audio, fail-fast on auth error, circuit breaker |
| `adtPayload.test.ts` | 5 | ADT 4.x MQTT audio message shape, speaker routing, base64/MP3 encoding, content-hash `clipKey` |
| `stopRecordings.test.ts` | 5 | Recorded stop-name clips: match by id/name/language, inline `data:` URI, fetch-fail → TTS fallback |
| `ttsSwitch.test.ts` | 5 | Provider switching: name list, construct-without-network, credential gating, and a published default voice re-keying the cache |
| `cache.test.ts` | 4 | TTS cache: bounded LRU + stats, disk spill, throwing-shared-layer degrade, disk prune |
| `ttsFailover.test.ts` | 4 | Provider auto-failover: fallback config guards, primary-equals-fallback clears, an open primary breaker routes pin/describe/synth to the fallback (cache key follows), no-fallback behaviour unchanged |
| `volume.test.ts` | 9 | Volume adaptation: disabled→null, stop-over-route precedence, time-of-day (incl. midnight wrap); combined rules — AND over time/weekday/route/stop/geofence, circle + polygon matching, trusted-fix gating, no-condition rules never match, specificity beats legacy singles |
| `audioFile.test.ts` | 3 | Preset MP3: fetch-once-then-cache, 500 retry, 404 drop-but-still-play |
| `prepush.test.ts` | 3 | Closed-corpus pre-push: whole remaining journey per stop + journey-constant once + volatile never, `clipKey` parity with the live pipeline's published payload, dedupe / journey change / per-journey cap |
| `prerender.test.ts` | 3 | Next-stop prerender warming, cache-hit after warm, de-dupe until journey changes |
| `resilienceHardening.test.ts` | 3 | Races: mid-render provider hot-swap key safety, never-settling publish, publish-while-disconnected |
| `synthDedup.test.ts` | 3 | Synth single-flight: concurrent collapse, later cache hit, non-cacheable dedup |
| `ttsCapabilities.test.ts` | 3 | The capability matrix: all 13 suppliers declare `{ ssml, prosodyVolume, phoneme, offline }` (fails on an undeclared newcomer); wrappers pass it through; undeclared → plain text |
| `audioFormat.test.ts` | 2 | Preset clips match TTS output format (24 kHz mono) |
| `fallbackCache.test.ts` | 2 | Resilient fallback buffer tagged & never written to the fleet cache |
| `breaker.test.ts` | 1 | Circuit breaker open → half-open single-probe |

### LED signs — matrix templates, fonts and frames

| Suite (`test/…`) | Cases | Area under test |
|---|--:|---|
| `vilniusRequirements.test.ts` | 38 | The Vilnius (Judu) sign set end to end — each authored requirement (capitals, bracket/comma alternation, keyword pictograms, trip-id depot takeover, mirrored rear, boarding notice, countdown floor, G-line inversion, extra-text rotation) asserted on the frames the engine actually publishes |
| `matrixParity.test.ts` | 24 | Browser ↔ engine parity for the MatrixRenderer pipeline: PNG decode, colour rules, real FNT ladder, cycle resolution, identical frames both sides, per-sign FF/Hanover protocol split |
| `ledTemplate.test.ts` | 20 | The template model + expression evaluator: optional chaining, object-map idiom, comparisons, no reachable globals (no `eval`) |
| `ledMatch.test.ts` | 16 | Value matching for rules and cycles: `eq` / `contains` / regex, whitespace-insensitivity, empty-expected never matches |
| `ledStops.test.ts` | 16 | Route stops from `list/stops`: call order, timing-point flags under any spelling, main-stops fallback, and the departed stop dropping off "Stopping at:" before linkprogress catches up |
| `hanoverEncoder.test.ts` | 15 | Hanover HCPS/SuperX frames: byte-exact Hilde-doc checksum vectors, SuperX §9 raster vector, round-trip, scroll flags, CP1252, infohub bridge JSON, corrupted-byte detection (`checksumOk`), the 32767-byte `\bin` cap, the concatenated exterior-frame split contract, all-spaces clear frame |
| `ffEncoder.test.ts` | 13 | Mobitec FF/graphic-font-`w` frames: checksum, column packing, round-trip, address mapping, hardware-scroll bytes |
| `ledShapes.test.ts` | 13 | Line / rectangle / circle primitives: continuity, stroke width, fill vs outline |
| `ledDestinations.test.ts` | 12 | Destination-code resolution: exact `(number, lineCode)` beats wildcard, unknown code leaves the feed alone |
| `ledRouteColors.test.ts` | 12 | Route-colour lookup (exact, case-insensitive, line before technical code) and lifting a GTFS feed's colours |
| `ledTextRules.test.ts` | 10 | Text transforms (upper/lower/title incl. Lithuanian), show-only-part, part rotation |
| `ledVia.test.ts` | 10 | `… via X` splitting and the *via passed* fact (true only once every call at the via stop is behind) |
| `destinationList.test.ts` | 9 | Pre-programmed destinations: code-only selection, override precedence, stored text beating the feed |
| `exteriorSign.test.ts` | 9 | Mobitec exterior sign: per-face text, 24px height, route-zone cap, scroll vs static, change signature |
| `fnt.test.ts` | 9 | Real Luminator `.FNT` parsing: metrics, glyph shape sanity, measurement + uppercase fallback |
| `ledRtl.test.ts` | 9 | Arabic/Hebrew LED text: Unifont fallback glyphs, contextual Arabic shaping, bidi reorder — all at the one `shared/fnt.ts` chokepoint, auto-detected with no authoring flag |
| `customerByConfiguration.test.ts` | 8 | The same primitives configured for a *different* operator — proving the Vilnius set is configuration, not code |
| `ffRgb.test.ts` | 8 | Colour FF frames (03090 §7.6): RGB444 conversion, escaping, colour table, colour bitmap |
| `ledAbbrev.test.ts` | 8 | Abbreviation rules: value vs word mode, word boundaries, `always`/`all` phases |
| `ledMideastDemo.test.ts` | 8 | The Jerusalem AR/HE/EN trilingual demo vehicle: low-res 16 px panels, `textAlternations` language rotation, route simulation |
| `ledSignClient.test.ts` | 8 | The standalone HTML LED-sign client (`/sign`, `/vehicle`): shows exactly the pixels the engine drives, live via the server bridge |
| `ledRender.test.ts` | 7 | Interior LED dot-matrix rasteriser: static vs scroll, glyph packing, Swedish diacritics, spacing |
| `ledSignVilnius.test.ts` | 5 | The Vilnius sign set rendered through the standalone sign client (same frames as the engine) |
| `engineDestinations.test.ts` | 2 | Destination-list priority modes on the wire (signs win / feed wins) |
| `ledEncoder.test.ts` | 2 | LED bitmap base64 payload, FF extension point |

### Config, proof and platform

| Suite (`test/…`) | Cases | Area under test |
|---|--:|---|
| `assistantAi.test.ts` | 16 | The portal's local AI assistant: OpenAI-compatible provider resolution (default local Ollama), request/plan shapes, error paths — `fetch` injected, no socket |
| `playerBridge.test.ts` | 16 | The browser player's server bridge: topic prefixes, filter matching, id validation (no wildcards/spaces), state-vs-announcement replay rules (tts/audio/display/tts-cache never replayed) |
| `configEnv.test.ts` | 13 | `loadEnv()` permutations: required vars, per-provider credential enforcement, `TTS_FALLBACK_PROVIDER` (typo throws at boot, not mid-outage), Hanover on-by-default with DIP-address remap, `RENDER_SCOPE` (typo throws), every default toggle |
| `diagnostics.test.ts` | 14 | `npm run doctor` / `GET /diag`: URL redaction/resolution helpers, TCP probe never throws, retained-heartbeat liveness (fresh/stale/absent/no-timestamp), heartbeat captured behind a large retained backlog without hanging the probe |
| `creatorApi.test.ts` | 8 | The portal's Creator API: scoped reads, one-transaction publish (monotonic version, rollback → no notify), body validation, `authorized()` write gate, tenant-scoped delete |
| `projects.test.ts` | 9 | Multi-project config: legacy migrate, deep-clone, duplicate, ref validation, publish-selection, multitenant scoping |
| `proofSink.test.ts` | 9 | Durable proof mirror: batching, auto-flush, parameterized multi-row INSERT shape, failure isolation |
| `sampleConfig.test.ts` | 9 | Shipped sample config integrity: unique ids, valid trigger→playlist refs, known variables, real speech renders |
| `fleet.test.ts` | 12 | The published fleet directory: every field derived, missing signals collapse to empty rather than crash, payload capped (`FLEET_MAX_BYTES`) so the retained directory never outgrows the broker |
| `gtfsFeed.test.ts` | 8 | GTFS zip reader: routes/trips/stops parsing, natural sort, status + distinct values |
| `liveVehicles.test.ts` | 8 | `/player/live` — which vehicles are announcing right now (the player setup screen's single-vehicle prefill) |
| `mqttWsProxy.test.ts` | 8 | The dashboard's MQTT relay: the trust boundary (credentials stay server-side) and the framing, not MQTT itself |
| `configStore.test.ts` | 7 | Config load: remote-first, throwing-remote fallback, empty/corrupt/partial handling, serialised concurrent loads |
| `mergeSamplePlaylists.test.ts` | 7 | Sample-library import: triggers untouched, idempotent top-up, voice remap, name-prefixing |
| `expectedCoverage.test.ts` | 6 | GTFS trip matching: overlap scoring, best-trip pick, tie-break, journey coverage / missed stops |
| `mqttClient.test.ts` | 6 | MQTT client hardening: throwing handler contained, non-JSON/scalars dropped, `/shape` skip, topic templating |
| `proofReport.test.ts` | 6 | Proof-of-play report: summary, per-route breakdown, scope filter, integrity hash, CSV, self-contained HTML |
| `offlineExport.test.ts` | 5 | Offline bundle export: corpus enumeration (per-stop/per-destination, volatile excluded), clip cap reported, exported clipKeys byte-for-byte cover what pre-push delivers |
| `historyLog.test.ts` | 5 | Proof append log: serialised appends, disk trim, atomic trim, durable-mirror isolation, flush-on-shutdown |
| `proof.test.ts` | 5 | Proof-of-play correlation: played-ack vs dispatched, failure/retry supersede, exterior exclusion |
| `pgConfigSource.test.ts` | 6 | LISTEN/NOTIFY subscription: one reconnect per connection loss, exponential backoff, clients grow linearly (leak regression), scope-filtered notifications, tenant-scoped fetch |
| `mqttBusIntegration.test.ts` | 3 | Real broker (aedes): connect → subscribe → receive → publish, and publish rejects when never connected |
| `engineBoot.test.ts` | 2 | Full engine boot against a real broker: ingest a journey, appear in the fleet directory, stop cleanly; `engine/health` published retained so a late subscriber sees a live engine immediately |
| `offlineBundle.test.ts` | 1 | Bundle round-trip: manifest, config envelope, verifiable clips and tts-cache entries |

---

## 3. Coverage by module (engine)

`node --test --experimental-test-coverage`, whole suite. **All files: 93.04 % lines · 84.59 % branches
· 73.33 % functions.** The aggregate includes every file the suite loads — the engine proper, the
portal's server libs (`scripts/lib/*`) and the Creator-side mirrors the parity tests import — so the
per-module rows below are what to read, not the single headline number.

| Module | Lines % | Branch % | Funcs % | Note |
|---|--:|--:|--:|---|
| `domain/announcement.ts` | 100 | 100 | 100 | |
| `domain/triggerParity.ts` | 100 | 100 | 91.7 | |
| `domain/customTrigger.ts` | 96.3 | 87.5 | 90.9 | incl. `pointInPolygon` geofence math |
| `engine/adtPayload.ts` | 100 | 100 | 100 | incl. the `clipKey` content hash |
| `engine/destinationList.ts` | 100 | 96.4 | 100 | |
| `engine/fleet.ts` | 100 | 100 | 100 | |
| `engine/hanover/hanoverEncoder.ts` | 100 | 98.2 | 100 | Hanover HCPS/SuperX sign adaptor |
| `engine/ledRender.ts` | 100 | 91.3 | 100 | |
| `engine/proofSink.ts` | 100 | 97.4 | 83.3 | |
| `engine/variableResolver.ts` | 100 | 92.1 | 100 | |
| `engine/volume.ts` | 100 | 78.6 | 100 | |
| `engine/matrix/globalState.ts` · `index.ts` · `layoutRenderer.ts` | 100 | 100 | 100 | the matrix render core |
| `engine/lexicon.ts` | 99.3 | 88.1 | 87.5 | |
| `engine/led-font.ts` | 99.3 | 84.6 | 80.0 | |
| `engine/playlistRenderer.ts` | 98.9 | 89.9 | 100 | incl. capability-gated SSML + volume drop |
| `engine/ff/ffEncoder.ts` | 98.8 | 92.0 | 92.3 | |
| `engine/prepush.ts` | 98.3 | 75.8 | 95.0 | closed-corpus pre-push |
| `engine/ff/ffRgbEncoder.ts` | 98.2 | 89.7 | 100 | |
| `engine/matrix/publish.ts` | 99.2 | 86.5 | 100 | incl. the per-sign FF/Hanover protocol split |
| `engine/matrix/png.ts` | 98.0 | **58.2** | 80.0 | PNG variants (interlace/bit depths) unexercised |
| `engine/prerender.ts` | 97.8 | **66.0** | 94.1 | branch gaps around volatile-playlist skip |
| `engine/customTriggers.ts` | 96.2 | 83.0 | 81.3 | |
| `engine/history.ts` | 96.1 | 85.3 | 83.3 | |
| `engine/scheduler.ts` | 96.1 | 84.6 | 94.7 | |
| `engine/ledEncoder.ts` | 95.5 | 80.0 | 100 | |
| `engine/exteriorSign.ts` | 93.6 | 88.9 | 83.3 | |
| `engine/proofReport.ts` | **86.3** | **68.4** | 87.5 | lowest engine line-cov; report-formatting branches |
| `pis/ptContext.ts` | 99.2 | 90.0 | 95.5 | |
| `pis/ptEngine.ts` | 98.5 | 93.2 | 72.7 | small function gaps around rarely-built events |
| `config.ts` | 98.9 | 82.4 | 87.5 | |
| `config/pgConfigSource.ts` | 98.1 | 82.6 | 78.3 | |
| `pipeline.ts` | 96.1 | 85.4 | 84.0 | |
| `mqtt/client.ts` | 96.1 | 81.0 | 90.9 | lifted by the real-broker integration test |
| `index.ts` | **80.9** | **52.6** | **35.7** | composition root — booted end-to-end twice (`engineBoot`); the wiring keeps growing (pre-push, template signs, destination lists, Hanover exterior faces) faster than its tests |
| `gtfs/expectedCoverage.ts` | 97.4 | 73.6 | 100 | |
| `gtfs/feed.ts` | 97.9 | **62.3** | 100 | malformed-feed branches |
| `logger.ts` | 100 | 94.1 | 100 | |
| `shared/assistantPrompt.ts` | 100 | 92.3 | 100 | |
| `shared/ledAbbrev.ts` · `ledCycle.ts` · `ledStops.ts` · `ledPtSymbolArt.ts` | 100 | 80–97 | 69–100 | |
| `shared/ledPresetVilnius.ts` · `ledPresetMideast.ts` | 98–100 | 91–96 | 82–86 | |
| `shared/ledLayoutRender.ts` | 96.2 | 89.5 | 92.5 | |
| `shared/ledMatch.ts` | 99.4 | 81.1 | 77.8 | |
| `shared/ledExpression.ts` | 89.1 | 79.0 | 77.0 | |
| `shared/ledOverride.ts` | 92.1 | 84.8 | 70.2 | |
| `shared/ledPtSymbols.ts` | 90.0 | 91.9 | 85.7 | |
| `shared/ledDestinations.ts` | **83.7** | 90.8 | **57.8** | Creator-side halves covered in the front-end suite |
| `shared/ledRtl.ts` | **83.2** | 86.7 | **55.9** | ditto — the browser exercises the other paths |
| `shared/ledVia.ts` · `ledTextRules.ts` · `ledRouteColors.ts` | **75–80** | 91–94 | 65–67 | ditto |
| `shared/fnt.ts` | **69.4** | 85.5 | **61.0** | engine renders through a subset; browser-side rendering covers the rest (`matrixParity` pins agreement) |
| `shared/ledShapes.ts` | **60.4** | 94.5 | **56.0** | ditto |
| `tts/provider.ts` · `resilient.ts` · `ssml.ts` | 100 | 82–100 | 93–100 | incl. the capability descriptor |
| `tts/azure.ts` | 100 | 95.6 | 87.5 | |
| `tts/elevenlabs.ts` | 99.4 | 82.6 | 75.0 | |
| `tts/cache.ts` | 97.0 | 85.1 | 87.0 | |
| `tts/http-tts.ts` | 97.8 | **70.0** | 66.7 | |
| `tts/mock.ts` | 94.3 | 100 | 71.4 | |
| `tts/polly.ts` | 92.5 | **70.3** | 81.3 | |
| `tts/openai-speech.ts` | 92.0 | **66.7** | 78.6 | |
| `tts/google.ts` | 88.3 | 81.5 | 76.9 | |
| `tts/acapela.ts` | 88.4 | 74.4 | 73.7 | the login flow has more states than the other suppliers |
| `tts/readspeaker.ts` | **86.8** | **58.8** | 70.0 | least-covered supplier client |
| `tts/index.ts` | 94.7 | 85.3 | 100 | provider construction switch + breaker-driven failover |
| `scripts/lib/player-bridge.ts` | 98.8 | 84.4 | 85.7 | |
| `scripts/lib/mqtt-ws-proxy.ts` | 98.8 | 79.6 | 85.0 | |
| `scripts/lib/assistant-ai.ts` | 100 | 86.5 | 84.6 | |
| `scripts/lib/live-vehicles.ts` | 100 | 90.9 | 100 | |
| `scripts/lib/diagnostics.ts` | **72.2** | 78.5 | 77.8 | helpers + the MQTT/liveness probes are tested; the presentation paths run in `npm run doctor` |
| `scripts/lib/example-playlists.mjs` · `merge-sample-playlists.mjs` | 87–93 | 25–53 | 83–100 | support scripts |
| `web/creator/app/lib/projects.ts` | 95.9 | 88.9 | 100 | pulled in by `projects.test.ts` |
| `web/creator/app/models/announcement.ts` | 96.0 | 95.9 | 80.0 | pulled in by the parity tests |
| `web/creator/app/models/custom-trigger.ts` | 84.6 | 87.1 | 79.4 | pulled in by the parity tests |
| `web/creator/app/models/led-template.ts` | 84.7 | 91.7 | **17.0** | large Creator model pulled in whole by the matrix parity tests; its editor logic is covered in the front-end suite |
| `web/led-sign/demo-config.ts` · `resolve.ts` | 95–100 | 82–92 | 91–100 | the standalone sign client's core |

---

## 4. Coverage gaps & risk read

**Well protected.** The safety-critical core — trigger firing (`ptEngine`), custom/geofence triggers,
the render→publish pipeline, priority/interrupt, config load + fallback, proof-of-play, the whole LED
matrix pipeline, and every resilience path (retry, breaker, single-flight, cache degrade, capability
degradation, pre-push dedupe/cap) — sits at 93–100 % line coverage with explicit hardening tests. The
suite's clear strength is **failure-mode behaviour**: throwing handlers, corrupt config, dead broker,
dead TTS, mid-render aborts.

**Weak spots, by risk:**

1. **`src/index.ts` — 79 % line / 32 % functions.** The composition root is booted end-to-end exactly
   once (`engineBoot.test.ts`) and its wiring keeps growing (pre-push, template signs, destination
   lists, health) faster than its tests. This remains the largest single gap, and it widened since
   the last report.
2. **`engine/proofReport.ts` — 86 % line / 68 % branch.** This produces the **compliance** CSV/HTML
   evidence; the uncovered branches are alternate report shapes worth pinning given its purpose.
3. **The newer TTS supplier clients** — `readspeaker` (59 % branch), `openai-speech` (67 %),
   `http-tts` (70 %), `polly` (70 %) are thinner than `azure`/`elevenlabs`; their error-envelope and
   voice-catalogue branches are the untested paths.
4. **Shared LED text helpers, engine side** — `fnt.ts` (69 % line), `ledShapes` (60 %),
   `ledVia`/`ledTextRules`/`ledRouteColors` (75–80 %). The same modules are exercised from the
   browser suite and `matrixParity` pins frame-level agreement, but an engine-only regression in the
   uncovered halves could pass CI's engine job.
5. **Branch gaps in input parsers** — `matrix/png.ts` (58 %), `gtfs/feed.ts` (62 %), `prerender.ts`
   (66 %). These handle third-party data (uploaded PNGs, operator GTFS zips); malformed input is the
   untested path.
6. **`scripts/lib/diagnostics.ts` — 46 % line.** Only its pure helpers are unit-tested; the probe
   orchestration runs for real in `npm run doctor`.

**Still with no tests:** `src/shared/payloads.ts` and `src/pis/types.ts` — type/schema declarations
with no executable logic.

---

## 5. Additional test cases not run (recommended)

Ordered by value.

1. **`index.ts` wiring** — drive `startEngine()` through eviction, a render-scope change, a config
   hot-swap, a destination-list publish and a pre-push round, asserting the topics that result.
   Highest-value gap (§4.1), and it has grown since first listed.
2. **Player end-to-end** — no automated test drives the Go/Python/browser players against a broker;
   the clip-cache contract (store on `tts/cache`, key-only replay, expiry → `expired` ack) is only
   asserted engine-side. The players now carry real logic, so this gap is bigger than it was.
3. **Report shapes** (`proofReport`) — empty scope, a journey with no GTFS match, and a CSV/HTML
   snapshot per shape, since this artefact is the compliance evidence.
4. **Malformed third-party input** — a truncated GTFS zip, an interlaced/16-bit PNG, a layout whose
   font is missing: assert *degrade*, never throw.
5. **Property/fuzz round-trips** on the encoders (`ffEncoder`, `ffRgbEncoder`, `ledEncoder`,
   `ledRender`) — arbitrary text/bitmaps encode→decode without loss and never throw.
6. **Load/scale** — a sustained-throughput run matching [`SCALING.md`](SCALING.md)'s numbers
   (events/s, cache hit-rate, loop lag) as a guarded CI job rather than a manual ladder.

Previously listed items **A–F are done**: the reconnect leak regression
(`pgConfigSource`), the supplier HTTP clients (`ttsClients`, extended by `ttsNewClients` +
`ttsCapabilities`), the MQTT connection lifecycle (`mqttBusIntegration`), `config.ts` branch
permutations (`configEnv`), full-boot E2E (`engineBoot`), and the front-end suite (§6).

---

## 6. Front-end test suite (`web/`)

Both Angular apps — the **Engine dashboard** (`web/src`) and the **CreatorStudio** authoring app
(`web/creator`) — are tested automatically.

**Runner.** Angular 20's first-party `@angular/build:unit-test` builder with **Vitest + jsdom**
(replacing the deprecated Karma + Chrome default). It runs entirely in Node — no browser process — so
it is fast, deterministic and CI-friendly. TestBed is auto-initialised; a shared setup file
(`src/test-setup.ts`) stubs the browser APIs the app touches on init (`fetch`, `WebSocket`,
`matchMedia`, `ResizeObserver`, canvas 2D, `requestAnimationFrame`), and a providers file supplies the
router, PrimeNG preset, animations and (for the creator) `MessageService`.

**Reproduce:**
```bash
npm run web:test                      # both apps (from the repo root)
cd web && npx ng test web             # dashboard only
cd web && npx ng test creator         # creator only
npm --prefix web run test:coverage    # both, with v8 coverage
```

**Results.**

| App | Files | Cases | Result | Lines | Branches | Funcs |
|---|--:|--:|---|--:|--:|--:|
| Dashboard (`web`) | 34 | 225 | all pass | 92.6 % | 65.6 % | 74.9 % |
| CreatorStudio (`creator`) | 41 | 706 | all pass | 90.5 % | 73.5 % | 70.5 % |
| **Front-end total** | **75** | **931** | **all pass** | | | |

**What's covered.**
- **Every component** is rendered under TestBed and has its public methods, computeds and (via
  dispatched DOM events) its template event-listeners exercised — not just constructed. The Leaflet
  components (`map`, `monitor-cockpit`, `geofences`, `simulator`) are driven method-by-method without
  rendering the map (jsdom has no layout for Leaflet).
- **Every service** has behavioural tests: `ThemeService`, `UiStateService`, `AlertsService`,
  `MqttService` (dashboard); `Tenant`, `Config`, `Project`, `Publish`, `Session`, `Gtfs`,
  `TtsCatalog` services (creator) — covering load/fallback, persistence, fetch success/error paths,
  undo/redo, derived signals, and the per-supplier capability set behind the Voices badges and the
  playlist editor's volume warning.
- **All pure logic**: `led-render`, `led-layout-render`, `led-expression`, `led-validation`,
  `led-trigger-override`, `led-vehicle-state`, `triggers` (dashboard); `lint`, `preview`, `gtfs` and
  the `announcement` / `custom-trigger` / `led-template` models (creator) — including the geofence
  geometry (`haversineM`, `pointInPolygon`, `pointInGeofence`) and the full custom-trigger evaluator.

**Where the percentages come from.** Function coverage sits in the ~70–75 % band because v8 only
counts a listener as covered when its DOM event actually fires, and the two heaviest areas — Leaflet
map rendering and the LED **canvas** editors (layout / display / destination sign) — cannot lay out
under jsdom. Those paths are instead pinned engine-side, where the *same* shared LED core
(`src/shared/led*.ts`) is exercised and `matrixParity.test.ts` asserts the browser and engine
renderers agree frame-for-frame.

---

## Appendix — full engine test-case catalog (738, all passing)

Generated from the runner, one section per file, in the order `node --test test/*.test.ts` runs them.

### `test/adtPayload.test.ts`
- spoken is included only when it differs from transcript
- speaker routing follows outputType (integer 0–100)
- audio content is base64 of the mp3, encoding is the ADT enum "MP3"
- clipKey is a content hash: stable for identical audio, different for different audio
- conforms to the ADT 4.x message shape

### `test/assistantAi.test.ts`
- defaults to a local Ollama when nothing is configured
- a bare host is completed to the chat-completions path
- a remote provider is labelled by host, not called "local"
- a nonsense timeout falls back to the default rather than disabling the timeout
- the request carries the brief, the catalogue and the question, at a low temperature
- a per-request model overrides the configured one
- parseAssistantJson reads plain JSON, fenced JSON, and JSON with chatter around it
- returns the plan and who produced it
- sends a bearer token only when one is configured
- an empty question never reaches the provider
- a refused connection explains that no model is running, and is retryable (503)
- a missing model says how to pull it
- bad credentials and rate limits are named as such
- a reply that is not a plan fails the turn instead of returning an empty one
- a JSON array is rejected — the plan must be an object
- a slow provider gives up rather than hanging the request

### `test/audioFile.test.ts`
- a preset MP3 is fetched once, then served from cache
- a transient 500 is retried, then succeeds
- a permanent 404 is not retried; the clip is dropped but the announcement still plays

### `test/audioFormat.test.ts`
- every bundled preset clip matches the TTS output format (24 kHz mono)
- the engine’s default TTS format is mono — a stereo default would break every preset

### `test/breaker.test.ts`
- circuit breaker: open fast-fails, then half-open admits exactly one probe

### `test/cache.test.ts`
- in-memory cache is a bounded LRU with hit/miss stats
- a key evicted from the memory LRU is still served from disk, not re-synthesised
- a THROWING shared-cache layer degrades to a miss — it must never silence the fleet
- the on-disk cache is pruned to fsMaxFiles — a 24/7 worker must not fill the disk

### `test/configEnv.test.ts`
- required: MQTT_URL is mandatory
- Hanover output: on by default with the documented DIP addresses; disable + remap via env
- TTS_FALLBACK_PROVIDER: unset → absent, valid → carried, typo → throws at boot (not mid-outage)
- tts provider: only the selected provider’s credentials are enforced
- RENDER_SCOPE: unset → selection, all → all, typo → throws
- num: unset/empty/non-numeric fall back; a valid number is parsed
- configSource.enabled: on only when not disabled AND a url AND a key are present
- SKIP_INCOMPLETE_ANNOUNCEMENTS: default true; only "false" disables
- STOPS_STRICT: default false; only "true" enables
- default-on toggles (resilience, audio cache, display, exterior, ff) flip on "false"
- display format / text source enum-ish env vars
- optional paths and creds are undefined when unset, populated when set
- sensible defaults for topics and tuning knobs

### `test/configStore.test.ts`
- remote-first: a published config wins over the on-disk file
- a THROWING remote falls back to the last-known-good file — the fleet keeps announcing
- a remote that returns nothing (not published yet) also falls back to the file
- no config file at all → boots EMPTY rather than crashing the engine
- a CORRUPT config file throws — it must not be mistaken for "empty"
- a config missing playlists/triggers is rejected, not half-loaded
- concurrent loads are serialised — a SLOW STALE fetch cannot overwrite a newer config

### `test/creatorApi.test.ts`
- GET /api/projects: scoped query, rows straight through
- GET /api/engine-config: row mapped to the wire shape; no row → null
- POST /api/publish: one transaction — upsert, monotonic version, commit — then notify
- POST /api/publish: a failing version insert rolls back and does NOT notify
- POST /api/publish: body validation — missing fleet/config → 400, proto keys refused
- writes are gated by authorized(); reads are open
- DELETE /api/projects/:id is tenant-scoped and validates the uuid
- unknown /api paths fall through unhandled (dashboard-server keeps routing)

### `test/customTriggers.test.ts`
- comparators coerce the authored value to the fact’s type
- a numeric comparator on a non-numeric fact is false, never NaN-true
- `changed` fires only against the PREVIOUS snapshot — and never on the first ingest
- groups: all/any/negate, and an empty group is not accidentally true
- geofences: a point inside a polygon, and `outside` is the true inverse
- becomes-true fires on the RISING EDGE only — not on every ingest the condition holds
- while-true fires on every ingest — throttled ONLY by the cooldown
- a flapping condition cannot machine-gun a becomes-true trigger — the cooldown still applies
- edge state is PER VEHICLE — one bus firing must not suppress another
- a disabled trigger (or one with no playlist) never fires — and forgets its edge
- forget(vehicle) drops that vehicle’s state — memory must not grow with a churning fleet
- connectionPlatformKnown reports whether the feed actually gave us a platform
- viaPassed + mainStops facts come from the stop list against the upcoming stop
- a departed stop leaves the sign at once — even while linkprogress still points at it
- lineCode + destinationNumber facts carry the driver-keyed code and its line code
- customTriggersUseGnss only reports true when a geofence block is actually present
- tripId / boarding / extraText / countdownMinutes facts, and their absent-feed defaults
- countdownMinutes rounds up, and is null rather than 0 when nothing is counting down

### `test/customerByConfiguration.test.ts`
- a different case rule: Title Case, from the same textTransform primitive
- a different split: on the word "via", keeping either side
- a journey with no "via" leaves the rules inert rather than blanking the sign
- a different symbol rule: exact line CODE, not a keyword in the destination
- a different inverted-sign rule: a REGEX on the line number, not a contains
- a different trigger: a numeric comparator on occupancy takes the front sign
- the cycle picks the calling-points arrangement only when the journey has calls
- the whole operator publishes real frames on the wire

### `test/destinationList.test.ts`
- a code-only selection on pis/0/destination resolves to the stored destination
- a manual destination/override selection wins over the destination topic
- a PRE-PROGRAMMED destination wins over the feed name / line the code also carried
- a partial pre-programmed entry only overrides the fields it defines; the feed keeps the rest
- applyDestination in FILL mode lets the feed win, filling only what the feed left blank
- destinationModes maps the three priorities to per-consumer override/fill
- no list, no active pointer, or an unknown code changes nothing
- a line-bound code falls back to the any-line entry when its line does not match
- a string-typed number from a loose feed still resolves

### `test/diagnostics.test.ts`
- redactUrl strips broker credentials
- hostPort applies each scheme default port
- browserWsUrl advertises a host-relative default, and honours the overrides
- resolveWsUrl substitutes the caller-visible host
- probeTcp reports a listening port as reachable
- probeTcp reports a closed port as unreachable rather than throwing
- engineAlive fails when nothing at all is retained
- engineAlive passes on a fresh beat, and says how fresh
- engineAlive fails on a STALE retained beat rather than reporting a dead engine as alive
- engineAlive tolerates a few missed beats before calling it dead
- engineAlive never reports ok when the beat has no usable timestamp
- summariseConfig counts the file fallback, and flags an unreadable one instead of throwing
- probeMqtt captures the retained heartbeat even behind a large retained backlog
- probeMqtt still returns promptly when the engine topics are genuinely absent

### `test/engineBoot.test.ts`
- startEngine boots against a real broker, ingests a journey into the fleet directory, and stops cleanly
- publishes engine/health RETAINED, so a late subscriber learns the engine is alive immediately

### `test/engineDestinations.test.ts`
- default list priority: the pre-programmed destination wins on the SIGNS over the feed name
- list priority "fill": the feed wins on the SIGNS; the stored list is only a lookup fallback

### `test/expectedCoverage.test.ts`
- tripOverlap = fraction of announced stops found in the trip (name-normalized)
- pickBestTrip chooses the highest-overlap trip and requires a real match
- pickBestTrip breaks ties toward the tighter trip and lets headsign nudge
- journeyCoverage flags expected stops with no confirmed announcement as missed
- computeExpectedCoverage: a stop never announced shows up as missed against the GTFS trip
- computeExpectedCoverage: a journey with no route match is reported unmatched, not mis-scored

### `test/exteriorSign.test.ts`
- face text: front/side show route + destination, rear shows route only
- each face is 24px high with the configured width; rear has no destination zone
- the route zone never exceeds 25% of the sign width
- the route renders at full height and the standard advance (no size-shrink)
- short destination is full size and static (no scroll)
- a destination that fits its zone renders full size and static (no condensing)
- a destination too wide for its zone scrolls (full size — never condensed tighter)
- returns null when there is neither route nor destination; route alone is enough
- signature changes only when route or destination changes

### `test/fallbackCache.test.ts`
- resilient fallback buffer is tagged; real synth output is not
- fallback audio plays but is NEVER written to the (fleet-wide) cache

### `test/ffEncoder.test.ts`
- checksum matches protocol §4 / example 1 (0x3FF → 0xFF → FE 01)
- graphic font w: a full 5-dot column encodes as 0x3F at y=4 (font doc example 1)
- an empty column is 0x20, a top-dot-only column is 0x21 (LSB = top)
- frame is well-formed and the checksum validates
- round-trips an arbitrary rendered bitmap (encode → decode → identical pixels)
- address is carried verbatim (1 front, 2 side, 3 rear, 10 interior)
- scroll frame: 0xD5 carries 4 area coordinates + 4 settings, immediately after 0xA5
- scroll defaults: continuous right-to-left, scrolling until the next message
- scroll frame round-trips: settings and pixels both survive encode → decode
- scroll speed rounds to a rate the 77 Hz panel honours
- decode rejects a scroll frame whose 0xD5 does not immediately follow 0xA5
- an over-limit scroll frame throws rather than being silently dropped by the sign
- a non-scroll frame is untouched by the scroll support (still 0xA2, no 0xD5)

### `test/ffRgb.test.ts`
- rgbTo444 reproduces the spec examples
- escapeRgbData applies §7.6.4: fd→fd00, fe→fd01, ff→fd02
- colour table: 0xD6, count, 16-bit big-endian RGB444 entries (colour 0 = background)
- text colour: 0xD7 + table index
- colour bitmap: mode 1, column-wise pairs, even sizeY, escaped wire vs raw sum
- odd-height bitmaps are padded to an even sizeY with black
- encodeFfRgb frames COLORTEXT + table + bitmap with a pre-escape checksum
- a sign too wide for the one-byte size field is rejected, not silently truncated

### `test/fleet.test.ts`
- empty map yields an empty fleet
- derives every FleetVehicle field from the aggregated topics
- missing optional signals collapse to empty/null, not crashes
- stopsLeft is null and totalStops null when the stop list is not current (strict + no refs)
- has stops but no nextStopSeq → stopsLeft null while totalStops still counts
- phase maps door-open to AT_STOP and off-route to OFF_ROUTE
- in-service vehicles sort ahead of NOT_IN_TRAFFIC, then by freshness
- ties within the same phase and freshness break by numeric vehicle id
- maxAgeMs drops ghosts that stopped publishing; 0 keeps every context
- fleetPayload under the cap passes through untouched — no total, nothing dropped
- fleetPayload over the cap drops the TAIL (least relevant), stays under maxBytes, and says so
- fleetPayload cap 0 disables capping entirely

### `test/fnt.test.ts`
- parses every Luminator .fnt file with sane metrics
- glyph shape sanity: the 7px A has a hole (not solid, not empty)
- fntMeasure sums widths + spacing; fntGlyph falls back to uppercase
- fontLadder sorts tallest → smallest; ladderFor honours the user order and falls back
- Lithuanian text falls back to base letters, never to the @ substitution glyph
- a character with no latin base still shows the font substitution glyph
- the blank placeholder cells are not drawn as wide holes
- measuring and rasterising agree about a folded string
- fntUnsupported reports what had to be substituted, in order, once each

### `test/geofencePolygon.test.ts`
- moving a vertex reshapes the ring, and the original array is left alone
- a moved vertex actually changes what is inside the fence
- an out-of-range vertex index is ignored, not appended
- inserting a vertex puts it exactly where the edge was clicked
- the last edge wraps back to the first vertex
- adding a vertex on an edge does not change the enclosed area
- removing a vertex drops it and closes the ring
- a triangle refuses to give up a vertex — two points enclose nothing
- the guard is what keeps a collapsed fence from silently matching nobody
- a square may lose a vertex, becoming a triangle
- editing is immutable throughout — no helper writes to its input
- the centroid of a square is its middle
- moving a polygon translates it without deforming it
- a moved polygon takes its containment with it
- moving a circle relocates its centre and keeps its radius
- a cloned polygon does not share its vertex array with the original
- a cloned circle is a real copy too

### `test/gtfsFeed.test.ts`
- routes() parses fields and sorts by short name naturally
- status() reports the feed name and entity counts
- values() returns distinct, naturally-sorted lines, destinations and stops
- trip() returns stops in sequence with names, coords and formatted times
- trip() returns null for an unknown trip id
- a feed missing shapes.txt does not crash; shape falls back to stop coordinates
- tripsForRoute() lists a route's trips ordered by direction then start time
- an empty / missing routes.txt yields no routes rather than throwing

### `test/hanoverEncoder.test.ts`
- Hilde doc example 1: [STX]01345<Køge St.<Øster Banevej[ETX]F3, byte-exact
- Hilde doc example 2: [STX]01345%Øster Banevej[ETX]6A checksum
- checksum is the two’s complement of the content sum (00 at the wrap)
- SuperX §9 worked example: 16×16 triangle packs to the documented ascii-hex
- graphic frame: SuperX picture page wrapped in a valid HCPS frame
- vertical raster: LSB is the top row of each 8-row band, top band first
- round-trips an arbitrary rendered bitmap (encode → decode → identical pixels)
- scroll option adds \sl/\sr + \rep to the page header
- address is one uppercase hex digit; out-of-range addresses throw
- CP1252: high-block specials map to single bytes, unmappable chars become ?
- infohub bridge: topic and JSON envelope match the Go SDK
- decode rejects malformed frames and FLAGS a corrupted byte (checksumOk=false)
- a picture over the 32767-byte SuperX \bin cap throws instead of shipping a broken frame
- exterior topic contract: concatenated self-addressed frames split cleanly on ETX
- an all-spaces Hilde body is a valid frame (the documented way to clear a sign)

### `test/historyLog.test.ts`
- appends are serialised — 200 rapid records produce 200 intact JSON lines, in order
- the file is trimmed to `max` — a 24/7 worker must not fill the disk
- the trim is atomic — the file is never left empty or half-written
- a THROWING durable mirror must not break the local audit trail
- flush() waits for queued writes — the tail is not lost on shutdown

### `test/ledAbbrev.test.ts`
- value mode replaces the whole value, case/space-insensitive
- word mode replaces every occurrence at word boundaries only
- phase: only `always` rules apply in the "always" phase; all apply in "all"
- value rules run before word rules; collapses double spaces
- regex metacharacters in the match are treated literally
- text no rule touched is returned verbatim, spacing included
- suggestAbbreviations offers the last word then the initials
- empty/absent rules are a no-op

### `test/ledDestinations.test.ts`
- an exact (number, lineCode) match wins over the any-line entry for the same number
- a number nothing covers resolves to null (the live feed keeps the sign)
- a line-bound code is not served to another line when there is no wildcard
- destination numbers parse from the Integer and the string shapes a feed may send
- rows drop trailing blanks so a one-row destination stays one row
- globalState feeds the tokens the renderer already reads
- an unsupplied row 2 still falls back to the via place (the existing token rule)
- empty fields are omitted rather than written as blanks (they must not mask live data)
- the face override is only reported for faces the destination actually binds
- the list payload is the spec shape (number, name, lineNumber only when set)
- duplicate codes for the same line are reported (the later one is unreachable)
- the active list is the one the pointer names, and a dangling pointer resolves to none

### `test/ledEncoder.test.ts`
- bitmap encoder produces a self-describing base64 payload
- ff encoder is a documented, not-yet-implemented extension point

### `test/ledMatch.test.ts`
- matchValue: eq is case- and whitespace-insensitive, contains looks anywhere
- matchValue: regex — the deck depot patterns against real trip ids
- matchValue: an empty expected value never matches — a half-filled rule claims nothing
- matchValue: an absent live value is just an empty string, never a crash
- a broken or oversized pattern fails CLOSED and never throws
- an exponentially backtracking pattern is REFUSED, not run
- the shape screen does not refuse the patterns real sign rules are written with
- the subject a pattern is tested against is capped
- compilePattern: caches by pattern AND flags, and stays usable after the cache is cleared
- a cached pattern gives the same answer every time (no lastIndex carry-over)
- matchParts: capture groups, falling back to the whole match
- the expression evaluator exposes Matches() — and still no other globals
- the custom-trigger `matches` comparator reads the same rule
- matchParts: EVERY match contributes, so a list of unknown length splits fully
- matchParts: a zero-width pattern terminates instead of spinning forever
- matchParts and matchValue do not share regex STATE across calls

### `test/ledMideastDemo.test.ts`
- the trilingual demo vehicle carries the low-res panels (front, colour, side, rear)
- every RTL scenario resolves its expected layout on the demo vehicle and lights the panel
- no mono panel renders as a solid block — text is never swallowed by its own field
- the line badge punches the digits out of its filled cell (an inverted badge, on mono)
- the front destination ROTATES Arabic → Hebrew → English (three different frames)
- the no-boarding notice shapes the lam-alef ligature (لا) and renders it
- the route simulation walks every stop; each state lights every exterior panel
- the client draws each mono RTL scenario pixel-identically to the engine's published frame

### `test/ledRender.test.ts`
- short text renders a static, centered 16×144 frame
- text wider than the viewport is marked scroll and keeps full width
- scroll threshold is at the viewport width (6px advance)
- characters advance by 6 → exactly one blank column between them
- 'I' rasterizes to the expected doubled glyph rows (MSB packing)
- Swedish Ö composes an umlaut in the top rows (not the fallback box)
- a space is entirely unlit

### `test/ledRouteColors.test.ts`
- routeColorFor: exact, case-insensitive, line number first then the technical code
- routeColorFor: a lookup is EXACT — line 1 must not match line 11
- routeColorsFromRoutes: lifts a GTFS feed, tolerating the spec's hash-less hex
- routeColorsFromRoutes: a feed with no colours yields NO rows, not rows of black
- routeColorsFromRoutes: one row per line — a repeated short name does not duplicate
- withRouteColors publishes the matched row into the state, and nothing else
- the SIGN background is not touched by the table — that was the bug
- a layout CAN still take the whole panel in the route colour — by saying so
- a layout colour RULE still wins over anything the element does with the livery
- an element wearing the livery colours ONLY its own box on the published frame
- all three colours are published — background, text AND outline
- the outline reaches the drawn text — a light brand colour stays readable

### `test/ledRtl.test.ts`
- isRtlChar / isRtlText detect Hebrew and Arabic, not Latin
- shapeArabic picks the correct contextual forms
- shapeArabic fuses lam-alef into a single ligature
- reorderVisual reverses Hebrew to visual order
- reorderVisual keeps a European number LTR inside RTL text
- toVisualForRender is a no-op for pure Latin (LTR path unchanged)
- fntChars/fntMeasure resolve Arabic + Hebrew to drawable glyphs with non-zero width
- a Text element renders Arabic and Hebrew with lit pixels (not blank, not placeholders)
- RTL and LTR of the same length differ in pixels (proves direction is applied)

### `test/ledShapes.test.ts`
- line: horizontal, vertical and diagonal runs are continuous and inclusive
- line: stroke width thickens the run
- rectangle: filled covers the box; an outline leaves the middle dark
- circle: an outline is a closed ring, and filling it fills the disc
- polygon: a filled triangle is solid and stays inside its bounds
- every built-in public-transport symbol draws ink at sign sizes, and stays in its box
- a hand-drawn size is used for the panel heights it covers, primitives elsewhere
- the drawn art is what actually lands on the panel, centred in the element
- every hand-drawn grid is rectangular and fits the height it claims
- the public-transport set covers park & ride, rail replacement and the school bus
- symbol ids are unique and every one has a label and a category
- the renderer draws Line / Ellipse / Symbol elements, tinting them with the text colour
- an existing Rectangle stays FILLED (no `filled` flag = the old behaviour)

### `test/ledSignClient.test.ts`
- the HTML client renders pixel-identically to the engine's published front frame
- parity holds across live values (the sign follows the same content the engine publishes)
- parity through a colour rule that INVERTS a mono sign (background compositing agrees)
- parity when a size-mismatched layout is re-canvassed to a bigger panel
- the client output is strictly binary — monochrome, never a grey value
- signSpecs: skips interior/announcement panels; pickSign selects by face or id
- signSpecs shows the FOLLOWED vehicle's own panels, falling back to the active vehicle
- a colour panel resolves as rgb; a mono panel as mono

### `test/ledSignVilnius.test.ts`
- the demo vehicle carries every Vilnius panel: front, colour, side, left, rear
- every deck scenario resolves its expected layout on the demo vehicle, through the CLIENT path
- all 17 documented cases cover all 13 preset layouts — nothing is unreachable in the demo
- the client draws each mono scenario pixel-identically to the engine's published frame
- the vehicle board resolves EVERY panel from one shared state, engine-identical, for every case

### `test/ledStops.test.ts`
- routeStopsFromList: call order, timing flags under any spelling, junk rows dropped
- mainStops: timing-point-flagged stops win when the feed marks any
- mainStops: no timing flags → every remaining stop is a main stop
- mainStops: no journey progress yet → the whole route; junk rows are skipped
- firstStopAheadSeq: a DEPARTURE/PASSAGE for the pointed-at stop moves the cut past it
- firstStopAheadSeq: the vehicle is still AT the stop it has arrived at
- firstStopAheadSeq: only stopinfo about the POINTED-AT stop counts — a retained one can’t skip stops
- mainStops: a stop the vehicle has left is gone from the row before linkprogress catches up
- mainStopsText: sign-ready comma line, empty when nothing remains
- STOPPING_AT_TEXT_EXPR: prefixes the label only while main stops remain
- {mainStops} token: resolves as a plain globalState fact for text-mode elements
- bottom-row layout: ink in the stops row while stops remain, none once the list is empty
- routeStopsFromList reads the boarding flag under each spelling the feeds use
- routeStopsFromList leaves the flag ABSENT when the feed never mentions boarding
- boardingAllowedAt: only an explicit restriction says no
- NO_BOARDING_EXPR gates on the explicit false only — never on a missing fact

### `test/ledTemplate.test.ts`
- evaluator: optional chaining, object-map idiom, comparisons
- evaluator: contains via compiled string method, and && / || / ??
- evaluator: cannot reach dangerous globals (no eval)
- interpolateTemplate: mixes static text with live MQTT/PIS variables
- interpolateTemplate: destination rows and destination symbols
- cycle: first enabled cycle wins
- cycle: rotation advances by elapsed time
- cycle: a sub-cycle left at duration 0 still gets a slot in the rotation
- cycle: switch/case selection
- layout: renders text into the correct regions
- layout: static text with an inline {token} renders the resolved value
- layout: empty expression renders nothing lit; packed frame matches dims
- Scale: text too wide for every font falls back to SCROLLING (readable head at t=0, moves later)
- Scale: fitting text stays static; an abbreviation that fits beats scrolling
- Scale: scaleScrollFallback:false restores clip-at-smallest (static, no scrolling)
- Scale readable floor: static text never renders below the min height; forced floors scroll instead
- textFrames: the pipeline is resolve → split → transform → rotate
- textFrames: an element with no rules behaves exactly as before
- an inverted colour rule produces a real inverted FRAME, not just preview colours
- a Lithuanian destination rasterises as its base letters, not as @

### `test/ledTextRules.test.ts`
- applyTextTransform: upper/lower/title, including Lithuanian diacritics
- applyTextTransform: unset or "none" leaves the text exactly as written
- splitTextParts: bracket — the deck Req 2 headsign
- splitTextParts: comma and dash — deck Req 3 and Req 7
- splitTextParts: via delegates to the one via rule, and regex uses its capture groups
- splitTextParts: empty input yields no parts; no rule yields the whole value
- pickTextPart: first/rest/last/index, and an index past the end shows NOTHING
- applyTextSplit: deck Req 2 rows, and no rule is a no-op
- dynamicAlternations: a value that does not split gives ONE frame (the rule stays inert)
- DESTINATION_HAS_BRACKET_EXPR gates exactly on a bracketed headsign

### `test/ledVia.test.ts`
- splitViaDestination: infix "via" splits into main + via (any case, extra spaces)
- splitViaDestination: no infix via → whole text is the main part
- isViaPassed: true only once every call at the via stop is behind the upcoming stop
- isViaPassed: anything unknowable keeps the via visible (false)
- HAS_VIA_DESTINATION_EXPR: true on infix via or a viaName fact, false otherwise
- HAS_VIA_DESTINATION_EXPR: the viaPassed fact turns the gate off once the via stop is behind
- {destinationMain}/{destinationVia}: split tokens resolve; viaName fact wins for the via row
- cycle swap: via in the destination selects the two-row layout, plain destination the default
- cycle swap: once the via stop is passed the display falls back to the default layout
- two-row via layout renders ink in BOTH rows (destination above, via below)

### `test/lexicon.test.ts`
- respells whole words, case-insensitive
- longer entries win over sub-words
- does not touch partial matches
- no lexicon leaves text unchanged
- ssml: an IPA entry becomes a <phoneme> tag
- ssml: a say-as entry wraps the match
- ssml: a plain phonetic entry respells exactly like applyLexicon (raw text, builder escapes it)
- ssml: entries are scoped by language
- ssml: attribute + inner text are XML-escaped
- ssml: empty/undefined lexicon leaves text unchanged
- matches non-ASCII names that ASCII \b never could (Malmö, Örebro)
- non-ASCII match still respects whole-word boundaries
- ssml: an IPA entry fires for a non-ASCII name
- normalizeIpa strips surrounding /…/ and […] delimiters
- validateIpa accepts good IPA and normalizes delimiters
- validateIpa rejects the classic authoring mistakes
- ssml: an unusable IPA degrades to the respelling instead of emitting broken SSML
- ssml: IPA wrapped in slashes still becomes a clean <phoneme>

### `test/liveVehicles.test.ts`
- reports one entry per vehicle inside the window, newest first
- the route and destination reported are the vehicle's LATEST, not its first
- drops vehicles that went quiet before the window
- an unparseable timestamp is skipped, not treated as the end of the window
- older vehicleKey-only records resolve to the same identity as newer split fields
- a tenant-less deployment yields an empty tenant, not a bogus one
- records with no vehicle at all are ignored
- an empty log is quiet rather than an error

### `test/manifestSync.test.ts`
- engine ↔ CreatorStudio manifest are in strict bidirectional parity
- the engine implements the expected 33 CreatorStudio trigger types
- service-disruption is NOT a trigger type (removed to match CreatorStudio)

### `test/matrixParity.test.ts`
- png: decodes 8-bit RGBA with colours + alpha mask; rejects non-PNG
- engine colours: colour rule (line 5 → blue bg / black outline / white text) resolves + renders
- engine FNT: text renders with the real font ladder (ink fits the 16px region, taller than 6px)
- engine symbols: {lineNumber} draws the mapped PNG when the value matches
- vertical alignment: Top / Middle / Bottom land the INK exactly (leading font, 24px region)
- vertical alignment: exact in a snug region equal to the ink height
- vertical alignment: Wrap block Bottom reaches the region bottom (ink-based)
- buildLedSigns: renders display-mode signs, skips announcement-mode, emits FF + signature
- buildLedSigns: the active vehicle roster wins; colour signs get rgb bytes + COLORTEXT FF
- buildLedSigns: a protocol 'hanover' sign gets an HCPS frame instead of FF
- buildLedSigns: a firing custom trigger overrides the front layout; display is the fallback
- buildLedSigns: the selected destination's own template beats the display, loses to a trigger
- buildLedSigns: built-in not-in-traffic trigger overrides bound faces and BLANKS unbound ones
- buildLedSigns: any held-state built-in trigger overrides (welcome aboard on journey-activated)
- png: a corrupt chunk length (high bit set) fails the decode instead of looping forever
- cycleEvaluator: condition-gated rotation entries are skipped while their condition is false
- buildLedSigns: a size-mismatched bound layout is re-canvassed to the PANEL resolution
- buildGlobalState: geo:<id> facts surface as __geofences[<id>] for cycle geofence blocks
- ledOverride: the level-type default priorities equal DEFAULT_TRIGGER_PRIORITIES (no drift)
- buildLedSigns: per-sign subtopic entries — kebab position slugs (deduped) + per-sign FF
- buildLedSigns: a "when tight" abbreviation is applied only where the full text does not fit
- buildLedSigns: null payload when nothing resolves
- a coloured BACKGROUND reaches the wire — the field is published, not just the text

### `test/mergeSamplePlaylists.test.ts`
- the triggers are left exactly as they were — nothing new announces
- every added playlist is unreferenced — no trigger fires one
- all 22 sample playlists land, alongside the ones already there
- re-running is a no-op — a dashboard import can be topped up, not doubled
- the added playlists speak in a voice THIS config has, not the sample's ElevenLabs one
- names are prefixed, so the 8 that clash with live playlists stay tellable apart
- a missing live config is a clear error, not a half-written file

### `test/mqttBusIntegration.test.ts`
- MqttBus connects, subscribes, and receives a parsed inbound message over a real broker
- MqttBus.publish delivers a payload to a broker subscriber
- MqttBus.publish rejects when never connected (no unbounded queue)

### `test/mqttClient.test.ts`
- a throwing handler is contained — ONE bad message must not kill the engine
- non-JSON payloads are dropped, not parsed into a crash
- a JSON scalar is not delivered — only objects are
- the /shape payload is skipped BEFORE parsing (it is the biggest message on the bus)
- topicFor substitutes tenant and vehicle, and tolerates a tenant-less topic
- publish before connect rejects rather than throwing into the caller

### `test/mqttWsProxy.test.ts`
- topicAllowed admits what the dashboard uses
- topicAllowed refuses everything else — the server holds fleet-wide credentials
- frames round-trip, preserving binary payloads
- decodeFrame rejects a truncated frame instead of inventing a topic
- relays subscribe/publish upstream, and drops what is out of bounds
- forwards broker messages as binary frames, and upstream state as status
- closing the browser socket ends its upstream connection
- a wrong token is refused when the dashboard is gated

### `test/multilingual.test.ts`
- event stopNameML picks the playlist language variant (by code)
- falls back to default name when the language is missing
- name_Multilanguage keyed by language name (Swedish) also resolves
- destination and following stops use their ML variants from state.extra
- when no ML is present the default names are still used
- {currentStop} speaks the departed stop on departing-stop, not the upcoming one
- {currentStop} still falls back to the event stop when no distinct current stop is carried
- {occupancy}, {countdown} and {geofenceName} resolve from the event payload
- unset event payloads resolve to empty, never a literal token
- service/situation body variables resolve under both CreatorStudio names

### `test/pgConfigSource.test.ts`
- subscribe: a connection loss schedules exactly ONE reconnect despite the synchronous end
- subscribe: consecutive failures back off exponentially (1s, 2s, 4s…)
- subscribe: clients grow linearly (one per reconnect), never unbounded
- subscribe: notifications are scope-filtered; an unparseable payload still refetches
- fetch: returns the config only when it has playlists + triggers
- fetch: tenant scoping adds the second filter

### `test/offlineBundle.test.ts`
- bundle round-trip: manifest, config envelope, verifiable clips and tts-cache entries

### `test/offlineExport.test.ts`
- enumerateCorpus: per-stop × stops, constant once, per-destination × destinations, volatile excluded with its tokens
- enumerateCorpus: the clip cap stops enumeration and is reported
- parity: exported clipKeys cover everything the pre-push delivers, byte-for-byte
- a {destination} job with no destination is skipped as unresolved, never exported
- RecordingTtsCache records exactly the segment entries the corpus touches

### `test/perElementVoice.test.ts`
- consecutive same-voice elements still merge into ONE tts request
- an element with its own voice splits the run (3 runs, voices in order)
- dynamic-text resolves name variants in the ELEMENT voice language
- per-element volume splits the run and flows to the voice (default 100 stays merged)
- no prosody-volume capability: authored volume is dropped and the runs STAY MERGED
- volume defaults to full (100%): unset + legacy 0-1 fractions play full; only a deliberate 1-99 percent attenuates
- SSML provider: an IPA lexicon reaches the synth text but never the transcript/spoken
- non-SSML provider: the same lexicon respells in plain text (no tags)
- a pause at a voice boundary folds into the preceding run as a trailing break

### `test/pipeline.test.ts`
- publishes ADT audio on the vehicle’s tts topic, with the transcript and speaker volumes
- exterior playlist routes to the EXTERNAL speaker and publishes no interior sign
- interior announcement also publishes the LED sign and the raw Mobitec FF frame
- the Hanover twin carries the SAME pixels as the published sign bitmap (and scrolls when wide)
- publishes engine/metrics and appends a proof-of-play record with location + trigger
- SKIP_INCOMPLETE: an unresolved variable drops the announcement entirely — nothing is published
- skipIncomplete=false: the same announcement is spoken anyway (opt-out honoured)
- superseded mid-render (abort): the audio is NOT published — no talking over the interrupt
- a trigger whose playlist does not exist publishes nothing and does not throw
- a TTS failure is contained: renderAndPublish resolves, the fleet keeps running
- repetitions: the clip is repeated and the estimated duration scales with it
- an interior+exterior trigger renders both, and each goes to its own speaker

### `test/playerBridge.test.ts`
- prefixFor builds the pis topic, with and without a tenant
- topicMatchesFilter matches a wildcard filter by prefix, not by accident
- isValidId rejects MQTT wildcards, spaces and slashes
- sseMessage is a single well-formed default-event frame
- stream subscribes once, forwards matching messages, and cleans up on close
- two players on one vehicle share a single subscription (ref-counted)
- stream rejects an invalid vehicle id before touching the broker
- stream caps total concurrent connections (503 at capacity)
- stream caps distinct vehicle subscriptions but allows more clients on an existing one
- isReplayableStateTopic keeps journey state, never announcements or rendered frames
- a second client asking for replay gets the vehicle state it missed — but no stale announcement
- a player that does not ask for replay is unaffected
- the state cache dies with its last listener, so a snapshot can never go stale
- the replay cache is bounded per vehicle
- publishAck publishes the record to the server-chosen played topic
- the played topic is configurable (browser cannot choose it)

### `test/prepush.test.ts`
- pushes the WHOLE remaining journey per-stop, journey-constant clips once, volatile never
- the pushed clipKey is byte-identical to what the live pipeline later publishes
- re-running onProgress de-dupes; a new journey re-pushes; the per-journey cap holds

### `test/prerender.test.ts`
- pre-renders the next N stops and skips volatile playlists
- a real render after warming is a cache hit (no extra synth)
- re-running onProgress de-dupes (no re-warm) until the journey changes

### `test/prerequisites.test.ts`
- requiresDoorOpen suppresses the trigger until the door is open
- requiresStopButtonPressed gates on the stop-request signal
- requiresVelocityBelow gates on GPS speed (km/h)
- no gates (or no condition) → trigger fires normally
- requiresDoorClosed is the mirror gate — blocked while the door is open
- requiresVelocityAbove gates the other way, and below+above form a window
- a velocity gate is read in the authored unit (mph)
- a gate whose signal is absent fails closed (no GPS speed → no announcement)

### `test/priority.test.ts`
- unset priority → per-type default level; safety tier interrupts, ambient does not
- flat shape: bare number + explicit flags win over tier defaults
- nested shape (trigger-manifest style) is accepted and wins
- interrupt tier is configurable
- triggerPriority returns the effective numeric level (both shapes)
- end-to-end: a high-priority alarm interrupts a playing ambient message with NO config flags
- interrupt aborts the superseded render so it can skip publishing (no double audio)
- forget() releases a vehicle slot and aborts its in-flight render

### `test/projects.test.ts`
- migrateLegacyToProject wraps the legacy config as a "Default" project
- newProject deep-clones the config (no aliasing of the source)
- duplicateProject gets a fresh id, a "(copy)" name and an independent config
- validateProjectRefs flags enabled triggers referencing a missing playlist
- validateProjectRefs ignores disabled triggers and checks sequence references
- selectPublishConfig returns the selected project config, or null for an unknown id
- upsertProject / removeProject are pure array ops
- projects carry tenant + fleet, and duplicate keeps them (multitenant scoping)
- nextSelectedId prefers the current selection, else the first project, else null

### `test/proof.test.ts`
- coverage correlates played acks to dispatched audio by traceId
- a failed ack counts as failed, not confirmed
- a later played ack supersedes an earlier failed one (retry that succeeded)
- exterior records and untraceable audio are excluded from the rate
- no dispatches → coverage is 1 (nothing owed)

### `test/proofReport.test.ts`
- report summary + per-record outcomes correlate dispatch to playback
- breakdown by route computes per-group coverage
- scope filters by route + time window (played rows correlate across the window)
- integrity hash is stable for the same data and changes when an outcome changes
- CSV has one row per dispatched announcement with its status
- HTML report is self-contained and shows the coverage headline + integrity hash

### `test/proofSink.test.ts`
- record() buffers without throwing and does NOT flush until the batch fills / flush() is called
- reaching maxBatch triggers a flush automatically
- flush() runs one parameterized multi-row INSERT into the configured table with the mapped columns
- null-able columns are emitted as null when the record omits them
- flush() on an empty buffer is a no-op (no query)
- a failing insert is contained: neither record() nor flush() reject into the caller
- an invalid table name fails loudly at construction (identifier guard)
- createProofSink() returns null unless PROOF_DB_ENABLED=true AND DATABASE_URL is set
- createProofSink() returns a ProofSink when enabled with a database

### `test/ptContext.test.ts`
- parseTopic splits {tenant}/{vehicleId}/pis/0/<sub>
- fullyTracked: discovery-only vehicles are not served; full data flips it on
- routeStops: call-ordered rows with timing flags (any spelling), staleness-aware
- deriveState resolves next stop, destination and phase
- onward connections are enriched with delay / platform / cancellation
- a sub-minute connection delay is not announced (rounds to nothing)
- stopName looks up by call sequence number
- a stale stop list (different journey ref) is not used
- countdownSeconds derives from the spec departureDateTime (not a countdownSeconds field)
- {distanceToStop} is spoken in metres — the feed unit, by product decision
- {distanceToStop} is empty (not "0 meters") when the feed omits the distance

### `test/ptEngine.test.ts`
- ARRIVAL fires arrived-at-stop naming the arrived stop
- DEPARTURE fires departing-stop naming the upcoming stop
- PASSAGE fires stop-skipped (a skipped stop), NOT departing-stop
- DEPARTURE fires departing-stop (not stop-skipped)
- consecutive ARRIVAL at a new stop re-fires
- door sensor edges fire doors-open (at an approached stop) then doors-close
- doors-open is suppressed at a stop that was never approached (depot / mid-trip connect)
- trip-start fires ONLY at the genuine start, never for a vehicle first seen already running
- approaching-stop fires when the next stop changes
- connection-info names the connections stop (its own callSequenceNumber) + destination
- detour carries stop + destination so the message is not empty (chime-only bug)
- a service alert fires situation-message (NOT detour) with text + stop
- situation-message re-fires for a REPLACEMENT alert (count stays 1, heading/body change)
- situation-message does NOT re-fire when the alert array is merely reordered
- an empty-text situation message does NOT fire situation-message
- geofence does NOT re-fire on a brief GNSS signalQuality flicker while still inside
- journey-state transitions each fire their own trigger
- the final stop announces once when both last-stop triggers are enabled
- journey-approaching-last-stop still fires when last-stop is not enabled
- stop-skipped fires when the next-stop sequence jumps forward
- one physical skip reported BOTH ways announces once
- a skip reported both ways in ONE ingest still announces once
- a PASSAGE with no callSequenceNumber still announces the skip
- a seq-less PASSAGE announces once per stopinfo edge, not on every ingest
- consecutive skips of DIFFERENT stops each announce
- serving a stop closes the skip chapter, so a later skip of it announces again
- stop-request fires on the button rising edge only
- exit-side fires on a real side, skips Unknown
- exit-side honours the authored exitSides filter
- exit-side filtered to one side still fires when the feed says Both
- exit-side with no authored filter fires on every real side
- connections and situation messages fire their own triggers
- alarm activation and destination override fire
- passenger-load fires when occupancy crosses the threshold

### `test/resilienceHardening.test.ts`
- a provider hot-swap MID-RENDER cannot cache one supplier’s audio under another’s key
- a publish that never settles must not pin a render slot forever
- publish rejects (rather than hanging) when the client is not connected at all

### `test/resilientTts.test.ts`
- retries transient failures then succeeds
- returns the fallback audio when all attempts fail
- throws when synth fails and no fallback is configured
- does NOT retry auth errors (401/403) — fails fast
- circuit breaker opens after the threshold and fast-fails without calling the provider
- describe + name delegate to the inner provider (cache key unchanged)

### `test/sampleConfig.test.ts`
- every playlist id is unique
- every trigger points at a playlist that exists
- every trigger type is one the engine can actually fire
- no two ENABLED triggers share a type — only the first would ever fire
- every {variable} used is one the resolver knows
- every audio-file element points at a preset clip that exists
- every playlist renders to real speech with live journey data
- the connection examples cover the real-time cases a feed reports
- the platform announcement is gated on the platform actually being known

### `test/scheduler.test.ts`
- serialises per vehicle with priority interrupt / queue / drop
- expired queue entries are purged

### `test/stopRecordings.test.ts`
- matchStopRecording prefers stopId, then name, and honours language
- renderPlaylist plays the recorded clip for a matched stop name (and TTSs the rest)
- renderPlaylist falls back to TTS when no recording matches
- renderPlaylist plays an inline data: URI recording without any network fetch
- renderPlaylist falls back to TTS when the recording fetch fails

### `test/synthDedup.test.ts`
- concurrent identical synths collapse into a single synth (single-flight)
- a later identical request is a cache hit, not a second synth
- non-cacheable (fallback) synths are de-duplicated but not stored

### `test/triggerHardening.test.ts`
- strict-stops distrusts an unverifiable retained list only when enabled
- settle window suppresses the retained-topic burst, then fires real changes
- gate-hold: a door-gated arrival fires when the door opens on a later ingest
- gate-hold: a held edge expires and never fires after the hold window
- name-hold: connection-info waits for the new journey stop list, then names the stop
- name-hold: expires and fires nameless (late) when no stop list ever arrives
- name-hold + gate: a gated, nameless connection-info re-resolves the name when the gate clears
- name-hold: drops (no announcement) if the connections clear before the name resolves
- name-hold off (gateHoldMs 0): connection-info fires immediately, nameless (old behavior)
- with gate-hold off (0), a gated fire is simply dropped (no hold)

### `test/triggerJobsParity.test.ts`
- the Creator resolves a trigger to the same audio jobs the engine renders
- an interior or exterior playlist retires the legacy single playlist

### `test/ttsCapabilities.test.ts`
- every supplier declares the expected capability descriptor
- capabilitiesOf defaults an undeclared provider to plain text
- the resilience wrapper and the switchable provider pass capabilities through unchanged

### `test/ttsClients.test.ts`
- azure.synthesize(): 200 returns the audio buffer and builds the SSML POST with key + format headers
- azure.synthesize(): honours an explicit Azure voice id
- azure.synthesize(): a non-OK status throws a TtsError carrying res.status
- azure.listVoices(): 200 maps the catalogue to TtsVoice[]
- azure.listVoices(): a non-OK status throws a TtsError
- elevenlabs.synthesize(): 200 returns the buffer and posts to the voice URL with xi-api-key
- elevenlabs.synthesize(): uses an explicit non-Azure voice id in the URL
- elevenlabs.synthesize(): a non-OK status throws a plain Error (not TtsError)
- elevenlabs.listVoices(): 200 maps the catalogue to TtsVoice[]
- elevenlabs.listVoices(): a plain non-OK status throws a generic Error
- elevenlabs.listVoices(): a 403 HTML block page throws the proxy/firewall message
- elevenlabs.synthesize(): with proxyUrl set, POSTs the edge function and decodes base64 audioContent
- elevenlabs.synthesize(): proxy path never calls api.elevenlabs.io even for the default voice
- elevenlabs.synthesize(): proxy non-OK status throws a proxy-specific error
- elevenlabs.synthesize(): proxy 200 with no audioContent throws, surfacing the error field
- elevenlabs.describe(): proxy vs direct produce different cache keys for the same input
- acapela.synthesize(): with no token first logs in (POST /api/login/) then commands (POST /api/command/)
- acapela.synthesize(): a preconfigured token skips the login step
- acapela.listVoices(): logs in then GETs /api/nvoices/ and maps the rows
- acapela.listVoices(): also accepts the paginated { results: [...] } envelope
- acapela.listVoices(): a non-OK voices response throws the documented error
- acapela: a failed login throws "Acapela login failed"
- acapela: a login response missing the token throws "login response missing token"
- acapela.ensureToken(): with no token and no credentials throws

### `test/ttsFailover.test.ts`
- selectFallback guards: unconfigured refused, primary-equals-fallback clears, valid sticks
- switching the primary onto the fallback name clears the fallback
- an open primary breaker routes pin/describe/synth to the fallback — and the cache key follows
- without a fallback an open breaker still fails (or beeps) exactly as before

### `test/ttsNewClients.test.ts`
- google.synthesize(): posts SSML + MP3 config with the key in the query, decodes base64 audioContent
- google.synthesize(): honours an explicit Google voice id and derives its languageCode
- google.synthesize(): a non-OK status throws; a 200 without audioContent throws
- google.listVoices(): maps the catalogue to TtsVoice[]
- polly.synthesize(): signs the request, targets the regional host, returns the MP3 stream
- polly.synthesize(): speed becomes an SSML prosody rate wrapper
- polly.synthesize(): a non-OK status throws
- polly.listVoices(): GETs /v1/voices (signed) and maps the rows
- readspeaker.synthesize(): POSTs form fields with the key/voice/lang and returns audio
- readspeaker.synthesize(): a JSON body is treated as an error, not audio
- openai-speech.synthesize(): POSTs /v1/audio/speech with model/voice/format and returns audio
- openai-speech: name reflects the provider, and an auth key becomes a bearer header
- openai-speech.listVoices(): accepts both string ids and objects
- http-tts.synthesize(): POSTs text/voice/format with the configured auth header, accepts raw audio
- http-tts.synthesize(): also decodes a base64 JSON envelope (audioContent / audio)
- http-tts.synthesize(): a JSON body with no audio throws (surfacing the error)
- http-tts.synthesize(): a non-OK status throws with the provider name

### `test/ttsSsml.test.ts`
- speed → prosody rate (signed percentage)
- per-element volume → prosody volume (absolute 0–100)
- defaults (speed 1, volume 100) emit no prosody wrapper
- pitch is never rendered — any authored pitch emits no prosody pitch
- pitch does not affect the cache key (it does not affect the audio)
- lexicon SSML tags pass through unescaped while surrounding text is escaped
- plain author angle brackets are still escaped (no SSML injection)
- break tags still convert seconds → ms

### `test/ttsSwitch.test.ts`
- TTS_PROVIDER_NAMES lists every selectable supplier from the market research
- createProviderByName instantiates a usable TtsProvider for every name (no network at construction)
- isProviderConfigured gates strictly on the required credentials
- SwitchableTtsProvider.select switches only to a configured, different provider
- a published default voice replaces the env one — and re-keys the cache

### `test/variableParity.test.ts`
- every variable the Creator offers is one the engine resolver can fill
- the Creator ENGINE_VARIABLES mirror matches the resolver exactly
- the picker is a curated subset — aliases stay resolvable but unoffered

### `test/vilniusRequirements.test.ts`
- Req 1: the destination is drawn in capitals, whatever case the feed sends
- Req 2: an opening bracket puts everything after it on row 2
- Req 4: with BOTH a bracket and a comma, only the bracketed text alternates
- Req 3: a comma-separated destination alternates its parts
- Req 3: a destination with no comma shows ONE frame — the rule stays inert
- Req 5a/5b: a keyword anywhere in the destination selects the right pictogram
- Req 5a: the symbol cell is a SLOT — it draws nothing of its own when no keyword matches
- Req 5b: the station layout adds the English name to the rotation
- Req 6: the trip-id pattern takes the front and rear over
- Req 6: no trigger binds the SIDE face — two panels of different sizes live there
- Req 6: an ordinary trip id takes nothing over, and the return leg shows the destination
- Req 6: the depot sign names the depot, with the street from the brackets below it
- Req 7: row 1 is the primary destination; row 2 walks the dash-separated via places
- Req 7: a journey with a single via place gives a single, non-rotating frame
- Req 8: the rear sign is the front mirrored — line number right, symbol left
- Req 9: a boarding restriction takes the front sign, keeping the line number
- Req 9: boarding allowed — or a feed that never mentions boarding — puts no notice up
- Req 10: the countdown prints whole minutes, rounded up
- Req 10: the cycle holds the countdown to the two-minute floor
- Req G: a route number containing G inverts the sign, and no other route is touched
- Extra text: the message shows 3 s against 6 s of route information, and only while it is set
- the front display resolves a layout for every state, and prefers the countdown
- every display cycle and trigger points at a layout that exists
- every layout element fits inside its panel
- the worked destinations render on the layouts they were written for
- the preset vehicle is coherent: each sign bound to a display of its own size, addresses unique
- buildLedSigns publishes the right layout on every face, for every deck scenario
- buildLedSigns: an inverted route lights the field and punches the letters out of it
- every preset cycle binds its layout through a rotation, not a bare layoutId
- every gated cycle uses a rule the Creator can show as a visual block, not raw JS
- the side depot cycle compiles to exactly what the Creator's `matches` operator emits
- Req 5a/5b: the keyword pictograms light the symbol cell with NO artwork uploaded
- every preset symbol rule names a pictogram that exists
- a rule with an uploaded image still prefers the image over the built-in drawing
- the colour panel publishes RGB bytes and a COLORTEXT FF frame, the mono panels do not
- the colour panel wears each line's livery, straight from the route-colour table
- the G line inverts even though the table gives it a livery — legibility beats branding
- the colour layout leaves the SIGN's colours to its rules, and names none of its own

### `test/volume.test.ts`
- disabled adaptation returns null (use env default)
- priorityOrder: stop wins over route
- route match when no stop rule matches
- time-of-day range wraps midnight

