Files
houseplan-card/test/visual-continuity.test.mjs

218 lines
7.9 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
PAINT_BARRIER_MAX_MS,
VisualContinuityController,
contentFingerprint,
visualFrameFingerprint,
} from '../test-build/visual-continuity.js';
class FakeClock {
at = 10_000;
nextId = 1;
timers = new Map();
frames = new Map();
now = () => this.at;
setTimeout = (callback, delay) => {
const id = this.nextId++;
this.timers.set(id, { at: this.at + delay, callback });
return id;
};
clearTimeout = (id) => this.timers.delete(id);
requestAnimationFrame = (callback) => {
const id = this.nextId++;
this.frames.set(id, callback);
return id;
};
cancelAnimationFrame = (id) => this.frames.delete(id);
advance(ms) {
const end = this.at + ms;
while (true) {
const due = [...this.timers.entries()]
.filter(([, timer]) => timer.at <= end)
.sort((left, right) => left[1].at - right[1].at)[0];
if (!due) break;
this.at = due[1].at;
this.timers.delete(due[0]);
due[1].callback();
}
this.at = end;
}
frame() {
this.at += 16;
const callbacks = [...this.frames.values()];
this.frames.clear();
for (const callback of callbacks) callback(this.at);
}
}
async function commit(controller, clock, token, fingerprint = 'next') {
assert.equal(controller.candidateReady(token), true);
const result = controller.commitAfterPaint(token, {
updateComplete: async () => undefined,
stageValid: () => true,
assetsReady: () => true,
frameFingerprint: () => fingerprint,
});
await Promise.resolve();
clock.frame();
await Promise.resolve();
clock.frame();
return result;
}
test('quick return is a strict no-op; long return holds the complete frame', () => {
const clock = new FakeClock();
let updates = 0;
const controller = new VisualContinuityController(() => updates++, clock);
controller.markCompleteFrame('complete');
const baselineUpdates = updates;
const token = controller.visibility({
kind: 'visible', token: 1, at: clock.now(), hiddenFor: 2000, long: false,
});
assert.equal(token, 0);
assert.equal(controller.state, 'steady');
assert.equal(updates, baselineUpdates, 'quick return schedules no render');
controller.visibility({
kind: 'visible', token: 2, at: clock.now(), hiddenFor: 20_000, long: true,
});
assert.equal(controller.state, 'holding');
clock.advance(1000);
assert.equal(controller.overlayVisible, false);
assert.equal(controller.frameFingerprint, 'complete');
});
test('state-only frame identity refreshes without scheduling another render', () => {
const clock = new FakeClock();
let updates = 0;
const controller = new VisualContinuityController(() => updates++, clock);
controller.markCompleteFrame('state-1');
const baselineUpdates = updates;
controller.refreshCompleteFrame('state-2');
assert.equal(controller.frameFingerprint, 'state-2');
assert.equal(updates, baselineUpdates);
});
test('a candidate commits only after two paint opportunities', async () => {
const clock = new FakeClock();
const controller = new VisualContinuityController(() => undefined, clock);
controller.markCompleteFrame('old');
const token = controller.beginCandidate('resume');
const result = commit(controller, clock, token, 'new');
assert.equal(await result, true);
assert.equal(controller.state, 'steady');
assert.equal(controller.frameFingerprint, 'new');
});
test('overlay is delayed, never covers a complete stale frame, and fades after commit', async () => {
const clock = new FakeClock();
const controller = new VisualContinuityController(() => undefined, clock);
const token = controller.beginCandidate('cold-recovery', 'connection');
assert.equal(controller.overlayVisible, false);
clock.advance(149);
assert.equal(controller.overlayVisible, false);
clock.advance(1);
assert.equal(controller.overlayPhase, 'entering');
clock.frame();
assert.equal(controller.overlayPhase, 'fading-in');
clock.advance(150);
assert.equal(controller.overlayPhase, 'opaque');
assert.equal(await commit(controller, clock, token), true);
clock.advance(250);
assert.equal(controller.overlayPhase, 'leaving');
clock.advance(180);
assert.equal(controller.state, 'steady');
assert.equal(controller.overlayVisible, false);
});
test('a candidate that wins during fade-in cancels the overlay without a minimum hold', async () => {
const clock = new FakeClock();
const controller = new VisualContinuityController(() => undefined, clock);
const token = controller.beginCandidate('cold-recovery');
clock.advance(150);
clock.frame();
assert.equal(controller.overlayPhase, 'fading-in');
assert.equal(await commit(controller, clock, token), true);
assert.equal(controller.state, 'steady');
assert.equal(controller.overlayVisible, false);
});
test('a stale recovery token can never commit', async () => {
const clock = new FakeClock();
const controller = new VisualContinuityController(() => undefined, clock);
controller.markCompleteFrame('old');
const stale = controller.beginCandidate('first');
controller.beginCandidate('second');
assert.equal(controller.candidateReady(stale), false);
assert.equal(await controller.commitAfterPaint(stale, {
updateComplete: async () => undefined,
stageValid: () => true,
assetsReady: () => true,
frameFingerprint: () => 'stale',
}), false);
assert.equal(controller.frameFingerprint, 'old');
});
test('asset readiness is polled inside the bounded barrier and keeps a stale frame on timeout', async () => {
const clock = new FakeClock();
const controller = new VisualContinuityController(() => undefined, clock);
controller.markCompleteFrame('old');
const token = controller.beginCandidate('asset-refresh', 'asset');
controller.candidateReady(token);
const committed = controller.commitAfterPaint(token, {
updateComplete: async () => undefined,
stageValid: () => true,
assetsReady: () => false,
frameFingerprint: () => 'bad',
});
await Promise.resolve();
clock.advance(PAINT_BARRIER_MAX_MS);
assert.equal(await committed, false);
assert.equal(controller.state, 'steady');
assert.equal(controller.frameFingerprint, 'old');
assert.equal(controller.trace.at(-1)?.event, 'paint-barrier-timeout');
});
test('a real paint timeout without a complete frame becomes a recoverable error', async () => {
const clock = new FakeClock();
const controller = new VisualContinuityController(() => undefined, clock);
const token = controller.beginCandidate('cold-recovery');
controller.candidateReady(token);
const result = controller.commitAfterPaint(token, {
updateComplete: async () => undefined,
stageValid: () => true,
assetsReady: () => true,
frameFingerprint: () => 'late',
});
await Promise.resolve();
clock.advance(PAINT_BARRIER_MAX_MS);
assert.equal(await result, false);
assert.equal(controller.state, 'recovery-error');
assert.equal(controller.overlayPhase, 'opaque');
});
test('content fingerprints are stable across object key order and frame parts', () => {
assert.equal(contentFingerprint({ b: 2, a: 1 }), contentFingerprint({ a: 1, b: 2 }));
assert.notEqual(contentFingerprint({ a: 1 }), contentFingerprint({ a: 2 }));
assert.equal(visualFrameFingerprint([1, 'x']), visualFrameFingerprint([1, 'x']));
const shared = { value: 1 };
assert.equal(
contentFingerprint({ a: shared, b: shared }),
contentFingerprint({ a: { value: 1 }, b: { value: 1 } }),
'a shared acyclic reference is hashed by value, not misclassified as a cycle',
);
});
test('trace diagnostics are returned as a defensive copy', () => {
const clock = new FakeClock();
const controller = new VisualContinuityController(() => undefined, clock);
controller.note('sample', { stage: [10, 20] });
const trace = controller.trace;
trace[0].event = 'mutated';
trace[0].stage[0] = 999;
assert.equal(controller.trace[0].event, 'sample');
assert.deepEqual(controller.trace[0].stage, [10, 20]);
});