Files
feuerwerk/tests/core/asset-kind.test.ts
T
adminandClaude Opus 5.5 923baf2557 Let Spawn take an asset over an edge and find a child by name (#237)
An asset could only be picked as a setting on the Spawn node. It could
not be wired in, held in a variable or put into a list. Finding an
object below the own one needed a scene-wide search by name.

- "Asset" is now a kind like Animation or Sound: port, variable, list
  item and a new "Asset" literal node, with its own cube icon.
- Spawn and Is asset have an `asset` input. It uses the key of the old
  setting, so a saved asset reads as the value of the input and older
  projects keep spawning without a migration.
- New node "Child by name" searches below an object level by level.
  A child wins over a grandchild of the same name. Nothing found or no
  object gives null.

Tests:
- ./check.sh: 168 files, 2453 tests, CHECK EXIT 0.
- 9 new unit tests in tests/core/asset-kind.test.ts, catalog cases for
  Spawn, Is asset, Child by name and the literal.
- e2e 6z-ii (2 checks): node rows and a real drag of the literal onto
  `asset`. e2e 6: 119 checks passed. Spawn checks in 3 and 9 passed.
- Counter-checks: depth-first search fails 1, Spawn reading only the
  old setting fails 3, runtime ignoring the parent fails 1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 19:36:49 +02:00

160 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* An asset as a value (#237): Spawn and Is asset take it on a port, fed by the Asset literal, an Asset
* variable or a list item – and a pick saved before the setting became a port still spawns. Plus
* Child by name, which finds the nearest object of a name below another.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
import { isKindWith, VARIABLE_KINDS } from '../../src/core/properties/kinds';
import { tagged } from '../../src/core/model/Properties';
import {
makeBehavior,
graphEditor,
makeSpriteScene,
place,
roundTrip,
startWorld,
type SceneFixture,
} from '../helpers';
function sprite(f: SceneFixture, name: string): string {
const command = new CreateAssetCommand('sprite', name);
f.stack.execute(command);
return command.asset.id;
}
describe('the asset kind', () => {
it('can be declared, picked and tagged in a list', () => {
expect(VARIABLE_KINDS).toContain('asset');
for (const role of ['field', 'port', 'tagged', 'reference']) expect(isKindWith(role, 'asset')).toBe(true);
});
it('keeps an Asset variable and its value through saving', () => {
const f = makeSpriteScene('Crate');
const behavior = makeBehavior(f, 'Spawner');
f.stack.execute(new AddVariableCommand(behavior.id, 'what', 'asset'));
behavior.props.what = f.spriteId;
const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id)!;
expect(loaded.type === 'behavior' && loaded.variables.find((v) => v.key === 'what')?.kind).toBe('asset');
expect(loaded.props.what).toBe(f.spriteId);
});
});
describe('Spawn with an asset input', () => {
const spawnedOf = (f: SceneFixture, world: ReturnType<typeof startWorld>['world']) =>
world.entities.filter((e) => e.assetId === f.spriteId).length;
it('places the asset an Asset literal hands it over an edge', () => {
const f = makeSpriteScene('Crate');
const behavior = makeBehavior(f, 'Spawner');
const edit = graphEditor(f, behavior);
const start = edit.addNode('OnStart');
const literal = edit.addNode('Asset', { value: f.spriteId });
const spawn = edit.addNode('Spawn');
edit.wire([
[start, 'exec', spawn, 'exec'],
[literal, 'value', spawn, 'asset'],
]);
place(f, behavior.id, { x: 0, y: 0 });
const run = startWorld(f, { behaviors: true });
run.frame();
expect(spawnedOf(f, run.world)).toBe(1);
});
it('places the asset a list item tagged $asset names', () => {
const f = makeSpriteScene('Crate');
const behavior = makeBehavior(f, 'Spawner');
const edit = graphEditor(f, behavior);
const start = edit.addNode('OnStart');
const list = edit.addNode('List', { value: [tagged('asset', f.spriteId)] });
const item = edit.addNode('ItemAt');
const spawn = edit.addNode('Spawn');
edit.wire([
[start, 'exec', spawn, 'exec'],
[list, 'value', item, 'list'],
[item, 'item', spawn, 'asset'],
]);
place(f, behavior.id, { x: 0, y: 0 });
const run = startWorld(f, { behaviors: true });
run.frame();
expect(spawnedOf(f, run.world)).toBe(1);
});
it('still spawns what a project saved before the pick became a port', () => {
const f = makeSpriteScene('Crate');
const behavior = makeBehavior(f, 'Spawner');
const edit = graphEditor(f, behavior);
const start = edit.addNode('OnStart');
// The old setting and the new inline value share the key `asset`.
const spawn = edit.addNode('Spawn', { asset: f.spriteId });
edit.wire([[start, 'exec', spawn, 'exec']]);
place(f, behavior.id, { x: 0, y: 0 });
const loaded = { ...f, project: roundTrip(f.project) };
const run = startWorld(loaded, { behaviors: true });
run.frame();
expect(spawnedOf(loaded, run.world)).toBe(1);
});
});
describe('Child by name', () => {
/**
* Hitbox (top level, first in the scene) · Player › Weapon › Hitbox · Player › Weapon › Tag ·
* Player › Tag (placed after the deeper one).
*/
function scene() {
const f = makeSpriteScene('Player');
const hitbox = sprite(f, 'Hitbox');
const weapon = sprite(f, 'Weapon');
const tag = sprite(f, 'Tag');
const loose = place(f, hitbox, { x: 0, y: 0 });
const player = place(f, f.spriteId, { x: 0, y: 0 });
const blade = place(f, weapon, { x: 0, y: 0 }, player);
const inner = place(f, hitbox, { x: 0, y: 0 }, blade);
const deepTag = place(f, tag, { x: 0, y: 0 }, blade);
const nearTag = place(f, tag, { x: 0, y: 0 }, player);
return { f, loose, player, inner, deepTag, nearTag };
}
it('finds a grandchild below the object, not a namesake elsewhere', () => {
const { f, loose, player, inner } = scene();
const { world } = startWorld(f);
const found = world.childByName(world.findByObjectId(player)!, 'Hitbox');
expect(found?.objectId).toBe(inner);
expect(world.findByName('Hitbox')?.objectId).toBe(loose);
});
it('takes a child before a grandchild of the same name', () => {
const { f, player, nearTag } = scene();
const { world } = startWorld(f);
expect(world.childByName(world.findByObjectId(player)!, 'Tag')?.objectId).toBe(nearTag);
});
it('finds nothing above or beside the object', () => {
const { f, inner } = scene();
const { world } = startWorld(f);
expect(world.childByName(world.findByObjectId(inner)!, 'Player')).toBeUndefined();
expect(world.childByName(world.findByObjectId(inner)!, 'Hitbox')).toBeUndefined();
});
it('hands the found child on in a running graph', () => {
const { f, loose, player, inner } = scene();
const behavior = makeBehavior(f, 'Cleaner');
const edit = graphEditor(f, behavior);
const start = edit.addNode('OnStart');
const owner = edit.addNode('Owner');
const child = edit.addNode('ChildByName', { name: 'Hitbox' });
const destroy = edit.addNode('Destroy');
edit.wire([
[start, 'exec', destroy, 'exec'],
[owner, 'object', child, 'object'],
[child, 'child', destroy, 'object'],
]);
place(f, behavior.id, { x: 0, y: 0 }, player);
const run = startWorld(f, { behaviors: true });
run.frame();
expect(run.world.findByObjectId(inner)).toBeUndefined();
expect(run.world.findByObjectId(loose)).toBeDefined();
});
});