import { describe, expect, it } from 'vitest'; import { CreateTextureCommand } from '../../src/core/commands/assetCommands'; import type { TextureAsset } from '../../src/core/model/Asset'; import { findAsset, findPropertyUsages, findUsages } from '../../src/core/model/Project'; 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, 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']); }); });