mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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
This commit is contained in:
@@ -200,6 +200,77 @@ const out = await page.evaluate(async () => {
|
||||
|
||||
const resources = performance.getEntriesByType('resource')
|
||||
.map((entry) => new URL(entry.name).pathname);
|
||||
// #396 идёт последним намеренно: проверки выше считают saveCount
|
||||
// абсолютным значением, а пользовательская отмена теперь тоже пишет.
|
||||
// #396: interrupted zoom, structural cancellation, fast wheel anchor.
|
||||
const spaceOf = () => card._space;
|
||||
const storedZoom = () => {
|
||||
try {
|
||||
return (JSON.parse(localStorage.getItem('houseplan_card_zoom_v1') || '{}') || {})[spaceOf()];
|
||||
} catch { return undefined; }
|
||||
};
|
||||
const wheelAt = (x, y, deltaY) => stage().dispatchEvent(new WheelEvent('wheel', {
|
||||
clientX: x, clientY: y, deltaY, bubbles: true, cancelable: true, composed: true,
|
||||
}));
|
||||
const pointerDownAt = (x, y) => stage().dispatchEvent(new PointerEvent('pointerdown', {
|
||||
clientX: x, clientY: y, pointerId: 1, isPrimary: true, button: 0,
|
||||
bubbles: true, cancelable: true, composed: true,
|
||||
}));
|
||||
|
||||
directBaseline(1);
|
||||
await settle();
|
||||
const stageRect = () => stage().getBoundingClientRect();
|
||||
// (1) wheel, then touch the plan mid-flight: the shown zoom must be stored.
|
||||
wheelAt(stageRect().left + 600, stageRect().top + 400, -100);
|
||||
await frame();
|
||||
await frame();
|
||||
const interruptedShown0 = card._zoom;
|
||||
pointerDownAt(stageRect().left + 200, stageRect().top + 200);
|
||||
await card.updateComplete;
|
||||
const interruptedShown = card._zoom;
|
||||
const interruptedStored = storedZoom();
|
||||
const interruptedFroze = !card._cameraTransition.active
|
||||
&& close(interruptedShown, interruptedShown0, 1e-9);
|
||||
|
||||
// (2) structural cancellation writes nothing.
|
||||
directBaseline(1);
|
||||
await settle();
|
||||
const structuralStoredBefore = storedZoom();
|
||||
wheelAt(stageRect().left + 600, stageRect().top + 400, -100);
|
||||
await frame();
|
||||
await frame();
|
||||
const structuralSavesBefore = saveCount;
|
||||
card._cancelCameraTransition(false);
|
||||
await card.updateComplete;
|
||||
const structuralSaves = saveCount - structuralSavesBefore;
|
||||
const structuralStored = storedZoom();
|
||||
|
||||
// (3) six notches, one animation frame apart: the anchor must stay put.
|
||||
directBaseline(1);
|
||||
await settle();
|
||||
const anchorScreen = { x: stageRect().left + 600, y: stageRect().top + 400 };
|
||||
const worldUnder = () => {
|
||||
const box = card._view;
|
||||
const box2 = { x: box.x, y: box.y, w: box.w, h: box.h };
|
||||
const r = stageRect();
|
||||
return {
|
||||
x: box2.x + ((anchorScreen.x - r.left) / r.width) * box2.w,
|
||||
y: box2.y + ((anchorScreen.y - r.top) / r.height) * box2.h,
|
||||
};
|
||||
};
|
||||
const anchorBefore = worldUnder();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
wheelAt(anchorScreen.x, anchorScreen.y, -100);
|
||||
await frame();
|
||||
}
|
||||
await settle();
|
||||
const anchorAfter = worldUnder();
|
||||
const anchorDriftUnits = Math.hypot(anchorAfter.x - anchorBefore.x,
|
||||
anchorAfter.y - anchorBefore.y);
|
||||
const anchorDriftPx = anchorDriftUnits * (stageRect().width / card._view.w);
|
||||
directBaseline(1);
|
||||
await settle();
|
||||
|
||||
return {
|
||||
buttonHasIntermediateFrame: buttonSamples.some((sample) =>
|
||||
sample.zoom > 1.001 && sample.zoom < buttonExpected.zoom - 0.001),
|
||||
@@ -241,7 +312,18 @@ const out = await page.evaluate(async () => {
|
||||
glowAndStructuralFrameStayStable: JSON.stringify(glowAfter) === JSON.stringify(glowBefore),
|
||||
coldViewDoesNotLoadEditorRuntime: resources.every((path) =>
|
||||
!/houseplan-editor-runtime-/.test(path)),
|
||||
diagnostics: { projectionCancelled, isoExpected, isoFinal, glowBefore, glowAfter },
|
||||
// #396 AC1: touching the plan freezes the animated frame — and the frozen
|
||||
// frame is what the user sees, so it must be what gets persisted.
|
||||
userCancelPersistsTheShownZoom: interruptedShown > 1.001
|
||||
&& close(interruptedStored, interruptedShown, 1e-6),
|
||||
// #396 AC2: a structural cancellation writes nothing.
|
||||
structuralCancelPersistsNothing: structuralSaves === 0
|
||||
&& close(structuralStored, structuralStoredBefore, 1e-9),
|
||||
// #396 AC3: the anchor under the pointer survives a fast wheel series.
|
||||
fastWheelKeepsTheAnchor: anchorDriftPx < 0.5,
|
||||
diagnostics: { projectionCancelled, isoExpected, isoFinal, glowBefore, glowAfter,
|
||||
interruptedShown, interruptedStored, interruptedFroze, structuralSaves,
|
||||
structuralStored, structuralStoredBefore, anchorDriftPx },
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
instead of the browser prompt. Device, draft, plan and space deletion plus
|
||||
lock opening share clear consequences, safe Cancel/X/Escape behaviour and
|
||||
stale-target protection ([#32](https://github.com/Matysh/houseplan-card/issues/32)).
|
||||
- Zooming feels honest again: the zoom you see when you tap the plan
|
||||
mid-animation is the zoom that gets remembered, and a fast trackpad series
|
||||
keeps the point under the cursor exactly where it was
|
||||
([#396](https://github.com/Matysh/houseplan-card/issues/396)).
|
||||
|
||||
## v1.70.0-beta.1 — 2026-08-30
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@
|
||||
файла плана и пространства, а также открытие замка получили понятные
|
||||
последствия, безопасные «Отмена»/X/`Esc` и защиту от уже изменившейся цели
|
||||
([#32](https://github.com/Matysh/houseplan-card/issues/32)).
|
||||
- Зум снова честный: масштаб, который вы видите в момент касания плана,
|
||||
и запоминается, а быстрая серия колеса или трекпада удерживает точку под
|
||||
курсором ровно на месте
|
||||
([#396](https://github.com/Matysh/houseplan-card/issues/396)).
|
||||
|
||||
## v1.70.0-beta.1 — 2026-08-30
|
||||
|
||||
|
||||
@@ -169,14 +169,21 @@ targets и создаёт финальный commit jump.
|
||||
|
||||
Каждое новое wheel-событие:
|
||||
|
||||
1. берёт реально представленный camera state running tween;
|
||||
1. берёт реально представленный camera state running tween как точку старта
|
||||
анимации;
|
||||
2. накапливает zoom от предыдущего target, а не от запаздывающего кадра;
|
||||
3. вычисляет world-point под актуальным pointer в представленном viewport;
|
||||
3. вычисляет world-point под актуальным pointer **в целевом viewport running
|
||||
tween** — из того же состояния, из которого взят zoom в п.2 (при отсутствии
|
||||
running tween целевое и представленное совпадают). Правка #396: чтение
|
||||
точки из представленного кадра уводило anchor на 14–16 CSS px при интервале
|
||||
между событиями 8–33 мс, то есть на обычном трекпаде, и противоречило
|
||||
порогу ниже;
|
||||
4. строит новый target так, чтобы этот point остался под pointer;
|
||||
5. заменяет transition без queue и второго RAF;
|
||||
6. допускает немедленный разворот направления.
|
||||
|
||||
Без clamp anchor остаётся на месте с ошибкой не более 0.5 CSS px. При clamp
|
||||
Без clamp anchor остаётся на месте точно — расхождение мировой точки не
|
||||
превышает 1e-9 единиц плана (плавающая погрешность), а не «0.5 CSS px». При clamp
|
||||
смещение допускается только на ограниченной оси. `deltaMode` нормализуется так,
|
||||
чтобы line/page wheel не создавал многосекундную очередь; текущий один event =
|
||||
один factor остаётся совместимым.
|
||||
@@ -224,7 +231,16 @@ graph, wall model, projection cache или Glow source set. Нельзя вре
|
||||
- Retargetable wheel stream также даёт одну финальную запись.
|
||||
- Editor zoom не записывается в View intent.
|
||||
- Формат `LS_ZOOM` и warm viewport memo не меняется.
|
||||
- Structural cancellation не сохраняет stale camera target.
|
||||
- Отмена перехода бывает двух видов, и они пишут по-разному (уточнено #396;
|
||||
прежняя единственная строка про «stale target» их не различала, из-за чего
|
||||
прерванный зум терялся):
|
||||
- **пользовательская** — `_stagePointerDown`, то есть касание плана поверх
|
||||
собственного зума. Представленный кадр замораживается и остаётся на
|
||||
экране, значит он и есть текущее намерение: **сохраняется**;
|
||||
- **структурная** — смена пространства/режима/проекции, `_applyView`,
|
||||
resize, adoption конфига или layout, `_restoreZoom`, continuity recovery,
|
||||
disconnect. Вид заменяется целиком другим контрактом: **не сохраняется
|
||||
ничего**, цель устарела вместе с видом.
|
||||
- Immediate pinch/pan используют существующий gesture persistence contract;
|
||||
его отдельная оптимизация не входит в #82.
|
||||
- No-op не переписывает localStorage.
|
||||
|
||||
@@ -746,6 +746,39 @@ const MUTANT_DEFINITIONS = [
|
||||
replace: " this._persistDecorStyle();\n }, 0);",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'camera-cancel-loses-zoom',
|
||||
guard: 'node demo/smoke_smooth_zoom.mjs',
|
||||
because: 'the frame frozen by touching the plan is what the user sees; not '
|
||||
+ 'persisting it is the v1.69.0 regression #396 B1 closes',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' this._cancelCameraTransition(false, true);',
|
||||
replace: ' this._cancelCameraTransition(false);',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'camera-anchor-from-presented',
|
||||
guard: 'node demo/smoke_smooth_zoom.mjs',
|
||||
because: 'reading the anchor from the lagging frame walks the point under '
|
||||
+ 'the pointer 17 px away in a fast wheel series (#396 B2)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' const anchorFrom = (animated && this._cameraTransition.target)',
|
||||
replace: ' const anchorFrom = (false && this._cameraTransition.target)',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'glow-feather-thaws-during-camera',
|
||||
guard: 'node --test test/golden-matrix.test.mjs',
|
||||
because: 'the feather freeze must know about the animated transition, not '
|
||||
+ 'only about pinch and pan (#396 M2)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' && !this._cameraTransition.active;',
|
||||
replace: ' && true;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'error-code-dropped-from-contract',
|
||||
guard: 'python3 -m pytest tests_backend/test_backend_quality.py -q -p no:cacheprovider',
|
||||
|
||||
+33
-5
@@ -1245,9 +1245,22 @@ export class HouseplanCard extends LitElement {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private _cancelCameraTransition(commitTarget = false): void {
|
||||
/** Cancel a running camera transition.
|
||||
*
|
||||
* #396: two cancellations look alike and mean opposite things. A STRUCTURAL
|
||||
* one (space/mode change, adopt, resize, restore, disconnect) replaces the
|
||||
* view wholesale — its stale target must not be written anywhere. A USER
|
||||
* one (touching the plan on top of one's own zoom) freezes the presented
|
||||
* frame and leaves it on screen: that frame IS the current intent, and
|
||||
* before #82 the zoom commands persisted it synchronously. Keeping both on
|
||||
* one branch is what lost the interrupted zoom. */
|
||||
private _cancelCameraTransition(commitTarget = false, keepPresented = false): void {
|
||||
const presentedZoom = keepPresented && this._cameraTransition.active
|
||||
? this._cameraTransition.presented?.zoom
|
||||
: undefined;
|
||||
this._cameraTransition.cancel(commitTarget);
|
||||
this._cameraTransitionFit = null;
|
||||
if (presentedZoom !== undefined) this._saveZoom();
|
||||
}
|
||||
|
||||
/** A discrete camera command may follow a rapid mode click. Finish that
|
||||
@@ -6286,14 +6299,21 @@ export class HouseplanCard extends LitElement {
|
||||
sx: number,
|
||||
sy: number,
|
||||
newZoom: number,
|
||||
animated = false,
|
||||
): { target: CameraState; fit: ModeViewBox } | null {
|
||||
const stage = this._stageEl;
|
||||
if (!stage || stage.clientWidth <= 0 || stage.clientHeight <= 0) return null;
|
||||
const vb = this._baseVb();
|
||||
const fit = fitView(vb, this._stageAspect());
|
||||
const z = Math.min(HouseplanCard.ZOOM_MAX, Math.max(HouseplanCard.ZOOM_MIN, newZoom));
|
||||
// #396 AC3: the world point under the pointer is read from the SAME state
|
||||
// the zoom is accumulated from — the running target. Reading it from the
|
||||
// lagging presented frame made a fast trackpad series walk the anchor
|
||||
// 14-16 CSS px away, while spec #82 §10 promises it stays put.
|
||||
const anchorFrom = (animated && this._cameraTransition.target)
|
||||
|| this._cameraState();
|
||||
const target = cameraTargetAtAnchor(
|
||||
this._cameraState(), z, fit,
|
||||
anchorFrom, z, fit,
|
||||
stage.clientWidth, stage.clientHeight, sx, sy,
|
||||
);
|
||||
if (!target) return null;
|
||||
@@ -6323,7 +6343,7 @@ export class HouseplanCard extends LitElement {
|
||||
const factor = ev.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||||
const baseZoom = this._cameraTransition.target?.zoom ?? this._zoom;
|
||||
const result = this._cameraTargetAt(
|
||||
ev.clientX - r.left, ev.clientY - r.top, baseZoom * factor,
|
||||
ev.clientX - r.left, ev.clientY - r.top, baseZoom * factor, true,
|
||||
);
|
||||
if (result) this._startCameraTransition(
|
||||
result.target, result.fit, 'wheel', CAMERA_WHEEL_MS,
|
||||
@@ -6339,6 +6359,7 @@ export class HouseplanCard extends LitElement {
|
||||
stage.clientWidth / 2,
|
||||
stage.clientHeight / 2,
|
||||
baseZoom * (delta > 0 ? 1.4 : 1 / 1.4),
|
||||
true,
|
||||
);
|
||||
if (result) this._startCameraTransition(
|
||||
result.target, result.fit, 'button', CAMERA_BUTTON_MS,
|
||||
@@ -6407,7 +6428,9 @@ export class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _stagePointerDown(ev: PointerEvent): void {
|
||||
this._cancelCameraTransition(false);
|
||||
// The gesture that starts here freezes the animated frame and keeps it on
|
||||
// screen — so the shown zoom becomes the saved one (#396 AC1).
|
||||
this._cancelCameraTransition(false, true);
|
||||
if (this._vacFit) return; // no pan/swipe while fitting the robot map
|
||||
// The shared secondary controller owns palette dismissal in window
|
||||
// capture, including the matching synthetic click. Do not duplicate that
|
||||
@@ -10868,8 +10891,13 @@ export class HouseplanCard extends LitElement {
|
||||
const perUnit = this._stageEl?.clientWidth && view.w
|
||||
? this._stageEl.clientWidth / view.w
|
||||
: 1;
|
||||
// #396: the freeze must key on "the camera is moving", not on the two
|
||||
// gesture flags. An animated transition sets neither, so every tween frame
|
||||
// rebuilt the blur region — the very cost this gate was added to avoid.
|
||||
const cameraStill = !this._pinchStart && !this._panStart
|
||||
&& !this._cameraTransition.active;
|
||||
const feather = resolveGlowFeather(
|
||||
this._glowRuntimeState, perUnit, !this._pinchStart && !this._panStart,
|
||||
this._glowRuntimeState, perUnit, cameraStill,
|
||||
);
|
||||
return renderGlowPools({
|
||||
spots,
|
||||
|
||||
@@ -799,8 +799,15 @@ test('a light source paints exactly one region: the floor it can see', () => {
|
||||
assert.equal((glow.match(/<feGaussianBlur/g) || []).length, 1);
|
||||
assert.doesNotMatch(glow, /<mask /);
|
||||
assert.match(glow, /const next = GLOW_EDGE_FEATHER_PX \/ 2 \/ \(perUnit > 0 \? perUnit : 1\)/);
|
||||
// #396: the freeze keys on "the camera is still", not on the two gesture
|
||||
// flags. An animated transition sets neither, so every tween frame rebuilt
|
||||
// the blur region — the cost this gate exists to avoid. Both the predicate
|
||||
// and its use are pinned: a predicate that stops reading the transition is
|
||||
// the regression, and so is a call that stops reading the predicate.
|
||||
assert.match(source,
|
||||
/resolveGlowFeather\(\s*this\._glowRuntimeState, perUnit, !this\._pinchStart && !this\._panStart/);
|
||||
/const cameraStill = !this\._pinchStart && !this\._panStart\s*\n\s*&& !this\._cameraTransition\.active;/);
|
||||
assert.match(source,
|
||||
/resolveGlowFeather\(\s*this\._glowRuntimeState, perUnit, cameraStill,/);
|
||||
assert.match(glow,
|
||||
/filter=\$\{input\.featherEnabled \? 'url\(#hp-glowfeather\)' : nothing\}/,
|
||||
'the expensive whole-layer filter must be bypassed during a viewport gesture/transition');
|
||||
|
||||
@@ -136,3 +136,96 @@ test('invalid input and missing RAF settle safely without a loop', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user