Files
XZBT-NGN/test-fixtures/reference-exhibits/README.md
T
2026-09-14 07:57:18 -07:00

220 lines
10 KiB
Markdown

> **Local copy notice.** This directory contains locally corrected copies of
> the upstream reference exhibits. The corrections are documented in
> `../PROVENANCE.md`. The text below is the original upstream README; its
> verification claims describe the upstream state, not this corrected copy.
> Re-copying from the upstream source without reapplying corrections will
> reintroduce known defects.
# XZBT Exhibit Contract 5.2 — Reference Exhibits
Three unrelated exhibits and one generic host, all speaking XZBT Exhibit
Contract 5.2. The point of the set is not the exhibits; it is that a single
unmodified host drives all three, and that the contract layer is shared
rather than reimplemented per exhibit.
```
reference-exhibits/
shared/
contract-core.js generic contract engine (no domain knowledge)
host-transport.js same-origin postMessage transport (optional)
exhibit-shell.js generic DOM helpers (optional)
exhibit-shell.css generic page chrome (optional)
aquarium/ exhibit 1 — reef tank
planetarium/ exhibit 2 — sky dome
haunted-house/ exhibit 3 — the one with a real capability lifecycle
host-harness/ a generic host that knows nothing about any of them
tests/ Node test suites + a static server for manual checks
```
## Running it
No build step, no dependencies, no package manager.
**Standalone.** Open any exhibit's `index.html` directly in a browser. It
works from `file://` with the network off. The header reads `standalone` and
nothing is ever posted anywhere.
**With a host.** The harness uses `postMessage` with an origin check, and
`file://` documents report origin `null`, so serve over HTTP:
```
node tests/serve.js
```
Then open `http://localhost:8731/host-harness/index.html`, pick an exhibit,
and press Connect. The harness performs the handshake, reads `describe`, and
builds its entire control surface from the descriptors the exhibit published.
**Tests.**
```
node tests/run-all.js
```
137 checks, no dependencies. See *Verification* below for what they do and
do not cover.
## The layering
Each exhibit is split into four files, and the split is the whole argument:
| File | Knows about | Does not know about |
| --- | --- | --- |
| `exhibit.js` | the domain, the canvas, the native controls | the contract, target IDs, events |
| `contract-adapter.js` | the contract, the target catalog | rendering, DOM |
| `boot.js` | composition only | everything else |
| `index.html` | markup and script order | behaviour |
`exhibit.js` is a complete, working exhibit on its own. Delete
`contract-adapter.js`, `host-transport.js`, and `boot.js`'s last three lines
and you still have a functioning aquarium. That is deliberate: the contract is
a layer added on top of a working exhibit, not a scaffold the exhibit is built
inside of.
The shared `contract-core.js` is domain-free. It knows the *shape* of the
contract — envelopes, target kinds, revisions, sequences, error codes,
capability lifecycle — and nothing about fish, stars, or ghosts. Each adapter
supplies three things and gets a conforming surface:
1. a target catalog (canonical dotted IDs → descriptors)
2. a setter table (target ID → absolute, idempotent setter)
3. an action table (target ID → real impulse implementation)
## The three exhibits
| | Aquarium | Planetarium | Haunted House |
| --- | --- | --- | --- |
| Targets | 12 | 14 | 13 |
| Capabilities | `render` | `render` | `render`, `audio` |
| Notable | step-validated ranges | action with a typed argument | a genuinely gated action |
The Haunted House is the interesting one. Its audio is synthesized with the
Web Audio API, and browsers refuse to start an audio context until the user
has interacted with the page. So `audio` really does begin `available`, really
does pass through `loading` to `ready` on the first gesture, and really does
report `error` if the context cannot be created. The adapter mirrors that real
state into the capability registry; it never invents a transition to make the
contract look busy. `action.moan` and `action.slam-door` declare
`requires: ['audio']` and return `CAPABILITY_UNAVAILABLE` until the engine is
actually ready — while staying discoverable in `describe` the whole time.
## Contract decisions worth stating
**One mutation chokepoint.** Every persistent-state change — native UI,
hotkey, or host `set` — goes through `applyMutation`. One call is one
transaction: commit, increment `stateRevision` at most once, then emit the
resulting events carrying that revision. A host `set` and a slider drag are
the same transaction with a different `source`.
**No silent clamping.** An out-of-range value is rejected with
`INVALID_VALUE`, not quietly pinned to the boundary. A value off the declared
`step` is rejected too. A host that sends nonsense should be told.
**No-ops do not move the revision.** Setters are absolute and idempotent and
report whether anything actually changed. Setting a value to what it already
is returns `changed: false` and leaves `stateRevision` alone, so a host can
poll without inflating history.
**`stateRevision` survives reconnects; `sequence` does not.** A new session
resets the event sequence but deliberately leaves `stateRevision` intact, so a
reconnecting host can tell whether it missed anything.
**Source is assigned at the trusted boundary.** A `source` field inside an
incoming message is ignored. The transport stamps `host`; the UI path stamps
`ui`.
**Event shape is identical on every path.** `correlationId` is always present
(null when the change did not originate from a request) so a host can rely on
one schema regardless of what caused the change.
**Capabilities are honest.** An exhibit that cannot fail declares `ready`
once. An exhibit with a real lifecycle reports it. Nothing fakes a transition.
**Text is data.** Every string that reaches the DOM goes through
`textContent`. Nothing in this project assigns `innerHTML`, so a string
arriving from a host or a scenario can never become markup or script.
## Verification
### Automated — `node tests/run-all.js`
**`conformance.test.js` (126 checks).** Loads the real `contract-core.js` and
the real `contract-adapter.js` from each exhibit into a sandbox, with a small
stub standing in for the exhibit's own services. The adapter under test is the
genuine article; only the domain object behind it is a stub. Covers handshake
and session validation, `describe` shape, canonical target IDs, `state.get`
filtering, mutation semantics (commit, revision, no-op, absolute, rejection),
action semantics, event envelopes and monotonic sequence, session reset,
source trust, UI/host parity, malformed input, the capability lifecycle, and
action arguments. A final cross-exhibit group runs one generic request
sequence against all three.
**`transport.test.js` (11 checks).** Covers the postMessage layer against a
fake window: origin and source validation, version filtering, and — the reason
the file exists — that events are *pushed* to a connected host, not just
responses.
### Manual — browser
Verified in a real browser against a local server:
- All three exhibits render (canvas confirmed painted, not blank) and load
with a clean console.
- Every native control on all three exhibits was exercised; each routes
through the canonical path and produces the expected event with
`source: 'ui'`.
- The generic host connected to all three exhibits unmodified, built its UI
from their descriptors, and drove them: host `set` → exhibit commits → event
pushed back → host revision, sequence, and readout all update.
- The Haunted House capability lifecycle was observed end to end from a real
button click: `available → loading → ready`, with two
`capability.changed` events, after which the gated action succeeded.
- Standalone: each exhibit opened from `file://` with the server stopped.
Renders, contract works, native UI works, and
`performance.getEntriesByType('resource')` shows zero external resources.
### Not covered
- No automated browser tests. Rendering and the browser's audio policy are
verified by hand, as above.
- The exhibits are reference implementations, not production art. The
simulation is simple by design; the contract is the subject.
## Bugs found and fixed during verification
Recorded because they are the kind that pass a unit suite and fail a user:
1. **Target IDs were not canonical.** `action.cleanGlass`,
`telemetry.fishCount`, `sky.magnitudeLimit` and others used camelCase
segments. The catalog validator rejected them at construction. All IDs are
now lowercase-hyphenated (`action.clean-glass`, `telemetry.fish-count`,
`sky.magnitude-limit`).
2. **The transport never forwarded events.** It sent responses only, so a host
could issue a `set`, receive an acknowledgement, and never learn that
anything had changed. The exhibit looked correct in isolation and the
conformance suite passed; only the host/exhibit link was broken. The
transport now forwards the core's events to a connected host, and
`transport.test.js` pins it down.
3. **The harness raced its own navigation.** `connect()` called
`disconnect()` first, which queued an `about:blank` load that then beat the
real navigation, leaving an empty frame.
4. **The harness page never loaded `exhibit-shell.js`.** `harness.js` uses
`XZBTShell` for element lookup, so it threw on load and the page was inert.
5. **`correlationId` was conditionally present.** It was omitted for
UI-originated changes and included for host-originated ones, so the event
shape differed by path. It is now always present, null when not applicable.
## Adding a fourth exhibit
1. Write `exhibit.js` — a working exhibit with absolute, idempotent setters
that return `{ changed }`, a `read(targetId)`, and real actions. No
contract awareness.
2. Write `contract-adapter.js` — declare the catalog, bind setters, readers,
and actions, and route the UI through `core.applyMutation` /
`core.invokeAction`.
3. Write `boot.js` and `index.html` following an existing exhibit.
4. Add the exhibit to `EXHIBITS` in `tests/conformance.test.js` and to
`ADAPTER_EXPORTS`. The shared conformance group then runs against it
automatically.
5. Add it to the harness dropdown. No harness code changes — that is the test.