import assert from 'node:assert/strict'; import test from 'node:test'; import { createConfigAdoption, adoptAuthoritativeGated, adoptStructuralResponses, } from '../test-build/config-adoption.js'; import { contentFingerprint } from '../test-build/visual-continuity.js'; import { virtualLightSnapshot } from '../test-build/virtual-light-state.js'; // #500 — one owner for config/layout identity. These tests pin the transition // table the host used to implement inline (`_adoptStructuralResponses`, // `_cacheSnapshot`, `_sendConfigCandidate`, `rollbackOptimistic`) so the // extraction is provably behaviour-neutral (AC3, AC5, AC6) and the invariants // I2/I4 have a red witness. const cfg = (title, extra = {}) => ({ spaces: [{ id: 'f1', title }], settings: {}, ...extra }); const cloneOf = (value) => JSON.parse(JSON.stringify(value)); /** A host stub recording every side effect the adoption may trigger, in order. */ function hostStub(adoption, overrides = {}) { const calls = []; const rec = (name) => (...args) => { calls.push(name); return overrides[name]?.(...args); }; const host = { hass: { id: 'hass' }, _adoption: adoption, _space: 'f1', _model: [], _virtualLights: virtualLightSnapshot(null), _capturedSnapshotVirtual: 'kept', _geometryHistory: { clear: rec('geometryHistory.clear') }, _devicePositionHistory: { clear: rec('devicePositionHistory.clear') }, _pendingPhysicalWrites: { clear: rec('pendingPhysicalWrites.clear') }, _canOptimizeUndo: false, _undoKind: null, _serverCanWrite: null, _regSignature: 'sig', _continuity: { hasCompleteFrame: true, state: 'steady', note: (event, detail) => { calls.push(`note:${event}`); host.notes.push([event, detail]); }, }, notes: [], _signer: { prepareImage: async (_hass, url) => { calls.push(`prepareImage:${url}`); return overrides.assetReady ?? true; } }, _cancelDeviceDrag: rec('cancelDeviceDrag'), _clearRoomFocus: rec('clearRoomFocus'), _cancelCameraTransition: rec('cancelCameraTransition'), _clearGeometryGesture: rec('clearGeometryGesture'), _seedDecorStyle: rec('seedDecorStyle'), _adoptConfigCapabilities: rec('adoptConfigCapabilities'), _candidateBackdrop: (config) => config?.spaces?.[0]?.bg?.href || 'no-backdrop', _scheduleLoadRetry: rec('scheduleLoadRetry'), _beginContinuityCandidate: (reason) => { calls.push(`continuity:${reason}`); return 1; }, _syncDecorAssets: async () => { calls.push('syncDecorAssets'); }, _adoptInitialSpace: rec('adoptInitialSpace'), _resumePendingNavMode: rec('resumePendingNavMode'), _cacheSnapshot: rec('cacheSnapshot'), calls, }; return host; } const adoptedWith = (config, rev = 1) => { const adoption = createConfigAdoption(); adoptStructuralResponses(hostStub(adoption), { config, rev }, { layout: {}, rev: 0 }); return adoption; }; // --- AC3: transition table of the former _adoptStructuralResponses --------- test('AC3: an identical payload is an echo — body reference, histories and epoch state stay, only the revision moves', () => { const body = cfg('Ground floor'); const adoption = adoptedWith(body, 3); const host = hostStub(adoption); const echo = cloneOf(body); const result = adoptStructuralResponses(host, { config: echo, rev: 4 }, { layout: {}, rev: 2 }); assert.deepEqual(result, { configChanged: false, layoutChanged: false }); assert.equal(adoption.config, body, 'the echo must not replace the reactive root'); assert.equal(adoption.configRev, 4); assert.equal(adoption.layoutRev, 2); assert.ok(!host.calls.includes('geometryHistory.clear'), 'echo keeps local undo'); assert.ok(!host.calls.includes('cancelCameraTransition')); assert.ok(!host.notes.some(([event]) => event === 'config-candidate')); }); test('AC3: a different config replaces the body, retires baseline-bound state in the host order, and notes the candidate', () => { const adoption = adoptedWith(cfg('Ground floor'), 3); const host = hostStub(adoption); const next = cfg('Renamed'); const result = adoptStructuralResponses(host, { config: next, rev: 5, can_write: true, can_optimize_undo: true, undo_kind: 'optimize' }); assert.deepEqual(result, { configChanged: true, layoutChanged: false }); assert.equal(adoption.config, next, 'the adopted body is the response object itself (I4)'); assert.equal(adoption.configRev, 5); assert.equal(adoption.configFingerprint, contentFingerprint(next)); assert.deepEqual(host.calls.slice(0, 7), [ 'geometryHistory.clear', 'devicePositionHistory.clear', 'cancelDeviceDrag', 'pendingPhysicalWrites.clear', 'clearRoomFocus', 'cancelCameraTransition', 'clearGeometryGesture', ]); assert.ok(host.calls.indexOf('seedDecorStyle') > host.calls.indexOf('clearGeometryGesture')); assert.equal(host._serverCanWrite, true); assert.equal(host._canOptimizeUndo, true); assert.equal(host._undoKind, 'optimize'); assert.deepEqual(host.notes.at(-1), ['config-candidate', { configRev: 5 }]); }); test('AC3: the very first adoption does not clear a geometry gesture that never existed', () => { const adoption = createConfigAdoption(); const host = hostStub(adoption); adoptStructuralResponses(host, { config: cfg('First'), rev: 1 }); assert.ok(!host.calls.includes('clearGeometryGesture')); assert.ok(host.calls.includes('geometryHistory.clear')); }); test('AC3: layout-only change retires drag/camera state and notes the layout candidate; config side is untouched', () => { const adoption = adoptedWith(cfg('Ground floor'), 3); const host = hostStub(adoption); const layout = { dev1: { s: 'f1', x: 0.5, y: 0.5 } }; const result = adoptStructuralResponses(host, { config: cloneOf(adoption.config), rev: 3 }, { layout, rev: 9 }); assert.deepEqual(result, { configChanged: false, layoutChanged: true }); assert.equal(adoption.layout, layout); assert.equal(adoption.layoutRev, 9); assert.equal(adoption.layoutFingerprint, contentFingerprint(layout)); assert.deepEqual(host.calls.filter((c) => !c.startsWith('note') && c !== 'adoptConfigCapabilities'), ['cancelCameraTransition', 'devicePositionHistory.clear', 'cancelDeviceDrag']); assert.deepEqual(host.notes, [['layout-candidate', { layoutRev: 9 }]]); }); test('AC3: a response without rev keeps the previous revision; an absent layout response leaves layout identity alone', () => { const adoption = adoptedWith(cfg('Ground floor'), 3); adoptStructuralResponses(hostStub(adoption), { config: cfg('Other') }, { layout: { a: {} }, rev: 4 }); assert.equal(adoption.configRev, 3); assert.equal(adoption.layoutRev, 4); adoptStructuralResponses(hostStub(adoption), { config: cfg('Third'), rev: 8 }); assert.equal(adoption.configRev, 8); assert.equal(adoption.layoutRev, 4, 'no layout response → layout identity untouched'); assert.deepEqual(adoption.layout, { a: {} }); }); test('AC3: virtual lights are adopted with the response revision and only replaced when they differ', () => { const adoption = adoptedWith(cfg('Ground floor'), 3); const host = hostStub(adoption); adoptStructuralResponses(host, { config: cloneOf(adoption.config), rev: 4, virtual_lights: { rev: 1, config_rev: 4, off: ['vl.one'] }, }); assert.deepEqual([...host._virtualLights.off], ['vl.one']); assert.equal(host._capturedSnapshotVirtual, '', 'a changed set invalidates the captured render snapshot'); const before = host._virtualLights; host._capturedSnapshotVirtual = 'kept'; adoptStructuralResponses(host, { config: cloneOf(adoption.config), rev: 4, virtual_lights: { rev: 1, config_rev: 4, off: ['vl.one'] }, }); assert.equal(host._capturedSnapshotVirtual, 'kept', 'an identical set is not a change'); assert.equal(host._virtualLights, before, 'identical wire state keeps the snapshot object'); }); // --- §6.3: the gated sequence ----------------------------------------------- test('gated adoption: unchanged structure skips the backdrop gate and continuity, adopts, and runs the reload tail', async () => { const adoption = adoptedWith(cfg('Ground floor'), 3); const host = hostStub(adoption); const result = await adoptAuthoritativeGated(host, { cfgResp: { config: cloneOf(adoption.config), rev: 4 }, reason: 'config-reload', profile: 'reload', }); assert.deepEqual(result, { status: 'adopted', spaceChanged: false }); assert.ok(!host.calls.some((c) => c.startsWith('prepareImage'))); assert.ok(!host.calls.some((c) => c.startsWith('continuity:'))); assert.deepEqual(host.calls.slice(-4), ['syncDecorAssets', 'adoptInitialSpace', 'resumePendingNavMode', 'cacheSnapshot']); }); test('gated adoption: a changed structure prepares the candidate backdrop, starts the continuity candidate, then adopts (#490 M1 order)', async () => { const adoption = adoptedWith(cfg('Ground floor'), 3); const host = hostStub(adoption); let beforeAdoptSeen = null; const next = cfg('New', {}); next.spaces[0].bg = { href: '/local/new-plan.svg' }; const result = await adoptAuthoritativeGated(host, { cfgResp: { config: next, rev: 4 }, reason: 'summary-recovery', profile: 'reload', beforeAdopt: () => { beforeAdoptSeen = adoption.config; }, }); assert.equal(result.status, 'adopted'); const order = host.calls; assert.ok(order.indexOf('prepareImage:/local/new-plan.svg') < order.indexOf('continuity:summary-recovery')); assert.ok(order.indexOf('continuity:summary-recovery') < order.indexOf('geometryHistory.clear')); assert.notEqual(beforeAdoptSeen, next, 'beforeAdopt runs after the gate, before adoption'); assert.equal(adoption.config, next); }); test('gated adoption: a bounded asset failure adopts nothing, notes the failure and schedules a retry', async () => { const adoption = adoptedWith(cfg('Ground floor'), 3); const host = hostStub(adoption, { assetReady: false }); const before = adoption.config; const result = await adoptAuthoritativeGated(host, { cfgResp: { config: cfg('New'), rev: 4 }, reason: 'structural-response', profile: 'reload', beforeAdopt: () => assert.fail('beforeAdopt must not run when the gate refuses'), }); assert.deepEqual(result, { status: 'asset-wait' }); assert.equal(adoption.config, before); assert.equal(adoption.configRev, 3, 'the revision is not taken without its body'); assert.ok(host.calls.includes('note:asset-failed')); assert.ok(host.calls.includes('scheduleLoadRetry')); assert.ok(!host.calls.includes('cacheSnapshot')); }); test('gated adoption: the continuity candidate needs a complete steady frame', async () => { const adoption = adoptedWith(cfg('Ground floor'), 3); const host = hostStub(adoption); host._continuity.state = 'candidate'; await adoptAuthoritativeGated(host, { cfgResp: { config: cfg('New'), rev: 4 }, reason: 'config-reload', profile: 'reload' }); assert.ok(!host.calls.some((c) => c.startsWith('continuity:'))); }); test('post-write profile ends at adoption: no reload tail, the caller keeps its own (§6.3, r3)', async () => { const adoption = adoptedWith(cfg('Ground floor'), 3); const host = hostStub(adoption); const result = await adoptAuthoritativeGated(host, { cfgResp: { config: cfg('After import'), rev: 7 }, layResp: { layout: { d: {} }, rev: 2 }, reason: 'import-apply', profile: 'post-write', }); assert.equal(result.status, 'adopted'); for (const tail of ['syncDecorAssets', 'adoptInitialSpace', 'resumePendingNavMode', 'cacheSnapshot']) { assert.ok(!host.calls.includes(tail), `${tail} belongs to the caller on post-write paths`); } assert.ok(host.calls.some((c) => c.startsWith('prepareImage')), 'the gate itself applies to post-write paths (AC4)'); assert.equal(adoption.configRev, 7); assert.equal(adoption.layoutRev, 2); }); test('I2: a revision is never taken from a response other than the one carrying the adopted body', async () => { const adoption = adoptedWith(cfg('Ground floor'), 3); const host = hostStub(adoption); // A delete reply says config_rev 5, but a concurrent client already moved // the config to rev 6 before our re-read: the re-read wins, whole. const deleteReply = { config_rev: 5, layout_rev: 1 }; await adoptAuthoritativeGated(host, { cfgResp: { config: cfg('Concurrently renamed'), rev: 6 }, layResp: { layout: {}, rev: 2 }, reason: 'space-delete', profile: 'post-write', }); assert.equal(adoption.configRev, 6); assert.equal(adoption.layoutRev, 2); assert.notEqual(adoption.configRev, deleteReply.config_rev); assert.equal(typeof adoption.acceptPairWrite, 'function'); }); // --- our own writes ----------------------------------------------------------- test('stageConfigCandidate replaces the root only when canonicalization changed content (#224 H1) and pins the accepted fingerprint', () => { const adoption = adoptedWith(cfg('Ground floor'), 3); const live = adoption.config; const same = cloneOf(live); adoption.stageConfigCandidate(same); assert.equal(adoption.config, live, 'equal content keeps the reactive root'); const changed = cfg('Canonical'); adoption.stageConfigCandidate(changed); assert.equal(adoption.config, changed); assert.equal(adoption.configFingerprint, contentFingerprint(changed)); }); test('acceptConfigWrite takes the reply revision for the sent candidate; without rev it keeps the historical +1 guess (§15 п.3)', () => { const adoption = adoptedWith(cfg('Ground floor'), 3); const candidate = cfg('Sent'); adoption.acceptConfigWrite(candidate, { rev: 10 }); assert.equal(adoption.configRev, 10); assert.equal(adoption.configFingerprint, contentFingerprint(candidate)); adoption.acceptConfigWrite(candidate, {}); assert.equal(adoption.configRev, 11); adoption.acceptConfigWrite(candidate, undefined); assert.equal(adoption.configRev, 12); }); test('acceptPairWrite lands both bodies with both revisions; missing revisions fall back to +1 each', () => { const adoption = adoptedWith(cfg('Ground floor'), 3); const config = cfg('Optimized'); const layout = { d: { s: 'f1', x: 0.1, y: 0.2 } }; adoption.acceptPairWrite(config, layout, { config_rev: 4, layout_rev: 9 }); assert.equal(adoption.config, config); assert.equal(adoption.layout, layout); assert.equal(adoption.configRev, 4); assert.equal(adoption.layoutRev, 9); assert.equal(adoption.configFingerprint, contentFingerprint(config)); assert.equal(adoption.layoutFingerprint, contentFingerprint(layout)); adoption.acceptPairWrite(config, layout, { config_rev: 'x' }); assert.equal(adoption.configRev, 5); assert.equal(adoption.layoutRev, 10); }); test('layout: merged re-read keeps the reference on equal content; own replies move the revision monotonically', () => { const adoption = adoptedWith(cfg('Ground floor'), 3); const layout = { d: { s: 'f1', x: 0.1, y: 0.2 } }; adoption.stageLocalLayout(layout); let replaced = 0; assert.equal(adoption.adoptMergedLayout(cloneOf(layout), { rev: 5 }, () => { replaced += 1; }), false); assert.equal(adoption.layout, layout); assert.equal(adoption.layoutRev, 5); assert.equal(replaced, 0); const other = { d: { s: 'f1', x: 0.3, y: 0.2 } }; assert.equal(adoption.adoptMergedLayout(other, { rev: 6 }, () => { replaced += 1; }), true); assert.equal(adoption.layout, other); assert.equal(replaced, 1); adoption.noteLayoutRevision(4); assert.equal(adoption.layoutRev, 6, 'an older own reply is not a step back'); adoption.noteLayoutRevision(8); assert.equal(adoption.layoutRev, 8); adoption.noteLayoutRevision('9'); assert.equal(adoption.layoutRev, 8); }); // --- AC5: warm cache round-trip ---------------------------------------------- test('AC5: snapshot → restoreCached reproduces the identity exactly with the persisted LS_CFG keys', () => { const adoption = adoptedWith(cfg('Ground floor'), 3); adoption.adoptMergedLayout({ d: { s: 'f1', x: 0.1, y: 0.2 } }, { rev: 7 }); const snapshot = adoption.snapshot({ rev: 0, config_rev: 3, off: [] }); assert.deepEqual(Object.keys(snapshot).sort(), [ 'config', 'config_fingerprint', 'layout', 'layout_fingerprint', 'layout_rev', 'rev', 'virtual_lights', ]); const restored = createConfigAdoption(); assert.equal(restored.restoreCached(JSON.parse(JSON.stringify(snapshot))), true); assert.deepEqual(restored.config, adoption.config); assert.equal(restored.configRev, 3); assert.equal(restored.configFingerprint, adoption.configFingerprint); assert.deepEqual(restored.layout, adoption.layout); assert.equal(restored.layoutRev, 7); assert.equal(restored.layoutFingerprint, adoption.layoutFingerprint); }); test('AC5: a cache written before fingerprints existed restores with recomputed fingerprints; garbage is refused', () => { const adoption = createConfigAdoption(); const config = cfg('Old cache'); assert.equal(adoption.restoreCached({ config, rev: 2, layout: { d: {} }, layout_rev: 1 }), true); assert.equal(adoption.configFingerprint, contentFingerprint(config)); assert.equal(adoption.layoutFingerprint, contentFingerprint({ d: {} })); const empty = createConfigAdoption(); assert.equal(empty.restoreCached(null), false); assert.equal(empty.restoreCached({ config: { spaces: 'nope' } }), false); assert.equal(empty.config, null); }); test('snapshot re-pairs fingerprints with bodies mutated in place before the debounced write', () => { const adoption = adoptedWith(cfg('Ground floor'), 3); adoption.config.spaces[0].title = 'Mutated in place'; assert.notEqual(adoption.configFingerprint, contentFingerprint(adoption.config)); const snapshot = adoption.snapshot(null); assert.equal(snapshot.config_fingerprint, contentFingerprint(adoption.config)); assert.equal(adoption.configFingerprint, snapshot.config_fingerprint); assert.equal(createConfigAdoption().snapshot(null), null); }); // --- AC6: optimistic writes (#314/#439/#442) -------------------------------- test('AC6: a rejected optimistic write restores only its own candidate (#439)', () => { const adoption = adoptedWith(cfg('Ground floor'), 7); const previous = adoption.config; const attempted = cfg('Draft'); const attempt = adoption.beginOptimistic(previous, attempted); assert.equal(attempt.revision, 7); assert.equal(attempt.previousFingerprint, adoption.configFingerprint); adoption.stageLocalConfig(attempted); assert.equal(adoption.rollbackOptimistic(attempt), true); assert.deepEqual(adoption.config, previous); assert.notEqual(adoption.config, previous, 'the rollback snapshot is isolated from later mutations'); assert.equal(adoption.configFingerprint, attempt.previousFingerprint); assert.equal(adoption.configRev, 7, 'a rollback never touches the revision'); }); test('AC6: a conflict reload or a newer mutation wins over a rejected candidate (#439)', () => { const adoption = adoptedWith(cfg('Server before'), 3); const attempt = adoption.beginOptimistic(adoption.config, cfg('Draft')); adoptStructuralResponses(hostStub(adoption), { config: cfg('Server after'), rev: 4 }); const authoritative = adoption.config; assert.equal(adoption.rollbackOptimistic(attempt), false, 'a newer revision wins'); assert.equal(adoption.config, authoritative); const same = createConfigAdoption(); same.restoreCached({ config: cfg('Server before'), rev: 3 }); const attempt2 = same.beginOptimistic(same.config, cfg('Draft')); same.stageLocalConfig(cfg('Newer local edit')); assert.equal(same.rollbackOptimistic(attempt2), false, 'different content on the same revision wins'); assert.equal(same.config.spaces[0].title, 'Newer local edit'); }); test('AC6: an attempt from an older revision cannot roll back the same content accepted at a newer one (#442 AC2)', () => { const adoption = adoptedWith(cfg('Server before'), 3); const attempted = cfg('Draft'); const attempt = adoption.beginOptimistic(adoption.config, attempted); adoption.stageLocalConfig(attempted); // the queued write for the very same body was accepted meanwhile adoption.acceptConfigWrite(attempted, { rev: 4 }); assert.equal(contentFingerprint(adoption.config), attempt.attemptedFingerprint, 'content is identical — only the revision differs'); assert.equal(adoption.rollbackOptimistic(attempt), false, 'the revision guard alone must refuse'); assert.equal(adoption.config, attempted); assert.equal(adoption.configRev, 4); }); test('AC6: newer in-place content on the attempted root also wins (#442)', () => { const adoption = adoptedWith(cfg('Server before'), 3); const attempted = cfg('Draft'); const attempt = adoption.beginOptimistic(adoption.config, attempted); adoption.stageLocalConfig(attempted); attempted.settings.newer = true; assert.equal(adoption.rollbackOptimistic(attempt), false); assert.equal(adoption.config, attempted); });