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