Files
houseplan-card/test/viewport-transition.test.mjs
T
Codex 8f485c0b00 fix: the camera keeps the zoom you see and the point you hold (#396)
Three findings of the v1.70.0-beta.1 audit, all on the transition path
added by #82, all of the same shape — the new path did not inherit a
property the old one had.

B1: persisting the zoom moved into _settleCameraTransition only, and a
cancellation never settles. Touching the plan mid-flight — the literal
scenario of the issue — froze the shown frame and threw it away; before
which kind it is: the user one (_stagePointerDown) persists the frame
that stays on screen, the eleven structural ones keep writing nothing.
The distinction is now also written down in spec #82 §13, which had one
line for both.

B2: the anchor was read from the presented (lagging) frame while the
zoom accumulated from the target, so a six-notch trackpad series walked
the point under the cursor 17 px away — against §10's own promise. Both
now come from the same state. Spec §10 said to use the presented frame
and to keep the anchor within 0.5 px; those two are incompatible, and
the paragraph is corrected rather than left as a trap.

M2: the feather freeze keyed on the two gesture flags, which an
animated transition does not set, so every tween frame rebuilt the blur
region. It keys on 'the camera is still' now.

Guards: unit tests pin the anchor at 1e-9 across 8/16/33 ms series and
prove zoom accumulation is untouched; the smoke checks the shown zoom is
the stored one, that a structural cancellation stores nothing, and that
the anchor holds; three mutants (cancel-loses-zoom, anchor-from-
presented, feather-thaws) were run by hand and each reddens.

User-Visible: yes
Issue: #396
2026-08-31 01:36:59 +03:00

232 lines
9.4 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);
});
// --- #396 ---------------------------------------------------------------
// Три находки аудита v1.70.0-beta.1 на одном пути камеры. Проверяется то же,
// что и в бою: серия колеса поверх незакончившегося перехода и обрыв этого
// перехода касанием плана.
const WHEEL = 1.15;
const FIT = { x: 0, y: 0, w: 1000, h: 1000 };
const STAGE = { w: 800, h: 800 };
const ANCHOR = { x: 600, y: 400 };
/** Мировая точка под указателем для данного viewport. */
const worldUnderPointer = (viewBox) => ({
x: viewBox.x + (ANCHOR.x / STAGE.w) * viewBox.w,
y: viewBox.y + (ANCHOR.y / STAGE.h) * viewBox.h,
});
/** Одна серия из `count` нотчей с интервалом `gapMs`, как её ведёт карта:
* масштаб копится от цели, а якорь берётся из состояния `anchorFrom`. */
function wheelSeries({ count, gapMs, anchorFromTarget }) {
const harness = fakeClock();
let presented = camera(1, ...Object.values(FIT));
let target = null;
const controller = new CameraTransitionController(
{ frame: (state) => { presented = state.presented; }, settled: () => {} },
harness.clock,
);
for (let i = 0; i < count; i++) {
const base = target ?? presented;
const from = anchorFromTarget ? (target ?? presented) : presented;
const next = cameraTargetAtAnchor(
from, base.zoom * WHEEL, FIT, STAGE.w, STAGE.h, ANCHOR.x, ANCHOR.y,
);
controller.start(presented, next, 'wheel', 220);
target = next;
harness.step((i + 1) * gapMs);
}
return { target, presented };
}
test('#396 AC3: a fast wheel series keeps the world point under the pointer', () => {
const start = worldUnderPointer(FIT);
for (const gapMs of [8, 16, 33]) {
const { target } = wheelSeries({ count: 6, gapMs, anchorFromTarget: true });
const moved = worldUnderPointer(target.viewBox);
const drift = Math.hypot(moved.x - start.x, moved.y - start.y);
assert.ok(drift < 1e-9,
`anchor must not walk at ${gapMs} ms between notches, drifted ${drift}`);
}
// Контроль: якорь от отстающего кадра — это и есть дефект B2, он обязан
// давать заметный увод, иначе тест выше ничего не доказывает.
const { target } = wheelSeries({ count: 6, gapMs: 16, anchorFromTarget: false });
const moved = worldUnderPointer(target.viewBox);
const lagging = Math.hypot(moved.x - start.x, moved.y - start.y);
assert.ok(lagging > 1, `the lagging-frame anchor should drift, got ${lagging}`);
});
test('#396 AC4: fixing the anchor does not change zoom accumulation', () => {
for (const anchorFromTarget of [true, false]) {
const { target } = wheelSeries({ count: 6, gapMs: 8, anchorFromTarget });
assert.ok(Math.abs(target.zoom - WHEEL ** 6) < 1e-12,
`six notches must accumulate to 1.15^6, got ${target.zoom}`);
}
});
test('#396 AC1/AC2: only a user cancellation may persist the shown frame', () => {
// Контроллер сам ничего не сохраняет — решение принимает вызывающий по
// `presented`. Здесь фиксируется то, на что он опирается: после обрыва
// показанный кадр известен и лежит между началом и целью.
const harness = fakeClock();
let presented = null;
let settledCalls = 0;
const controller = new CameraTransitionController(
{ frame: (state) => { presented = state.presented; },
settled: () => { settledCalls++; } },
harness.clock,
);
controller.start(camera(1, 0, 0, 1000, 1000), camera(1.15, 0, 0, 870, 870),
'wheel', 220);
harness.step(120);
const shown = presented.zoom;
assert.ok(shown > 1 && shown < 1.15, 'the frame is mid-flight');
assert.equal(controller.active, true);
controller.cancel(false);
assert.equal(settledCalls, 0, 'a frozen cancellation does not settle');
assert.equal(controller.active, false);
assert.equal(controller.presented, null, 'state is dropped, the frame is the caller\'s');
// И обратный случай: коммит цели проходит через settled, как и раньше.
controller.start(camera(1, 0, 0, 1000, 1000), camera(1.15, 0, 0, 870, 870),
'wheel', 220);
harness.step(400);
assert.equal(settledCalls, 1, 'a completed transition settles exactly once');
});