Files
houseplan-card/test/viewport-transition.test.mjs
T
2026-08-30 16:11:35 +00:00

139 lines
4.9 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
CameraTransitionController,
cameraTargetAtAnchor,
interpolateCameraState,
sameCameraState,
validCameraState,
} from '../test-build/viewport-transition.js';
const camera = (zoom, x, y, w, h) => ({ zoom, viewBox: { x, y, w, h } });
function fakeClock() {
let now = 0;
let sequence = 0;
const callbacks = new Map();
return {
clock: {
now: () => now,
requestFrame: (callback) => { callbacks.set(++sequence, callback); return sequence; },
cancelFrame: (handle) => callbacks.delete(handle),
},
callbacks,
step(time) {
now = time;
const entry = callbacks.entries().next().value;
assert.ok(entry, 'one animation frame is scheduled');
callbacks.delete(entry[0]);
entry[1](time);
},
};
}
test('camera interpolation uses perceptual zoom, linear center and exact endpoints', () => {
const from = camera(1, 0, 0, 800, 600);
const to = camera(4, 500, 350, 200, 150);
assert.deepEqual(interpolateCameraState(from, to, 0), from);
assert.deepEqual(interpolateCameraState(from, to, 1), to);
const mid = interpolateCameraState(from, to, 0.5);
assert.ok(mid.zoom > 1 && mid.zoom < 4);
assert.ok(mid.viewBox.w < 800 && mid.viewBox.w > 200);
assert.equal(mid.viewBox.w / mid.viewBox.h, 4 / 3);
assert.ok(mid.viewBox.x > from.viewBox.x && mid.viewBox.x < to.viewBox.x);
});
test('anchor target preserves the presented world point without clamp', () => {
const from = camera(1, 100, 40, 800, 600);
const fit = { x: 0, y: 0, w: 800, h: 600 };
const target = cameraTargetAtAnchor(from, 2, fit, 800, 600, 120, 450);
assert.ok(target);
const before = [
from.viewBox.x + 120 / 800 * from.viewBox.w,
from.viewBox.y + 450 / 600 * from.viewBox.h,
];
const after = [
target.viewBox.x + 120 / 800 * target.viewBox.w,
target.viewBox.y + 450 / 600 * target.viewBox.h,
];
assert.ok(Math.abs(before[0] - after[0]) < 1e-9);
assert.ok(Math.abs(before[1] - after[1]) < 1e-9);
});
test('controller settles exactly and owns one RAF', () => {
const fake = fakeClock();
const frames = [];
const settled = [];
const controller = new CameraTransitionController({
frame: (state) => frames.push(state.presented),
settled: (state) => settled.push(state.presented),
}, fake.clock);
const from = camera(1, 0, 0, 800, 600);
const to = camera(2, 200, 150, 400, 300);
controller.start(from, to, 'button', 100);
assert.equal(controller.active, true);
assert.equal(fake.callbacks.size, 1);
fake.step(50);
assert.equal(fake.callbacks.size, 1);
assert.ok(frames.at(-1).zoom > 1 && frames.at(-1).zoom < 2);
fake.step(100);
assert.deepEqual(settled, [to]);
assert.equal(controller.active, false);
assert.equal(fake.callbacks.size, 0);
});
test('retarget starts from presented state and cancels the obsolete RAF', () => {
const fake = fakeClock();
const frames = [];
const controller = new CameraTransitionController({
frame: (state) => frames.push(state.presented),
settled: () => {},
}, fake.clock);
const from = camera(1, 0, 0, 800, 600);
const first = camera(2, 200, 150, 400, 300);
const reversed = camera(0.75, -133, -100, 1066, 800);
controller.start(from, first, 'wheel', 100);
fake.step(40);
const presented = controller.presented;
assert.ok(presented);
controller.start(presented, reversed, 'wheel', 100);
assert.deepEqual(frames.at(-1), presented);
assert.equal(fake.callbacks.size, 1);
assert.deepEqual(controller.target, reversed);
});
test('cancel policies either freeze or commit one exact target', () => {
const fake = fakeClock();
const settled = [];
const controller = new CameraTransitionController({
frame: () => {},
settled: (state) => settled.push(state.presented),
}, fake.clock);
const from = camera(1, 0, 0, 800, 600);
const to = camera(2, 200, 150, 400, 300);
controller.start(from, to, 'fit', 100);
controller.cancel(false);
assert.equal(settled.length, 0);
assert.equal(fake.callbacks.size, 0);
controller.start(from, to, 'fit', 100);
controller.cancel(true);
assert.deepEqual(settled, [to]);
assert.equal(fake.callbacks.size, 0);
});
test('invalid input and missing RAF settle safely without a loop', () => {
const from = camera(1, 0, 0, 800, 600);
const to = camera(2, 200, 150, 400, 300);
assert.equal(validCameraState(camera(0, 0, 0, 0, 0)), false);
assert.equal(cameraTargetAtAnchor(from, 2, to.viewBox, 0, 600, 10, 10), null);
const settled = [];
const controller = new CameraTransitionController({
frame: () => {},
settled: (state) => settled.push(state.presented),
}, { now: () => 0, requestFrame: () => null, cancelFrame: () => {} });
controller.start(from, to, 'button', 180);
assert.deepEqual(settled, [to]);
assert.equal(controller.active, false);
assert.equal(sameCameraState(to, { zoom: 2, viewBox: { ...to.viewBox } }), true);
});