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.
144 lines
5.6 KiB
TypeScript
144 lines
5.6 KiB
TypeScript
/**
|
||
* 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([]);
|
||
});
|
||
});
|