Files
feuerwerk/tests/viewport/view.test.ts
T
adminandClaude Opus 5.5 98b9b300d9 Zoom the scene view further in, up to 64 device pixels
The scene view stopped at 24 device pixels per world pixel, which is
too little to work on single texels of small pixel art.

- FREE_ZOOM_STEPS gains the steps 32, 48 and 64. All of them are whole
  numbers, so every texel keeps the same width on screen.
- The tile editor takes the same steps, so its fit can now land above
  24 on a small board.
- The unit test and the step list in e2e 39 follow the new top step.

Tests:
- ./check.sh: 178 files, 2573 tests, CHECK EXIT 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:12:01 +02:00

181 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(32);
expect(stepFreeZoom(64, 1)).toBe(64);
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(40)).toBe(32);
expect(zoomStepBelow(100)).toBe(64);
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);
});
});