/** 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, 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(); 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, }); }); 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); }); });