Images are uploaded to a content-addressed blob endpoint and stored as data/textures/<sha256>.<ext>; the project only keeps the file name. The sprite points at a texture asset through an ordinary property of the new kind `asset`, so instance overrides, "Apply to container", "Reset to asset", undo and `Set property` work without any extra code. The new kind also replaces the ad-hoc asset picker the graph nodes faked as an enum. Import goes through + -> Texture, drag & drop onto the canvas or Ctrl+V. Images wider than 2048px are downscaled client-side and re-encoded to WebP if needed, because nginx caps uploads at 1MB; an image that already fits is uploaded byte for byte so pixel art is never resampled. The sprite colour default changes from red to white, since colour tints the texture multiplicatively and a tint of white shows an image as it is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
140 lines
6.0 KiB
TypeScript
140 lines
6.0 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, 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<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;
|
||
|
||
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);
|
||
});
|
||
});
|