The normative coherent noise of 18.7 (ordered gradients, a 255-sample Fisher-Yates permutation, octave normalization, and curl as the explicit perpendicular of the potential's gradient, with published tolerances), the nine distributions with their normative draw-order table, all seventeen behaviors with their field contracts and the accumulating-versus-fresh split, component expansion independent of group nesting depth, particles, emitters, repeaters, trails, and links. Traces 1 through 19 of 18.10 are automated. The renderer core this builds on is corrected here rather than separately, because 4e is what exercises it: the production render path now passes a real compositing surface factory, so a masked group or a non-opaque layer composites instead of throwing and permanently deactivating the visual subsystem; system-level `behaviors` arrays run `validateBehaviors` at import with field context, so the six classes of invalid document that used to fail at activation now fail where an author can see them; velocity-accumulating behaviors integrate on object and repeater hosts; `morph` guards both sides of the z delta rather than poisoning perspective projection with NaN; `face-motion` initializes its follow state lazily and holds the previous rotation at zero velocity; a `ring` with equal start and end angles draws nothing instead of a radial spoke; a resolved non-integer creation count raises ERR_TYPE_MISMATCH at the boundary instead of being rounded; boolean leaves are type-checked; per-type required fields and the static half of the unbounded-emission rule are enforced at import; and a repeater rejects the emitter-only fields 18.5 says it has none of. A duration is now the authored literal or a non-negative finite number already in milliseconds, resolving the one design question the review triage left open. Runtime, validator, and specification agree on it. A guard test asserts that no two bundled modules declare the same top-level identifier: the bundler concatenates into one scope, so a private helper name collision is a SyntaxError in the artifact while every unit test still passes. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01ShxxFqFmCUDQnQvFNm4TKy
113 lines
6.0 KiB
JavaScript
113 lines
6.0 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import { mkdtempSync, readFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, resolve } from 'node:path';
|
|
import test from 'node:test';
|
|
import { ActivationController } from '../src/runtime/activation.js';
|
|
import { Diagnostics } from '../src/runtime/diagnostics.js';
|
|
import { LibraryManager } from '../src/runtime/library.js';
|
|
import { deriveStreamState, SeededRNG } from '../src/runtime/rng.js';
|
|
import { parseAndValidateExhibit } from '../src/runtime/validator.js';
|
|
import { buildStandalone, bundleRuntime } from '../tools/build-xzbt.mjs';
|
|
|
|
const fixedSource = readFileSync(resolve('exhibits/minimal-fixed.xzbt'), 'utf8');
|
|
const randomSource = readFileSync(resolve('exhibits/minimal-random.xzbt'), 'utf8');
|
|
|
|
test('the two Phase 1 exhibits parse and validate', () => {
|
|
assert.equal(parseAndValidateExhibit(fixedSource, 'minimal-fixed.xzbt').valid, true);
|
|
assert.equal(parseAndValidateExhibit(randomSource, 'minimal-random.xzbt').valid, true);
|
|
});
|
|
|
|
test('malformed JSON, unsupported versions, and invalid metadata fail predictably', () => {
|
|
assert.equal(parseAndValidateExhibit('{', 'broken.xzbt').errors[0].code, 'ERR_SCHEMA_VALIDATION');
|
|
const unsupported = JSON.stringify({ xzbt: '9', meta: { id: 'valid-id', name: 'Name' } });
|
|
assert.ok(parseAndValidateExhibit(unsupported).errors.some(({ code }) => code === 'ERR_UNSUPPORTED_VERSION'));
|
|
const invalidMeta = JSON.stringify({ xzbt: '0.1', meta: { id: 'Not Valid', name: '' } });
|
|
const codes = parseAndValidateExhibit(invalidMeta).errors.map(({ code }) => code);
|
|
assert.ok(codes.includes('ERR_INVALID_ID'));
|
|
assert.ok(codes.includes('ERR_SCHEMA_VALIDATION'));
|
|
});
|
|
|
|
test('production PRNG preserves the frozen GC4 vectors', () => {
|
|
assert.deepEqual(deriveStreamState(42, 'scenario', 'storm:1'), [471422921, 3327820418, 120898024, 15893603]);
|
|
const stream = new SeededRNG(42).stream('scenario', 'storm:1');
|
|
assert.deepEqual([stream.nextUint32(), stream.nextUint32(), stream.nextUint32(), stream.nextUint32()], [4101533927, 4149236977, 2113172072, 1672809915]);
|
|
});
|
|
|
|
test('library imports, ignores identical bytes, persists, and restores records', async () => {
|
|
const saved = [];
|
|
const persistence = { async saveExhibit(record) { saved.push(record); } };
|
|
const library = new LibraryManager({ persistence, diagnostics: new Diagnostics() });
|
|
assert.equal((await library.importSource(fixedSource, 'minimal-fixed.xzbt')).status, 'imported');
|
|
assert.equal((await library.importSource(fixedSource, 'minimal-fixed.xzbt')).status, 'identical');
|
|
assert.equal(saved.length, 1);
|
|
const restored = new LibraryManager({ diagnostics: new Diagnostics() });
|
|
restored.restore(saved);
|
|
assert.equal(restored.get('minimal-fixed').document.meta.name, 'Minimal Fixed');
|
|
});
|
|
|
|
test('activation prepares before teardown and recovers the previous exhibit on failure', async () => {
|
|
const events = [];
|
|
let shouldFail = false;
|
|
const make = (record) => ({
|
|
rng: new SeededRNG(42),
|
|
async activate() { events.push(`activate:${record.id}`); if (shouldFail && record.id === 'next') throw new Error('injected'); },
|
|
async deactivate() { events.push(`deactivate:${record.id}`); },
|
|
async dispose() { events.push(`dispose:${record.id}`); }
|
|
});
|
|
const controller = new ActivationController({ diagnostics: new Diagnostics(), performanceFactory: make });
|
|
const record = (id) => ({ id, document: { meta: { id, name: id }, runtime: { seed: 42 } } });
|
|
assert.equal(await controller.activate(record('previous')), true);
|
|
shouldFail = true;
|
|
assert.equal(await controller.activate(record('next')), false);
|
|
assert.equal(controller.current.record.id, 'previous');
|
|
assert.deepEqual(events, ['activate:previous', 'deactivate:previous', 'dispose:previous', 'activate:next', 'dispose:next', 'activate:previous']);
|
|
});
|
|
|
|
test('two clean standalone builds are byte-identical and self-contained', () => {
|
|
const directory = mkdtempSync(join(tmpdir(), 'xzbt-phase1-'));
|
|
const first = join(directory, 'first.html');
|
|
const second = join(directory, 'second.html');
|
|
buildStandalone(first);
|
|
buildStandalone(second);
|
|
const firstBytes = readFileSync(first);
|
|
const secondBytes = readFileSync(second);
|
|
assert.deepEqual(firstBytes, secondBytes);
|
|
const html = firstBytes.toString('utf8');
|
|
assert.doesNotMatch(html, /<(script|link)[^>]+(?:src|href)=/i);
|
|
// A module import *statement*, not the word in a comment: the bundler strips
|
|
// the former, and prose about import-stage validation is not a leak.
|
|
assert.doesNotMatch(html, /^[ \t]*import[ \t][^;(\n]*from[ \t]*['"]/m);
|
|
// C14: Also catch side-effect imports (import './chunk.js';).
|
|
assert.doesNotMatch(html, /^[ \t]*import[ \t]*['"]/m);
|
|
assert.doesNotMatch(html, /^[ \t]*export[ \t]/m);
|
|
assert.equal(createHash('sha256').update(firstBytes).digest('hex'), createHash('sha256').update(secondBytes).digest('hex'));
|
|
const script = html.match(/<script>([\s\S]*)<\/script>/)[1];
|
|
assert.doesNotThrow(() => new Function(script));
|
|
});
|
|
|
|
test('no two bundled modules declare the same top-level identifier', () => {
|
|
// The bundler strips imports and exports and concatenates into one scope, so
|
|
// two modules that each declare a private helper of the same name produce a
|
|
// SyntaxError in the artifact while every unit test still passes.
|
|
const sources = bundleRuntime();
|
|
const declarations = /^(?:const|let|class|function)\s+([A-Za-z_$][\w$]*)/gm;
|
|
const perModule = sources.split(/^\/\* (src\/runtime\/[\w.-]+) \*\/$/m);
|
|
const seen = new Map();
|
|
const collisions = [];
|
|
for (let index = 1; index < perModule.length; index += 2) {
|
|
const module = perModule[index];
|
|
const body = perModule[index + 1] ?? '';
|
|
const names = new Set();
|
|
let match;
|
|
declarations.lastIndex = 0;
|
|
while ((match = declarations.exec(body)) !== null) names.add(match[1]);
|
|
for (const name of names) {
|
|
if (seen.has(name)) collisions.push(`${name}: ${seen.get(name)} and ${module}`);
|
|
else seen.set(name, module);
|
|
}
|
|
}
|
|
assert.deepEqual(collisions, []);
|
|
});
|