generated from Labyricorn/labyricorn-project-template
- src/validation.js: add validateSurfaceCatalog() implementing Contract 5.3 §§31.2-31.5 normative validation order (validate individuals -> discard invalids -> evaluate primary invariant against working set) - src/host.js: emit xzbt:5.3 advisory (§6.5); store contractMinor; add surfaces=[] to resetView(); parse surfaces in refresh(true) - src/ui.js: add buildSurfaces() for generic descriptor-driven surface display; integrate renderedSurfaces tracking into render() - public/index.html: subtitle updated to Contract 5.2/5.3; add surfaces-section with surfaces div - public/style.css: add .surface, .surface-badge, .surface-meta styles - tests/surface-validation.test.js: 45 new tests covering all Part 8 malformed surface cases, lifecycle, registry refresh, 5.2 regression - docs/reference/XZBT-NGN-Step6.3-Surface-Discovery-Verification.md Test results: 88/88 pass (43 pre-existing + 45 new) Genericity scan: zero matches in src/, public/, server/ git diff --check: clean
497 lines
19 KiB
JavaScript
497 lines
19 KiB
JavaScript
/**
|
|
* Phase 6.3 — Surface catalog validation and lifecycle tests.
|
|
*
|
|
* Covers Contract 5.3 §§31.2-31.5 normative validation order,
|
|
* host state lifecycle, registry refresh behavior, and 5.2 backward
|
|
* compatibility. No exhibit-specific IDs appear in this file.
|
|
*/
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { validateSurfaceCatalog, validateCatalog } from '../src/validation.js';
|
|
import { ExhibitHost } from '../src/host.js';
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Helpers
|
|
* ------------------------------------------------------------------ */
|
|
|
|
/** Minimal valid surface descriptor; caller may override fields. */
|
|
function validSurface(overrides = {}) {
|
|
return {
|
|
id: 'surface.primary-view',
|
|
label: 'Primary View',
|
|
kind: 'surface',
|
|
primary: true,
|
|
url: 'primary.html',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** Run validateSurfaceCatalog, capturing diagnostics. */
|
|
function validate(surfaces) {
|
|
const diags = [];
|
|
const result = validateSurfaceCatalog({ surfaces }, msg => diags.push(msg));
|
|
return { result, diags };
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Absent / empty array forms (Contract 5.3 §31.3 forms 1 & 2)
|
|
* ------------------------------------------------------------------ */
|
|
|
|
test('absent surfaces field is accepted and treated as empty', () => {
|
|
const { result, diags } = validate(undefined);
|
|
assert.deepEqual(result, []);
|
|
assert.equal(diags.length, 0);
|
|
});
|
|
|
|
test('surfaces: [] is accepted and treated identically to absent', () => {
|
|
const { result, diags } = validate([]);
|
|
assert.deepEqual(result, []);
|
|
assert.equal(diags.length, 0);
|
|
});
|
|
|
|
test('non-array surfaces field treated as absent', () => {
|
|
const { result } = validate('not-an-array');
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Individual entry validation — required fields (§31.2)
|
|
* ------------------------------------------------------------------ */
|
|
|
|
test('valid single-entry catalog (one primary) is accepted', () => {
|
|
const { result } = validate([validSurface()]);
|
|
assert.equal(result.length, 1);
|
|
assert.equal(result[0].id, 'surface.primary-view');
|
|
});
|
|
|
|
test('entry missing id is discarded', () => {
|
|
const s = validSurface(); delete s.id;
|
|
const { result, diags } = validate([s]);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /Discarded/.test(d)));
|
|
});
|
|
|
|
test('entry with non-string id is discarded', () => {
|
|
const { result } = validate([validSurface({ id: 42 })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('bare single-segment id (no dot) is discarded', () => {
|
|
const { result, diags } = validate([validSurface({ id: 'nodot' })]);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /Discarded/.test(d)));
|
|
});
|
|
|
|
test('id with uppercase is discarded', () => {
|
|
const { result } = validate([validSurface({ id: 'Surface.control' })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('id with leading digit is discarded', () => {
|
|
const { result } = validate([validSurface({ id: '1surface.control' })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('id with empty segment is discarded', () => {
|
|
const { result } = validate([validSurface({ id: 'surface..control' })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('id with underscore is discarded', () => {
|
|
const { result } = validate([validSurface({ id: 'surface_x.control' })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('entry missing label is discarded', () => {
|
|
const s = validSurface(); delete s.label;
|
|
const { result, diags } = validate([s]);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /label/.test(d)));
|
|
});
|
|
|
|
test('entry with empty string label is discarded', () => {
|
|
const { result } = validate([validSurface({ label: '' })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('entry with non-string label is discarded', () => {
|
|
const { result } = validate([validSurface({ label: 123 })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('entry with wrong kind is discarded', () => {
|
|
const { result, diags } = validate([validSurface({ kind: 'state' })]);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /kind/.test(d)));
|
|
});
|
|
|
|
test('entry with kind impulse is discarded', () => {
|
|
const { result } = validate([validSurface({ kind: 'impulse' })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('entry missing kind is discarded', () => {
|
|
const s = validSurface(); delete s.kind;
|
|
const { result } = validate([s]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('entry with non-boolean primary (string) is discarded', () => {
|
|
const { result, diags } = validate([validSurface({ primary: 'true' })]);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /primary/.test(d)));
|
|
});
|
|
|
|
test('entry with primary: 1 (number) is discarded', () => {
|
|
const { result } = validate([validSurface({ primary: 1 })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('entry with primary: null is discarded', () => {
|
|
const { result } = validate([validSurface({ primary: null })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('entry missing url is discarded', () => {
|
|
const s = validSurface(); delete s.url;
|
|
const { result, diags } = validate([s]);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /url/.test(d)));
|
|
});
|
|
|
|
test('absolute url is rejected as individually-invalid', () => {
|
|
const { result, diags } = validate([validSurface({ url: 'https://evil.example/page.html' })]);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /url/.test(d)));
|
|
});
|
|
|
|
test('protocol-relative url is rejected as individually-invalid', () => {
|
|
const { result, diags } = validate([validSurface({ url: '//evil.example/page.html' })]);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /url/.test(d)));
|
|
});
|
|
|
|
test('url with scheme (http:) is rejected', () => {
|
|
const { result } = validate([validSurface({ url: 'http:page.html' })]);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
test('relative path url is accepted', () => {
|
|
const { result } = validate([validSurface({ url: 'views/primary.html' })]);
|
|
assert.equal(result.length, 1);
|
|
});
|
|
|
|
test('relative path with query string is accepted', () => {
|
|
const { result } = validate([validSurface({ url: 'primary.html?surface=main' })]);
|
|
assert.equal(result.length, 1);
|
|
});
|
|
|
|
test('bare query string is accepted (single-page exhibit)', () => {
|
|
const { result } = validate([validSurface({ url: '?surface=main' })]);
|
|
assert.equal(result.length, 1);
|
|
});
|
|
|
|
test('fragment-only url is accepted', () => {
|
|
const { result } = validate([validSurface({ url: '#surface-main' })]);
|
|
assert.equal(result.length, 1);
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Primary invariant evaluated after individual validation (§31.3)
|
|
* ------------------------------------------------------------------ */
|
|
|
|
test('individually-invalid primary discarded; remaining valid primary => catalog valid', () => {
|
|
const entries = [
|
|
validSurface({ id: 'INVALID_ID', primary: true }),
|
|
validSurface({ id: 'surface.secondary', label: 'Secondary', primary: true, url: 'secondary.html' }),
|
|
];
|
|
const { result } = validate(entries);
|
|
assert.equal(result.length, 1);
|
|
assert.equal(result[0].id, 'surface.secondary');
|
|
});
|
|
|
|
test('individually-invalid primary discarded; zero valid primaries => whole catalog fallback', () => {
|
|
const entries = [
|
|
validSurface({ id: 'BAD', primary: true }),
|
|
validSurface({ id: 'surface.secondary', label: 'Secondary', primary: false, url: 'secondary.html' }),
|
|
];
|
|
const { result, diags } = validate(entries);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /SURFACE_CATALOG_REJECTED/.test(d) || /exactly one/.test(d)));
|
|
});
|
|
|
|
test('zero primaries among valid entries => whole catalog rejected', () => {
|
|
const entries = [
|
|
validSurface({ id: 'surface.a', label: 'A', primary: false, url: 'a.html' }),
|
|
validSurface({ id: 'surface.b', label: 'B', primary: false, url: 'b.html' }),
|
|
];
|
|
const { result, diags } = validate(entries);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /SURFACE_CATALOG_REJECTED/.test(d) || /exactly one/.test(d)));
|
|
});
|
|
|
|
test('multiple valid primaries => whole catalog rejected', () => {
|
|
const entries = [
|
|
validSurface({ id: 'surface.a', label: 'A', primary: true, url: 'a.html' }),
|
|
validSurface({ id: 'surface.b', label: 'B', primary: true, url: 'b.html' }),
|
|
];
|
|
const { result, diags } = validate(entries);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(diags.some(d => /SURFACE_CATALOG_REJECTED/.test(d) || /exactly one/.test(d)));
|
|
});
|
|
|
|
test('all entries individually invalid => absent-equivalent, not malformed-primary', () => {
|
|
const entries = [
|
|
{ id: 'BAD ID', label: 'x', kind: 'surface', primary: true, url: 'x.html' },
|
|
{ id: 'surface.y', label: '', kind: 'surface', primary: true, url: 'y.html' },
|
|
];
|
|
const { result, diags } = validate(entries);
|
|
assert.deepEqual(result, []);
|
|
assert.ok(!diags.some(d => /SURFACE_CATALOG_REJECTED/.test(d)), 'empty valid set is absent-equivalent, not a primary violation');
|
|
});
|
|
|
|
test('non-empty valid catalog with one primary is fully returned', () => {
|
|
const entries = [
|
|
validSurface({ id: 'surface.ctrl', label: 'Control', primary: true, url: 'ctrl.html' }),
|
|
validSurface({ id: 'surface.display', label: 'Display', primary: false, url: 'display.html' }),
|
|
validSurface({ id: 'surface.info', label: 'Info', primary: false, url: 'info.html' }),
|
|
];
|
|
const { result } = validate(entries);
|
|
assert.equal(result.length, 3);
|
|
assert.equal(result.filter(s => s.primary).length, 1);
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Optional fields preserved generically (§31.2)
|
|
* ------------------------------------------------------------------ */
|
|
|
|
test('optional fields are preserved in validated descriptors', () => {
|
|
const s = validSurface({
|
|
description: 'The operator control surface.',
|
|
role: 'control',
|
|
aspectRatio: '16:9',
|
|
category: 'operator',
|
|
requires: ['render'],
|
|
});
|
|
const { result } = validate([s]);
|
|
assert.equal(result.length, 1);
|
|
assert.equal(result[0].description, 'The operator control surface.');
|
|
assert.equal(result[0].role, 'control');
|
|
assert.equal(result[0].aspectRatio, '16:9');
|
|
assert.equal(result[0].category, 'operator');
|
|
assert.deepEqual(result[0].requires, ['render']);
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Duplicate id handling
|
|
* ------------------------------------------------------------------ */
|
|
|
|
test('duplicate id: second entry with same id is discarded', () => {
|
|
const entries = [
|
|
validSurface({ id: 'surface.ctrl', label: 'First', primary: true, url: 'ctrl.html' }),
|
|
validSurface({ id: 'surface.ctrl', label: 'Duplicate', primary: false, url: 'ctrl2.html' }),
|
|
];
|
|
const { result, diags } = validate(entries);
|
|
assert.equal(result.length, 1);
|
|
assert.equal(result[0].label, 'First');
|
|
assert.ok(diags.some(d => /duplicate/.test(d)));
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Malformed surfaces do NOT invalidate targets/capabilities
|
|
* ------------------------------------------------------------------ */
|
|
|
|
test('malformed surfaces field does not throw and does not affect validateCatalog', () => {
|
|
const message = {
|
|
exhibit: {}, contract: { major: 5 },
|
|
registryRevision: 0, stateRevision: 0,
|
|
capabilities: [],
|
|
targets: [{ id: 'sample.level', kind: 'state', valueType: 'boolean', readable: true, writable: true, requires: [] }],
|
|
surfaces: [{ id: 'BAD', label: '', kind: 'not-surface', primary: 'yes', url: 'https://evil.example' }],
|
|
};
|
|
assert.doesNotThrow(() => validateCatalog(message));
|
|
const { result } = validate(message.surfaces);
|
|
assert.deepEqual(result, []);
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* ExhibitHost lifecycle — surfaces cleared on disconnect, refreshed on
|
|
* registry.changed (Parts 3 & 4)
|
|
* ------------------------------------------------------------------ */
|
|
|
|
function peer53() {
|
|
let receive, session = 0, sequence = 0;
|
|
let currentSurfaces = [
|
|
{ id: 'surface.primary', label: 'Primary', kind: 'surface', primary: true, url: 'primary.html' },
|
|
{ id: 'surface.secondary', label: 'Secondary', kind: 'surface', primary: false, url: 'secondary.html' },
|
|
];
|
|
const fixture = {
|
|
transport: {
|
|
subscribe(fn) { receive = fn; return () => {}; },
|
|
close() {},
|
|
send(m) {
|
|
const out = { xzbt: '5.3', requestId: m.requestId, sessionId: `s${session}` };
|
|
if (m.type === 'hello') {
|
|
session++; sequence = 0;
|
|
receive({ ...out, type: 'hello.result', sessionId: `s${session}`, contract: { major: 5, minor: 3 }, exhibit: { product: 'Synthetic 5.3 peer' } });
|
|
} else if (m.type === 'describe') {
|
|
receive({ ...out, type: 'describe.result', exhibit: { product: 'Synthetic 5.3 peer' },
|
|
contract: { major: 5, minor: 3 }, targets: [{ id: 'sample.state', kind: 'state', valueType: 'boolean',
|
|
readable: true, writable: true, requires: [] }], capabilities: [],
|
|
registryRevision: 1, stateRevision: 0, surfaces: currentSurfaces });
|
|
} else if (m.type === 'state.get') {
|
|
receive({ ...out, type: 'state.result', stateRevision: 0, values: { 'sample.state': true } });
|
|
}
|
|
}
|
|
},
|
|
event(type, payload = {}) {
|
|
receive({ xzbt: '5.3', type, sessionId: `s${session}`, sequence: ++sequence, timestamp: Date.now(), ...payload });
|
|
},
|
|
setSurfaces(list) { currentSurfaces = list; }
|
|
};
|
|
return fixture;
|
|
}
|
|
|
|
function peer52() {
|
|
let receive, session = 0;
|
|
const fixture = {
|
|
transport: {
|
|
subscribe(fn) { receive = fn; return () => {}; },
|
|
close() {},
|
|
send(m) {
|
|
const out = { xzbt: '5.2', requestId: m.requestId, sessionId: `s${session}` };
|
|
if (m.type === 'hello') {
|
|
session++;
|
|
receive({ ...out, type: 'hello.result', sessionId: `s${session}`, contract: { major: 5, minor: 2 }, exhibit: { product: 'Synthetic 5.2 peer' } });
|
|
} else if (m.type === 'describe') {
|
|
receive({ ...out, type: 'describe.result', exhibit: { product: 'Synthetic 5.2 peer' },
|
|
contract: { major: 5, minor: 2 }, targets: [{ id: 'sample.state', kind: 'state', valueType: 'boolean',
|
|
readable: true, writable: true, requires: [] }], capabilities: [],
|
|
registryRevision: 1, stateRevision: 0 });
|
|
} else if (m.type === 'state.get') {
|
|
receive({ ...out, type: 'state.result', stateRevision: 0, values: { 'sample.state': true } });
|
|
}
|
|
}
|
|
}
|
|
};
|
|
return fixture;
|
|
}
|
|
|
|
const settle = () => new Promise(resolve => setTimeout(resolve, 25));
|
|
|
|
test('Contract 5.3 exhibit: surfaces discovered after connect', async t => {
|
|
const fixture = peer53();
|
|
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
|
|
t.after(() => host.disconnect());
|
|
await host.connect(fixture.transport);
|
|
assert.equal(host.contractMinor, 3);
|
|
assert.equal(host.surfaces.length, 2);
|
|
assert.equal(host.surfaces.filter(s => s.primary).length, 1);
|
|
assert.equal(host.surfaces[0].id, 'surface.primary');
|
|
});
|
|
|
|
test('disconnect clears surface catalog', async t => {
|
|
const fixture = peer53();
|
|
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
|
|
t.after(() => host.disconnect());
|
|
await host.connect(fixture.transport);
|
|
assert.equal(host.surfaces.length, 2);
|
|
host.disconnect();
|
|
assert.equal(host.surfaces.length, 0, 'surfaces must be empty after disconnect');
|
|
});
|
|
|
|
test('reconnect after disconnect rediscovers surface catalog', async t => {
|
|
const fixture = peer53();
|
|
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
|
|
t.after(() => host.disconnect());
|
|
await host.connect(fixture.transport);
|
|
assert.equal(host.surfaces.length, 2);
|
|
host.disconnect();
|
|
assert.equal(host.surfaces.length, 0);
|
|
await host.connect(fixture.transport);
|
|
assert.equal(host.surfaces.length, 2, 'surfaces restored after reconnect');
|
|
});
|
|
|
|
test('registry.changed refreshes surface catalog when it changes', async t => {
|
|
const fixture = peer53();
|
|
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
|
|
t.after(() => host.disconnect());
|
|
await host.connect(fixture.transport);
|
|
assert.equal(host.surfaces.length, 2);
|
|
|
|
fixture.setSurfaces([
|
|
{ id: 'surface.primary', label: 'Primary Updated', kind: 'surface', primary: true, url: 'primary.html' }
|
|
]);
|
|
fixture.event('registry.changed');
|
|
await settle();
|
|
|
|
assert.equal(host.surfaces.length, 1);
|
|
assert.equal(host.surfaces[0].label, 'Primary Updated');
|
|
});
|
|
|
|
test('registry.changed can clear surfaces entirely', async t => {
|
|
const fixture = peer53();
|
|
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
|
|
t.after(() => host.disconnect());
|
|
await host.connect(fixture.transport);
|
|
assert.equal(host.surfaces.length, 2);
|
|
fixture.setSurfaces([]);
|
|
fixture.event('registry.changed');
|
|
await settle();
|
|
assert.equal(host.surfaces.length, 0);
|
|
});
|
|
|
|
/* ------------------------------------------------------------------ *
|
|
* Contract 5.2 backward compatibility
|
|
* ------------------------------------------------------------------ */
|
|
|
|
test('Contract 5.2 exhibit: surfaces stays empty, no error', async t => {
|
|
const fixture = peer52();
|
|
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
|
|
t.after(() => host.disconnect());
|
|
await host.connect(fixture.transport);
|
|
assert.equal(host.contractMinor, 2);
|
|
assert.equal(host.surfaces.length, 0);
|
|
assert.ok(!host.logs.some(l => l.code === 'SURFACE_CATALOG'));
|
|
});
|
|
|
|
test('Contract 5.2 exhibit: controls, state and events still work', async t => {
|
|
const fixture = peer52();
|
|
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
|
|
t.after(() => host.disconnect());
|
|
await host.connect(fixture.transport);
|
|
assert.equal(host.status, 'connected');
|
|
assert.equal(host.sync, 'synchronized');
|
|
assert.equal(host.catalog.length, 1);
|
|
assert.equal(host.surfaces.length, 0);
|
|
});
|
|
|
|
test('NGN emits xzbt 5.3 advisory on hello; 5.2 exhibit negotiates normally', async t => {
|
|
const sentMessages = [];
|
|
let receive, session = 0;
|
|
const transport = {
|
|
subscribe(fn) { receive = fn; return () => {}; },
|
|
close() {},
|
|
send(m) {
|
|
sentMessages.push(m);
|
|
const out = { xzbt: '5.2', requestId: m.requestId, sessionId: `s${session}` };
|
|
if (m.type === 'hello') {
|
|
session++;
|
|
receive({ ...out, type: 'hello.result', sessionId: `s${session}`, contract: { major: 5, minor: 2 }, exhibit: { product: 'Test 5.2' } });
|
|
} else if (m.type === 'describe') {
|
|
receive({ ...out, type: 'describe.result', exhibit: { product: 'Test 5.2' }, contract: { major: 5, minor: 2 },
|
|
targets: [], capabilities: [], registryRevision: 0, stateRevision: 0 });
|
|
} else if (m.type === 'state.get') {
|
|
receive({ ...out, type: 'state.result', stateRevision: 0, values: {} });
|
|
}
|
|
}
|
|
};
|
|
const host = new ExhibitHost({ debounceMs: 5, timeoutMs: 200 });
|
|
t.after(() => host.disconnect());
|
|
await host.connect(transport);
|
|
const helloMsg = sentMessages.find(m => m.type === 'hello');
|
|
assert.equal(helloMsg.xzbt, '5.3', 'NGN emits xzbt 5.3 advisory');
|
|
assert.equal(host.contractMinor, 2, 'negotiated minor reflects what 5.2 exhibit reported');
|
|
assert.equal(host.status, 'connected');
|
|
});
|