Files
feuerwerk/tests/viewport/view.test.ts
T
adminandClaude Opus 5.5 021a6dcb56
game-maker / gate (push) Successful in 2m58s
game-maker / suite (push) Failing after 8m34s
Zoom the tile tab in the same steps as the scene view
The tile tab zoomed freely, so the pixels of a tile could end up
unevenly wide at odd factors. Scene view, container tab and animation
preview already snap to FREE_ZOOM_STEPS. The tile tab now does the
same, so every pixel editor zooms alike.

- The wheel steps through FREE_ZOOM_STEPS, counted in device pixels.
  The board position is rounded to whole device pixels.
- New wheelSteps() in src/viewport/view.ts turns wheel events into
  steps: one notch is one step, a trackpad gathers 50 px per step.
  FreeViewport uses it too, so the rule exists only once.
- Fit takes the largest step at which the whole board fits. Refitting
  on a new tab or board size stays as before.
- panAndZoom takes an optional zoom rule instead of limits. Graph and
  node board keep zooming freely.
- A saved view from before the steps snaps to the nearest step.
- The cap parameter of frameBoxes from 4027afc has no user anymore.
  It is removed together with its unit test.

Fit rounds down, and below 100 percent the steps are far apart. In the
small e2e window the 48 px board lands at 25 instead of 41 percent,
the 16 px board at 100 instead of 124 percent.

Tests:
- check.sh: 161 files, 2370 tests, exit 0 (-1 cap test,
  +1 wheelSteps test)
- e2e 1-5 and 39: all passed, 169 checks. Three checks in 39 now
  expect steps: Fit on a step with the next one too large, one notch
  goes 0.25 to 0.5, 16 px tiles refit to at least 100 percent.
- Counter check: a free Fit factor fails exactly those three checks
  (zoom 0.413 and 1.2396).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:30:41 +02:00

