Files
feuerwerk/tests/viewport/view.test.ts
T
adminandClaude Opus 5.5 24d360aeec
game-maker / gate (push) Successful in 4m5s
game-maker / suite (push) Failing after 12m45s
Add zoom steps and a larger start caption to the game link (#266)
The released game only ever zoomed in whole steps up to 400%, so a
small camera on a large screen stayed far smaller than it could be.
The "Click to play" caption was drawn in the 11 px panel title size
and was easy to miss.

- GAME_SCALE_STEPS gains 300%, 500% and 600%. The game tab picker,
  Auto in the editor and the game link all read the same list.
- The start caption takes its size from the new Theme.captionSize
  (16) instead of a constant in GameScreen.
- The player page keeps the default arrow cursor at all times. It no
  longer switches to the hand over the caption.

Tests:
- view.test: Auto for 320x180 in 2000x2000 is now 6, with cases
  for 3 and 5. play-settings.test takes 2.5 and 8 as invalid steps.
- e2e 60 checks the caption against the panel title size and the
  arrow cursor before and after the start. Module 03 follows the new
  step list.
- ./check.sh 2808 tests, CHECK EXIT 0 (before the last Theme tweak,
  which changes no test). Full e2e run ALL PASSED, 1574 checks.

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

222 lines
8.5 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,
freeViewOfCamera,
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('freeViewOfCamera', () => {
const cases = [
{ camera: { x: 10, y: 20, props: { width: 640, height: 360, zoom: 1 } }, w: 800, h: 600, ratio: 1 },
{
camera: { x: -33.5, y: 7, props: { width: 640, height: 360, zoom: 1.37 } },
w: 801,
h: 601,
ratio: 1.25,
},
{ camera: { x: 0, y: 0, props: { width: 320, height: 180, zoom: 2 } }, w: 1111, h: 555, ratio: 1.5 },
];
const modes = ['auto', 'fit', 1, 0.5] as const;
it('puts every world point on the screen pixel the game view puts it on', () => {
for (const { camera, w, h, ratio } of cases) {
for (const mode of modes) {
const free = freeViewOfCamera(camera, w, h, mode, ratio);
const scene = freeView(free.x, free.y, free.zoom, w, h, ratio);
const game = cameraView(camera, w, h, mode, ratio);
for (const [wx, wy] of [
[camera.x, camera.y],
[camera.x - 300, camera.y + 170],
[123, -45],
] as const) {
const a = worldToScreen(scene, wx, wy);
const b = worldToScreen(game, wx, wy);
expect(a.x).toBeCloseTo(b.x, 9);
expect(a.y).toBeCloseTo(b.y, 9);
}
}
}
});
it('keeps the whole step auto picks, so pixel art stays even', () => {
expect(freeViewOfCamera(cases[0]!.camera, 1300, 800, 'auto').zoom).toBe(2);
});
});
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, 1000, 800)).toBe(3);
expect(gameScaleFactor('auto', 320, 180, 1700, 1000)).toBe(5);
expect(gameScaleFactor('auto', 320, 180, 2000, 2000)).toBe(6);
});
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);
});
});