From 961919e017b196104c0c8b6fe4b6881399831186 Mon Sep 17 00:00:00 2001 From: Labyricorn Date: Mon, 14 Sep 2026 13:01:45 -0700 Subject: [PATCH] Step 5 Complete --- .gitignore | 4 +- .../devlog/post-step-4-completion/contents.lr | 1 - AGENTS.md | 8 +- README.md | 313 +--- STEP5-MVP-COMPLETION-REPORT.md | 394 ++++ docs/implementation_plan.md | 255 +++ docs/walkthrough.md | 34 + public/index.html | 27 +- public/style.css | 11 + src/connection.js | 49 + src/ui.js | 53 +- src/validation.js | 2 + test-fixtures/PROVENANCE.md | 5 + test-fixtures/STEP5-VERIFICATION.md | 147 ++ test-fixtures/check-scifi-arguments.cjs | 38 + .../step5-acceptance-rerun-browser.txt | 7 + test-fixtures/evidence/step5-aquarium.json | 66 + .../evidence/step5-closure-browser-suite.txt | 7 + .../evidence/step5-closure-checks.json | 25 + .../evidence/step5-closure-haunted-house.json | 17 + .../evidence/step5-closure-scifi.json | 4 + .../evidence/step5-haunted-house.json | 128 ++ test-fixtures/evidence/step5-operator.jpg | Bin 0 -> 66965 bytes test-fixtures/evidence/step5-planetarium.json | 68 + .../evidence/step5-reference-suite.txt | 6 + .../evidence/step5-scifi-arguments-files.json | 68 + .../step5-scifi-arguments-ui-final.json | 5 + .../evidence/step5-scifi-arguments-ui.json | 5 + test-fixtures/evidence/step5-scifi-files.json | 50 + .../evidence/step5-scifi-session.json | 1651 +++++++++++++++++ test-fixtures/host-verification.js | 5 +- .../haunted-house/exhibit.js | 3 +- tests/connection.test.js | 63 + tests/haunted-house.test.js | 63 + 34 files changed, 3309 insertions(+), 273 deletions(-) create mode 100644 STEP5-MVP-COMPLETION-REPORT.md create mode 100644 docs/implementation_plan.md create mode 100644 docs/walkthrough.md create mode 100644 src/connection.js create mode 100644 test-fixtures/STEP5-VERIFICATION.md create mode 100644 test-fixtures/check-scifi-arguments.cjs create mode 100644 test-fixtures/evidence/step5-acceptance-rerun-browser.txt create mode 100644 test-fixtures/evidence/step5-aquarium.json create mode 100644 test-fixtures/evidence/step5-closure-browser-suite.txt create mode 100644 test-fixtures/evidence/step5-closure-checks.json create mode 100644 test-fixtures/evidence/step5-closure-haunted-house.json create mode 100644 test-fixtures/evidence/step5-closure-scifi.json create mode 100644 test-fixtures/evidence/step5-haunted-house.json create mode 100644 test-fixtures/evidence/step5-operator.jpg create mode 100644 test-fixtures/evidence/step5-planetarium.json create mode 100644 test-fixtures/evidence/step5-reference-suite.txt create mode 100644 test-fixtures/evidence/step5-scifi-arguments-files.json create mode 100644 test-fixtures/evidence/step5-scifi-arguments-ui-final.json create mode 100644 test-fixtures/evidence/step5-scifi-arguments-ui.json create mode 100644 test-fixtures/evidence/step5-scifi-files.json create mode 100644 test-fixtures/evidence/step5-scifi-session.json create mode 100644 tests/connection.test.js create mode 100644 tests/haunted-house.test.js diff --git a/.gitignore b/.gitignore index 5b0ed67..319790e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,6 @@ Thumbs.db # Temporary and editor files *.swp -*~ \ No newline at end of file +*~ +# Locally supplied external exhibits; never vendor them into NGN. +/test-fixtures/local/ diff --git a/.labyricorn/devlog/post-step-4-completion/contents.lr b/.labyricorn/devlog/post-step-4-completion/contents.lr index 2d967f5..3810930 100644 --- a/.labyricorn/devlog/post-step-4-completion/contents.lr +++ b/.labyricorn/devlog/post-step-4-completion/contents.lr @@ -25,4 +25,3 @@ body: | - Comprehensive test fixtures and test suites under `test‑fixtures/` and `tests/`. All modified files (`AGENTS.md`, `README.md`) were updated to reflect the latest project state. The commit was authored as part of the Step 4 milestone and is tagged accordingly. ---- diff --git a/AGENTS.md b/AGENTS.md index 3174e45..8680d9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,10 +49,16 @@ scoped `AGENTS.md` files before making changes. ## Authoritative project documents, in priority order: +This repository is the canonical home of the shared Contract Specification and +Authoring Guide, and owns the NGN Implementation Plan. Documents in +`docs/reference/` describe external implementations, not NGN architecture. +Reference exhibits under `test-fixtures/` are test/reference fixtures, not product +code. Keep exhibit-specific names and paths in test configuration or documentation. + - XZBT Exhibit Contract Specification v5.2 - XZBT-NGN Exhibit Engine Implementation Plan v5.2 - XZBT Exhibit Authoring Guide v5.2 - Step 3.7 reference exhibit report - Do not infer XZBT-NGN behavior from SciFi-XZBT source code. -- SciFi-XZBT is an external exhibit implementation, not the NGN architecture. \ No newline at end of file +- SciFi-XZBT is an external exhibit implementation, not the NGN architecture. diff --git a/README.md b/README.md index f93f1e3..c6a8e4a 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,41 @@ # XZBT-NGN Exhibit Engine -A minimal, single-exhibit engineering host for XZBT Exhibit Contract 5.2. -It discovers controls from `describe`, tracks the exhibit's reported state, -and exposes set/invoke operations, events, sessions, and protocol errors. +A single-exhibit host for XZBT Exhibit Contract 5.2. It negotiates sessions, +discovers descriptor-driven controls, inspects reported state, sends set/invoke +operations, and displays capabilities, events, revisions, and protocol errors. + +Step 5 implementation and verification are recorded in +[the completion report](STEP5-MVP-COMPLETION-REPORT.md). The SciFi-XZBT argument +descriptors are corrected and both text invocations pass through the operator UI. +Step 5 is complete: 27 automated tests and all three browser reference checks +pass. Haunted House reports conformant values, and publishing validation passes. +Step 6 is planned but has not begun. ## Run locally -Requires Node.js 22 or later. No npm dependencies or build step are needed. +Requires Node.js 22 or later. No npm dependencies or build step. ```powershell npm start ``` Open `http://127.0.0.1:4173`. Enter a same-origin exhibit path and select -**Load exhibit**. The host negotiates a real session, calls `describe` and -`state.get`, then renders the discovered catalog. **Reconnect** establishes -a new session without reloading the exhibit; **Refresh state** requests a -new authoritative snapshot. **Disconnect** removes the exhibit frame. +**Load exhibit**. The host sends `hello`, `describe`, and `state.get` and renders +the discovered controls. Enter another path and select **Load exhibit** to switch. -Included test fixture paths: +- **Reconnect** negotiates a new session with the current frame, preserving the + exhibit instance. After **Disconnect**, it reloads the remembered URL; the new + exhibit instance starts with its own initial state. +- **Disconnect** removes the frame and clears session, catalog, state and events. +- **Refresh state** reads a new authoritative snapshot. +- Controls are grouped by the exhibit's category (or Other when omitted). + Reported values and draft inputs are distinct; select **Set** or **Invoke** + to submit. Rediscovery resets drafts. +- Expand **Capabilities**, **State and metadata**, or **Protocol diagnostics** + details to inspect engineering information. The latest diagnostic is also + shown near the connection panel. **Clear log** dismisses historical diagnostics. + +Included reference fixture paths: ```text /test-fixtures/reference-exhibits/aquarium/index.html @@ -26,12 +43,13 @@ Included test fixture paths: /test-fixtures/reference-exhibits/haunted-house/index.html ``` -These copied fixtures have documented compatibility corrections; see -[fixture provenance](test-fixtures/PROVENANCE.md). They are not NGN product -logic. Additional trusted HTML exhibits can be placed in a directory under -`public/` and loaded by their same-origin URL. The server exposes only -`public/`, `src/`, and `test-fixtures/`, and binds to `127.0.0.1`. -Set the `PORT` environment variable to change the default port. +These are locally corrected test/reference fixtures, not NGN product code or +pristine upstream conformance evidence. See [provenance](test-fixtures/PROVENANCE.md). +Place additional trusted HTML exhibits and their relative assets under `public/` +and enter their served URL. A filesystem path or a different server origin cannot +be attached directly. The server exposes only `public/`, `src/`, and +`test-fixtures/`, binds to `127.0.0.1`, and denies paths escaping those directories. +For a port conflict, set `$env:PORT='4175'` before `npm start` and use that origin. ## Verification @@ -41,240 +59,53 @@ python devlog_editor.py --validate git diff --check ``` -With the server running, open -`http://127.0.0.1:4173/test-fixtures/host-verification.html` and select -**Run verification** for repeatable real-browser transport tests against all -three exhibits. Manual admin UI and native exhibit gesture checks are recorded -in [the Step 4 report](STEP4-MVP-REPORT.md). +Run the browser suite at `/test-fixtures/host-verification.html`; its timestamped +results appear on the page and in the browser console. The normal operator UI +checks and external SciFi-XZBT preparation are documented in +[Step 5 verification](test-fixtures/STEP5-VERIFICATION.md). Saved browser evidence +is under `test-fixtures/evidence/`. No source edits or dedicated harness are +needed for ordinary operation. -## Implementation boundaries +## Canonical documentation -- `src/host.js`: session/request correlation, catalog, state cache, events, - synchronization, and control operations. -- `src/validation.js`: descriptor, value, and clarified argument validation. -- `src/transport/post-message.js`: same-origin frame transport only. -- `src/ui.js` and `public/`: descriptor-driven engineering interface. -- `server/serve.js`: loopback static server with explicit served directories. -- `tests/`: focused automated host and server tests. -- `test-fixtures/`: copied reference exhibits and real-browser verification. +This repository is the canonical home for the shared +[XZBT Exhibit Contract Specification 5.2](docs/contract/XZBT-Exhibit-Contract-Specification-v5.2.md) +and [XZBT Exhibit Authoring Guide 5.2](docs/authoring/XZBT-Exhibit-Authoring-Guide-v5.2-Step3.7.md). +The [NGN Implementation Plan 5.2](docs/architecture/XZBT-NGN-Exhibit-Engine-Implementation-Plan-v5.2.md) +also belongs here. Its historical phase numbering describes the longer roadmap; +the current Step 5 completion pass does not authorize those later features. +SciFi-XZBT is an external exhibit implementation, not the NGN architecture. +`docs/reference/` contains external implementation context, not host requirements. -The state display is a cache of exhibit reports. Events update reported values; -editable fields are drafts and are submitted only when Set/Invoke is selected. -Draft fields start from the first snapshot and do not overwrite themselves on -each incoming event. Registry/capability rediscovery rebuilds the controls. -Logs retain the latest 200 entries in memory. The interface intentionally has -no persistence, accounts, MIDI, scenarios, webhooks, packaging, or orchestration. +## Implementation and MVP boundary -## Template and publishing records +- `src/host.js`: negotiation, requests, discovery, state cache, events and recovery. +- `src/connection.js`: URL validation and frame load/disconnect/reconnect lifecycle. +- `src/validation.js`: descriptor, state value and argument validation. +- `src/transport/post-message.js`: same-origin frame transport. +- `src/ui.js`, `public/`: generic operator interface. +- `server/serve.js`: loopback static server. +- `tests/`, `test-fixtures/`: automated checks, reference fixtures and browser evidence. -A starter template and baseline structure for projects compatible with -[Labyricorn](https://www.labyricorn.com). +The MVP supports one exhibit, Contract 5.2 discovery, generic target rendering, +state inspection, set/invoke, capabilities, events, session/revision/sequence +tracking, reconnect and synchronization recovery. It has in-memory state only; +logs retain 200 entries. Idle polling is not enabled. The last connection URL is +remembered only until the page is reloaded. -This template provides the required `.labyricorn/` publishing records, assistant -guidance files (`AGENTS.md`), devlog structure, and the standalone -`devlog_editor.py` management utility for local validation and GUI editing. +Not implemented: multi-surface presentation, casting/display endpoints, MIDI, +MCP/webhooks, scenarios/recording, telemetry acquisition, multi-exhibit +orchestration, persisted operator configurations, remote deployment, accounts, +databases, cloud services, plugins, commercial licensing or production branding. ---- +## Publishing records -## Overview - -Projects published on Labyricorn maintain their own exhibition pages and -development logs directly inside their Git repositories under the `.labyricorn/` -directory. The Labyricorn site importer periodically synchronizes these records -as read-only content. - -This template includes: - -- **`.labyricorn/` publishing structure**: Validated Lektor-format records for - project identity and development logs. -- **`devlog_editor.py`**: Standalone GUI and CLI tool for managing devlog entries, - validating publishing contracts, and safely publishing changes. -- **`AGENTS.md` instructions**: Scoped assistant instructions at repository root - and publishing directories to ensure AI assistants adhere to the publishing - contract. -- **`.gitignore`**: Configured exclusions for Python environments, caches, and build - artifacts. - ---- - -## Directory Structure - -```text -. -├── .gitignore -├── .labyricorn/ -│ ├── AGENTS.md # Assistant instructions for publishing content -│ ├── README.md # Publishing structure documentation -│ ├── project/ -│ │ ├── AGENTS.md # Instructions for the project exhibition record -│ │ ├── contents.lr # Project identity and exhibition narrative -│ │ └── # Project logo (PNG, JPEG, WebP) -│ └── devlog/ -│ ├── AGENTS.md # Instructions for devlog records -│ ├── contents.lr # Devlog index record -│ └── / # (Optional) Devlog entry directories -│ ├── contents.lr # Devlog entry record -│ └── # Entry attachments (PNG, JPEG, WebP) -├── AGENTS.md # Assistant instructions for the repository -├── devlog_editor.py # Standalone devlog editor & validator -├── LICENSE # License file -└── README.md # Project documentation -``` - ---- - -## Getting Started - -### 1. Configure the Project Identity - -Edit `.labyricorn/project/contents.lr` to set your project details: - -```ini -_model: project ---- -schema_version: 1 ---- -project_id: my-project-id ---- -title: My Project Title ---- -summary: - -A concise summary of the project for listings and cards. ---- -status: active ---- -started: 2026-08-23 ---- -author: Author Name ---- -repository_url: https://git.labyricorn.com/Owner/my-project ---- -default_branch: main ---- -tags: python, web, tool ---- -body: - -An exhibition narrative describing the project background, goals, and features. -``` - -#### Project Fields: -- `project_id`: Stable lowercase hyphenated slug (e.g. `my-project`). -- `title`: Display title for the project. -- `summary`: Short multi-line summary. -- `status`: One of `active`, `released`, `maintained`, or `archived`. -- `started`: Start date in `YYYY-MM-DD` format. -- `author`: Author or organization name. -- `repository_url`: Public credential-free HTTPS Git URL. -- `default_branch`: Target branch name (e.g. `main`). -- `logo`: (Optional) Local filename of an image inside `.labyricorn/project/`. -- `tags`: Comma-separated technology or topic labels. -- `body`: Markdown narrative of the project. - -### 2. Configure the Devlog Index - -Edit `.labyricorn/devlog/contents.lr` to customize the devlog title and summary: - -```ini -_model: devlog ---- -schema_version: 1 ---- -title: My Project development log ---- -summary: Milestones and development updates for My Project. -``` - -### 3. Add Devlog Entries - -To add an entry manually, create a directory under `.labyricorn/devlog//` -containing `contents.lr`: - -```ini -_model: devlog-entry ---- -schema_version: 1 ---- -title: Initial Project Milestone ---- -date: 2026-08-23 ---- -author: Author Name ---- -summary: - -Overview of the milestone accomplishments. ---- -tags: release, setup ---- -source_commit: <40-character-commit-hash> ---- -body: - -Detailed Markdown description of the milestone, technical decisions, and progress. -``` - -Alternatively, use `devlog_editor.py` to create and edit entries interactively. - ---- - -## Devlog Editor - -The included `devlog_editor.py` script is a standalone utility with zero external -dependencies (uses Python standard library and Tkinter). - -### CLI Validation - -Validate all project and devlog records without opening a GUI: - -```bash -python devlog_editor.py --validate -``` - -### Graphical Editor - -Launch the Tkinter management interface to view repository status, draft entries, -and publish updates: - -```bash -python devlog_editor.py -``` - -Features of the GUI editor: -- **Repository status**: Displays current branch, remote, and ahead/behind counts. -- **Entry manager**: Create, edit, and delete devlog entries with automatic slug and date handling. -- **Publishing safety**: Stages and commits only `.labyricorn/` publishing files, leaving application source files untouched. - ---- - -## Publishing Rules & Constraints - -The Labyricorn site importer enforces strict validation rules on imported repositories: - -1. **Native Lektor Records**: Records must be named `contents.lr` and declare `schema_version: 1`. -2. **No Raw HTML**: HTML tags (` diff --git a/public/style.css b/public/style.css index 2bff3cd..68d687e 100644 --- a/public/style.css +++ b/public/style.css @@ -13,3 +13,14 @@ pre { white-space: pre-wrap; overflow-wrap: anywhere; background: #111d28; paddi .controls { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; } .controls label { min-width: 150px; } .notice { color: #ffcd7a; } details { margin: 8px 0; } summary { cursor: pointer; } output { display: block; margin: 8px 0; } button:focus-visible, input:focus-visible, select:focus-visible { outline: 2px solid #7cceff; outline-offset: 2px; } +#status { font-weight: 650; padding: 8px 12px; border-left: 4px solid #ffcd7a; } +#status[data-state=connected] { border-color: #80d6ad; } +#status[data-state=error], #latest-error { color: #ffcd7a; } +#loaded-url { overflow-wrap: anywhere; font-size: 13px; } +#identity { font-weight: 600; } +#capabilities { display: flex; flex-wrap: wrap; gap: 8px 24px; padding-left: 20px; } +.target-group { margin-top: 24px; } +.target-group > h3 { background: #233444; padding: 8px 12px; } +.target { padding: 16px 12px; } +.target code { overflow-wrap: anywhere; } +@media (max-width: 600px) { header, main { padding: 12px; } section { padding: 12px; } .connection-row input { flex-basis: 100%; } } diff --git a/src/connection.js b/src/connection.js new file mode 100644 index 0000000..984fbbf --- /dev/null +++ b/src/connection.js @@ -0,0 +1,49 @@ +import { ProtocolError } from './validation.js'; + +export function exhibitURL(value, base) { + if (!value.trim()) throw new Error('Enter an exhibit URL or served path.'); + const url = new URL(value.trim(), base); + if (!['http:', 'https:'].includes(url.protocol) || url.origin !== new URL(base).origin || url.username || url.password) { + throw new Error('Enter a same-origin HTTP exhibit URL or served path.'); + } + return url.href; +} + +// Frame lifecycle only; protocol negotiation remains in ExhibitHost. +export class ExhibitConnection { + constructor({ host, createFrame, transport, base, changed = () => {}, loadTimeoutMs = 15000 }) { + Object.assign(this, { host, createFrame, transport, base, changed, loadTimeoutMs }); + this.frame = null; this.url = ''; this.loading = false; this.generation = 0; + } + disconnect() { + this.generation++; clearTimeout(this.timer); this.loading = false; + this.frame?.remove(); this.frame = null; this.host.disconnect(); this.changed(); + } + load(value) { + const url = exhibitURL(value, this.base); // Invalid input preserves the active exhibit. + this.disconnect(); this.url = url; this.loading = true; + const generation = this.generation; + const frame = this.createFrame(); this.frame = frame; + frame.addEventListener('load', () => { + if (generation !== this.generation) return; + clearTimeout(this.timer); this.loading = false; this.attach(); + }); + this.timer = setTimeout(() => { + if (generation !== this.generation) return; + this.disconnect(); + this.host.report(new ProtocolError('LOAD_TIMEOUT', 'Exhibit did not finish loading. Check the served path and reconnect.')); + }, this.loadTimeoutMs); + frame.src = url; this.changed(); + } + async attach() { + if (!this.frame || this.loading) return; + const generation = this.generation; + try { await this.host.connect(this.transport(this.frame)); } + catch (error) { if (generation === this.generation && !error.code) this.host.report(error); } + this.changed(); + } + reconnect() { + if (this.frame && !this.loading) return this.attach(); + if (this.url) this.load(this.url); + } +} diff --git a/src/ui.js b/src/ui.js index 676e2ac..dcb15b2 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,12 +1,21 @@ import { ExhibitHost } from './host.js'; import { argumentSchema } from './validation.js'; import { postMessageTransport } from './transport/post-message.js'; +import { ExhibitConnection } from './connection.js'; const host = new ExhibitHost(); const byId = id => document.getElementById(id); const json = value => JSON.stringify(value, null, 2); const rows = new Map(); -let frame = null, renderedCatalog = null; +let renderedCatalog = null; +const connection = new ExhibitConnection({ host, base: location.href, transport: postMessageTransport, changed: render, + createFrame() { + const frame = document.createElement('iframe'); frame.title = 'Connected exhibit'; + // Append only after src is assigned to avoid negotiating with the blank document. + queueMicrotask(() => { if (connection.frame === frame) byId('frame-container').append(frame); }); + return frame; + } +}); function element(tag, text, parent) { const node = document.createElement(tag); if (text !== undefined) node.textContent = text; parent?.append(node); return node; @@ -38,8 +47,15 @@ function editor(spec, label, parent) { } function buildCatalog() { rows.clear(); byId('catalog').replaceChildren(); + if (!host.catalog.length) element('p', 'Connect an exhibit to discover its controls.', byId('catalog')); + const groups = new Map(); for (const target of host.catalog) { - const section = element('article', undefined, byId('catalog')); section.className = 'target'; + const category = target.category || 'Other'; + if (!groups.has(category)) { + const group = element('div', undefined, byId('catalog')); group.className = 'target-group'; + element('h3', category, group); groups.set(category, group); + } + const section = element('article', undefined, groups.get(category)); section.className = 'target'; element('h3', target.label ?? target.id, section); element('code', target.id, section); element('p', `${target.kind} · ${target.readable ? 'readable' : 'not readable'} · ${target.writable ? 'writable' : 'not writable'}`, section); const current = element('output', '', section); @@ -85,16 +101,27 @@ function buildCatalog() { } } function render() { - byId('status').textContent = `${host.status} · ${host.sync}`; + byId('status').textContent = connection.loading ? 'Loading exhibit…' : `${host.status} · ${host.sync}`; + byId('status').dataset.state = host.status; + byId('identity').textContent = host.exhibit + ? `${host.exhibit.product} · ${host.exhibit.version ?? ''} · Contract ${host.contract.major}.${host.contract.minor}` + : 'No exhibit connected. Enter a served path to begin.'; + byId('loaded-url').textContent = connection.url ? `Exhibit location: ${connection.url}` : ''; byId('metadata').textContent = json({ sessionId: host.sessionId, contract: host.contract, exhibit: host.exhibit, registryRevision: host.registryRevision, stateRevision: host.stateRevision, sequence: host.sequence }); - byId('capabilities').textContent = json(host.capabilities); + byId('capabilities').replaceChildren(); + if (!host.capabilities.length) element('p', host.sessionId ? 'No capabilities declared.' : 'Capabilities appear after discovery.', byId('capabilities')); + for (const capability of host.capabilities) element('li', `${capability.id}: ${capability.state}`, byId('capabilities')); byId('state').textContent = json(Object.fromEntries(host.values)); byId('events').textContent = json(host.eventLog); byId('errors').textContent = json(host.logs); + const latest = host.logs.findLast(entry => entry.code !== 'SESSION'); + byId('latest-error').textContent = latest ? `Latest diagnostic (${latest.time}): ${latest.code}: ${latest.message}` : ''; + byId('latest-error').hidden = !latest; byId('target-count').textContent = `(${host.catalog.length})`; byId('refresh').disabled = host.status !== 'connected'; - byId('reconnect').disabled = !frame; + byId('reconnect').disabled = !connection.url || connection.loading || host.status === 'negotiating'; + byId('disconnect').disabled = !connection.frame; if (host.catalog !== renderedCatalog) { renderedCatalog = host.catalog; buildCatalog(); } for (const row of rows.values()) { row.current.textContent = host.values.has(row.target.id) ? `Reported value: ${json(host.values.get(row.target.id))}` : 'No persistent value reported.'; @@ -106,22 +133,14 @@ function render() { if (row.submit) row.submit.disabled = row.busy || host.status !== 'connected' || !!row.schemaError || unavailable.length > 0; } } -async function attach() { - if (!frame) return; - try { await host.connect(postMessageTransport(frame)); } catch (error) { if (!error.code) host.report(error); } -} byId('connection').addEventListener('submit', event => { event.preventDefault(); try { - const url = new URL(byId('exhibit-url').value, location.href); - if (url.origin !== location.origin || !['http:', 'https:'].includes(url.protocol)) throw new Error('Enter a same-origin HTTP exhibit URL.'); - host.disconnect(); frame?.remove(); - frame = document.createElement('iframe'); frame.title = 'Connected exhibit'; - frame.addEventListener('load', attach); frame.src = url.href; - byId('frame-container').append(frame); render(); + connection.load(byId('exhibit-url').value); } catch (error) { host.report(error); } }); -byId('reconnect').addEventListener('click', attach); -byId('disconnect').addEventListener('click', () => { host.disconnect(); frame?.remove(); frame = null; render(); }); +byId('reconnect').addEventListener('click', () => connection.reconnect()); +byId('disconnect').addEventListener('click', () => connection.disconnect()); byId('refresh').addEventListener('click', () => host.refresh().catch(error => host.report(error))); +byId('clear-errors').addEventListener('click', () => { host.logs = []; render(); }); host.subscribe(render); render(); diff --git a/src/validation.js b/src/validation.js index cd48042..407750c 100644 --- a/src/validation.js +++ b/src/validation.js @@ -92,6 +92,8 @@ export function validateCatalog(message) { && typeof t.readable === 'boolean' && typeof t.writable === 'boolean' && Array.isArray(t.requires) && t.requires.every(id => typeof id === 'string'), 'Invalid target descriptor.'); ids.add(t.id); + check(t.restorable === undefined || typeof t.restorable === 'boolean', 'Invalid target restorable metadata.'); + check(t.category === undefined || typeof t.category === 'string', 'Invalid target category metadata.'); if (t.kind === 'range') check(Number.isFinite(t.min) && Number.isFinite(t.max) && t.min <= t.max && Number.isFinite(t.step) && t.step > 0, 'Invalid range descriptor.'); if (t.kind === 'selection') check(Array.isArray(t.options) diff --git a/test-fixtures/PROVENANCE.md b/test-fixtures/PROVENANCE.md index 07195e7..f082203 100644 --- a/test-fixtures/PROVENANCE.md +++ b/test-fixtures/PROVENANCE.md @@ -20,6 +20,11 @@ Local compatibility corrections: - Planetarium's magnitude reader and native dispatch still used the old camelCase ID. They now match its existing canonical `sky.magnitude-limit` descriptor, fixing a null snapshot value and failed native writes. +- Final Step 5 closure: Haunted House's haunt-level reader now clamps to 0–1 + and rounds to integer hundredths, matching its unchanged 0.01 descriptor step. + The internal drifting simulation and Manifest behavior remain continuous; + snapshots and mutation events share this normalized reader. Previously the + reader rounded to thousandths and could report off-step values. NGN has no fixture-specific target logic. These corrections apply only to the local fixture copies. No upstream fixes or independent conformance diff --git a/test-fixtures/STEP5-VERIFICATION.md b/test-fixtures/STEP5-VERIFICATION.md new file mode 100644 index 0000000..24a10d3 --- /dev/null +++ b/test-fixtures/STEP5-VERIFICATION.md @@ -0,0 +1,147 @@ +# Step 5 repeatable verification + +Latest rerun (2026-09-14): the corrected SciFi text targets pass through the normal +UI; NGN tests pass 25/25. The browser gate currently fails at 2/3 on Haunted House's +haunt-level step, and publishing validation still fails. See completion report Q. + +Run from the NGN repository root with Node.js 22+. Use `npm start` and the normal +UI at `http://127.0.0.1:4173/`. No npm install, build, source edit, external mount, +or test harness is required for operator use. Use the same origin/port for every +exhibit. `npm test` runs the automated tests. + +## External SciFi-XZBT preparation + +SciFi-XZBT is supplied separately. The run recorded here used the actual working +copy at `G:/.vibe/SciFi-XZBT`, whose HEAD was +`179b9db715aaa92999c5bfef451b6877f30079fe` with local changes and untracked contract +files. This is NOT evidence for that commit alone or for a released dist build. +The original SHA-256 manifest `evidence/step5-scifi-files.json` identifies the +initial 12 files tested. The authorized follow-up corrected the external adapter's +two argument descriptors and matching validator. Its fresh copy is identified by +`evidence/step5-scifi-arguments-files.json`; all 12 source/copy hashes match. + +For this source layout, copy the runtime entry point and its relative assets +without modifying them. Use a fresh destination name when repeating with a newer +copy, to avoid mixing versions: + +```powershell +$exhibitSource = 'G:/.vibe/SciFi-XZBT' +$exhibitDestination = 'test-fixtures/local/scifi-arguments' +New-Item -ItemType Directory -Path $exhibitDestination +Copy-Item -LiteralPath (Join-Path $exhibitSource 'index.html') -Destination $exhibitDestination +Copy-Item -LiteralPath (Join-Path $exhibitSource 'js') -Destination $exhibitDestination -Recurse +Copy-Item -LiteralPath (Join-Path $exhibitSource 'css') -Destination $exhibitDestination -Recurse +``` + +`test-fixtures/local/` is ignored by Git. Do not copy the external repository's +credentials, .git, or development tooling into a served directory. A future +self-contained distributable can instead be placed under `public/` unchanged. +The tested source copy uses only index.html, js/ and css/ for this workflow. + +Enter `/test-fixtures/local/scifi-arguments/index.html` in the **normal NGN UI**, then +select **Load exhibit**. Expected identity: SciFi-XZBT 5.2.0, Contract 5.2, +61 discovered targets. Inspect Session and exhibit metadata and Current +persistent state. Controls below refer to labels discovered from this copy; +they are test instructions, not product configuration. + +1. Set Master Volume to 0.42; verify reported value and state.changed. +2. Set Master Mute true; verify reported value. +3. Set Observation Viewport Active true, then false; verify reported values + and native exhibit behavior within its single frame. +4. Select Active Audio Preset / Interprise-G: Main Engineering; verify + preset.selected and related changes share one resulting revision. +5. Invoke Comm Badge Chime; verify action.executed (acceptance, not audio fidelity). +6. Invoke Demat Cycle Impulse while in Starfleet; expect CAPABILITY_UNAVAILABLE. +7. Select Active Sci-Fi Universe / Whataverse / Tardix, then invoke Demat Cycle + Impulse; verify action.executed in that context. +8. Inspect Speak Text Line and Display Ticker Message descriptors. A conformant + version declares an `arguments` array and NGN shows its text field. Enter text + and invoke; verify acceptance/event. **The corrected copy passes this check:** + both fields appear and action.executed contains the exact submitted args.text. + Speech declares maxLength 2000, ticker 512, both required strings. Correct the + external source before copying; do not add a host workaround or patch only the + served copy. The original copy's legacy args.text failure remains in old evidence. +9. Inspect Incoming events: source, correlationId, monotonically increasing + sequence and stateRevision. Reconnect while attached: new session, cleared + event log/sequence baseline, same exhibit state and revision. +10. Disconnect: no frame/catalog/cache remains. Reconnect: URL reloads, a new + instance and session are discovered. Initial state/revision may differ from + the removed instance. No source changes required. + +Classify failures before changing NGN: host defect, external exhibit contract +defect, conformant but awkward behavior, or environment limitation. Sound +acceptance/events are covered; audible output and optional AI downloads are not +certified. Text-argument failure blocks Step 5 acceptance. + +## Reference regression through the normal UI + +Use these paths, changing only the URL field: + +| Exhibit | URL | Representative operations | +| --- | --- | --- | +| Aquarium | /test-fixtures/reference-exhibits/aquarium/index.html | Population 16, Paused true, Species Blue Tang, Feed | +| Planetarium | /test-fixtures/reference-exhibits/planetarium/index.html | Azimuth 0.75, Sky frozen true, Constellation Cygnus, Advance time with Include minutes checked and minutes 30 | +| Haunted House | /test-fixtures/reference-exhibits/haunted-house/index.html | Haunt level 0.5, Held true, Room Library, Manifest | + +Inspect reports and events, reconnect and confirm a new session. Disconnect and +reload the remembered URL. Aquarium and Planetarium pass the representative +flows. Haunted House emits Manifest's action event, but during the effect a +snapshot can contain a haunt-level value off its declared 0.01 step; NGN reports +INVALID_MESSAGE and preserves its previous cache. This is a pre-existing fixture +limitation, not a new host regression. No fixture repair was made in Step 5. + +The existing transport suite at `/test-fixtures/host-verification.html` remains a +separate regression check: select Run verification, expect 3/3, and save the +visible timestamped text or browser console output. The latest rerun produced +2/3, failing Haunted House's step validation. Its first impulse with no capability +requirements is Manifest, and the fixture's continuous drift can also produce +off-step snapshots. The earlier statement that this suite avoids Manifest was +incorrect. Do not reinterpret a historical 3/3 as the current result or as complete +fixture conformance certification. + +## Failure and cancellation checks + +- Start with no URL: useful disconnected controls/empty catalog. +- Enter an off-origin URL: reject and preserve the current exhibit. +- Enter a missing local URL: document-load timeout (15 seconds in the checked + browser), or hello timeout (5 seconds if an error page finishes loading). + Enter a valid path to recover. +- Disconnect during loading or switch rapidly: old frame load callbacks must + not attach to the new exhibit. Focused automated lifecycle tests cover this. +- Refresh state reads current values. Logs can be cleared; prior diagnostics + are timestamped and do not imply the current connection is failed. + +## Evidence and repeatability limits + +`evidence/step5-reference-suite.txt` is the actual timestamped browser suite output. +`step5-scifi-session.json` contains the actual discovered catalog, session metadata, +events and failures. The three reference JSON files contain DOM-rendered runtime +metadata/events; Haunted House also includes its errors. `step5-operator.jpg` +shows the operator panel. Evidence was captured in local Chromium on 2026-09-14. + +A fresh `npm start` succeeded on port 4175 because an existing service occupied +4173; the final browser transport suite ran on that fresh process. The normal UI +ran on 4173. No separate pristine checkout was created; tests/startup use the +current source tree without build products. SciFi repeatability requires the +external files described above. Repository publishing validation has an existing +malformed trailing record block in the Post Step 4 Completion devlog; see report. + +Follow-up evidence is separate from those historical artifacts: +`step5-scifi-arguments-ui.json` captures the corrected descriptors, text fields, +and successful events; `step5-scifi-arguments-files.json` identifies the tested +working copy; `step5-acceptance-rerun-browser.txt` records the current 2/3 failure. +`step5-scifi-arguments-ui-final.json` records the repeated checks after line-ending +cleanup, including a draft reset on speech capability rediscovery and successful +ticker resubmission with the exact intended text. +The follow-up started a fresh server on 4173 and performed the requested narrow +SciFi checks, rather than repeating every earlier operator case above. + +The optional external validator regression check is: + +```powershell +node test-fixtures/check-scifi-arguments.cjs G:/.vibe/SciFi-XZBT/js/contract-adapter.js +``` + +It checks descriptor arrays, required/type/unknown-key/length rejection before +execution, maximum-length acceptance, and unchanged argumentless behavior. It +supplements the real operator UI checks; it does not replace them. diff --git a/test-fixtures/check-scifi-arguments.cjs b/test-fixtures/check-scifi-arguments.cjs new file mode 100644 index 0000000..21710b4 --- /dev/null +++ b/test-fixtures/check-scifi-arguments.cjs @@ -0,0 +1,38 @@ +// Optional external-exhibit regression check; not NGN product configuration. +// Usage: node test-fixtures/check-scifi-arguments.cjs +const fs = require('node:fs'); +const vm = require('node:vm'); +const assert = require('node:assert/strict'); + +async function main() { + const context = { window: {}, console }; + vm.createContext(context); + vm.runInContext(fs.readFileSync(process.argv[2], 'utf8'), context); + const calls = []; + const adapter = new context.window.XZBTContractAdapter({ bindings: { invokers: { + 'speech.say': args => calls.push(args), + 'display.ticker': args => calls.push(args), + 'speech.generate-announcement': args => calls.push(args) + } } }); + for (const [id, maxLength] of [['speech.say', 2000], ['display.ticker', 512]]) { + const descriptor = adapter.targets.get(id); + assert.equal(Object.hasOwn(descriptor, 'args'), false); + assert.deepEqual(JSON.parse(JSON.stringify(descriptor.arguments)), [ + { name: 'text', type: 'string', required: true, maxLength } + ]); + for (const args of [{}, { text: null }, { text: 42 }, { text: 'x', extra: true }, { text: 'x'.repeat(maxLength + 1) }]) { + const before = calls.length; + const result = await adapter.invokeAction(id, args); + assert.equal(result.code, 'INVALID_VALUE'); + assert.equal(calls.length, before, 'invalid arguments must not execute'); + } + const text = 'x'.repeat(maxLength); + assert.equal((await adapter.invokeAction(id, { text })).ok, true); + assert.equal(calls.at(-1).text, text); + console.log(`PASS ${id}: arguments metadata, required/type/unknown/length rejection, exact length accepted`); + } + assert.equal((await adapter.invokeAction('speech.generate-announcement', {})).ok, true); + assert.equal((await adapter.invokeAction('speech.generate-announcement', { text: 'x' })).code, 'INVALID_VALUE'); + console.log('PASS argumentless impulse: empty args accepted, unknown key rejected'); +} +main().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/test-fixtures/evidence/step5-acceptance-rerun-browser.txt b/test-fixtures/evidence/step5-acceptance-rerun-browser.txt new file mode 100644 index 0000000..adb1a20 --- /dev/null +++ b/test-fixtures/evidence/step5-acceptance-rerun-browser.txt @@ -0,0 +1,7 @@ +Started: 2026-09-14T19:41:45.445Z +PASS aquarium: 12 targets; negotiation, discovery, state/range/selection, invoke, errors, events, no-op, reconnect, session invalidation/recovery. +PASS planetarium: 14 targets; negotiation, discovery, state/range/selection, invoke, errors, events, no-op, reconnect, session invalidation/recovery. +FAIL haunted-house: room.haunt-level: Value must follow step 0.01. +2/3 real exhibits passed. +Finished: 2026-09-14T19:41:45.575Z + diff --git a/test-fixtures/evidence/step5-aquarium.json b/test-fixtures/evidence/step5-aquarium.json new file mode 100644 index 0000000..4ad1feb --- /dev/null +++ b/test-fixtures/evidence/step5-aquarium.json @@ -0,0 +1,66 @@ +{ + "events": [ + { + "correlationId": "ngn-2-4", + "sequence": 1, + "sessionId": "sess-1a192", + "source": "host", + "stateRevision": 1, + "target": "tank.population", + "timestamp": 1789398588359, + "type": "state.changed", + "value": 16, + "xzbt": "5.2" + }, + { + "correlationId": "ngn-2-6", + "sequence": 2, + "sessionId": "sess-1a192", + "source": "host", + "stateRevision": 2, + "target": "tank.paused", + "timestamp": 1789398588908, + "type": "state.changed", + "value": true, + "xzbt": "5.2" + }, + { + "correlationId": "ngn-2-8", + "sequence": 3, + "sessionId": "sess-1a192", + "source": "host", + "stateRevision": 3, + "target": "tank.species", + "timestamp": 1789398589178, + "type": "selection.changed", + "value": "blue-tang", + "xzbt": "5.2" + }, + { + "args": {}, + "correlationId": "ngn-2-10", + "sequence": 4, + "sessionId": "sess-1a192", + "source": "host", + "target": "action.feed", + "timestamp": 1789398589436, + "type": "action.executed", + "xzbt": "5.2" + } + ], + "metadata": { + "contract": { + "major": 5, + "minor": 2 + }, + "exhibit": { + "build": "reference-exhibit", + "product": "Aquarium", + "version": "1.0.0" + }, + "registryRevision": 1, + "sequence": 4, + "sessionId": "sess-1a192", + "stateRevision": 3 + } +} \ No newline at end of file diff --git a/test-fixtures/evidence/step5-closure-browser-suite.txt b/test-fixtures/evidence/step5-closure-browser-suite.txt new file mode 100644 index 0000000..1324d83 --- /dev/null +++ b/test-fixtures/evidence/step5-closure-browser-suite.txt @@ -0,0 +1,7 @@ +Started: 2026-09-14T19:55:22.193Z +PASS aquarium: 12 targets; negotiation, discovery, state/range/selection, invoke, errors, events, no-op, reconnect, session invalidation/recovery. +PASS planetarium: 14 targets; negotiation, discovery, state/range/selection, invoke, errors, events, no-op, reconnect, session invalidation/recovery. +PASS haunted-house: 13 targets; negotiation, discovery, state/range/selection, invoke, errors, events, no-op, reconnect, session invalidation/recovery. +3/3 real exhibits passed. +Finished: 2026-09-14T19:55:22.342Z + diff --git a/test-fixtures/evidence/step5-closure-checks.json b/test-fixtures/evidence/step5-closure-checks.json new file mode 100644 index 0000000..181b2bd --- /dev/null +++ b/test-fixtures/evidence/step5-closure-checks.json @@ -0,0 +1,25 @@ +{ + "capturedAt": "2026-09-14T19:56:36.172Z", + "checks": [ + { + "status": "fulfilled", + "value": { + "chunk_id": "82977d", + "wall_time_seconds": 1.3615515999999999, + "exit_code": 0, + "original_token_count": 585, + "output": "\n> xzbt-ngn@0.1.0 test\n> node --test\n\n✔ connection accepts served paths and same-origin URLs, rejects unsupported locations (1.7406ms)\n✔ switch and disconnect cancel old frame loads; invalid URL preserves active frame (1.1493ms)\n✔ reconnect negotiates existing frame and reloads remembered URL after disconnect (0.4236ms)\n✔ stalled document load ends with recoverable error (20.0644ms)\n✔ optional descriptor metadata is permissive when omitted and typed when supplied (1.4027ms)\n✔ Haunted House reports bounded hundredths while preserving continuous drift (28.2601ms)\n✔ Haunted House snapshots and mutation events stay valid through Manifest, drift and Calm (11.0154ms)\n✔ negotiates, discovers and reads real session fields (4.696ms)\n✔ sets all persistent kinds, invokes arguments, preserves no-op revisions (1.6233ms)\n✔ invalid values and required/unknown arguments never reach transport (1.4478ms)\n✔ argument type/enum/bounds/step/length rules and unsupported future types (0.5322ms)\n✔ surfaces exhibit errors and invalid session requires reconnect (0.9883ms)\n✔ unsupported version and timeout are explicit failures (11.4367ms)\n✔ native events update cache; shared revision is not a gap (0.8535ms)\n✔ sequence and revision gaps resync and remain visible (26.0243ms)\n✔ capability and registry changes debounce discovery; normal state does not (52.8678ms)\n✔ events arriving during snapshot are replayed without lost updates (0.6791ms)\n✔ old session traffic does not mutate new cache (0.4186ms)\n✔ malformed persistent events trigger recovery (25.6604ms)\n✔ postMessage verifies origin and source and cleans up (0.4321ms)\n✔ local server serves host and denies repository/private paths (41.9438ms)\n✔ invalid reported types do not contaminate the cache (26.2967ms)\n✔ invalid snapshot values preserve previous cache and mark uncertainty (0.6139ms)\n✔ snapshot revision cannot roll back a previously observed transaction (0.322ms)\n✔ registry rediscovery adds descriptors and removes obsolete cached targets (25.7427ms)\n✔ unsupported future impulse arguments do not hide the catalog (0.7149ms)\n✔ capability requirements block unusable services without deleting targets (0.4759ms)\nℹ tests 27\nℹ suites 0\nℹ pass 27\nℹ fail 0\nℹ cancelled 0\nℹ skipped 0\nℹ todo 0\nℹ duration_ms 388.0334\n" + } + }, + { + "status": "fulfilled", + "value": { + "chunk_id": "9c0253", + "wall_time_seconds": 31.5457821, + "exit_code": 0, + "original_token_count": 8, + "output": "devlog-editor: devlog is valid\r\n" + } + } + ] +} diff --git a/test-fixtures/evidence/step5-closure-haunted-house.json b/test-fixtures/evidence/step5-closure-haunted-house.json new file mode 100644 index 0000000..1ad1afd --- /dev/null +++ b/test-fixtures/evidence/step5-closure-haunted-house.json @@ -0,0 +1,17 @@ +{ + "capturedAt": "2026-09-14T19:57:02.871Z", + "checks": [ + { + "phase": "held Manifest", + "snapshot": "- banner:\n - heading \"XZBT-NGN Exhibit Engine\" [level=1]\n - paragraph: Contract 5.2 · One exhibit at a time\n- main:\n - heading \"Connection\" [level=2]\n - generic: Exhibit URL (same origin)\n - textbox \"Exhibit URL (same origin)\":\n - /placeholder: /public/exhibits/example/index.html\n - text: /test-fixtures/reference-exhibits/haunted-house/index.html\n - button \"Load exhibit\"\n - button \"Reconnect\"\n - button \"Disconnect\"\n - paragraph: Enter a trusted exhibit's local served path or a URL on this origin. Load exhibit also switches exhibits. Reconnect starts a new session, or reloads the last exhibit after disconnect.\n - status: connected · synchronized\n - paragraph: Haunted House · 1.0.0 · Contract 5.2\n - paragraph: \"Exhibit location: http://127.0.0.1:4173/test-fixtures/reference-exhibits/haunted-house/index.html\"\n - iframe:\n - banner:\n - heading \"Haunted House\" [level=1]\n - generic: Fifth room — live haunt\n - generic: host attached\n - main:\n - generic \"Haunted house room view\"\n - complementary:\n - heading \"Presence\" [level=2]\n - generic: Room\n - combobox \"Room\":\n - option \"Foyer\"\n - option \"Library\" [selected]\n - option \"Cellar\"\n - option \"Attic\"\n - option \"Nursery\"\n - generic: Spirit\n - combobox \"Spirit\":\n - option \"Restless\" [selected]\n - option \"Vengeful\"\n - option \"Mourning\"\n - option \"Mischievous\"\n - generic: Haunt level\n - generic: 100%\n - slider \"Haunt level 100%\"\n - heading \"Atmosphere\" [level=2]\n - generic: Fog density\n - generic: 25%\n - slider \"Fog density 25%\"\n - generic: Candlelight\n - generic: 55%\n - slider \"Candlelight 55%\"\n - text: \"Audio engine:\"\n - strong: available\n - button \"Audio on\" [pressed]\n - generic: Simulation\n - button \"Resume\" [pressed]\n - heading \"Actions\" [level=2]\n - button \"Moan\"\n - button \"Slam door\"\n - button \"Manifest\"\n - button \"Calm\"\n - contentinfo:\n - generic: House\n - generic: The apparition manifests fully.\n - heading \"Exhibit controls (13)\" [level=2]\n - generic \"Capabilities\"\n - paragraph: Reported values are authoritative. New values are drafts; select Set or Invoke to submit. Rediscovery resets drafts.\n - heading \"room\" [level=3]\n - article:\n - heading \"Room\" [level=3]\n - code: room.current\n - paragraph: selection · readable · writable\n - status: \"Reported value: \\\"library\\\"\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - combobox \"New value\":\n - option \"Foyer\"\n - option \"Library\" [selected]\n - option \"Cellar\"\n - option \"Attic\"\n - option \"Nursery\"\n - button \"Set\"\n - article:\n - heading \"Spirit\" [level=3]\n - code: room.spirit\n - paragraph: selection · readable · writable\n - status: \"Reported value: \\\"restless\\\"\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - combobox \"New value\":\n - option \"Restless\" [selected]\n - option \"Vengeful\"\n - option \"Mourning\"\n - option \"Mischievous\"\n - button \"Set\"\n - article:\n - heading \"Haunt level\" [level=3]\n - code: room.haunt-level\n - paragraph: range · readable · writable\n - status: \"Reported value: 1\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"0.5\"\n - button \"Set\"\n - article:\n - heading \"Fog density\" [level=3]\n - code: room.fog-density\n - paragraph: range · readable · writable\n - status: \"Reported value: 0.25\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"0.25\"\n - button \"Set\"\n - article:\n - heading \"Candlelight\" [level=3]\n - code: room.candlelight\n - paragraph: range · readable · writable\n - status: \"Reported value: 0.55\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"0.55\"\n - button \"Set\"\n - article:\n - heading \"Audio enabled\" [level=3]\n - code: room.audio-enabled\n - paragraph: state · readable · writable\n - status: \"Reported value: true\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - checkbox \"New value\" [checked]\n - button \"Set\"\n - article:\n - heading \"Held\" [level=3]\n - code: room.paused\n - paragraph: state · readable · writable\n - status: \"Reported value: true\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - checkbox \"New value\" [checked]\n - button \"Set\"\n - heading \"action\" [level=3]\n - article:\n - heading \"Moan\" [level=3]\n - code: action.moan\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Slam door\" [level=3]\n - code: action.slam-door\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Manifest\" [level=3]\n - code: action.manifest\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Calm\" [level=3]\n - code: action.calm\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - heading \"telemetry\" [level=3]\n - article:\n - heading \"Audio engine state\" [level=3]\n - code: telemetry.audio-state\n - paragraph: state · readable · not writable\n - status: \"Reported value: \\\"available\\\"\"\n - generic \"Target descriptor\"\n - paragraph\n - article:\n - heading \"Room damping\" [level=3]\n - code: telemetry.room-damp\n - paragraph: state · readable · not writable\n - status: \"Reported value: 0.6\"\n - generic \"Target descriptor\"\n - paragraph\n - heading \"State and metadata\" [level=2]\n - button \"Refresh state\"\n - generic \"Session and exhibit metadata\"\n - generic: \"{ \\\"sessionId\\\": \\\"sess-12844\\\", \\\"contract\\\": { \\\"major\\\": 5, \\\"minor\\\": 2 }, \\\"exhibit\\\": { \\\"product\\\": \\\"Haunted House\\\", \\\"version\\\": \\\"1.0.0\\\", \\\"build\\\": \\\"reference-exhibit\\\" }, \\\"registryRevision\\\": 1, \\\"stateRevision\\\": 3, \\\"sequence\\\": 4 }\"\n - generic \"Current persistent state\" [active]\n - paragraph: Last reported exhibit values. This host view may be uncertain while resynchronizing.\n - generic: \"{ \\\"room.current\\\": \\\"library\\\", \\\"room.spirit\\\": \\\"restless\\\", \\\"room.haunt-level\\\": 1, \\\"room.fog-density\\\": 0.25, \\\"room.candlelight\\\": 0.55, \\\"room.audio-enabled\\\": true, \\\"room.paused\\\": true, \\\"telemetry.audio-state\\\": \\\"available\\\", \\\"telemetry.room-damp\\\": 0.6 }\"\n - heading \"Protocol diagnostics\" [level=2]\n - generic \"Incoming events\"\n - paragraph: Latest 200 session events, including source, sequence, revision, and correlation when supplied.\n - generic \"Incoming events\": \"[ { \\\"xzbt\\\": \\\"5.2\\\", \\\"type\\\": \\\"state.changed\\\", \\\"sessionId\\\": \\\"sess-12844\\\", \\\"sequence\\\": 1, \\\"timestamp\\\": 1789415741975, \\\"stateRevision\\\": 1, \\\"target\\\": \\\"room.paused\\\", \\\"value\\\": true, \\\"source\\\": \\\"host\\\", \\\"correlationId\\\": \\\"ngn-2-4\\\" }, { \\\"xzbt\\\": \\\"5.2\\\", \\\"type\\\": \\\"state.changed\\\", \\\"sessionId\\\": \\\"sess-12844\\\", \\\"sequence\\\": 2, \\\"timestamp\\\": 1789415742259, \\\"stateRevision\\\": 2, \\\"target\\\": \\\"room.haunt-level\\\", \\\"value\\\": 0.5, \\\"source\\\": \\\"host\\\", \\\"correlationId\\\": \\\"ngn-2-6\\\" }, { \\\"xzbt\\\": \\\"5.2\\\", \\\"type\\\": \\\"selection.changed\\\", \\\"sessionId\\\": \\\"sess-12844\\\", \\\"sequence\\\": 3, \\\"timestamp\\\": 1789415742544, \\\"stateRevision\\\": 3, \\\"target\\\": \\\"room.current\\\", \\\"value\\\": \\\"library\\\", \\\"source\\\": \\\"host\\\", \\\"correlationId\\\": \\\"ngn-2-8\\\" }, { \\\"xzbt\\\": \\\"5.2\\\", \\\"type\\\": \\\"action.executed\\\", \\\"sessionId\\\": \\\"sess-12844\\\", \\\"sequence\\\": 4, \\\"timestamp\\\": 1789415793706, \\\"target\\\": \\\"action.manifest\\\", \\\"args\\\": {}, \\\"source\\\": \\\"host\\\", \\\"correlationId\\\": \\\"ngn-2-10\\\" } ]\"\n - generic \"Protocol and errors\"\n - paragraph: Latest 200 entries across connections in this page. Timestamps identify earlier failures.\n - button \"Clear log\"\n - log: \"[ { \\\"time\\\": \\\"2026-09-14T19:55:08.301Z\\\", \\\"code\\\": \\\"SESSION\\\", \\\"message\\\": \\\"Exhibit session established.\\\", \\\"detail\\\": { \\\"sessionId\\\": \\\"sess-12844\\\" } } ]\"" + }, + { + "phase": "drifting after Manifest", + "snapshot": "- banner:\n - heading \"XZBT-NGN Exhibit Engine\" [level=1]\n - paragraph: Contract 5.2 · One exhibit at a time\n- main:\n - heading \"Connection\" [level=2]\n - generic: Exhibit URL (same origin)\n - textbox \"Exhibit URL (same origin)\":\n - /placeholder: /public/exhibits/example/index.html\n - text: /test-fixtures/reference-exhibits/haunted-house/index.html\n - button \"Load exhibit\"\n - button \"Reconnect\"\n - button \"Disconnect\"\n - paragraph: Enter a trusted exhibit's local served path or a URL on this origin. Load exhibit also switches exhibits. Reconnect starts a new session, or reloads the last exhibit after disconnect.\n - status: connected · synchronized\n - paragraph: Haunted House · 1.0.0 · Contract 5.2\n - paragraph: \"Exhibit location: http://127.0.0.1:4173/test-fixtures/reference-exhibits/haunted-house/index.html\"\n - iframe:\n - banner:\n - heading \"Haunted House\" [level=1]\n - generic: Fifth room — live haunt\n - generic: host attached\n - main:\n - generic \"Haunted house room view\"\n - complementary:\n - heading \"Presence\" [level=2]\n - generic: Room\n - combobox \"Room\":\n - option \"Foyer\"\n - option \"Library\" [selected]\n - option \"Cellar\"\n - option \"Attic\"\n - option \"Nursery\"\n - generic: Spirit\n - combobox \"Spirit\":\n - option \"Restless\" [selected]\n - option \"Vengeful\"\n - option \"Mourning\"\n - option \"Mischievous\"\n - generic: Haunt level\n - generic: 100%\n - slider \"Haunt level 100%\"\n - heading \"Atmosphere\" [level=2]\n - generic: Fog density\n - generic: 25%\n - slider \"Fog density 25%\"\n - generic: Candlelight\n - generic: 55%\n - slider \"Candlelight 55%\"\n - text: \"Audio engine:\"\n - strong: available\n - button \"Audio on\" [pressed]\n - generic: Simulation\n - button \"Hold\"\n - heading \"Actions\" [level=2]\n - button \"Moan\"\n - button \"Slam door\"\n - button \"Manifest\"\n - button \"Calm\"\n - contentinfo:\n - generic: House\n - generic: The house is quiet — awaiting instruction.\n - heading \"Exhibit controls (13)\" [level=2]\n - generic \"Capabilities\"\n - paragraph: Reported values are authoritative. New values are drafts; select Set or Invoke to submit. Rediscovery resets drafts.\n - heading \"room\" [level=3]\n - article:\n - heading \"Room\" [level=3]\n - code: room.current\n - paragraph: selection · readable · writable\n - status: \"Reported value: \\\"library\\\"\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - combobox \"New value\":\n - option \"Foyer\"\n - option \"Library\" [selected]\n - option \"Cellar\"\n - option \"Attic\"\n - option \"Nursery\"\n - button \"Set\"\n - article:\n - heading \"Spirit\" [level=3]\n - code: room.spirit\n - paragraph: selection · readable · writable\n - status: \"Reported value: \\\"restless\\\"\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - combobox \"New value\":\n - option \"Restless\" [selected]\n - option \"Vengeful\"\n - option \"Mourning\"\n - option \"Mischievous\"\n - button \"Set\"\n - article:\n - heading \"Haunt level\" [level=3]\n - code: room.haunt-level\n - paragraph: range · readable · writable\n - status: \"Reported value: 0.26\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"0.5\"\n - button \"Set\"\n - article:\n - heading \"Fog density\" [level=3]\n - code: room.fog-density\n - paragraph: range · readable · writable\n - status: \"Reported value: 0.25\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"0.25\"\n - button \"Set\"\n - article:\n - heading \"Candlelight\" [level=3]\n - code: room.candlelight\n - paragraph: range · readable · writable\n - status: \"Reported value: 0.55\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"0.55\"\n - button \"Set\"\n - article:\n - heading \"Audio enabled\" [level=3]\n - code: room.audio-enabled\n - paragraph: state · readable · writable\n - status: \"Reported value: true\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - checkbox \"New value\" [checked]\n - button \"Set\"\n - article:\n - heading \"Held\" [level=3]\n - code: room.paused\n - paragraph: state · readable · writable\n - status: \"Reported value: false\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - checkbox \"New value\"\n - button \"Set\"\n - heading \"action\" [level=3]\n - article:\n - heading \"Moan\" [level=3]\n - code: action.moan\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Slam door\" [level=3]\n - code: action.slam-door\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Manifest\" [level=3]\n - code: action.manifest\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Calm\" [level=3]\n - code: action.calm\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - heading \"telemetry\" [level=3]\n - article:\n - heading \"Audio engine state\" [level=3]\n - code: telemetry.audio-state\n - paragraph: state · readable · not writable\n - status: \"Reported value: \\\"available\\\"\"\n - generic \"Target descriptor\"\n - paragraph\n - article:\n - heading \"Room damping\" [level=3]\n - code: telemetry.room-damp\n - paragraph: state · readable · not writable\n - status: \"Reported value: 0.6\"\n - generic \"Target descriptor\"\n - paragraph\n - heading \"State and metadata\" [level=2]\n - button \"Refresh state\" [active]\n - generic \"Session and exhibit metadata\"\n - generic: \"{ \\\"sessionId\\\": \\\"sess-12844\\\", \\\"contract\\\": { \\\"major\\\": 5, \\\"minor\\\": 2 }, \\\"exhibit\\\": { \\\"product\\\": \\\"Haunted House\\\", \\\"version\\\": \\\"1.0.0\\\", \\\"build\\\": \\\"reference-exhibit\\\" }, \\\"registryRevision\\\": 1, \\\"stateRevision\\\": 4, \\\"sequence\\\": 5 }\"\n - generic \"Current persistent state\"\n - paragraph: Last reported exhibit values. This host view may be uncertain while resynchronizing.\n - generic: \"{ \\\"room.current\\\": \\\"library\\\", \\\"room.spirit\\\": \\\"restless\\\", \\\"room.haunt-level\\\": 0.26, \\\"room.fog-density\\\": 0.25, \\\"room.candlelight\\\": 0.55, \\\"room.audio-enabled\\\": true, \\\"room.paused\\\": false, \\\"telemetry.audio-state\\\": \\\"available\\\", \\\"telemetry.room-damp\\\": 0.6 }\"\n - heading \"Protocol diagnostics\" [level=2]\n - generic \"Incoming events\"\n - paragraph: Latest 200 session events, including source, sequence, revision, and correlation when supplied.\n - generic \"Incoming events\": \"[ { \\\"xzbt\\\": \\\"5.2\\\", \\\"type\\\": \\\"state.changed\\\", \\\"sessionId\\\": \\\"sess-12844\\\", \\\"sequence\\\": 1, \\\"timestamp\\\": 1789415741975, \\\"stateRevision\\\": 1, \\\"target\\\": \\\"room.paused\\\", \\\"value\\\": true, \\\"source\\\": \\\"host\\\", \\\"correlationId\\\": \\\"ngn-2-4\\\" }, { \\\"xzbt\\\": \\\"5.2\\\", \\\"type\\\": \\\"state.changed\\\", \\\"sessionId\\\": \\\"sess-12844\\\", \\\"sequence\\\": 2, \\\"timestamp\\\": 1789415742259, \\\"stateRevision\\\": 2, \\\"target\\\": \\\"room.haunt-level\\\", \\\"value\\\": 0.5, \\\"source\\\": \\\"host\\\", \\\"correlationId\\\": \\\"ngn-2-6\\\" }, { \\\"xzbt\\\": \\\"5.2\\\", \\\"type\\\": \\\"selection.changed\\\", \\\"sessionId\\\": \\\"sess-12844\\\", \\\"sequence\\\": 3, \\\"timestamp\\\": 1789415742544, \\\"stateRevision\\\": 3, \\\"target\\\": \\\"room.current\\\", \\\"value\\\": \\\"library\\\", \\\"source\\\": \\\"host\\\", \\\"correlationId\\\": \\\"ngn-2-8\\\" }, { \\\"xzbt\\\": \\\"5.2\\\", \\\"type\\\": \\\"action.executed\\\", \\\"sessionId\\\": \\\"sess-12844\\\", \\\"sequence\\\": 4, \\\"timestamp\\\": 1789415793706, \\\"target\\\": \\\"action.manifest\\\", \\\"args\\\": {}, \\\"source\\\": \\\"host\\\", \\\"correlationId\\\": \\\"ngn-2-10\\\" }, { \\\"xzbt\\\": \\\"5.2\\\", \\\"type\\\": \\\"state.changed\\\", \\\"sessionId\\\": \\\"sess-12844\\\", \\\"sequence\\\": 5, \\\"timestamp\\\": 1789415795623, \\\"stateRevision\\\": 4, \\\"target\\\": \\\"room.paused\\\", \\\"value\\\": false, \\\"source\\\": \\\"host\\\", \\\"correlationId\\\": \\\"ngn-2-13\\\" } ]\"\n - generic \"Protocol and errors\"\n - paragraph: Latest 200 entries across connections in this page. Timestamps identify earlier failures.\n - button \"Clear log\"\n - log: \"[ { \\\"time\\\": \\\"2026-09-14T19:55:08.301Z\\\", \\\"code\\\": \\\"SESSION\\\", \\\"message\\\": \\\"Exhibit session established.\\\", \\\"detail\\\": { \\\"sessionId\\\": \\\"sess-12844\\\" } } ]\"" + }, + { + "phase": "reconnected", + "snapshot": "- banner:\n - heading \"XZBT-NGN Exhibit Engine\" [level=1]\n - paragraph: Contract 5.2 · One exhibit at a time\n- main:\n - heading \"Connection\" [level=2]\n - generic: Exhibit URL (same origin)\n - textbox \"Exhibit URL (same origin)\":\n - /placeholder: /public/exhibits/example/index.html\n - text: /test-fixtures/reference-exhibits/haunted-house/index.html\n - button \"Load exhibit\"\n - button \"Reconnect\"\n - button \"Disconnect\"\n - paragraph: Enter a trusted exhibit's local served path or a URL on this origin. Load exhibit also switches exhibits. Reconnect starts a new session, or reloads the last exhibit after disconnect.\n - status: connected · synchronized\n - paragraph: Haunted House · 1.0.0 · Contract 5.2\n - paragraph: \"Exhibit location: http://127.0.0.1:4173/test-fixtures/reference-exhibits/haunted-house/index.html\"\n - iframe:\n - banner:\n - heading \"Haunted House\" [level=1]\n - generic: Fifth room — live haunt\n - generic: host attached\n - main:\n - generic \"Haunted house room view\"\n - complementary:\n - heading \"Presence\" [level=2]\n - generic: Room\n - combobox \"Room\":\n - option \"Foyer\"\n - option \"Library\" [selected]\n - option \"Cellar\"\n - option \"Attic\"\n - option \"Nursery\"\n - generic: Spirit\n - combobox \"Spirit\":\n - option \"Restless\" [selected]\n - option \"Vengeful\"\n - option \"Mourning\"\n - option \"Mischievous\"\n - generic: Haunt level\n - generic: 100%\n - slider \"Haunt level 100%\"\n - heading \"Atmosphere\" [level=2]\n - generic: Fog density\n - generic: 25%\n - slider \"Fog density 25%\"\n - generic: Candlelight\n - generic: 55%\n - slider \"Candlelight 55%\"\n - text: \"Audio engine:\"\n - strong: available\n - button \"Audio on\" [pressed]\n - generic: Simulation\n - button \"Hold\"\n - heading \"Actions\" [level=2]\n - button \"Moan\"\n - button \"Slam door\"\n - button \"Manifest\"\n - button \"Calm\"\n - contentinfo:\n - generic: House\n - generic: The house is quiet — awaiting instruction.\n - heading \"Exhibit controls (13)\" [level=2]\n - generic \"Capabilities\"\n - paragraph: Reported values are authoritative. New values are drafts; select Set or Invoke to submit. Rediscovery resets drafts.\n - heading \"room\" [level=3]\n - article:\n - heading \"Room\" [level=3]\n - code: room.current\n - paragraph: selection · readable · writable\n - status: \"Reported value: \\\"library\\\"\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - combobox \"New value\":\n - option \"Foyer\"\n - option \"Library\" [selected]\n - option \"Cellar\"\n - option \"Attic\"\n - option \"Nursery\"\n - button \"Set\"\n - article:\n - heading \"Spirit\" [level=3]\n - code: room.spirit\n - paragraph: selection · readable · writable\n - status: \"Reported value: \\\"restless\\\"\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - combobox \"New value\":\n - option \"Restless\" [selected]\n - option \"Vengeful\"\n - option \"Mourning\"\n - option \"Mischievous\"\n - button \"Set\"\n - article:\n - heading \"Haunt level\" [level=3]\n - code: room.haunt-level\n - paragraph: range · readable · writable\n - status: \"Reported value: 0.26\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"0.26\"\n - button \"Set\"\n - article:\n - heading \"Fog density\" [level=3]\n - code: room.fog-density\n - paragraph: range · readable · writable\n - status: \"Reported value: 0.25\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"0.25\"\n - button \"Set\"\n - article:\n - heading \"Candlelight\" [level=3]\n - code: room.candlelight\n - paragraph: range · readable · writable\n - status: \"Reported value: 0.55\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"0.55\"\n - button \"Set\"\n - article:\n - heading \"Audio enabled\" [level=3]\n - code: room.audio-enabled\n - paragraph: state · readable · writable\n - status: \"Reported value: true\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - checkbox \"New value\" [checked]\n - button \"Set\"\n - article:\n - heading \"Held\" [level=3]\n - code: room.paused\n - paragraph: state · readable · writable\n - status: \"Reported value: false\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - checkbox \"New value\"\n - button \"Set\"\n - heading \"action\" [level=3]\n - article:\n - heading \"Moan\" [level=3]\n - code: action.moan\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Slam door\" [level=3]\n - code: action.slam-door\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Manifest\" [level=3]\n - code: action.manifest\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Calm\" [level=3]\n - code: action.calm\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - heading \"telemetry\" [level=3]\n - article:\n - heading \"Audio engine state\" [level=3]\n - code: telemetry.audio-state\n - paragraph: state · readable · not writable\n - status: \"Reported value: \\\"available\\\"\"\n - generic \"Target descriptor\"\n - paragraph\n - article:\n - heading \"Room damping\" [level=3]\n - code: telemetry.room-damp\n - paragraph: state · readable · not writable\n - status: \"Reported value: 0.6\"\n - generic \"Target descriptor\"\n - paragraph\n - heading \"State and metadata\" [level=2]\n - button \"Refresh state\"\n - generic \"Session and exhibit metadata\"\n - generic: \"{ \\\"sessionId\\\": \\\"sess-29bfc\\\", \\\"contract\\\": { \\\"major\\\": 5, \\\"minor\\\": 2 }, \\\"exhibit\\\": { \\\"product\\\": \\\"Haunted House\\\", \\\"version\\\": \\\"1.0.0\\\", \\\"build\\\": \\\"reference-exhibit\\\" }, \\\"registryRevision\\\": 1, \\\"stateRevision\\\": 4, \\\"sequence\\\": null }\"\n - generic \"Current persistent state\"\n - paragraph: Last reported exhibit values. This host view may be uncertain while resynchronizing.\n - generic: \"{ \\\"room.current\\\": \\\"library\\\", \\\"room.spirit\\\": \\\"restless\\\", \\\"room.haunt-level\\\": 0.26, \\\"room.fog-density\\\": 0.25, \\\"room.candlelight\\\": 0.55, \\\"room.audio-enabled\\\": true, \\\"room.paused\\\": false, \\\"telemetry.audio-state\\\": \\\"available\\\", \\\"telemetry.room-damp\\\": 0.6 }\"\n - heading \"Protocol diagnostics\" [level=2]\n - generic \"Incoming events\"\n - paragraph: Latest 200 session events, including source, sequence, revision, and correlation when supplied.\n - generic \"Incoming events\": \"[]\"\n - generic \"Protocol and errors\"\n - paragraph: Latest 200 entries across connections in this page. Timestamps identify earlier failures.\n - button \"Clear log\"\n - log: \"[ { \\\"time\\\": \\\"2026-09-14T19:55:08.301Z\\\", \\\"code\\\": \\\"SESSION\\\", \\\"message\\\": \\\"Exhibit session established.\\\", \\\"detail\\\": { \\\"sessionId\\\": \\\"sess-12844\\\" } }, { \\\"time\\\": \\\"2026-09-14T19:56:46.979Z\\\", \\\"code\\\": \\\"SESSION\\\", \\\"message\\\": \\\"Exhibit session established.\\\", \\\"detail\\\": { \\\"sessionId\\\": \\\"sess-29bfc\\\" } } ]\"" + } + ] +} diff --git a/test-fixtures/evidence/step5-closure-scifi.json b/test-fixtures/evidence/step5-closure-scifi.json new file mode 100644 index 0000000..6e90d26 --- /dev/null +++ b/test-fixtures/evidence/step5-closure-scifi.json @@ -0,0 +1,4 @@ +{ + "capturedAt": "2026-09-14T19:57:29.131Z", + "snapshot": "- banner:\n - heading \"XZBT-NGN Exhibit Engine\" [level=1]\n - paragraph: Contract 5.2 · One exhibit at a time\n- main:\n - heading \"Connection\" [level=2]\n - generic: Exhibit URL (same origin)\n - textbox \"Exhibit URL (same origin)\":\n - /placeholder: /public/exhibits/example/index.html\n - text: /test-fixtures/local/scifi-arguments/index.html\n - button \"Load exhibit\"\n - button \"Reconnect\"\n - button \"Disconnect\"\n - paragraph: Enter a trusted exhibit's local served path or a URL on this origin. Load exhibit also switches exhibits. Reconnect starts a new session, or reloads the last exhibit after disconnect.\n - status: connected · synchronized\n - paragraph: SciFi-XZBT · 5.2.0 · Contract 5.2\n - paragraph: \"Exhibit location: http://127.0.0.1:4173/test-fixtures/local/scifi-arguments/index.html\"\n - iframe:\n - banner:\n - generic \"Click to Change Sci-Fi Universe\":\n - generic: STARFLIGHT\n - generic: ▼\n - generic: \"INTERPRISE-G: MAIN BRIDGE\"\n - button \"MUTE\"\n - generic: 75%\n - generic: STARFLIGHT SOUND MATRIX v2.9\n - main:\n - complementary:\n - generic: STARSHIP PRESETS\n - 'button \"Interprise-G: Main Bridge STARFLIGHT COMMAND\"':\n - generic: \"Interprise-G: Main Bridge\"\n - generic: STARFLIGHT COMMAND\n - 'button \"Interprise-G: Main Engineering STARFLIGHT COMMAND\"':\n - generic: \"Interprise-G: Main Engineering\"\n - generic: STARFLIGHT COMMAND\n - 'button \"Interprise-G: Crew Quarters STARFLIGHT COMMAND\"':\n - generic: \"Interprise-G: Crew Quarters\"\n - generic: STARFLIGHT COMMAND\n - 'button \"USS Wayfarer: Bridge STARFLIGHT COMMAND\"':\n - generic: \"USS Wayfarer: Bridge\"\n - generic: STARFLIGHT COMMAND\n - 'button \"USS Wayfarer: Class-N Worp Core STARFLIGHT COMMAND\"':\n - generic: \"USS Wayfarer: Class-N Worp Core\"\n - generic: STARFLIGHT COMMAND\n - 'button \"USS Defiance: Tactical Bridge STARFLIGHT COMMAND\"':\n - generic: \"USS Defiance: Tactical Bridge\"\n - generic: STARFLIGHT COMMAND\n - 'button \"Deep Space 8: Ops Center STARFLIGHT COMMAND\"':\n - generic: \"Deep Space 8: Ops Center\"\n - generic: STARFLIGHT COMMAND\n - 'button \"Interprise NCC-1701.5: Bridge (TOS) STARFLIGHT COMMAND\"':\n - generic: \"Interprise NCC-1701.5: Bridge (TOS)\"\n - generic: STARFLIGHT COMMAND\n - 'button \"Interprise NCC-1701.5: Engineering (TOS) STARFLIGHT COMMAND\"':\n - generic: \"Interprise NCC-1701.5: Engineering (TOS)\"\n - generic: STARFLIGHT COMMAND\n - 'button \"Interprise NS-01: Command Bridge STARFLIGHT COMMAND\"':\n - generic: \"Interprise NS-01: Command Bridge\"\n - generic: STARFLIGHT COMMAND\n - generic: \"INTERPRISE-G: MAIN BRIDGE\"\n - generic: Warm, low-frequency 50Hz hull tone with gentle ventilation and soft LCARD computer chirps.\n - generic: AUDIO ENGINE\n - generic: STANDBY\n - generic: CORE FREQ\n - generic: 58.0 Hz\n - button \"▶ ENGAGE\":\n - generic: ▶\n - generic: ENGAGE\n - button \"WARP JUMP\"\n - button \"RED ALERT\"\n - button \"YELLOW ALERT\"\n - button \"WATCH EXPERIENCE\"\n - generic: Full-screen procedural visuals, soundscape, telemetry, events, and optional local AI announcements.\n - 'generic \"Toggle starship viewport window frame (ON: framed starship lounge / OFF: pure deep space)\"':\n - generic: VIEWPORT\n - checkbox \"Toggle Observation Viewport Frame\" [checked]\n - generic: \"ON\"\n - 'generic \"Toggle vertical structural window pillars / mullions (OFF: unobstructed panoramic view / ON: structural posts)\"':\n - generic: PILLARS\n - checkbox \"Toggle Window Pillars\"\n - generic: \"OFF\"\n - generic \"Controls how frequently and intensely Observation generates transient visual activity\":\n - generic: ACTIVITY\n - slider \"Observation activity level\"\n - generic: 60%\n - generic \"Keep OBSERVATION profile/status information visible. Turn off to fade it out over 30 seconds.\":\n - generic: HUD HOLD\n - checkbox \"Keep Observation information visible\" [checked]\n - generic: \"ON\"\n - generic: MAIN MASTER VOLUME\n - generic: 75%\n - generic: \"-2.5 dB\"\n - button \"MUTE\"\n - button \"25%\"\n - button \"50%\"\n - button \"75%\"\n - button \"MAX\"\n - button \"−\"\n - slider \"Main Volume\"\n - button \"+\"\n - generic: FNC // FUTURISTIC NOICE CANCELLATION\n - generic: ACTIVE\n - button \"FNC ENABLED\" [pressed]\n - generic: FNC LEVEL\n - slider \"Futuristic Noice Cancellation level\"\n - generic: 65%\n - generic: Comfort suppression active — Life Support 83%, Hull 49%, Worp Core 34% reduction.\n - generic: ACOUSTIC FREQUENCY SPECTRUM\n - generic: PROCEDURAL HARMONIC ANALYSIS\n - generic: \"SPECTRUM BIN: 32 CH // REAL-TIME FFT\"\n - generic: PROCEDURAL SOUND ENGINE MIXER\n - generic: LIVE PARAMETER MODULATION\n - generic: 1. HULL DRONE\n - generic: 65%\n - generic: LEVEL\n - slider\n - generic: BASE TONE\n - generic: 50 Hz\n - slider\n - generic: DAMPING CUTOFF\n - generic: 105 Hz\n - slider\n - generic: 2. WORP CORE\n - generic: 35%\n - generic: LEVEL\n - slider\n - generic: PULSE RATE (BPM)\n - generic: 48 BPM\n - slider\n - generic: REACTOR PITCH\n - generic: 58 Hz\n - slider\n - generic: 3. LIFE SUPPORT\n - generic: 55%\n - generic: LEVEL\n - slider\n - generic: AIRFLOW AIR FILTER\n - generic: 1600 Hz\n - slider\n - generic: 4. TELEMETRY\n - generic: 45%\n - generic: LEVEL\n - slider\n - generic: BACKGROUND CHIRP DENSITY\n - generic: 65%\n - slider\n - complementary:\n - generic: INTERMIX CASCADE\n - generic: INTERMIX CHAMBER // ACTIVE\n - generic: GENERATIVE EXPERIENCE\n - generic: LOCAL WEBLLM + KOKORO TTS\n - generic: LOCAL GENERATIVE SYSTEM\n - generic: STANDBY\n - text: LANGUAGE MODEL\n - text: Qwen3-1.7B-q4f16_1-MLC\n - text: Automatically prepared when the experience starts. No model selection required.\n - text: WEBLLM\n - generic: \"OFF\"\n - text: KOKORO\n - generic: \"OFF\"\n - button \"PREPARE AI\"\n - button \"GENERATE NOW\" [disabled]\n - checkbox \"AUTO UPDATES\" [checked]\n - text: AUTO UPDATES\n - checkbox \"SPEAK\" [checked]\n - text: SPEAK\n - combobox \"Kokoro voice\":\n - option \"Kokoro voice loads on preparation\" [selected]\n - generic: VOICE AUTOMATION\n - generic: 84%\n - slider \"Robotic voice effect intensity\"\n - generic: AUTO CADENCE\n - generic: 90-180 SEC\n - slider\n - generic: AI and neural voice models load only when needed and are cached by the browser when supported.\n - generic: GENERATIVE CHANNEL STANDBY\n - generic: TV PRESENTATION\n - generic: CAST-FRIENDLY 16:9\n - checkbox \"LOCK EXPERIENCE TO 16:9\" [checked]\n - text: LOCK EXPERIENCE TO 16:9\n - generic: RENDER DENSITY\n - generic: 1080P TARGET\n - combobox:\n - option \"720P PERFORMANCE\"\n - option \"1080P STANDARD\" [selected]\n - option \"1440P HIGH\"\n - option \"2160P ULTRA\"\n - generic: The scene keeps a canonical 16:9 layout on phones, PCs, and cast displays. Chrome still negotiates the final Cast stream resolution.\n - generic: SLEEP TIMER\n - generic: TIMER INACTIVE (CONTINUOUS)\n - button \"15M\"\n - button \"30M\"\n - button \"60M\"\n - button \"OFF\"\n - generic: LCARD KEYPAD\n - button \"COM BADGE CHIRP\"\n - button \"DOOR PNEUMATIC SWISH\"\n - button \"BOSUN'S PIPE WHISTLE\"\n - button \"MEDBAY ECG MONITOR\"\n - button \"CARDASSIAN BULKHEAD DOOR\"\n - button \"TURBOCHUTE WHOOSH\"\n - button \"FABRICATOR SHIMMER\"\n - button \"TRANSLOCATOR CYCLE\"\n - button \"LCARD SINGLE CHIRP\"\n - contentinfo:\n - generic: \"SYSTEM STATUS: NORMAL\"\n - link \"VIBE ENGINEERED BY LABYRICORN STUDIOS\":\n - /url: https://www.labyricorn.com/\n - generic: \"SPACE: PLAY/PAUSE | M: MUTE | ↑/↓: VOLUME\"\n - heading \"Exhibit controls (61)\" [level=2]\n - generic \"Capabilities\"\n - paragraph: Reported values are authoritative. New values are drafts; select Set or Invoke to submit. Rediscovery resets drafts.\n - heading \"audio\" [level=3]\n - article:\n - heading \"Master Volume\" [level=3]\n - code: mix.master\n - paragraph: range · readable · writable\n - status: \"Reported value: 0.75\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"0.75\"\n - button \"Set\"\n - article:\n - heading \"Hull Drone Level\" [level=3]\n - code: mix.hull.level\n - paragraph: range · readable · writable\n - status: \"Reported value: 0.65\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"0.65\"\n - button \"Set\"\n - article:\n - heading \"Hull Drone Frequency\" [level=3]\n - code: mix.hull.frequency\n - paragraph: range · readable · writable\n - status: \"Reported value: 50\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"50\"\n - button \"Set\"\n - article:\n - heading \"Hull Drone Cutoff\" [level=3]\n - code: mix.hull.cutoff\n - paragraph: range · readable · writable\n - status: \"Reported value: 105\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"105\"\n - button \"Set\"\n - article:\n - heading \"Drive / Core Level\" [level=3]\n - code: mix.drive.level\n - paragraph: range · readable · writable\n - status: \"Reported value: 0.35\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"0.35\"\n - button \"Set\"\n - article:\n - heading \"Drive Pulse Rate\" [level=3]\n - code: mix.drive.pulse-rate\n - paragraph: range · readable · writable\n - status: \"Reported value: 48\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"48\"\n - button \"Set\"\n - article:\n - heading \"Drive Carrier Frequency\" [level=3]\n - code: mix.drive.carrier\n - paragraph: range · readable · writable\n - status: \"Reported value: 58\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"58\"\n - button \"Set\"\n - article:\n - heading \"Environmental Airflow Level\" [level=3]\n - code: mix.environment.level\n - paragraph: range · readable · writable\n - status: \"Reported value: 0.55\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"0.55\"\n - button \"Set\"\n - article:\n - heading \"Environmental Filter Cutoff\" [level=3]\n - code: mix.environment.cutoff\n - paragraph: range · readable · writable\n - status: \"Reported value: 1600\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"1600\"\n - button \"Set\"\n - article:\n - heading \"Telemetry Level\" [level=3]\n - code: mix.telemetry.level\n - paragraph: range · readable · writable\n - status: \"Reported value: 0.45\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"0.45\"\n - button \"Set\"\n - article:\n - heading \"Telemetry Density\" [level=3]\n - code: mix.telemetry.density\n - paragraph: range · readable · writable\n - status: \"Reported value: 0.65\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"0.65\"\n - button \"Set\"\n - article:\n - heading \"Futuristic Noise Cancellation Level\" [level=3]\n - code: fnc.level\n - paragraph: range · readable · writable\n - status: \"Reported value: 0.65\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"0.65\"\n - button \"Set\"\n - article:\n - heading \"Futuristic Noise Cancellation Active\" [level=3]\n - code: fnc.enabled\n - paragraph: state · readable · writable\n - status: \"Reported value: true\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - checkbox \"New value\" [checked]\n - button \"Set\"\n - heading \"speech\" [level=3]\n - article:\n - heading \"Speech Robotic Modulation\" [level=3]\n - code: speech.robot-amount\n - paragraph: range · readable · writable\n - status: \"Reported value: 0.84\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"0.84\"\n - button \"Set\"\n - article:\n - heading \"Speak Text Line\" [level=3]\n - code: speech.say\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - text: text\n - textbox \"text\"\n - button \"Invoke\"\n - article:\n - heading \"Generate Ambient AI Announcement\" [level=3]\n - code: speech.generate-announcement\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - heading \"observation\" [level=3]\n - article:\n - heading \"Observation Visual Activity\" [level=3]\n - code: view.activity\n - paragraph: range · readable · writable\n - status: \"Reported value: 0.6\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - spinbutton \"New value\": \"0.6\"\n - button \"Set\"\n - article:\n - heading \"Observation Viewport Active\" [level=3]\n - code: view.observation\n - paragraph: state · readable · writable\n - status: \"Reported value: false\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - checkbox \"New value\"\n - button \"Set\"\n - article:\n - heading \"Starship Bezel Frame Framing\" [level=3]\n - code: view.viewport-frame\n - paragraph: state · readable · writable\n - status: \"Reported value: true\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - checkbox \"New value\" [checked]\n - button \"Set\"\n - heading \"transport\" [level=3]\n - article:\n - heading \"Audio Engine Online\" [level=3]\n - code: transport.playing\n - paragraph: state · readable · writable\n - status: \"Reported value: false\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - checkbox \"New value\"\n - button \"Set\"\n - article:\n - heading \"Master Mute\" [level=3]\n - code: transport.muted\n - paragraph: state · readable · writable\n - status: \"Reported value: false\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - checkbox \"New value\"\n - button \"Set\"\n - heading \"alert\" [level=3]\n - article:\n - heading \"Active Tactical Alert Status\" [level=3]\n - code: alert.active\n - paragraph: selection · readable · writable\n - status: \"Reported value: \\\"none\\\"\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - combobox \"New value\":\n - option \"Condition Green (Normal)\" [selected]\n - option \"Yellow Alert\"\n - option \"Red Alert\"\n - button \"Set\"\n - heading \"universe\" [level=3]\n - article:\n - heading \"Active Sci-Fi Universe\" [level=3]\n - code: universe.selected\n - paragraph: selection · readable · writable\n - status: \"Reported value: \\\"starfleet\\\"\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - combobox \"New value\":\n - option \"Starfleet / Galaxy\" [selected]\n - option \"Whataverse / Tardix\"\n - option \"Industrial / Weyland\"\n - option \"Bioships / Leviathan\"\n - option \"Retro Future / 1960s\"\n - option \"Colonial Military\"\n - option \"Deep Space Generation\"\n - option \"Frontier & Salvage\"\n - option \"Orbital Stations & Docks\"\n - option \"Whimsical Fleet\"\n - button \"Set\"\n - heading \"preset\" [level=3]\n - article:\n - heading \"Active Audio Preset\" [level=3]\n - code: preset.selected\n - paragraph: selection · readable · writable\n - status: \"Reported value: \\\"tng-bridge\\\"\"\n - generic \"Target descriptor\"\n - paragraph\n - text: New value\n - combobox \"New value\":\n - 'option \"Interprise-G: Main Bridge\" [selected]'\n - 'option \"Interprise-G: Main Engineering\"'\n - 'option \"Interprise-G: Crew Quarters\"'\n - 'option \"USS Wayfarer: Bridge\"'\n - 'option \"USS Wayfarer: Class-N Worp Core\"'\n - 'option \"USS Defiance: Tactical Bridge\"'\n - 'option \"Deep Space 8: Ops Center\"'\n - 'option \"Interprise NCC-1701.5: Bridge (TOS)\"'\n - 'option \"Interprise NCC-1701.5: Engineering (TOS)\"'\n - 'option \"Interprise NS-01: Command Bridge\"'\n - option \"1962 Classic Console (1st / 2nd Doctors)\"\n - option \"Edwardian Secondary Console (4th Doctor)\"\n - option \"The Coral Living TARDIX (9th / 10th Doctors)\"\n - option \"The Bronze Workshop (11th Doctor)\"\n - option \"The Cool Machine (12th Doctor)\"\n - option \"The Humming Crystal Console (13th Doctor)\"\n - option \"The Endless White (14th / 15th Doctors)\"\n - 'option \"USCSS Nostramo: Ore Refinery Bridge\"'\n - 'option \"Serenety: Firewing-Class Cargo Hold\"'\n - 'option \"Red Dwarve: Main Drive Corridor\"'\n - 'option \"Starbug Z: Cockpit Environment\"'\n - 'option \"The Rasa: Dark Matter Bridge\"'\n - 'option \"Rocinanta: Combat Ops & Epsilon Drive\"'\n - 'option \"Hawk Translocator: Command Module\"'\n - 'option \"Valley Foundry: Agro-Dome Forest Hub\"'\n - 'option \"Meya: Leviathon Central Nexus\"'\n - 'option \"Talym: Gunship Neural Bridge\"'\n - 'option \"The Lexxi: Primary Organ Bridge\"'\n - 'option \"Vorlan Cruiser: Sentient Core\"'\n - 'option \"Wraithe Hive Ship: Throne Chamber\"'\n - 'option \"Species 8675309: Fluid Bioship\"'\n - 'option \"Jupiter Duece: Upper Deck Astrogator\"'\n - 'option \"The Liberation: Zen Flight Bridge\"'\n - 'option \"USS Cygnis: Victorian Engine Hall\"'\n - 'option \"USS Palomina: Deep Research Pod\"'\n - 'option \"Discovery Zero: Habitation Centrifuge\"'\n - 'option \"Dim Star: Bomb Bay & Quarters\"'\n - 'option \"Gunstarr: Tactical Combat Cockpit\"'\n - 'option \"The Seeker: 25th Century Flagship\"'\n - 'option \"USS Solaco: Conestega Hangar Deck\"'\n - 'option \"Battlestar Galactica: Combat Information Center (CIC)\"'\n - 'option \"Pale Star: Minbara/Vorlan Hybrid Bridge\"'\n - 'option \"EAS Agamemnon II: Omega Destroyer Bridge\"'\n - 'option \"Andromeda Ascending: Command Deck\"'\n - 'option \"Excaliber: Triumph-Class Heavy Combat Core\"'\n - 'option \"Colonial Adder Mk II: Cockpit Atmosphere\"'\n - 'option \"The Avalin: Interstellar Cruise Concourse\"'\n - 'option \"The Nightflier: Telepathic Corridor\"'\n - 'option \"USS Ascention: Generation Ship Promenade\"'\n - 'option \"Ark Zero: Evacuation Ark Bridge\"'\n - 'option \"Icarus I: Solar Shield Core\"'\n - 'option \"Event Boundary: Gravity Singularity Core\"'\n - 'option \"Lewis & Clarke: Rescue Cutter Bridge\"'\n - 'option \"The Betsy: Salvage Freighter Mess\"'\n - 'option \"BeBoppa: Living Quarters & Hangar\"'\n - 'option \"Outlaw Sun: Grapple Bridge\"'\n - 'option \"Scorpius: Wanderer Salvage Vessel\"'\n - 'option \"The Raider: Havoc Shuttle Bridge\"'\n - 'option \"The Milan: M-Ship Cockpit Lounge\"'\n - 'option \"Babylon 4: Core Control & Zocolo\"'\n - 'option \"Moonbase Beta: Main Mission Control\"'\n - 'option \"Sebastopol Station: Habitation Deck\"'\n - 'option \"Tyco Station: Asteroid Construction Bay\"'\n - 'option \"Ceres Minor Station: Sub-Crustal Tunnels\"'\n - 'option \"Gatehouse Station: Quarantine Transfer Deck\"'\n - 'option \"USS Norval: Command Bridge\"'\n - 'option \"NSEA Protractor: Beryllium Orb Engine Room\"'\n - 'option \"Heart of Silver: Unlikelihood Bridge\"'\n - 'option \"HMS Camden Key: Flight Deck & Mess\"'\n - 'option \"Spaceorb One: Mega-Warship Bridge\"'\n - button \"Set\"\n - heading \"display\" [level=3]\n - article:\n - heading \"Display Ticker Message (Transient)\" [level=3]\n - code: display.ticker\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - text: text\n - textbox \"text\"\n - button \"Invoke\"\n - heading \"event\" [level=3]\n - article:\n - heading \"Red Alert Impulse\" [level=3]\n - code: event.alert-red\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Warp Jump Impulse\" [level=3]\n - code: event.warp-jump\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Demat Cycle Impulse\" [level=3]\n - code: event.demat\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Time Vortex Impulse\" [level=3]\n - code: event.vortex\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Cloister Bell Impulse\" [level=3]\n - code: event.cloister\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Flak Barrage Impulse\" [level=3]\n - code: event.flak-barrage\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"FTL Jump Impulse\" [level=3]\n - code: event.ftl-jump\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Singularity Engage Impulse\" [level=3]\n - code: event.singularity\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Solar Flare Roar Impulse\" [level=3]\n - code: event.solar-roar\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Afterburner Ignite Impulse\" [level=3]\n - code: event.afterburner\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Ludicrous Speed Impulse\" [level=3]\n - code: event.ludicrous-speed\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Improbability Drive Flip\" [level=3]\n - code: event.improbability-flip\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - heading \"sfx\" [level=3]\n - article:\n - heading \"Comm Badge Chime\" [level=3]\n - code: sfx.comm-badge\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Pneumatic Door Swish\" [level=3]\n - code: sfx.door-swish\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Bosun Pipe Call\" [level=3]\n - code: sfx.bosun-whistle\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Medical Vital Monitor\" [level=3]\n - code: sfx.medical-monitor\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Heavy Bulkhead Door\" [level=3]\n - code: sfx.cardassian-door\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Turbochute Whoosh\" [level=3]\n - code: sfx.turbolift\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Fabricator Materialize\" [level=3]\n - code: sfx.replicator\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Translocator Beam\" [level=3]\n - code: sfx.transporter\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Console Single Chirp\" [level=3]\n - code: sfx.console-chirp\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Sonic Acoustic Screwdriver\" [level=3]\n - code: sfx.sonic\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Console Mechanical Lever Clunk\" [level=3]\n - code: sfx.lever-clunk\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Tardix Wood & Lock Door\" [level=3]\n - code: sfx.tardix-door\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Demat Switch Rotor\" [level=3]\n - code: sfx.demat-switch\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Telepathic Circuit Chime\" [level=3]\n - code: sfx.telepathic-chime\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Air Handler Valve Thud\" [level=3]\n - code: sfx.air-handler\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Mag-Boot Solenoid Latch\" [level=3]\n - code: sfx.mag-boot\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Comlock Electronic Tone\" [level=3]\n - code: sfx.comlock\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Solar Radiation Wind\" [level=3]\n - code: sfx.solar-roar\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Cassette Magnetic Transport Clunk\" [level=3]\n - code: sfx.tape-clunk\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Thruster Burner Pulse\" [level=3]\n - code: sfx.afterburner\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Radiation Geiger Counter\" [level=3]\n - code: sfx.geiger\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Air Valve & Dock Clamp\" [level=3]\n - code: sfx.dock-clamp\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"Dradis Combat Sonar Ping\" [level=3]\n - code: sfx.dradis-ping\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - article:\n - heading \"HAL Logic Melodic Chime\" [level=3]\n - code: sfx.hal-chime\n - paragraph: impulse · not readable · not writable\n - status: No persistent value reported.\n - generic \"Target descriptor\"\n - paragraph\n - button \"Invoke\"\n - heading \"State and metadata\" [level=2]\n - button \"Refresh state\"\n - generic \"Session and exhibit metadata\"\n - generic: \"{ \\\"sessionId\\\": \\\"sess-319js95v\\\", \\\"contract\\\": { \\\"major\\\": 5, \\\"minor\\\": 2 }, \\\"exhibit\\\": { \\\"product\\\": \\\"SciFi-XZBT\\\", \\\"version\\\": \\\"5.2.0\\\", \\\"build\\\": \\\"production\\\" }, \\\"registryRevision\\\": 1, \\\"stateRevision\\\": 0, \\\"sequence\\\": 3 }\"\n - generic \"Current persistent state\"\n - paragraph: Last reported exhibit values. This host view may be uncertain while resynchronizing.\n - generic: \"{ \\\"mix.master\\\": 0.75, \\\"mix.hull.level\\\": 0.65, \\\"mix.hull.frequency\\\": 50, \\\"mix.hull.cutoff\\\": 105, \\\"mix.drive.level\\\": 0.35, \\\"mix.drive.pulse-rate\\\": 48, \\\"mix.drive.carrier\\\": 58, \\\"mix.environment.level\\\": 0.55, \\\"mix.environment.cutoff\\\": 1600, \\\"mix.telemetry.level\\\": 0.45, \\\"mix.telemetry.density\\\": 0.65, \\\"speech.robot-amount\\\": 0.84, \\\"fnc.level\\\": 0.65, \\\"view.activity\\\": 0.6, \\\"transport.playing\\\": false, \\\"transport.muted\\\": false, \\\"fnc.enabled\\\": true, \\\"view.observation\\\": false, \\\"view.viewport-frame\\\": true, \\\"alert.active\\\": \\\"none\\\", \\\"universe.selected\\\": \\\"starfleet\\\", \\\"preset.selected\\\": \\\"tng-bridge\\\" }\"\n - heading \"Protocol diagnostics\" [level=2]\n - generic \"Incoming events\"\n - paragraph: Latest 200 session events, including source, sequence, revision, and correlation when supplied.\n - generic \"Incoming events\": \"[ { \\\"xzbt\\\": \\\"5.2\\\", \\\"type\\\": \\\"action.executed\\\", \\\"sessionId\\\": \\\"sess-319js95v\\\", \\\"sequence\\\": 1, \\\"timestamp\\\": 1789415838707, \\\"target\\\": \\\"display.ticker\\\", \\\"args\\\": { \\\"text\\\": \\\"Step 5 final closure ticker check.\\\" }, \\\"correlationId\\\": \\\"ngn-5-23\\\", \\\"source\\\": \\\"host\\\" }, { \\\"xzbt\\\": \\\"5.2\\\", \\\"type\\\": \\\"action.executed\\\", \\\"sessionId\\\": \\\"sess-319js95v\\\", \\\"sequence\\\": 2, \\\"timestamp\\\": 1789415843565, \\\"target\\\": \\\"speech.say\\\", \\\"args\\\": { \\\"text\\\": \\\"Step 5 final closure speech check.\\\" }, \\\"correlationId\\\": \\\"ngn-5-25\\\", \\\"source\\\": \\\"host\\\" }, { \\\"xzbt\\\": \\\"5.2\\\", \\\"type\\\": \\\"capability.changed\\\", \\\"sessionId\\\": \\\"sess-319js95v\\\", \\\"sequence\\\": 3, \\\"timestamp\\\": 1789415843614, \\\"capability\\\": \\\"speech\\\", \\\"state\\\": \\\"ready\\\", \\\"source\\\": \\\"system\\\" } ]\"\n - generic \"Protocol and errors\"\n - paragraph: Latest 200 entries across connections in this page. Timestamps identify earlier failures.\n - button \"Clear log\"\n - log: \"[ { \\\"time\\\": \\\"2026-09-14T19:55:08.301Z\\\", \\\"code\\\": \\\"SESSION\\\", \\\"message\\\": \\\"Exhibit session established.\\\", \\\"detail\\\": { \\\"sessionId\\\": \\\"sess-12844\\\" } }, { \\\"time\\\": \\\"2026-09-14T19:56:46.979Z\\\", \\\"code\\\": \\\"SESSION\\\", \\\"message\\\": \\\"Exhibit session established.\\\", \\\"detail\\\": { \\\"sessionId\\\": \\\"sess-29bfc\\\" } }, { \\\"time\\\": \\\"2026-09-14T19:57:03.069Z\\\", \\\"code\\\": \\\"SESSION\\\", \\\"message\\\": \\\"Exhibit session established.\\\", \\\"detail\\\": { \\\"sessionId\\\": \\\"sess-319js95v\\\" } } ]\"" +} diff --git a/test-fixtures/evidence/step5-haunted-house.json b/test-fixtures/evidence/step5-haunted-house.json new file mode 100644 index 0000000..3bd08a4 --- /dev/null +++ b/test-fixtures/evidence/step5-haunted-house.json @@ -0,0 +1,128 @@ +{ + "events": [ + { + "correlationId": "ngn-11-35", + "sequence": 1, + "sessionId": "sess-1ce04", + "source": "host", + "stateRevision": 1, + "target": "room.haunt-level", + "timestamp": 1789398645709, + "type": "state.changed", + "value": 0.5, + "xzbt": "5.2" + }, + { + "correlationId": "ngn-11-37", + "sequence": 2, + "sessionId": "sess-1ce04", + "source": "host", + "stateRevision": 2, + "target": "room.paused", + "timestamp": 1789398646251, + "type": "state.changed", + "value": true, + "xzbt": "5.2" + }, + { + "correlationId": "ngn-11-39", + "sequence": 3, + "sessionId": "sess-1ce04", + "source": "host", + "stateRevision": 3, + "target": "room.current", + "timestamp": 1789398646528, + "type": "selection.changed", + "value": "library", + "xzbt": "5.2" + }, + { + "args": {}, + "correlationId": "ngn-11-41", + "sequence": 4, + "sessionId": "sess-1ce04", + "source": "host", + "target": "action.manifest", + "timestamp": 1789398646792, + "type": "action.executed", + "xzbt": "5.2" + } + ], + "logs": [ + { + "code": "SESSION", + "detail": { + "sessionId": "sess-1a192" + }, + "message": "Exhibit session established.", + "time": "2026-09-14T15:09:20.864Z" + }, + { + "code": "SESSION", + "detail": { + "sessionId": "sess-21bcb" + }, + "message": "Exhibit session established.", + "time": "2026-09-14T15:09:49.720Z" + }, + { + "code": "SESSION", + "detail": { + "sessionId": "sess-14131" + }, + "message": "Exhibit session established.", + "time": "2026-09-14T15:09:50.283Z" + }, + { + "code": "SESSION", + "detail": { + "sessionId": "sess-194b7" + }, + "message": "Exhibit session established.", + "time": "2026-09-14T15:10:00.111Z" + }, + { + "code": "SESSION", + "detail": { + "sessionId": "sess-22e" + }, + "message": "Exhibit session established.", + "time": "2026-09-14T15:10:24.934Z" + }, + { + "code": "SESSION", + "detail": { + "sessionId": "sess-1ce04" + }, + "message": "Exhibit session established.", + "time": "2026-09-14T15:10:25.249Z" + }, + { + "code": "INVALID_MESSAGE", + "detail": null, + "message": "room.haunt-level: Value must follow step 0.01.", + "time": "2026-09-14T15:10:46.252Z" + }, + { + "code": "INVALID_MESSAGE", + "detail": null, + "message": "room.haunt-level: Value must follow step 0.01.", + "time": "2026-09-14T15:10:46.530Z" + } + ], + "metadata": { + "contract": { + "major": 5, + "minor": 2 + }, + "exhibit": { + "build": "reference-exhibit", + "product": "Haunted House", + "version": "1.0.0" + }, + "registryRevision": 1, + "sequence": 4, + "sessionId": "sess-1ce04", + "stateRevision": 3 + } +} \ No newline at end of file diff --git a/test-fixtures/evidence/step5-operator.jpg b/test-fixtures/evidence/step5-operator.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fe74212bebdbeaffc43bfb58c9d3ed192b626eb2 GIT binary patch literal 66965 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∓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 { - run.disabled = true; results.textContent = ''; document.querySelector('#fixtures').replaceChildren(); + run.disabled = true; results.textContent = `Started: ${new Date().toISOString()}\n`; document.querySelector('#fixtures').replaceChildren(); let passed = 0; for (const name of ['aquarium', 'planetarium', 'haunted-house']) { const host = new ExhibitHost(); @@ -74,5 +74,6 @@ run.addEventListener('click', async () => { } catch (error) { results.textContent += `FAIL ${name}: ${error.message}\n`; } finally { host.disconnect(); frame.remove(); } } - results.textContent += `${passed}/3 real exhibits passed.\n`; run.disabled = false; + results.textContent += `${passed}/3 real exhibits passed.\nFinished: ${new Date().toISOString()}\n`; run.disabled = false; + console.info(results.textContent); }); diff --git a/test-fixtures/reference-exhibits/haunted-house/exhibit.js b/test-fixtures/reference-exhibits/haunted-house/exhibit.js index 03ed0c5..28c3bc2 100644 --- a/test-fixtures/reference-exhibits/haunted-house/exhibit.js +++ b/test-fixtures/reference-exhibits/haunted-house/exhibit.js @@ -582,7 +582,8 @@ switch (targetId) { case 'room.current': return h.room; case 'room.spirit': return h.spirit; - case 'room.haunt-level': return Math.round(h.hauntLevel * 1000) / 1000; + // Keep the simulation continuous; report bounded, integer hundredths. + case 'room.haunt-level': return Math.round(clamp(h.hauntLevel, 0, 1) * 100) / 100; case 'room.fog-density': return h.fogDensity; case 'room.candlelight': return h.candlelight; case 'room.audio-enabled': return h.audioEnabled; diff --git a/tests/connection.test.js b/tests/connection.test.js new file mode 100644 index 0000000..0e6c34e --- /dev/null +++ b/tests/connection.test.js @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ExhibitConnection, exhibitURL } from '../src/connection.js'; +import { validateCatalog } from '../src/validation.js'; + +const base = 'http://127.0.0.1:4173/'; +test('connection accepts served paths and same-origin URLs, rejects unsupported locations', () => { + assert.equal(exhibitURL('/public/example/index.html', base), base + 'public/example/index.html'); + assert.equal(exhibitURL(' public/example/ ', base), base + 'public/example/'); + for (const value of ['', ' ', 'file:///C:/exhibit.html', 'https://other.example/', 'http://127.0.0.1:4174/', 'javascript:alert(1)', 'http://user:pass@127.0.0.1:4173/']) { + assert.throws(() => exhibitURL(value, base)); + } +}); +function setup(t, loadTimeoutMs = 1000) { + const frames = [], connections = [], errors = []; + const host = { disconnect() {}, async connect(frame) { connections.push(frame); }, report(e) { errors.push(e); } }; + const connection = new ExhibitConnection({ host, base, loadTimeoutMs, transport: frame => frame, + createFrame() { + const frame = { handlers: {}, addEventListener(type, fn) { this.handlers[type] = fn; }, remove() { this.removed = true; } }; + frames.push(frame); return frame; + } + }); + t.after(() => connection.disconnect()); + return { connection, frames, connections, errors }; +} +test('switch and disconnect cancel old frame loads; invalid URL preserves active frame', async t => { + const { connection, frames, connections } = setup(t); + connection.load('/public/one/'); connection.load('/public/two/'); + frames[0].handlers.load(); assert.equal(connections.length, 0); + frames[1].handlers.load(); assert.equal(connections.length, 1); + assert.throws(() => connection.load('https://other.example/')); + assert.equal(connection.frame, frames[1]); + connection.disconnect(); frames[1].handlers.load(); + assert.equal(connections.length, 1); assert.equal(connection.frame, null); + assert.ok(frames.every(f => f.removed)); +}); +test('reconnect negotiates existing frame and reloads remembered URL after disconnect', async t => { + const { connection, frames, connections } = setup(t); + connection.load('/public/one/'); frames[0].handlers.load(); + await connection.reconnect(); assert.equal(connections.length, 2); assert.equal(frames.length, 1); + connection.disconnect(); connection.reconnect(); + assert.equal(frames.length, 2); assert.equal(frames[1].src, base + 'public/one/'); + frames[1].handlers.load(); assert.equal(connections.length, 3); +}); +test('stalled document load ends with recoverable error', async t => { + const { connection, frames, errors } = setup(t, 5); + connection.load('/public/stalled/'); + await new Promise(resolve => setTimeout(resolve, 20)); + assert.equal(connection.loading, false); assert.equal(connection.frame, null); + assert.equal(errors[0].code, 'LOAD_TIMEOUT'); + connection.reconnect(); assert.equal(frames.length, 2); +}); +test('optional descriptor metadata is permissive when omitted and typed when supplied', () => { + const message = { exhibit: {}, contract: { major: 5 }, registryRevision: 0, stateRevision: 0, capabilities: [], + targets: [{ id: 'sample.enabled', kind: 'state', readable: true, writable: true, valueType: 'boolean', requires: [] }] }; + validateCatalog(message); + for (const metadata of [{ restorable: false, category: '' }, { restorable: true, category: 'Custom category' }]) { + validateCatalog({ ...message, targets: [{ ...message.targets[0], ...metadata }] }); + } + for (const metadata of [{ restorable: 'false' }, { restorable: null }, { restorable: 1 }, { category: null }, { category: [] }, { category: false }]) { + assert.throws(() => validateCatalog({ ...message, targets: [{ ...message.targets[0], ...metadata }] }), { code: 'INVALID_MESSAGE' }); + } +}); diff --git a/tests/haunted-house.test.js b/tests/haunted-house.test.js new file mode 100644 index 0000000..1faa5f6 --- /dev/null +++ b/tests/haunted-house.test.js @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import { validateReportedValue } from '../src/validation.js'; + +function setup() { + const context = vm.createContext({ window: {} }); + for (const file of ['shared/contract-core.js', 'haunted-house/exhibit.js', 'haunted-house/contract-adapter.js']) { + vm.runInContext(readFileSync(new URL(`../test-fixtures/reference-exhibits/${file}`, import.meta.url), 'utf8'), context); + } + const exhibit = new context.window.HauntedHouseExhibit({ + canvas: { getContext: () => ({ setTransform() {} }), getBoundingClientRect: () => ({ width: 100, height: 100 }) }, + announcer: { flash() {} } + }); + const core = context.window.HauntedHouseContract.create(exhibit, { sync() {} }); + const descriptor = core.catalog.get('room.haunt-level'); + assert.deepEqual([descriptor.min, descriptor.max, descriptor.step], [0, 1, 0.01]); + return { exhibit, core, descriptor }; +} + +test('Haunted House reports bounded hundredths while preserving continuous drift', () => { + const { exhibit, core, descriptor } = setup(); + for (const [raw, expected] of [[-0.1, 0], [0, 0], [0.0049, 0], [0.0051, 0.01], [0.303, 0.3], [0.999, 1], [1, 1], [1.1, 1]]) { + exhibit.house.hauntLevel = raw; + assert.equal(core.readValue(descriptor.id), expected); + assert.equal(exhibit.house.hauntLevel, raw, 'reading must not quantize the simulation'); + } + for (const room of ['foyer', 'library', 'cellar', 'attic', 'nursery']) { + exhibit.setRoom(room); + for (const spirit of ['restless', 'vengeful', 'mourning', 'mischievous']) { + exhibit.setSpirit(spirit); + exhibit.house.hauntLevel = 0.303; + for (let i = 0; i < 600; i++) { + exhibit.house.step(1 / 60); + validateReportedValue(descriptor, core.readValue(descriptor.id)); + } + assert.notEqual(exhibit.house.hauntLevel, 0.303, 'simulation must keep drifting'); + } + } +}); + +test('Haunted House snapshots and mutation events stay valid through Manifest, drift and Calm', () => { + const { exhibit, core, descriptor } = setup(); + core.handleHello({ supportedContractMajors: [5] }); + const checkSnapshot = () => { + const snapshot = core.stateSnapshot(); + for (const target of core.catalog.descriptors().filter(t => t.readable)) validateReportedValue(target, snapshot.values[target.id]); + return snapshot.values[descriptor.id]; + }; + assert.equal(core.invokeAction('action.manifest', {}, 'host').ok, true); + assert.equal(exhibit.house.hauntLevel, 1); + assert.equal(checkSnapshot(), 1); + for (let i = 0; i < 600; i++) { exhibit.house.step(1 / 60); checkSnapshot(); } + for (const value of [0, 0.01, 0.29, 0.5, 0.99, 1]) { + assert.equal(core.applyMutation(descriptor.id, value, 'ui').ok, true); + assert.equal(checkSnapshot(), value); + } + for (const event of core.eventLog().filter(e => e.type === 'state.changed')) validateReportedValue(core.catalog.get(event.target), event.value); + assert.ok(core.eventLog().some(e => e.type === 'action.executed' && e.target === 'action.manifest')); + assert.equal(core.invokeAction('action.calm', {}, 'host').ok, true); + assert.equal(checkSnapshot(), 0); +});