findPropertyUsages walks override sites, so Project.ts imported instance.ts while instance.ts imported Project.ts – the repo's only value-level import cycle. The queries now sit in usages.ts and Project.ts is a base type module again. requireScene has had no caller since holderId replaced sceneId. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
244 lines
10 KiB
TypeScript
244 lines
10 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { CreateTextureCommand } from '../../src/core/commands/assetCommands';
|
||
import type { TextureAsset } from '../../src/core/model/Asset';
|
||
import { findAsset } from '../../src/core/model/Project';
|
||
import { findPropertyUsages, findUsages } from '../../src/core/model/usages';
|
||
import {
|
||
DEFAULT_SLICE,
|
||
isTextureFile,
|
||
nextFrameNames,
|
||
resolveTexture,
|
||
sliceFrames,
|
||
textureRefIds,
|
||
uniqueFrameName,
|
||
} from '../../src/core/model/texture';
|
||
import { deserializeProject } from '../../src/core/store/serialize';
|
||
import { makeSpriteScene, place, roundTrip } from '../helpers';
|
||
|
||
/** A syntactically valid stored file name (64 hex characters plus an extension). */
|
||
const FILE = `${'a1b2c3d4'.repeat(8)}.png`;
|
||
const OTHER_FILE = `${'0f'.repeat(32)}.webp`;
|
||
|
||
function addTexture(fixture: ReturnType<typeof makeSpriteScene>, file = FILE, name = 'Bricks') {
|
||
const command = new CreateTextureCommand(name, { file, width: 64, height: 48 });
|
||
fixture.stack.execute(command);
|
||
return command.asset;
|
||
}
|
||
|
||
describe('texture file names', () => {
|
||
it('accepts the content-addressed form the server produces', () => {
|
||
expect(isTextureFile(FILE)).toBe(true);
|
||
expect(isTextureFile(OTHER_FILE)).toBe(true);
|
||
expect(isTextureFile(`${'ab'.repeat(32)}.jpg`)).toBe(true);
|
||
});
|
||
|
||
it('rejects anything a request must not be built from', () => {
|
||
expect(isTextureFile('')).toBe(false);
|
||
expect(isTextureFile('bricks.png')).toBe(false);
|
||
// Too short, wrong case, an unsupported extension, and the two traversal shapes.
|
||
expect(isTextureFile('abc.png')).toBe(false);
|
||
expect(isTextureFile(`${'A1'.repeat(32)}.png`)).toBe(false);
|
||
expect(isTextureFile(`${'ab'.repeat(32)}.svg`)).toBe(false);
|
||
expect(isTextureFile(`../${'ab'.repeat(32)}.png`)).toBe(false);
|
||
expect(isTextureFile(`${'ab'.repeat(32)}.png/../x`)).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('resolveTexture', () => {
|
||
it('resolves a sprite property to the file and the filter', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
const sprite = findAsset(fixture.project, fixture.spriteId)!;
|
||
sprite.props.texture = texture.id;
|
||
|
||
// A whole-texture reference carries no rectangle.
|
||
const whole = { file: FILE, rect: null, sourceWidth: 64, sourceHeight: 48 };
|
||
expect(resolveTexture(fixture.project, sprite.props)).toEqual({ ...whole, smooth: true });
|
||
texture.props.filter = 'nearest';
|
||
expect(resolveTexture(fixture.project, sprite.props)).toEqual({ ...whole, smooth: false });
|
||
});
|
||
|
||
it('resolves a frame id to the rectangle it cuts, not to the whole picture', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
texture.frames.push({ id: 'f1', name: 'Idle', x: 16, y: 8, w: 32, h: 24 });
|
||
const sprite = findAsset(fixture.project, fixture.spriteId)!;
|
||
|
||
sprite.props.texture = 'f1';
|
||
expect(resolveTexture(fixture.project, sprite.props)).toEqual({
|
||
file: FILE,
|
||
smooth: true,
|
||
rect: { x: 16, y: 8, w: 32, h: 24 },
|
||
sourceWidth: 64,
|
||
sourceHeight: 48,
|
||
});
|
||
|
||
// A frame that is gone dangles like any other reference: no picture, not a wrong one.
|
||
texture.frames.length = 0;
|
||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||
});
|
||
|
||
it('yields nothing rather than throwing when the reference cannot be used', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
const sprite = findAsset(fixture.project, fixture.spriteId)!;
|
||
|
||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||
sprite.props.texture = 'does-not-exist';
|
||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||
// Pointing at an asset that is not a texture at all.
|
||
sprite.props.texture = fixture.sceneId;
|
||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||
// A texture whose upload never produced a file.
|
||
sprite.props.texture = texture.id;
|
||
texture.file = '';
|
||
expect(resolveTexture(fixture.project, sprite.props)).toBeNull();
|
||
});
|
||
|
||
it('survives the round trip, and a hand-written file name does not', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
findAsset(fixture.project, fixture.spriteId)!.props.texture = texture.id;
|
||
|
||
const loaded = roundTrip(fixture.project);
|
||
const reloaded = findAsset(loaded, texture.id) as TextureAsset;
|
||
expect(reloaded.file).toBe(FILE);
|
||
expect(reloaded.width).toBe(64);
|
||
expect(resolveTexture(loaded, findAsset(loaded, fixture.spriteId)!.props)?.file).toBe(FILE);
|
||
|
||
const tampered = deserializeProject({
|
||
assets: [{ id: 't', type: 'texture', name: 'X', file: '/etc/passwd', width: -3, height: 'big' }],
|
||
});
|
||
const dropped = findAsset(tampered, 't') as TextureAsset;
|
||
expect(dropped.file).toBe('');
|
||
expect(dropped.width).toBe(0);
|
||
expect(dropped.height).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('CreateTextureCommand', () => {
|
||
it('adds and removes the asset', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
expect(findAsset(fixture.project, texture.id)).toBeDefined();
|
||
|
||
fixture.stack.undo();
|
||
expect(findAsset(fixture.project, texture.id)).toBeUndefined();
|
||
fixture.stack.redo();
|
||
expect((findAsset(fixture.project, texture.id) as TextureAsset).file).toBe(FILE);
|
||
});
|
||
|
||
it('refuses a file name that was never uploaded', () => {
|
||
const fixture = makeSpriteScene();
|
||
expect(() =>
|
||
fixture.stack.execute(new CreateTextureCommand('Bad', { file: 'bricks.png', width: 1, height: 1 })),
|
||
).toThrow();
|
||
});
|
||
});
|
||
|
||
describe('findPropertyUsages', () => {
|
||
it('finds a texture that is worn but never placed', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
const sprite = findAsset(fixture.project, fixture.spriteId)!;
|
||
sprite.props.texture = texture.id;
|
||
const objectId = place(fixture, fixture.spriteId, { x: 0, y: 0 });
|
||
|
||
// A texture is never placed, so `findUsages` is blind to it – hence this function.
|
||
expect(findUsages(fixture.project, texture.id)).toHaveLength(0);
|
||
|
||
const usages = findPropertyUsages(fixture.project, texture.id);
|
||
expect(usages).toHaveLength(1);
|
||
expect(usages[0]).toMatchObject({ on: 'asset', key: 'texture' });
|
||
|
||
fixture.object(objectId).overrides.texture = texture.id;
|
||
expect(findPropertyUsages(fixture.project, texture.id)).toHaveLength(2);
|
||
expect(findPropertyUsages(fixture.project, texture.id).some((u) => u.on === 'object')).toBe(true);
|
||
});
|
||
|
||
it('counts sprites wearing a frame of the texture, not just the texture itself', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
texture.frames.push({ id: 'f1', name: 'Idle', x: 0, y: 0, w: 8, h: 8 });
|
||
findAsset(fixture.project, fixture.spriteId)!.props.texture = 'f1';
|
||
|
||
// The asset id alone under-reports sliced sheets, so deleting one asks about every id it owns.
|
||
expect(findPropertyUsages(fixture.project, texture.id)).toHaveLength(0);
|
||
expect(findPropertyUsages(fixture.project, textureRefIds(texture))).toHaveLength(1);
|
||
});
|
||
|
||
it('ignores unrelated ids and an empty reference', () => {
|
||
const fixture = makeSpriteScene();
|
||
const texture = addTexture(fixture);
|
||
findAsset(fixture.project, fixture.spriteId)!.props.texture = texture.id;
|
||
|
||
expect(findPropertyUsages(fixture.project, 'other')).toHaveLength(0);
|
||
// An empty reference must not report every sprite that has no texture.
|
||
expect(findPropertyUsages(fixture.project, '')).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe('slicing a sheet', () => {
|
||
it('cuts a grid in reading order and derives the cell size from the picture', () => {
|
||
const rects = sliceFrames(64, 64, { ...DEFAULT_SLICE, columns: 2, rows: 2 });
|
||
expect(rects).toEqual([
|
||
{ x: 0, y: 0, w: 32, h: 32 },
|
||
{ x: 32, y: 0, w: 32, h: 32 },
|
||
{ x: 0, y: 32, w: 32, h: 32 },
|
||
{ x: 32, y: 32, w: 32, h: 32 },
|
||
]);
|
||
});
|
||
|
||
it('accounts for an offset and for spacing between cells', () => {
|
||
// 2 columns with a 2 px border and 4 px between them: (64 − 2 − 4) / 2 = 29 px cells.
|
||
const rects = sliceFrames(64, 64, {
|
||
...DEFAULT_SLICE,
|
||
columns: 2,
|
||
rows: 1,
|
||
offsetX: 2,
|
||
spacingX: 4,
|
||
});
|
||
expect(rects).toEqual([
|
||
{ x: 2, y: 0, w: 29, h: 64 },
|
||
{ x: 35, y: 0, w: 29, h: 64 },
|
||
]);
|
||
});
|
||
|
||
it('refuses rather than guessing when the numbers cannot describe a grid', () => {
|
||
// An unmeasured picture, more cells than pixels, and the values a form can produce.
|
||
expect(sliceFrames(0, 0, DEFAULT_SLICE)).toEqual([]);
|
||
expect(sliceFrames(4, 4, { ...DEFAULT_SLICE, columns: 8, rows: 8 })).toEqual([]);
|
||
expect(sliceFrames(64, 64, { ...DEFAULT_SLICE, columns: 0 })).toEqual([]);
|
||
// NaN passes every `<` comparison, so it has to be rejected in its own right.
|
||
expect(sliceFrames(64, 64, { ...DEFAULT_SLICE, columns: NaN })).toEqual([]);
|
||
expect(sliceFrames(64, 64, { ...DEFAULT_SLICE, spacingX: NaN })).toEqual([]);
|
||
});
|
||
});
|
||
|
||
describe('frame names', () => {
|
||
const sheet = (...names: string[]): TextureAsset => ({
|
||
id: 't',
|
||
type: 'texture',
|
||
name: 'Sheet',
|
||
props: {},
|
||
file: '',
|
||
width: 64,
|
||
height: 64,
|
||
frames: names.map((name, i) => ({ id: `f${i}`, name, x: 0, y: 0, w: 1, h: 1 })),
|
||
});
|
||
|
||
it('numbers a clashing name from 2, the way asset names are numbered', () => {
|
||
expect(uniqueFrameName(sheet(), 'Idle')).toBe('Idle');
|
||
expect(uniqueFrameName(sheet('Idle'), 'Idle')).toBe('Idle 2');
|
||
expect(uniqueFrameName(sheet('Idle', 'Idle 2'), 'Idle')).toBe('Idle 3');
|
||
// A frame keeps its own name available, so renaming it to itself is not a clash.
|
||
expect(uniqueFrameName(sheet('Idle'), 'Idle', 'f0')).toBe('Idle');
|
||
});
|
||
|
||
it('numbers a whole slice from 1 and steps over names already taken', () => {
|
||
expect(nextFrameNames(sheet(), 3)).toEqual(['Frame 1', 'Frame 2', 'Frame 3']);
|
||
// Re-slicing a sheet that keeps its frames must not append a number to a number.
|
||
expect(nextFrameNames(sheet('Frame 2'), 3)).toEqual(['Frame 1', 'Frame 3', 'Frame 4']);
|
||
});
|
||
});
|