Files
feuerwerk/tests/core/struct-shape-ports.test.ts
T
rootandClaude Fable 5.1 11c900c998 Drop the per-list item declaration; every item carries its own kind
A list no longer says what its items are. ListDef.items, ListDef.fields,
ListFieldDef.items and SetListItemsCommand are gone; the single seam is
src/core/properties/listItems.ts. JSON already tells number, text, boolean
and struct apart, so only two kinds need a tag: {"$color": n} and the new
{"$texture": id}. Field names cannot start with "$", so a tag never
collides with a struct field. On a data link a texture item arrives as
its bare id; the graph has no images.

List tab: the mark in front of each row is the kind picker, "+ Item"
asks for the kind, both are ordinary value edits (one undo step). A list
inside a struct field only offers the flat kinds because the depth rule
forbids a struct item there (ListAccess.kinds); the menu used to offer
it and silently do nothing.

Legacy lists that declared items: 'color' are tagged on load.

Actor demo: the four walk animations and four standing frames live in
two list variables of texture items indexed by facing (Item at), so the
graph can be rebuilt in the editor. The stand frames get their grid ids
back; 44 nodes instead of 46.

Also: the user's edits to on-start-during-play.md and the new note
reseed-because-of-list.md.

Verified: check.sh 794 tests, full e2e 631 checks, smokes 24/19/20.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 13:35:02 +02:00

141 lines
5.6 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.
/**
* Seeding the fields of a Pack/Expand from a variable that already has the shape: a struct declares
* it, a list shows it in the struct items it holds. The node keeps declaring its own ports – this
* only copies a shape that exists elsewhere in the behavior.
*/
import { describe, expect, it } from 'vitest';
import { AddPortCommand, AddPortsCommand } from '../../src/core/commands/portCommands';
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
import {
type DeclaredPortKind,
fieldPortKind,
missingFieldPorts,
resolveDef,
structShapeOf,
} from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
import type { StructFieldDef } from '../../src/core/properties/PropertySchema';
import { graphEditor, makeBehavior, makeProject } from '../helpers';
const PIECE: readonly StructFieldDef[] = [
{ key: 'shape', kind: 'number' },
{ key: 'name', kind: 'text' },
{ key: 'held', kind: 'boolean' },
{ key: 'tint', kind: 'color' },
{ key: 'cells', kind: 'list' },
];
function behaviorWithPiece() {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B');
fixture.stack.execute(new AddVariableCommand(behavior.id, 'piece', 'struct'));
for (const field of PIECE) fixture.stack.execute(new AddStructFieldCommand(behavior.id, 'piece', field));
return { ...fixture, behavior, edit: graphEditor(fixture, behavior) };
}
const varOf = (behavior: BehaviorAsset, key: string) => behavior.variables.find((v) => v.key === key)!;
describe('the shape a variable declares', () => {
it('is the fields of a struct variable and the shape of the struct items a list holds', () => {
const fixture = behaviorWithPiece();
const { behavior, stack } = fixture;
stack.execute(new AddVariableCommand(behavior.id, 'shapes', 'list'));
// No declaration to read: the shape is the one the items in there actually have.
varOf(behavior, 'shapes').default = [{ x: 0, y: 0 }];
expect(structShapeOf(varOf(behavior, 'piece'))?.map((f) => f.key)).toEqual([
'shape',
'name',
'held',
'tint',
'cells',
]);
expect(structShapeOf(varOf(behavior, 'shapes'))?.map((f) => f.key)).toEqual(['x', 'y']);
});
it('is absent for a number, and for a list with no struct item in it', () => {
const fixture = behaviorWithPiece();
const { behavior, stack } = fixture;
stack.execute(new AddVariableCommand(behavior.id, 'score', 'number'));
stack.execute(new AddVariableCommand(behavior.id, 'names', 'list'));
varOf(behavior, 'names').default = ['a', 'b'];
expect(structShapeOf(varOf(behavior, 'score'))).toBeUndefined();
expect(structShapeOf(varOf(behavior, 'names'))).toBeUndefined();
});
/** A color is stored as a number and the graph has no port kind for it – every other kind is itself. */
it('reads a color field as a number port and a list field as a list port', () => {
expect(PIECE.map(fieldPortKind)).toEqual<DeclaredPortKind[]>([
'number',
'text',
'boolean',
'number',
'list',
]);
});
});
describe('seeding a node from that shape', () => {
it('leaves a field the node already declares alone', () => {
const { behavior, stack, edit } = behaviorWithPiece();
const pack = edit.addNode('PackStruct');
stack.execute(new AddPortCommand(behavior.id, pack.id, { key: 'shape', kind: 'text' }));
const ports = missingFieldPorts(pack, structShapeOf(varOf(behavior, 'piece')) ?? []);
expect(ports.map((p) => p.key)).toEqual(['name', 'held', 'tint', 'cells']);
});
it('adds every field as one undo step, and the ports become inputs of the Pack', () => {
const fixture = behaviorWithPiece();
const { behavior, stack, edit } = fixture;
const pack = edit.addNode('PackStruct');
const ports = missingFieldPorts(pack, structShapeOf(varOf(behavior, 'piece')) ?? []);
stack.execute(new AddPortsCommand(behavior.id, pack.id, ports));
const inputs = resolveDef(pack, behavior.graph, behaviorLookup(fixture.project))!.inputs;
expect(inputs.map((p) => `${p.key}:${p.type}`)).toEqual([
'shape:number',
'name:string',
'held:boolean',
'tint:number',
'cells:list',
]);
stack.undo();
expect(pack.ports ?? []).toEqual([]);
});
/** All-or-nothing: a clash halfway down must not leave the node with the fields before it. */
it('adds nothing at all when one of the ports clashes', () => {
const { behavior, stack, edit } = behaviorWithPiece();
const pack = edit.addNode('PackStruct');
stack.execute(new AddPortCommand(behavior.id, pack.id, { key: 'held', kind: 'boolean' }));
expect(() =>
stack.execute(
new AddPortsCommand(
behavior.id,
pack.id,
PIECE.map((f) => ({ key: f.key, kind: 'number' })),
),
),
).toThrow('"held" already exists');
expect((pack.ports ?? []).map((p) => p.key)).toEqual(['held']);
});
/** A field may be called `exec`, a port may not – the one name where a shape does not fit a node. */
it('rejects a shape whose field is called exec, and says why', () => {
const { behavior, stack, edit } = behaviorWithPiece();
stack.execute(new AddStructFieldCommand(behavior.id, 'piece', { key: 'exec', kind: 'number' }));
const pack = edit.addNode('PackStruct');
const ports = missingFieldPorts(pack, structShapeOf(varOf(behavior, 'piece')) ?? []);
expect(() => stack.execute(new AddPortsCommand(behavior.id, pack.id, ports))).toThrow(
'taken by the flow port',
);
expect(pack.ports ?? []).toEqual([]);
});
});