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