Files
feuerwerk/tests/editor/view-tween.test.ts
T
adminandClaude Opus 5.5 5cb7ef7bed
game-maker / gate (push) Successful in 3m47s
game-maker / suite (push) Successful in 15m1s
Show empty asset groups on request, and add by the heading (#361)
The asset panel only listed the categories and types that hold assets.
To make the first asset of a kind you had to know its category in the
`+` menu. A switch now lists all of them, and a right-click on a
heading offers just what sits under it.

- General settings has "Show empty asset categories and types", off by
  default and kept in this browser. Applying it refreshes the panel at
  once through a new `onGeneralSettings` listener.
- `groupAssets(assets, empty)` gives every category and type when
  asked. The empty ones stand faint with a count of 0, and an empty
  type has no chevron, since there is nothing to unfold.
- `ListItem.faint` draws a row's label, hint and icons at half opacity,
  like a disabled widget.
- A right-click on a category heading offers the types of that
  category without a submenu, on a type heading only that type. The
  empty space below the rows still opens the whole `+` menu.
  `ListView.onListMenu` passes the heading's id for this.
- Every menu of new assets says "Create asset" above its entries, so a
  click on a type reads as making one. `MenuOptions.title` draws that
  muted line. It is not an entry and matches no search.

Tests:
- Unit asset-groups and view-tween: 23 green, 2 new. Counter-test
  with the empty flag ignored: the new groups test fails.
- e2e 04 (melektaus-atx): ALL PASSED, 67 checks, 4 new and 1
  rewritten. Counter-test with the heading id dropped: the 3 menu
  checks fail. The title is checked on the `+` button and on a type
  heading (2 checks extended), the rerun passed with 67 checks.
- Gate (melektaus-atx): CHECK EXIT 0, 272 files, 4212 tests. It ran
  before the menu title. Afterwards only the menu and asset-groups
  units, typecheck and lint ran, all clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 06:51:59 +02:00

241 lines
8.2 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 { easeOut, tweenView, type ViewMap, ViewTween } from '../../src/editor/viewTween';
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('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);
});
});