/** * 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([ '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([]); }); });