mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
218 lines
7.9 KiB
JavaScript
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]);
|
|
});
|