Add textures for sprites
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>
This commit is contained in:
1 parent
6168df41f8
commit
5a6af74e44
47 files changed
+1995
-110
No files matched your search
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* The two pure halves of the `asset` property kind: how a stored id reads, and which assets a
|
||||
* picker may offer for it. Both are used by the inspector row and by the graph node view, so
|
||||
* they are pinned here rather than through a widget.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||||
import type { AssetType } from '../../src/core/model/AssetTypes';
|
||||
import type { Project } from '../../src/core/model/Project';
|
||||
import { assetChoiceLabel, assetRefChoices, assetRefLabel } from '../../src/core/properties/assetRef';
|
||||
import { makeProject, type ProjectFixture } from '../helpers';
|
||||
|
||||
/** Adds an asset and returns its id, so a test can reference it. */
|
||||
function add(fixture: ProjectFixture, type: AssetType, name: string): string {
|
||||
const command = new CreateAssetCommand(type, name);
|
||||
fixture.stack.execute(command);
|
||||
return command.asset.id;
|
||||
}
|
||||
|
||||
/** A project holding one asset of every type, each named after its type. */
|
||||
function oneOfEach(): { project: Project; ids: Record<AssetType, string> } {
|
||||
const fixture = makeProject();
|
||||
const types: AssetType[] = [
|
||||
'scene',
|
||||
'camera',
|
||||
'sprite',
|
||||
'text',
|
||||
'behavior',
|
||||
'collider',
|
||||
'container',
|
||||
'texture',
|
||||
];
|
||||
const ids = Object.fromEntries(types.map((type) => [type, add(fixture, type, type)])) as Record<
|
||||
AssetType,
|
||||
string
|
||||
>;
|
||||
return { project: fixture.project, ids };
|
||||
}
|
||||
|
||||
describe('assetRefLabel', () => {
|
||||
it('reads as the referenced asset name', () => {
|
||||
const fixture = makeProject();
|
||||
const id = add(fixture, 'sprite', 'Coin');
|
||||
expect(assetRefLabel(fixture.project, id)).toBe('Coin');
|
||||
});
|
||||
|
||||
it('reads as "None" when nothing is referenced', () => {
|
||||
expect(assetRefLabel(makeProject().project, '')).toBe('None');
|
||||
});
|
||||
|
||||
it('keeps showing the id of a reference that no longer resolves', () => {
|
||||
expect(assetRefLabel(makeProject().project, 'gone')).toBe('gone?');
|
||||
});
|
||||
|
||||
it('marks a deleted asset as dangling rather than falling back to "None"', () => {
|
||||
const fixture = makeProject();
|
||||
const id = add(fixture, 'sprite', 'Coin');
|
||||
fixture.stack.undo();
|
||||
expect(assetRefLabel(fixture.project, id)).toBe(`${id}?`);
|
||||
});
|
||||
|
||||
it('dangles without a project, but the empty reference is still "None"', () => {
|
||||
expect(assetRefLabel(null, 'x')).toBe('x?');
|
||||
expect(assetRefLabel(null, '')).toBe('None');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetRefChoices', () => {
|
||||
it('offers every placeable asset when no types are given', () => {
|
||||
const { project } = oneOfEach();
|
||||
expect(assetRefChoices(project).map((a) => a.type)).toEqual([
|
||||
'behavior',
|
||||
'camera',
|
||||
'collider',
|
||||
'container',
|
||||
'sprite',
|
||||
'text',
|
||||
]);
|
||||
});
|
||||
|
||||
it('never offers a scene or a texture as a placeable asset', () => {
|
||||
const { project } = oneOfEach();
|
||||
const types = assetRefChoices(project).map((a) => a.type);
|
||||
expect(types).not.toContain('scene');
|
||||
expect(types).not.toContain('texture');
|
||||
});
|
||||
|
||||
it('offers exactly the given types, placeable or not', () => {
|
||||
const { project, ids } = oneOfEach();
|
||||
expect(assetRefChoices(project, ['texture']).map((a) => a.id)).toEqual([ids.texture]);
|
||||
expect(assetRefChoices(project, ['scene', 'sprite']).map((a) => a.id)).toEqual([ids.scene, ids.sprite]);
|
||||
});
|
||||
|
||||
it('offers nothing when no asset has one of the given types', () => {
|
||||
const fixture = makeProject();
|
||||
add(fixture, 'sprite', 'Coin');
|
||||
expect(assetRefChoices(fixture.project, ['texture'])).toEqual([]);
|
||||
});
|
||||
|
||||
it('sorts by name, not by creation order', () => {
|
||||
const fixture = makeProject();
|
||||
add(fixture, 'sprite', 'Zebra');
|
||||
add(fixture, 'sprite', 'Apple');
|
||||
add(fixture, 'text', 'Mango');
|
||||
expect(assetRefChoices(fixture.project).map((a) => a.name)).toEqual(['Apple', 'Mango', 'Zebra']);
|
||||
});
|
||||
|
||||
it('sorts namesakes by type so the order does not depend on creation order', () => {
|
||||
const fixture = makeProject();
|
||||
add(fixture, 'text', 'Coin');
|
||||
add(fixture, 'sprite', 'Coin');
|
||||
expect(assetRefChoices(fixture.project).map(assetChoiceLabel)).toEqual(['Coin (sprite)', 'Coin (text)']);
|
||||
});
|
||||
|
||||
it('leaves the asset order of the project alone', () => {
|
||||
const fixture = makeProject();
|
||||
add(fixture, 'sprite', 'Zebra');
|
||||
add(fixture, 'sprite', 'Apple');
|
||||
assetRefChoices(fixture.project);
|
||||
expect(fixture.project.assets.map((a) => a.name)).toEqual(['Zebra', 'Apple']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('node asset params', () => {
|
||||
// There used to be a second notion of an asset reference: an enum with empty options that the
|
||||
// graph inspector filled in. This keeps the nodes on the real kind.
|
||||
it('use the asset kind and offer every placeable asset', () => {
|
||||
const params = BUILT_IN_NODES.flatMap((def) => def.params).filter((p) => p.key === 'asset');
|
||||
expect(params.length).toBeGreaterThan(0);
|
||||
for (const param of params) {
|
||||
expect(param.kind).toBe('asset');
|
||||
expect(param.kind === 'asset' && param.assetTypes).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,8 @@ describe('asset commands', () => {
|
||||
enabled: true,
|
||||
width: 100,
|
||||
height: 100,
|
||||
color: 0xe0553f,
|
||||
color: 0xffffff,
|
||||
texture: '',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { HttpProjectStore } from '../../src/core/store/HttpProjectStore';
|
||||
import { HttpTextureStore } from '../../src/core/store/HttpTextureStore';
|
||||
import { StaleRevisionError } from '../../src/core/store/ProjectStore';
|
||||
import { makeProject } from '../helpers';
|
||||
|
||||
const FILE = `${'a'.repeat(64)}.png`;
|
||||
/** What nginx answers when it refuses a request body larger than its 1 MB limit. */
|
||||
const NGINX_413 = '<html><head><title>413 Request Entity Too Large</title></head><body></body></html>';
|
||||
|
||||
/** Replaces `fetch` with one that always answers the same way; the mock records the request. */
|
||||
function stubFetch(body: BodyInit | null, init: ResponseInit) {
|
||||
const mock = vi.fn<typeof fetch>(() => Promise.resolve(new Response(body, init)));
|
||||
vi.stubGlobal('fetch', mock);
|
||||
return mock;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('HttpProjectStore', () => {
|
||||
it('reads a JSON body and keeps the server message on failure', async () => {
|
||||
stubFetch(JSON.stringify([{ id: 'p', name: 'P', revision: 1, updatedAt: 'now' }]), { status: 200 });
|
||||
await expect(new HttpProjectStore('/api').list()).resolves.toHaveLength(1);
|
||||
|
||||
stubFetch(JSON.stringify({ error: 'Project not found' }), { status: 404 });
|
||||
await expect(new HttpProjectStore('/api').load('p')).rejects.toThrow(/Project not found \(404\)/);
|
||||
});
|
||||
|
||||
it('explains a non-JSON error body by its status instead of calling it invalid JSON', async () => {
|
||||
stubFetch(NGINX_413, { status: 413, statusText: 'Request Entity Too Large' });
|
||||
const { project } = makeProject();
|
||||
await expect(new HttpProjectStore('/api').save(project, 1)).rejects.toThrow(/too large \(413\)/i);
|
||||
});
|
||||
|
||||
it('still reports a successful response that is not JSON as a defect', async () => {
|
||||
stubFetch('<html>hello</html>', { status: 200 });
|
||||
await expect(new HttpProjectStore('/api').list()).rejects.toThrow(/invalid JSON \(200\)/);
|
||||
});
|
||||
|
||||
it('turns a 409 into a StaleRevisionError', async () => {
|
||||
stubFetch(JSON.stringify({ error: 'Project was modified elsewhere', revision: 7 }), { status: 409 });
|
||||
const { project } = makeProject();
|
||||
await expect(new HttpProjectStore('/api').save(project, 3)).rejects.toBeInstanceOf(StaleRevisionError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HttpTextureStore', () => {
|
||||
it('uploads the raw bytes and returns what the server stored', async () => {
|
||||
const mock = stubFetch(JSON.stringify({ file: FILE, width: 2, height: 3, bytes: 4 }), { status: 201 });
|
||||
const bytes = new Blob([new Uint8Array([1, 2, 3, 4])]);
|
||||
const stored = await new HttpTextureStore('/api').upload(bytes, 'image/png');
|
||||
|
||||
expect(stored).toEqual({ file: FILE, width: 2, height: 3, bytes: 4 });
|
||||
const [url, init] = mock.mock.calls[0]!;
|
||||
expect(url).toBe('/api?r=textures');
|
||||
expect(init?.method).toBe('POST');
|
||||
// The body *is* the image: no multipart wrapper, no JSON.
|
||||
expect(init?.body).toBe(bytes);
|
||||
expect(init?.headers).toEqual({ 'Content-Type': 'image/png' });
|
||||
});
|
||||
|
||||
it('reports the nginx size limit as a size problem', async () => {
|
||||
stubFetch(NGINX_413, { status: 413, statusText: 'Request Entity Too Large' });
|
||||
const bytes = new Blob([new Uint8Array(8)]);
|
||||
await expect(new HttpTextureStore('/api').upload(bytes, 'image/png')).rejects.toThrow(
|
||||
/too large \(413\)/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('lists and deletes', async () => {
|
||||
stubFetch(JSON.stringify([{ file: FILE, bytes: 12, updatedAt: 'now' }]), { status: 200 });
|
||||
await expect(new HttpTextureStore('/api').list()).resolves.toEqual([
|
||||
{ file: FILE, bytes: 12, updatedAt: 'now' },
|
||||
]);
|
||||
|
||||
const mock = stubFetch(null, { status: 204 });
|
||||
await new HttpTextureStore('/api').delete(FILE);
|
||||
expect(mock.mock.calls[0]![0]).toBe(`/api?r=${encodeURIComponent(`textures/${FILE}`)}`);
|
||||
expect(mock.mock.calls[0]![1]?.method).toBe('DELETE');
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,13 @@ describe('PropertySchema', () => {
|
||||
});
|
||||
|
||||
it('provides defaults for every key', () => {
|
||||
expect(defaultsFor('sprite')).toEqual({ enabled: true, width: 100, height: 100, color: 0xe0553f });
|
||||
expect(defaultsFor('sprite')).toEqual({
|
||||
enabled: true,
|
||||
width: 100,
|
||||
height: 100,
|
||||
color: 0xffffff,
|
||||
texture: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('coerces values against the definition', () => {
|
||||
@@ -41,7 +47,13 @@ describe('resolve', () => {
|
||||
it('fills asset props with defaults and ignores unknown keys', () => {
|
||||
const sprite = buildAsset('sprite', 'Box');
|
||||
sprite.props = { width: 50, bogus: 1 };
|
||||
expect(resolveAssetProps(sprite)).toEqual({ enabled: true, width: 50, height: 100, color: 0xe0553f });
|
||||
expect(resolveAssetProps(sprite)).toEqual({
|
||||
enabled: true,
|
||||
width: 50,
|
||||
height: 100,
|
||||
color: 0xffffff,
|
||||
texture: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('applies sparse overrides on top of asset values', () => {
|
||||
@@ -53,6 +65,7 @@ describe('resolve', () => {
|
||||
width: 100,
|
||||
height: 100,
|
||||
color: 0x00ff00,
|
||||
texture: '',
|
||||
});
|
||||
|
||||
object.overrides.color = 0x0000ff;
|
||||
|
||||
@@ -34,7 +34,8 @@ describe('serialize', () => {
|
||||
],
|
||||
},
|
||||
{ id: 'sp1', type: 'sprite', props: { width: 10 } },
|
||||
{ id: 'x', type: 'texture' },
|
||||
// An asset type this build does not know (a newer document, a renamed type).
|
||||
{ id: 'x', type: 'sound' },
|
||||
{ type: 'camera' },
|
||||
],
|
||||
},
|
||||
@@ -58,6 +59,12 @@ describe('serialize', () => {
|
||||
templateId: '',
|
||||
});
|
||||
expect(project.assets[1]!.name).toBe('sprite');
|
||||
expect(project.assets[1]!.props).toEqual({ enabled: true, width: 10, height: 100, color: 0xe0553f });
|
||||
expect(project.assets[1]!.props).toEqual({
|
||||
enabled: true,
|
||||
width: 10,
|
||||
height: 100,
|
||||
color: 0xffffff,
|
||||
texture: '',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateTextureCommand } from '../../src/core/commands/assetCommands';
|
||||
import { CommandError } from '../../src/core/commands/Command';
|
||||
import { isTextureAsset } from '../../src/core/model/Asset';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { makeProject } from '../helpers';
|
||||
|
||||
const FILE = `${'b'.repeat(64)}.webp`;
|
||||
|
||||
describe('CreateTextureCommand', () => {
|
||||
it('adds a texture asset carrying the uploaded image, and removes it again', () => {
|
||||
const { project, stack } = makeProject();
|
||||
const command = new CreateTextureCommand('Hero', { file: FILE, width: 64, height: 32 });
|
||||
stack.execute(command);
|
||||
|
||||
const asset = findAsset(project, command.asset.id);
|
||||
expect(asset && isTextureAsset(asset)).toBe(true);
|
||||
expect(asset).toMatchObject({ type: 'texture', name: 'Hero', file: FILE, width: 64, height: 32 });
|
||||
// The schema default comes along, so the new texture is a complete asset – nothing to fill in later.
|
||||
expect(asset?.props).toEqual({ filter: 'linear' });
|
||||
|
||||
stack.undo();
|
||||
expect(project.assets).toHaveLength(0);
|
||||
stack.redo();
|
||||
expect(findAsset(project, command.asset.id)?.name).toBe('Hero');
|
||||
});
|
||||
|
||||
it('rejects a file name the server cannot have produced – in execute, not in the constructor', () => {
|
||||
const { project, stack } = makeProject();
|
||||
const command = new CreateTextureCommand('Hero', { file: '../secrets.png', width: 1, height: 1 });
|
||||
expect(() => stack.execute(command)).toThrow(CommandError);
|
||||
expect(project.assets).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps two assets for the same file apart', () => {
|
||||
const { project, stack } = makeProject();
|
||||
stack.execute(new CreateTextureCommand('Hero', { file: FILE, width: 8, height: 8 }));
|
||||
stack.execute(new CreateTextureCommand('Hero copy', { file: FILE, width: 8, height: 8 }));
|
||||
expect(project.assets.map((a) => a.id)).toHaveLength(2);
|
||||
expect(new Set(project.assets.map((a) => a.id)).size).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user