mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
141 lines
5.4 KiB
JavaScript
141 lines
5.4 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
ModeTransitionController,
|
|
interpolateModeVisualState,
|
|
viewBoxFromViewport,
|
|
viewportFromViewBox,
|
|
} from '../test-build/mode-transition.js';
|
|
|
|
const endpoint = (mode, chrome, stageHeight, viewBox, opacity = mode === 'view' ? 0 : 1) => ({
|
|
presentedMode: mode,
|
|
editorChromeHeight: chrome,
|
|
stageWidth: 800,
|
|
stageHeight,
|
|
viewport: viewportFromViewBox(viewBox, 800),
|
|
stageColor: mode === 'view' ? 'rgb(20, 30, 40)' : 'rgb(255, 255, 255)',
|
|
paperColor: mode === 'view' ? '#202830' : '#ffffff',
|
|
sceneBrightness: mode === 'view' ? 0.9 : 1,
|
|
architectureOpacity: mode === 'decor' ? 0.35 : 1,
|
|
backdropOpacity: mode === 'decor' ? 0.5 : 1,
|
|
viewWeight: mode === 'view' ? 1 : 0,
|
|
editorWeight: mode === 'view' ? 0 : 1,
|
|
toolbarContentOpacity: opacity,
|
|
});
|
|
|
|
test('camera round-trip preserves center and uses the current stage aspect', () => {
|
|
const view = { x: 10, y: 20, w: 400, h: 300 };
|
|
const viewport = viewportFromViewBox(view, 800);
|
|
const roundTrip = viewBoxFromViewport(viewport, 800, 600);
|
|
assert.deepEqual(roundTrip, view);
|
|
const narrow = viewBoxFromViewport(viewport, 400, 600);
|
|
assert.equal(narrow.x + narrow.w / 2, viewport.centerX);
|
|
assert.equal(narrow.y + narrow.h / 2, viewport.centerY);
|
|
assert.equal(narrow.w / narrow.h, 400 / 600);
|
|
});
|
|
|
|
test('one interpolation owns chrome, stage, camera, colors and content fade', () => {
|
|
const from = endpoint('view', 0, 600, { x: 0, y: 0, w: 800, h: 600 });
|
|
const to = endpoint('plan', 120, 480, { x: 100, y: 80, w: 600, h: 360 });
|
|
const mid = interpolateModeVisualState(from, to, 0.5);
|
|
assert.ok(mid.editorChromeHeight > 0 && mid.editorChromeHeight < 120);
|
|
assert.ok(mid.editorChromeHeight > 108,
|
|
'the shared timeline follows cubic-bezier(0.2, 0.7, 0.2, 1), not a generic easing');
|
|
assert.ok(mid.stageHeight > 480 && mid.stageHeight < 600);
|
|
assert.equal(mid.viewport.viewBox.w / mid.viewport.viewBox.h, mid.stageWidth / mid.stageHeight);
|
|
assert.ok(mid.sceneBrightness > 0.9 && mid.sceneBrightness < 1);
|
|
assert.ok(mid.toolbarContentOpacity > 0 && mid.toolbarContentOpacity < 1);
|
|
assert.match(mid.stageColor, /^rgba\(/);
|
|
});
|
|
|
|
test('controller is deterministic, settles exactly and leaves no scheduled frame', () => {
|
|
let now = 0;
|
|
let sequence = 0;
|
|
const callbacks = new Map();
|
|
const clock = {
|
|
now: () => now,
|
|
requestFrame: (callback) => { callbacks.set(++sequence, callback); return sequence; },
|
|
cancelFrame: (handle) => callbacks.delete(handle),
|
|
};
|
|
const frames = [];
|
|
let settled = null;
|
|
const controller = new ModeTransitionController({
|
|
frame: (state) => frames.push(state.presented),
|
|
settled: (state) => { settled = state.presented; },
|
|
}, clock);
|
|
const from = endpoint('view', 0, 600, { x: 0, y: 0, w: 800, h: 600 });
|
|
const to = endpoint('devices', 96, 504, { x: 50, y: 30, w: 700, h: 441 });
|
|
controller.start(from, to, 'devices', 100);
|
|
assert.equal(controller.active, true);
|
|
const step = (time) => {
|
|
now = time;
|
|
const [id, callback] = callbacks.entries().next().value;
|
|
callbacks.delete(id);
|
|
callback(time);
|
|
};
|
|
step(50);
|
|
assert.ok(controller.presented.stageHeight > 504 && controller.presented.stageHeight < 600);
|
|
step(100);
|
|
assert.deepEqual(settled, to);
|
|
assert.equal(controller.active, false);
|
|
assert.equal(callbacks.size, 0);
|
|
assert.ok(frames.length >= 3);
|
|
});
|
|
|
|
test('zero-duration and interrupted transitions commit one exact target', () => {
|
|
let sequence = 0;
|
|
const callbacks = new Map();
|
|
const clock = {
|
|
now: () => 0,
|
|
requestFrame: (callback) => { callbacks.set(++sequence, callback); return sequence; },
|
|
cancelFrame: (handle) => callbacks.delete(handle),
|
|
};
|
|
const settled = [];
|
|
const controller = new ModeTransitionController({
|
|
frame: () => {},
|
|
settled: (state) => settled.push(state.presented),
|
|
}, clock);
|
|
const view = endpoint('view', 0, 600, { x: 0, y: 0, w: 800, h: 600 });
|
|
const plan = endpoint('plan', 120, 480, { x: 40, y: 30, w: 720, h: 432 });
|
|
|
|
controller.start(view, plan, 'plan', 0);
|
|
assert.deepEqual(settled.pop(), plan);
|
|
assert.equal(callbacks.size, 0);
|
|
|
|
controller.start(view, plan, 'plan', 220);
|
|
controller.cancel(true);
|
|
assert.deepEqual(settled.pop(), plan);
|
|
assert.equal(controller.active, false);
|
|
assert.equal(callbacks.size, 0);
|
|
});
|
|
|
|
test('retarget starts from the actually presented frame and invalidates old RAF', () => {
|
|
let now = 0;
|
|
let sequence = 0;
|
|
const callbacks = new Map();
|
|
const clock = {
|
|
now: () => now,
|
|
requestFrame: (callback) => { callbacks.set(++sequence, callback); return sequence; },
|
|
cancelFrame: (handle) => callbacks.delete(handle),
|
|
};
|
|
const frames = [];
|
|
const controller = new ModeTransitionController({
|
|
frame: (state) => frames.push(state.presented),
|
|
settled: () => {},
|
|
}, clock);
|
|
const view = endpoint('view', 0, 600, { x: 0, y: 0, w: 800, h: 600 });
|
|
const plan = endpoint('plan', 120, 480, { x: 40, y: 30, w: 720, h: 432 });
|
|
const decor = endpoint('decor', 160, 440, { x: 80, y: 50, w: 640, h: 352 });
|
|
|
|
controller.start(view, plan, 'plan', 100);
|
|
now = 40;
|
|
const [oldHandle, oldFrame] = callbacks.entries().next().value;
|
|
callbacks.delete(oldHandle);
|
|
oldFrame(now);
|
|
const presented = controller.presented;
|
|
controller.start(presented, decor, 'decor', 100);
|
|
|
|
assert.deepEqual(frames.at(-1), presented);
|
|
assert.equal(callbacks.size, 1, 'the previous generation cannot retain a scheduled frame');
|
|
});
|