Files
feuerwerk/tests/core/struct-shape-ports.test.ts
T
root 14f542365e Build a Pack or Expand node's ports from a struct variable
The port editor gets 'From variable' next to '+ Field': pick a struct variable
of the behavior and its fields land as ports. It only adds - a field the node
already declares stays as it is, because it may carry an edge - and the entry
is disabled once there is nothing left to add. A list of structs counts too:
it declares the shape of every item, which is exactly the bundle a Pack builds
for it. A color field arrives as a number port; the graph has no color type.

AddPortsCommand is new underneath: adding five ports had to be one undo step,
and it checks every key before it appends a single one, so a collision does
not leave half the list behind. AddPortCommand is a three-line subclass of it,
which leaves its sixteen call sites untouched.
2026-09-10 21:35:30 +02:00

144 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 declares the shape. 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,
SetListItemsCommand,
} 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', items: 'number' },
];
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 item fields of a list of structs', () => {
const fixture = behaviorWithPiece();
const { behavior, stack } = fixture;
stack.execute(new AddVariableCommand(behavior.id, 'shapes', 'list'));
stack.execute(new SetListItemsCommand(behavior.id, 'shapes', 'struct'));
for (const key of ['x', 'y'])
stack.execute(new AddStructFieldCommand(behavior.id, 'shapes', { key, kind: 'number' }));
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 variable that declares no bundle', () => {
const fixture = behaviorWithPiece();
const { behavior, stack } = fixture;
stack.execute(new AddVariableCommand(behavior.id, 'score', 'number'));
stack.execute(new AddVariableCommand(behavior.id, 'names', 'list'));
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([]);
});
});