Files
feuerwerk/tests/editor/view-tween.test.ts
T
adminandClaude Opus 5.5 be41b1f0d7
game-maker / gate (push) Successful in 4m21s
game-maker / suite (push) Canceled after 8m42s
Glide the asset and object lists and scroll past their end (#309)
The asset panel stopped with its last group flush on the bottom edge,
and both lists on the left jumped on every wheel step while the tabs
next to them already zoom with a transition.

- ScrollContainer gets tailSpace, empty room always kept below the
  content. Content that fits only without it scrolls by the rest. The
  content is made that much taller, so clicks and the context menu on
  that room work as on any empty part of the list.
- The asset panel keeps six rows of it. The other lists keep none.
- ScrollContainer gets wheelStep, which by default scrolls at once.
- scrollTween() in viewTween.ts hands the wheel step to an axisTween.
  The duration is the zoom transition's from the General settings,
  quick steps add up and stop at the end of the list, and dragging the
  bar or a jump to a row ends the glide where it stands.
- Only the asset panel and Scene objects use it. Inspector, menus and
  the console still jump.
- Wiki: Concepts describes the glide and the room below the assets.

Not checked in the browser by me. The user picked the six rows and
asked for the glide after trying the room below the list.

Tests:
- Unit scroll-container: 10 tests (3 new for tailSpace).
- Unit view-tween: 4 new with a fake clock (glide lands on target,
  quick steps add up and stop at the end, a jump ends it, 0 ms jumps).
- Gate on melektaus-atx: CHECK EXIT 0, 299 files,
  4593 tests (4586 before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:14:54 +02:00

359 lines
12 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,
easeOut,
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, and finishes by drawing it', () => {
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);
tween.change(double(s));
tween.finish();
expect(s.view.scale).toBe(4);
expect(s.draws).toBe(draws + 1);
expect(clock.attached).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,
});
});
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 });
stop();
keepGeneralSettings({ showEmptyAssetGroups: false });
expect(told).toBe(1);
});
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);
});
});