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 { isTextureFile, resolveTexture } 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`; /** Adds a texture asset carrying `file` and returns it. */ 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; expect(resolveTexture(fixture.project, sprite.props)).toEqual({ file: FILE, smooth: true }); texture.props.filter = 'nearest'; expect(resolveTexture(fixture.project, sprite.props)).toEqual({ file: FILE, smooth: false }); }); 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 the "is it used?" question `findUsages` answers is blind // to it – that is exactly the gap this function fills. 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('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); }); });