Files
feuerwerk/tests/editor/view-tween.test.ts
T
adminandClaude Opus 5.5 d4fc425233
game-maker / gate (push) Successful in 4m19s
game-maker / suite (push) Failing after 17m35s
Draw the editor's mouse pointer from SVG files, on screen too
The pointer drawn into an editor recording lived as paths in code, so
it could not be looked at or edited by hand. The shapes are now SVG
files, and the editor can show the same pointer on screen, so what is
recorded is what was seen.

- src/core/recording/cursors/ holds one file per shape: arrow, hand,
  open-hand, fist, move, crosshair, text and resize. Resize is turned
  for the vertical and diagonal directions.
- data-hot="x y" on the <svg> is the hotspot in viewBox units. Width
  and height in the file are ignored, and anything before <svg>, such
  as <?xml ...> from a drawing program, is cut off.
- The open hand had six fingers. It has a thumb and four now, the
  middle one longest.
- CURSOR_SIZE = 0.8 scales every pointer the same on screen and in a
  recording, so the files keep their units.
- General settings has "Custom mouse pointer", on by default. It hangs
  the files into Pixi's cursorStyles as CSS cursors with the keyword as
  fallback, so the system still draws the pointer and the canvas does
  not. Off gives Pixi's own values back at once.
- A recording draws the pointer from the same files at its own size,
  so it stays sharp in 4K. It still reads the shape from the canvas
  when that holds a url(...) cursor.

Tests:
- Unit recording and view-tween: 3 new, green. They read viewBox and
  data-hot in an edited form, the boxes of turned shapes, the default
  size and old settings without the switch. Red without data-hot and
  without cutting before <svg>.
- e2e 46i-ii: the canvas cursor is a url(...) ending in default and
  the image loads. Off gives inherit and stores false, on brings the
  file back. Red without useCursorFiles.
- e2e 67e: the recording is still black at the mouse position.
- The README names 2046 e2e checks.
- Gate on melektaus-atx: 304 files, 4702 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:33:57 +02:00

450 lines
16 KiB
TypeScript