179 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** The viewport transforms: plain geometry, so pinned here rather than through the browser. */
import { describe, expect, it } from 'vitest';
import {
cameraFactor,
cameraView,
FREE_ZOOM_STEPS,
freeView,
GAME_SCALE_STEPS,
gameScaleFactor,
isInside,
nearestZoomStep,
screenToWorld,
stepFreeZoom,
wheelSteps,
worldBounds,
worldToScreen,
zoomStepBelow,
} from '../../src/viewport/view';
describe('freeView', () => {
it('puts the view centre in the middle of the widget', () => {
const view = freeView(50, -20, 2, 800, 600);
expect(worldToScreen(view, 50, -20)).toEqual({ x: 400, y: 300 });
expect(screenToWorld(view, 400, 300)).toEqual({ x: 50, y: -20 });
});
it('scales distances by the zoom', () => {
const view = freeView(0, 0, 2, 800, 600);
expect(worldToScreen(view, 100, 0)).toEqual({ x: 600, y: 300 });
});
it('round-trips any point', () => {
const view = freeView(13, -7, 0.35, 640, 480);
const back = screenToWorld(view, ...(Object.values(worldToScreen(view, 123, -456)) as [number, number]));
expect(back.x).toBeCloseTo(123, 9);
expect(back.y).toBeCloseTo(-456, 9);
});
it('covers the widget in world units', () => {
const bounds = worldBounds(freeView(0, 0, 2, 800, 600));
expect(bounds).toEqual({ left: -200, top: -150, right: 200, bottom: 150 });
});
});
describe('free view zoom steps under Windows scaling', () => {
it('zoom counts device pixels, so zoom 1 at 125% is one screen pixel per world pixel', () => {
const view = freeView(0, 0, 1, 800, 600, 1.25);
expect(view.scale * 1.25).toBe(1);
expect(freeView(0, 0, 3, 800, 600, 1.25).scale * 1.25).toBeCloseTo(3, 12);
});
it('every step gives each texel a whole number of device pixels, or a whole fraction below 1', () => {
for (const step of FREE_ZOOM_STEPS)
expect(step >= 1 ? Number.isInteger(step) : Number.isInteger(1 / step)).toBe(true);
});
it('the wheel moves one step and stays at the ends', () => {
expect(stepFreeZoom(1, 1)).toBe(2);
expect(stepFreeZoom(2, -1)).toBe(1);
expect(stepFreeZoom(1, -1)).toBe(0.5);
expect(stepFreeZoom(6, 1)).toBe(8);
expect(stepFreeZoom(24, 1)).toBe(24);
expect(stepFreeZoom(0.125, -1)).toBe(0.125);
});
it('fit rounds down to a step, and a saved zoom snaps to the nearest', () => {
expect(zoomStepBelow(2.9)).toBe(2);
expect(zoomStepBelow(0.7)).toBe(0.5);
expect(zoomStepBelow(0.01)).toBe(0.125);
expect(zoomStepBelow(20)).toBe(16);
expect(zoomStepBelow(100)).toBe(24);
expect(nearestZoomStep(1.37)).toBe(1);
expect(nearestZoomStep(0.4)).toBe(0.5);
});
it('a mouse notch is a step, a trackpad gathers travel, and turning drops what was left', () => {
const wheel = wheelSteps();
expect(wheel(-100)).toBe(1);
expect(wheel(100)).toBe(-1);
expect([wheel(-20), wheel(-20), wheel(-20)]).toEqual([0, 0, 1]);
expect(wheel(-40)).toBe(0);
// The 40 up still pending are dropped: 40 down alone are no step yet, 60 are.
expect(wheel(40)).toBe(0);
expect(wheel(20)).toBe(-1);
});
});
describe('cameraView', () => {
const camera = { x: 10, y: 20, props: { width: 640, height: 360 } };
it('fits the camera frame into the widget and centres it', () => {
// 640×360 into 800×600: width is the tighter fit (1.25 vs 1.67), so bars top and bottom.
const view = cameraView(camera, 800, 600);
expect(view.scale).toBeCloseTo(1.25, 9);
expect(view.rect).toEqual({ x: 0, y: 75, w: 800, h: 450 });
// The camera position sits at the centre of the frame, not of the widget.
expect(worldToScreen(view, 10, 20)).toEqual({ x: 400, y: 300 });
});
it('reports the letterbox as outside', () => {
const view = cameraView(camera, 800, 600);
expect(isInside(view, 400, 300)).toBe(true);
expect(isInside(view, 400, 20)).toBe(false); // top bar
expect(isInside(view, 400, 580)).toBe(false); // bottom bar
});
it('draws a fixed step at exactly that many pixels per unit, starting on a whole pixel', () => {
const view = cameraView(camera, 801, 601, 0.5);
expect(view.scale).toBe(0.5);
expect(view.rect).toEqual({ x: 241, y: 211, w: 320, h: 180 });
});
it('cuts a frame larger than the view to the view, keeping the camera in the middle', () => {
const view = cameraView(camera, 800, 600, 2);
expect(view.scale).toBe(2);
expect(view.rect).toEqual({ x: 0, y: 0, w: 800, h: 600 });
expect(worldToScreen(view, 10, 20)).toEqual({ x: 400, y: 300 });
expect(worldBounds(view)).toEqual({ left: -190, top: -130, right: 210, bottom: 170 });
expect(isInside(view, 1, 1)).toBe(true);
});
});
describe('cameraView with a zoomed camera', () => {
const zoomed = { x: 0, y: 0, props: { width: 640, height: 360, zoom: 2 } };
it('100% shows the camera picture pixel for pixel, the zoom magnifies the world on top', () => {
const view = cameraView(zoomed, 800, 600, 1);
expect(view.scale).toBe(2);
expect(view.rect).toEqual({ x: 80, y: 120, w: 640, h: 360 });
});
it('fit and auto size the picture, not the world region', () => {
expect(cameraFactor(zoomed.props, 800, 600, 'fit')).toBeCloseTo(1.25, 9);
expect(cameraView(zoomed, 800, 600, 'fit').scale).toBeCloseTo(2.5, 9);
expect(cameraFactor(zoomed.props, 800, 600, 'auto')).toBe(1);
});
});
describe('cameraView under Windows scaling at 125%', () => {
const plain = { x: 0, y: 0, props: { width: 640, height: 360, zoom: 1 } };
it('100% is one device pixel per camera pixel, starting on a whole device pixel', () => {
const view = cameraView(plain, 801, 601, 1, 1.25);
expect(view.scale * 1.25).toBeCloseTo(1, 12);
expect(view.rect.w).toBeCloseTo(512, 12);
expect(view.rect.h).toBeCloseTo(288, 12);
expect(view.rect.x * 1.25).toBeCloseTo(181, 9);
expect(view.rect.y * 1.25).toBeCloseTo(196, 9);
});
it('auto picks the step in device pixels, and fit reports device pixels', () => {
expect(cameraFactor(plain.props, 1100, 700, 'auto', 1.25)).toBe(2);
expect(cameraFactor(plain.props, 1100, 700, 'auto')).toBe(1);
expect(cameraFactor(plain.props, 800, 600, 'fit', 1.25)).toBeCloseTo(1.5625, 9);
expect(cameraView(plain, 800, 600, 'fit', 1.25).scale).toBeCloseTo(1.25, 9);
});
});
describe('gameScaleFactor', () => {
it('fit is the exact factor that fills the tighter side', () => {
expect(gameScaleFactor('fit', 640, 360, 800, 600)).toBeCloseTo(1.25, 9);
});
it('auto takes the largest step that still fits', () => {
expect(gameScaleFactor('auto', 640, 360, 800, 600)).toBe(1);
expect(gameScaleFactor('auto', 640, 360, 1280, 720)).toBe(2);
expect(gameScaleFactor('auto', 640, 360, 639, 600)).toBe(0.5);
expect(gameScaleFactor('auto', 320, 180, 2000, 2000)).toBe(4);
});
it('auto falls back to the smallest step when not even that fits', () => {
expect(gameScaleFactor('auto', 4000, 4000, 800, 600)).toBe(0.25);
});
it('a fixed step ignores the view size', () => {
for (const step of GAME_SCALE_STEPS) expect(gameScaleFactor(step, 640, 360, 10, 10)).toBe(step);
});
});