diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 67f3825d..2eaae100 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -203,7 +203,8 @@ const MUTANT_DEFINITIONS = [ }, { id: 'version-recovery-ignores-editor-state', - guard: 'node demo/smoke_version_recovery.mjs', + guard: 'node --test --test-name-pattern="card adapter maps editor state" ' + + 'test/version-recovery-card.test.mjs', because: 'a kiosk card in an editor must preserve unsaved work instead of silently reloading ' + 'when its versions differ (#462 AC8)', patches: [{ @@ -214,7 +215,8 @@ const MUTANT_DEFINITIONS = [ }, { id: 'version-recovery-ignores-dialog-state', - guard: 'node demo/smoke_version_recovery.mjs', + guard: 'node --test --test-name-pattern="card adapter maps blocking surfaces" ' + + 'test/version-recovery-card.test.mjs', because: 'a first-class dialog or contextual surface must block the kiosk auto-reload ' + 'rather than disappear underneath it (#462 AC8)', patches: [{ @@ -225,7 +227,8 @@ const MUTANT_DEFINITIONS = [ }, { id: 'version-recovery-ignores-pending-config-write', - guard: 'node demo/smoke_version_recovery.mjs', + guard: 'node --test --test-name-pattern="card adapter maps config writes" ' + + 'test/version-recovery-card.test.mjs', because: 'a pending configuration write must block kiosk reload so an accepted edit cannot ' + 'be lost during version recovery (#462 AC8)', patches: [{ @@ -236,7 +239,8 @@ const MUTANT_DEFINITIONS = [ }, { id: 'version-recovery-ignores-interaction-pause', - guard: 'node demo/smoke_version_recovery.mjs', + guard: 'node --test --test-name-pattern="card adapter maps interaction pause" ' + + 'test/version-recovery-card.test.mjs', because: 'recent pointer, keyboard, touch and native more-info interaction must defer the ' + 'silent kiosk reload by the same shared pause contract (#462 AC8)', patches: [{ diff --git a/test/version-recovery-card-contract.test.mjs b/test/version-recovery-card-contract.test.mjs index 5f55c96c..2cedf7d3 100644 --- a/test/version-recovery-card-contract.test.mjs +++ b/test/version-recovery-card-contract.test.mjs @@ -11,15 +11,17 @@ const controller = read('src/version-recovery.ts'); const styles = read('src/styles/base.styles.ts'); const occurrences = (source, fragment) => source.split(fragment).length - 1; +const matches = (source, pattern) => source.match(pattern)?.length || 0; test('one host seam owns every full-card config/get request', () => { - const direct = "callWS({ type: 'houseplan/config/get'"; - assert.equal(occurrences(adapter, direct), 1, 'the adapter is the only WS request owner'); - assert.equal(occurrences(card, direct), 0, 'card flows must use the authoritative seam'); - assert.equal(occurrences(editor, direct), 0, 'lazy editor flows must use the host seam'); - assert.equal(occurrences(onboarding, direct), 0, 'onboarding flows must use the host seam'); + const direct = /callWS(?:<[^>]+>)?\(\{ type: 'houseplan\/config\/get'/g; + assert.equal(matches(adapter, direct), 1, 'the adapter is the only WS request owner'); + assert.equal(matches(card, direct), 0, 'card flows must use the authoritative seam'); + assert.equal(matches(editor, direct), 0, 'lazy editor flows must use the host seam'); + assert.equal(matches(onboarding, direct), 0, 'onboarding flows must use the host seam'); - assert.match(card, /private _getAuthoritativeConfig\(\): Promise/); + assert.match(card, + /private _getAuthoritativeConfig\(\): Promise/); assert.equal(occurrences(card, 'this._getAuthoritativeConfig()'), 2, 'initial load and config-only reload both use the seam'); assert.equal(occurrences(editor, 'this.host._getAuthoritativeConfig()'), 3, diff --git a/test/version-recovery-card.test.mjs b/test/version-recovery-card.test.mjs new file mode 100644 index 00000000..dffd3ada --- /dev/null +++ b/test/version-recovery-card.test.mjs @@ -0,0 +1,132 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { cardVersionReloadSafetySnapshot } from '../test-build/version-recovery-card.js'; + +const pending = (size = 0) => ({ size }); +const debounce = (value = false) => ({ pending: () => value }); + +function safeHost(overrides = {}) { + return { + ownerDocument: { defaultView: null }, + isConnected: true, + _config: { kiosk: true }, + _mode: 'view', + _editing: false, + _loadOk: true, + _loading: false, + _continuityDataReady: true, + _resumeSettling: false, + _connectionWasLost: false, + _booting: false, + _bootFading: false, + _bootSettling: false, + _bootSoft: false, + _continuity: { + hasCompleteFrame: true, + state: 'steady', + overlayBlocksInteraction: false, + }, + _stageEl: { clientWidth: 800, clientHeight: 600 }, + _pointers: pending(), + _touchContacts: pending(), + _touchSequenceMultitouch: false, + _roomPointer: null, + _panStart: null, + _panLock: null, + _pinchStart: null, + _swipeStart: null, + _tabDrag: null, + _tabDragRelease: null, + _drag: null, + _deviceDrag: null, + _rlResize: null, + _resize: null, + _physicalDrag: null, + _physicalRotate: null, + _opDrag: null, + _decorDraft: null, + _decorMove: null, + _dtDrag: null, + _bdDrag: null, + _furnTouchPending: null, + _compassDrag: null, + _viewportGestureDirty: false, + _devicePositionBusy: false, + _modeTransitionBusy: false, + _cameraTransition: { active: false }, + _slide: '', + _warmModeRequest: 0, + _writesPending: 0, + _saveConfigDebounced: debounce(), + _pendingPhysicalWrites: pending(), + _persistLayout: debounce(), + _dirtyPos: pending(), + _sentPos: pending(), + _cyclePausedUntil: 0, + _zoom: 1, + _editorSecondaryDialogBlocked: false, + _partitionDeleteDialog: null, + _roomDeleteDialog: null, + _backdropGuard: null, + _vacFit: null, + _editorSecondary: null, + _furnPalette: null, + _decorImagePalette: null, + ...overrides, + }; +} + +test('card adapter baseline is safe only after the full rendered card settles', () => { + assert.deepEqual(cardVersionReloadSafetySnapshot(safeHost()), { + connected: true, + initialFrameSettled: true, + viewOnly: true, + surfacesIdle: true, + configWritesIdle: true, + physicalWritesIdle: true, + layoutWritesIdle: true, + gesturesIdle: true, + interactionPauseElapsed: true, + baseZoom: true, + }); +}); + +test('card adapter maps editor state into the kiosk reload guard', () => { + for (const overrides of [ + { _config: { kiosk: false } }, + { _mode: 'plan' }, + { _editing: true }, + ]) { + assert.equal(cardVersionReloadSafetySnapshot(safeHost(overrides)).viewOnly, false); + } +}); + +test('card adapter maps blocking surfaces into the kiosk reload guard', () => { + for (const overrides of [ + { _editorSecondaryDialogBlocked: true }, + { _partitionDeleteDialog: {} }, + { _roomDeleteDialog: {} }, + { _backdropGuard: {} }, + { _vacFit: {} }, + { _editorSecondary: { hasOpenGroup: true } }, + { _furnPalette: {} }, + { _decorImagePalette: {} }, + ]) { + assert.equal(cardVersionReloadSafetySnapshot(safeHost(overrides)).surfacesIdle, false); + } +}); + +test('card adapter maps config writes into the kiosk reload guard', () => { + assert.equal(cardVersionReloadSafetySnapshot(safeHost({ _writesPending: 1 })).configWritesIdle, + false); + assert.equal(cardVersionReloadSafetySnapshot(safeHost({ + _saveConfigDebounced: debounce(true), + })).configWritesIdle, false); +}); + +test('card adapter maps interaction pause into the kiosk reload guard', () => { + assert.equal(cardVersionReloadSafetySnapshot(safeHost({ + _cyclePausedUntil: Date.now() + 60_000, + })).interactionPauseElapsed, false); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json index de3014dc..3a4635dd 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -26,7 +26,7 @@ "src/space-geometry.ts", "src/junction-limits.ts", "src/space-order.ts", "src/card-editor-validation.ts", "src/signing.ts", "src/initial-load.ts", "src/space-model-selection.ts", "src/space-dialog.ts", - "src/visual-continuity.ts", "src/version-recovery.ts", "src/mode-transition.ts", "src/viewport-transition.ts", "src/room-fit.ts", "src/editor-runtime-loader.ts", "src/editor-secondary.ts", "src/pointer-modality.ts", + "src/visual-continuity.ts", "src/version-recovery.ts", "src/version-recovery-card.ts", "src/mode-transition.ts", "src/viewport-transition.ts", "src/room-fit.ts", "src/editor-runtime-loader.ts", "src/editor-secondary.ts", "src/pointer-modality.ts", "src/render-device-snapshot.ts", "src/render-invalidation.ts", "src/houseplan-render-lifecycle.ts", "src/live-interaction-runtime.ts", "src/live-viewport.ts", "src/live-hover.ts", "src/live-editor.ts", "src/pointer-move-queue.ts", "src/command-stack.ts", "src/device-position-history.ts", "src/coordinate-canonicalization.ts",