Files
feuerwerk/tests/core/aspect.test.ts
T
rootandClaude Opus 5 0b34bdd990 Fit sprite size to the texture aspect ratio
Add ↔ next to Width and ↕ next to Height in the inspector. Each sets its
field so the sprite matches the ratio of its assigned texture or frame,
reading the frame's own rect rather than the sheet's.

The buttons reuse the existing FieldActions convention, so they simply
do not appear when no texture is assigned, when the sprite is already
proportional, or on assets whose schema has no texture property.

Comparison is a cross product with a relative tolerance: 3135 of the
40000 size pairs up to 200x200 do not survive a round trip through a
division, so an exact check would leave the button standing after its
own click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:27:38 +02:00

84 lines
3.9 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.
import { describe, expect, it } from 'vitest';
import { CreateTextureCommand } from '../../src/core/commands/assetCommands';
import { AddFrameCommand } from '../../src/core/commands/frameCommands';
import type { PropertyBag } from '../../src/core/model/Properties';
import { fitAxisToAspect, matchesAspect, texturePixelSize } from '../../src/core/properties/aspect';
import { makeSpriteScene } from '../helpers';
const FILE = `${'a1b2c3d4'.repeat(8)}.png`;
/** A project with a 64 × 48 sheet carrying one 30 × 10 frame. */
function makeSheet(width = 64, height = 48) {
const fixture = makeSpriteScene();
const texture = new CreateTextureCommand('Sheet', { file: FILE, width, height });
fixture.stack.execute(texture);
const frame = new AddFrameCommand(texture.asset.id, { x: 2, y: 4, w: 30, h: 10 });
fixture.stack.execute(frame);
return { ...fixture, textureId: texture.asset.id, frameId: frame.frameId };
}
const props = (bag: PropertyBag): PropertyBag => ({ width: 100, height: 100, ...bag });
describe('texturePixelSize', () => {
it('is the whole image for a texture id and the rectangle for a frame id', () => {
const sheet = makeSheet();
expect(texturePixelSize(sheet.project, props({ texture: sheet.textureId }))).toEqual({ w: 64, h: 48 });
expect(texturePixelSize(sheet.project, props({ texture: sheet.frameId }))).toEqual({ w: 30, h: 10 });
});
it('is null when the sprite wears nothing, or an id that no longer resolves', () => {
const sheet = makeSheet();
expect(texturePixelSize(sheet.project, props({ texture: '' }))).toBeNull();
expect(texturePixelSize(sheet.project, props({}))).toBeNull();
expect(texturePixelSize(sheet.project, props({ texture: 'gone' }))).toBeNull();
});
it('is null while the image has not been measured, so no ratio is invented', () => {
const sheet = makeSheet(0, 0);
expect(texturePixelSize(sheet.project, props({ texture: sheet.textureId }))).toBeNull();
});
});
describe('fitAxisToAspect', () => {
const size = { w: 64, h: 48 };
it('takes the other side as given and returns the proportional one', () => {
expect(fitAxisToAspect(size, props({ height: 60 }), 'width')).toBe(80);
expect(fitAxisToAspect(size, props({ width: 80 }), 'height')).toBe(60);
});
it('returns null when the side it would read from is unusable', () => {
expect(fitAxisToAspect(size, props({ height: 0 }), 'width')).toBeNull();
expect(fitAxisToAspect(size, props({ height: -5 }), 'width')).toBeNull();
expect(fitAxisToAspect(size, props({ width: Number.NaN }), 'height')).toBeNull();
});
});
describe('matchesAspect', () => {
it('compares proportions, not sizes', () => {
const size = { w: 64, h: 48 };
expect(matchesAspect(size, props({ width: 64, height: 48 }))).toBe(true);
expect(matchesAspect(size, props({ width: 8, height: 6 }))).toBe(true);
expect(matchesAspect(size, props({ width: 100, height: 100 }))).toBe(false);
});
it('holds after fitting one side, whatever the division left behind', () => {
// Measured, not assumed: of the 40 000 size pairs up to 200 × 200, 3135 do not survive the
// round trip through a division. 1 × 11 is one of them, and an exact comparison would call
// the sprite this very button just fitted not proportional – leaving the button standing.
const size = { w: 1, h: 11 };
const width = fitAxisToAspect(size, props({ height: 100 }), 'width')!;
expect(width * size.h).not.toBe(100 * size.w);
expect(matchesAspect(size, props({ width, height: 100 }))).toBe(true);
const height = fitAxisToAspect(size, props({ width }), 'height')!;
expect(height).not.toBe(100);
expect(matchesAspect(size, props({ width, height }))).toBe(true);
});
it('is false for a degenerate sprite rather than dividing by zero', () => {
const size = { w: 64, h: 48 };
expect(matchesAspect(size, props({ width: 0, height: 0 }))).toBe(false);
expect(matchesAspect(size, props({ width: Number.NaN, height: 10 }))).toBe(false);
});
});