Files
feuerwerk/tests/viewport/view.test.ts
T
adminandClaude Opus 5.5 30de4d35a8
game-maker / gate (push) Successful in 3m21s
game-maker / suite (push) Failing after 9m15s
Give the scene tab a header with Fit and the eye (#245)
The scene view had no header bar of its own, and its eye sat in the
top bar. Switching between Scene and Game also showed two different
pictures, since nothing tied the free view to the camera.

- Theme.headerPadding (4) replaces Theme.padding in the top bar and
  in TabHeader, for the padding and for minHeight. Both bars shrink
  from 40 to 32 px. Nothing else uses the new value.
- The new ScenePane wraps the scene view in a header bar with Fit
  and the visibility eye. The eye is gone from the top bar, which
  keeps the menu, undo and redo.
- Fit with an active camera shows the world exactly as the Game tab
  does at its chosen step: the same zoom, and the camera centre on
  the same screen pixel. The rule is the pure freeViewOfCamera in
  viewport/view.ts, which also mirrors the letterbox snapping.
- Fit without a camera frames all content on a whole step, as
  before.
- New hook __gm.sceneHeaderAt(name). e2e 46g opens the eye there.
- The doc comment above Theme.treeIndent went away in the working
  tree along with this. It is committed as found.

Not checked: the two views only match while they have the same size.
With the tilemap panel open the scene view is narrower, and Auto may
pick a smaller step there.

Tests:
- ./check.sh: 178 files, 2575 tests, CHECK EXIT 0
- 2 new unit tests in view.test.ts: three cameras times four steps
  land on the same screen point as in the Game tab, Auto stays whole
- e2e 3h (2 checks): a real click on Fit at Auto and at Fit, crate,
  camera frame and view box match in both tabs
- GM_STEPS=1-3,46: ALL PASSED, 94 checks
- Counter-probes: without the centre offset the unit test fails,
  with Fit ignoring the camera both 3h checks fail

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

220 lines
8.4 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, 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);
});
});