/** The zoom transition (#309): the way between two views, the driver that shows it, and its setting. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
generalSettings,
keepGeneralSettings,
onGeneralSettings,
resetGeneralSettings,
zoomTransitionMs,
} from '../../src/editor/generalSettings';
import { zoomedAbout } from '../../src/editor/graph/graphView';
import { localToImage, zoomAbout } from '../../src/editor/texture/sheetView';
import {
axisTween,
carriedSpeed,
easeOut,
glide,
glideSlope,
scrollTween,
tweenView,
type ViewMap,
ViewTween,
} from '../../src/editor/viewTween';
import { ScrollContainer, Spacer, VStack } from '../../src/ui';
function fakeClock() {
let time = 0;
const ticks = new Set<() => void>();
return {
now: () => time,
add: (fn: () => void) => void ticks.add(fn),
remove: (fn: () => void) => void ticks.delete(fn),
advance(ms: number) {
time += ms;
for (const fn of [...ticks]) fn();
},
get attached() {
return ticks.size;
},
};
}
/** A surface with a view and nothing else; `draws` counts what it was asked to draw. */
function surface(start: ViewMap = { scale: 1, x: 0, y: 0 }) {
const s = { view: { ...start }, draws: 0 };
const target = {
read: () => ({ ...s.view }),
write: (map: ViewMap) => void (s.view = { ...map }),
draw: () => void (s.draws += 1),
};
return { s, target };
}
/** The world point shown at a screen point. */
const worldAt = (view: ViewMap, x: number, y: number) => ({
x: (x - view.x) / view.scale,
y: (y - view.y) / view.scale,
});
describe('the way between two views', () => {
it('starts on the first and ends on the second', () => {
const from = { scale: 1, x: 10, y: 20 };
const to = { scale: 3, x: -40, y: 5 };
expect(tweenView(from, to, 0)).toEqual(from);
expect(tweenView(from, to, 1)).toEqual(to);
});
it('keeps the graph point under the pointer under it all the way', () => {
const from = { pan: { x: 120, y: 80 }, zoom: 1 };
const to = zoomedAbout(from, { x: 300, y: 200 }, 2);
const a = { scale: from.zoom, x: from.pan.x, y: from.pan.y };
const b = { scale: to.zoom, x: to.pan.x, y: to.pan.y };
for (const t of [0.1, 0.5, 0.9]) {
const mid = worldAt(tweenView(a, b, t), 300, 200);
expect(mid.x).toBeCloseTo(180, 9);
expect(mid.y).toBeCloseTo(120, 9);
}
});
it('keeps the picture pixel under the pointer under it in the sheet tabs', () => {
const from = { panX: 40, panY: -12, zoom: 4 };
const to = zoomAbout(from, 0.5, 250, 90);
const mid = tweenView(
{ scale: from.zoom, x: from.panX, y: from.panY },
{ scale: to.zoom, x: to.panX, y: to.panY },
0.4,
);
const pixel = localToImage({ panX: mid.x, panY: mid.y, zoom: mid.scale }, 250, 90);
const before = localToImage(from, 250, 90);
expect(pixel.x).toBeCloseTo(before.x, 9);
expect(pixel.y).toBeCloseTo(before.y, 9);
});
it('moves the scale by equal ratios, so halfway from 1 to 4 is 2', () => {
expect(tweenView({ scale: 1, x: 0, y: 0 }, { scale: 4, x: 0, y: 0 }, 0.5).scale).toBeCloseTo(2, 12);
});
it('slides between two views of the same scale', () => {
expect(tweenView({ scale: 2, x: 0, y: 10 }, { scale: 2, x: 100, y: 30 }, 0.25)).toEqual({
scale: 2,
x: 25,
y: 15,
});
});
it('eases out: most of the way early, nothing past the end', () => {
expect(easeOut(0)).toBe(0);
expect(easeOut(1)).toBe(1);
expect(easeOut(2)).toBe(1);
expect(easeOut(0.5)).toBeCloseTo(0.875, 12);
});
});
describe('the transition of a view', () => {
const double = (s: { view: ViewMap }) => () => {
s.view = { scale: s.view.scale * 2, x: s.view.x * 2, y: s.view.y * 2 };
};
it('shows the way from what was on screen to the new view, and lets go at the end', () => {
const clock = fakeClock();
const { s, target } = surface();
const tween = new ViewTween(target, () => 200, clock);
tween.change(double(s));
expect(s.view).toEqual({ scale: 1, x: 0, y: 0 });
expect(tween.moving).toBe(true);
expect(tween.heading).toEqual({ scale: 2, x: 0, y: 0 });
clock.advance(100);
expect(s.view.scale).toBeGreaterThan(1);
expect(s.view.scale).toBeLessThan(2);
clock.advance(100);
expect(s.view).toEqual({ scale: 2, x: 0, y: 0 });
expect(tween.moving).toBe(false);
expect(clock.attached).toBe(0);
});
it('adds quick steps up to exactly as many steps, starting the way from what is shown', () => {
const clock = fakeClock();
const { s, target } = surface();
const tween = new ViewTween(target, () => 200, clock);
tween.change(double(s));
clock.advance(50);
const shown = { ...s.view };
tween.change(double(s));
expect(tween.heading?.scale).toBe(4);
expect(s.view).toEqual(shown);
clock.advance(200);
expect(s.view.scale).toBe(4);
});
it('keeps its pace when a wheel event makes no step', () => {
const clock = fakeClock();
const { s, target } = surface();
const tween = new ViewTween(target, () => 200, clock);
tween.change(double(s));
clock.advance(50);
tween.change(() => {});
clock.advance(150);
expect(tween.moving).toBe(false);
expect(s.view.scale).toBe(2);
});
it('lets go when something else moves the view', () => {
const clock = fakeClock();
const { s, target } = surface();
const tween = new ViewTween(target, () => 200, clock);
tween.change(double(s));
clock.advance(50);
s.view = { scale: 7, x: 1, y: 2 };
clock.advance(16);
expect(s.view).toEqual({ scale: 7, x: 1, y: 2 });
expect(tween.moving).toBe(false);
expect(clock.attached).toBe(0);
});
it('jumps at once at 0 ms', () => {
const clock = fakeClock();
const { s, target } = surface();
const tween = new ViewTween(target, () => 0, clock);
tween.change(double(s));
expect(s.view.scale).toBe(2);
expect(tween.moving).toBe(false);
expect(clock.attached).toBe(0);
});
it('lands on the target without drawing', () => {
const clock = fakeClock();
const { s, target } = surface();
const tween = new ViewTween(target, () => 200, clock);
tween.change(double(s));
clock.advance(50);
const draws = s.draws;
tween.land();
expect(s.view.scale).toBe(2);
expect(s.draws).toBe(draws);
expect(clock.attached).toBe(0);
});
/** How fast the scale moves, in ratio per ms, over the next `ms`. */
const pace = (s: { view: ViewMap }, clock: ReturnType<typeof fakeClock>, ms: number) => {
const before = s.view.scale;
clock.advance(ms);
return Math.log(s.view.scale / before) / ms;
};
it('goes on at the pace it had when a quick step comes in, instead of starting over', () => {
const clock = fakeClock();
const { s, target } = surface();
const tween = new ViewTween(target, () => 200, clock);
tween.change(double(s));
clock.advance(48);
const before = pace(s, clock, 2);
tween.change(double(s));
const after = pace(s, clock, 2);
expect(after / before).toBeGreaterThan(0.9);
expect(after / before).toBeLessThan(1.1);
clock.advance(200);
expect(s.view.scale).toBe(4);
});
it('pans along during a transition: the rest of the way moves with the pan, and it lands moved', () => {
const clock = fakeClock();
const { s, target } = surface();
const tween = new ViewTween(target, () => 200, clock);
tween.change(double(s));
clock.advance(60);
let hold = { x: 0, y: 0 };
tween.target(() => (hold = { x: s.view.x, y: s.view.y }));
expect(hold).toEqual({ x: 0, y: 0 });
const shown = { ...s.view };
tween.carry(() => (s.view = { ...s.view, x: hold.x + 30, y: hold.y - 10 }));
expect(s.view).toEqual({ ...shown, x: shown.x + 30, y: shown.y - 10 });
expect(tween.moving).toBe(true);
clock.advance(16);
expect(tween.moving).toBe(true);
expect(s.view.scale).toBeGreaterThan(shown.scale);
clock.advance(200);
expect(s.view).toEqual({ scale: 2, x: 30, y: -10 });
});
});
describe('the curve of a transition', () => {
it('starts at the speed it is given and settles at the end', () => {
for (const speed of [0, 1.5, 3, 4]) {
expect(glide(0, speed)).toBe(0);
expect(glide(1, speed)).toBe(1);
expect(glideSlope(0, speed)).toBeCloseTo(speed, 12);
expect(glideSlope(1, speed)).toBe(0);
expect((glide(1e-6, speed) - glide(0, speed)) / 1e-6).toBeCloseTo(speed, 4);
}
});
it('never runs back or past its end up to the steepest start', () => {
for (const speed of [0, 2, 4]) {
let last = 0;
for (let i = 1; i <= 100; i++) {
const share = glide(i / 100, speed);
expect(share).toBeGreaterThanOrEqual(last);
expect(share).toBeLessThanOrEqual(1);
last = share;
}
}
});
it('carries the pace of a slide over to a longer slide the same way, and starts at rest against it', () => {
const old = { from: { scale: 1, x: 0, y: 0 }, to: { scale: 1, x: 100, y: 0 } };
const from = tweenView(old.from, old.to, 0.5);
// 0.01 of 100 px per ms is 1 px/ms; over 200 ms on a way of 150 px that is a speed of 4/3.
expect(carriedSpeed(old, 0.5, 0.01, from, { scale: 1, x: 200, y: 0 }, 200)).toBeCloseTo(4 / 3, 6);
expect(carriedSpeed(old, 0.5, 0.01, from, { scale: 1, x: -50, y: 0 }, 200)).toBe(0);
});
});
describe('a view whose axes zoom apart (#377)', () => {
function song(clock: ReturnType<typeof fakeClock>) {
const s = { view: { panX: 40, pxPerTick: 0.02, panY: 10, trackHeight: 52 } };
const time = axisTween(
() => ({ scale: s.view.pxPerTick, offset: s.view.panX }),
({ scale, offset }) => (s.view = { ...s.view, pxPerTick: scale, panX: offset }),
() => {},
() => 200,
clock,
);
const tracks = axisTween(
() => ({ scale: s.view.trackHeight, offset: s.view.panY }),
({ scale, offset }) => (s.view = { ...s.view, trackHeight: scale, panY: offset }),
() => {},
() => 200,
clock,
);
return { s, time, tracks };
}
it('zooms the time about the pointer and leaves the tracks alone', () => {
const clock = fakeClock();
const { s, time, tracks } = song(clock);
const tickAt = (x: number) => (x - s.view.panX) / s.view.pxPerTick;
const held = tickAt(300);
time.change(() => (s.view = { ...s.view, pxPerTick: 0.08, panX: 300 - held * 0.08 }));
clock.advance(80);
expect(s.view.pxPerTick).toBeGreaterThan(0.02);
expect(s.view.pxPerTick).toBeLessThan(0.08);
expect(tickAt(300)).toBeCloseTo(held, 9);
expect(s.view.trackHeight).toBe(52);
expect(s.view.panY).toBe(10);
expect([time.moving, tracks.moving]).toEqual([true, false]);
clock.advance(200);
expect(s.view.pxPerTick).toBe(0.08);
expect(time.moving).toBe(false);
});
it('moves both axes on a Fit, each landing where the jump would', () => {
const clock = fakeClock();
const { s, time, tracks } = song(clock);
const fitted = { panX: 30, pxPerTick: 0.005, panY: 20, trackHeight: 90 };
time.change(() => tracks.change(() => (s.view = { ...fitted })));
clock.advance(100);
expect(s.view.pxPerTick).toBeLessThan(0.02);
expect(s.view.trackHeight).toBeGreaterThan(52);
expect([time.moving, tracks.moving]).toEqual([true, true]);
clock.advance(200);
expect(s.view).toEqual(fitted);
expect(clock.attached).toBe(0);
});
});
describe('a list scrolled by the wheel', () => {
function list(clock: ReturnType<typeof fakeClock>) {
const scroll = new ScrollContainer();
scroll.resize(200, 100);
const rows = new VStack();
for (let i = 0; i < 20; i++) rows.add(new Spacer(20));
scroll.setContent(rows);
const tween = scrollTween(scroll, () => 200, clock);
const wheel = (deltaY: number) => scroll.wheelStep(() => scroll.scrollTo(scroll.offset + deltaY));
return { scroll, tween, wheel };
}
it('glides to where the step leads and lands there', () => {
const clock = fakeClock();
const { scroll, tween, wheel } = list(clock);
wheel(100);
expect(scroll.offset).toBe(0);
clock.advance(80);
expect(scroll.offset).toBeGreaterThan(0);
expect(scroll.offset).toBeLessThan(100);
clock.advance(200);
expect(scroll.offset).toBe(100);
expect(tween.moving).toBe(false);
});
it('adds up quick steps and stops at the end of the list', () => {
const clock = fakeClock();
const { scroll, wheel } = list(clock);
wheel(100);
clock.advance(50);
wheel(100);
clock.advance(400);
expect(scroll.offset).toBe(200);
wheel(500);
clock.advance(400);
expect(scroll.offset).toBe(scroll.maxScroll);
});
it('lets go when the bar or a jump to a row moves the list', () => {
const clock = fakeClock();
const { scroll, tween, wheel } = list(clock);
wheel(100);
clock.advance(50);
scroll.scrollTo(250);
clock.advance(10);
expect(tween.moving).toBe(false);
expect(scroll.offset).toBe(250);
});
it('jumps at once with the transition switched off', () => {
const { scroll } = list(fakeClock());
scrollTween(scroll, () => 0, fakeClock());
scroll.wheelStep(() => scroll.scrollTo(60));
expect(scroll.offset).toBe(60);
});
});
describe('the general settings', () => {
function memoryStore() {
const items = new Map<string, string>();
return {
items,
getItem: (key: string) => items.get(key) ?? null,
setItem: (key: string, value: string) => void items.set(key, value),
};
}
beforeEach(() => resetGeneralSettings());
afterEach(() => {
vi.unstubAllGlobals();
resetGeneralSettings();
});
it('starts at 200 ms where nothing was kept', () => {
vi.stubGlobal('window', { localStorage: memoryStore() });
expect(generalSettings().zoomTransitionMs).toBe(200);
});
it('keeps the transition in the browser and reads it back on a new page', () => {
const store = memoryStore();
vi.stubGlobal('window', { localStorage: store });
keepGeneralSettings({ zoomTransitionMs: 350 });
resetGeneralSettings();
expect(generalSettings().zoomTransitionMs).toBe(350);
expect(JSON.parse(store.items.get('game-maker.general-settings') ?? '')).toEqual({
zoomTransitionMs: 350,
showEmptyAssetGroups: false,
customCursor: true,
});
});
it('hides empty asset groups until switched on, keeps the switch and tells listeners (#361)', () => {
vi.stubGlobal('window', { localStorage: memoryStore() });
expect(generalSettings().showEmptyAssetGroups).toBe(false);
let told = 0;
const stop = onGeneralSettings(() => (told += 1));
keepGeneralSettings({ showEmptyAssetGroups: true });
resetGeneralSettings();
expect(generalSettings()).toEqual({
zoomTransitionMs: 200,
showEmptyAssetGroups: true,
customCursor: true,
});
stop();
keepGeneralSettings({ showEmptyAssetGroups: false });
expect(told).toBe(1);
});
it('shows the custom mouse pointer until switched off, and keeps the switch', () => {
const store = memoryStore();
vi.stubGlobal('window', { localStorage: store });
expect(generalSettings().customCursor).toBe(true);
keepGeneralSettings({ customCursor: false });
resetGeneralSettings();
expect(generalSettings().customCursor).toBe(false);
// Settings kept before the switch existed show it.
store.setItem('game-maker.general-settings', JSON.stringify({ zoomTransitionMs: 100 }));
resetGeneralSettings();
expect(generalSettings().customCursor).toBe(true);
});
it('holds the transition to whole ms from 0 to 1000', () => {
expect([zoomTransitionMs(5000), zoomTransitionMs(-3), zoomTransitionMs(12.6)]).toEqual([1000, 0, 13]);
expect(zoomTransitionMs('fast')).toBe(200);
});
});