/** * Properties: the Variables node for the object it is handed. Its type decides what a port carries: * a property of that type keeps its own default and field, Any names plain ports of a picked kind. */ import { describe, expect, it } from 'vitest'; import { CommandError } from '../../src/core/commands/Command'; import { CollapseNodesCommand } from '../../src/core/commands/graphCommands'; import { AddPortCommand, RenamePortCommand } from '../../src/core/commands/portCommands'; import { RenameVariableCommand } from '../../src/core/commands/variableCommands'; import { type DeclaredPort, GraphInterpreter, inlineInputDef, NO_LOOKUP, OBJECT_BEHAVIOR_PARAM, OBJECT_TYPE_PARAM, propertyPort, resolveDef, } from '../../src/core/graph'; import type { BehaviorAsset } from '../../src/core/model/Asset'; import { graphLookup } from '../../src/core/model/graphLookup'; import type { PropertyBag } from '../../src/core/model/Properties'; import { fakeContext, fakeEntity, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers'; const SPRITE = { [OBJECT_TYPE_PARAM]: 'sprite' }; const set = (key: string): DeclaredPort => ({ key, kind: 'number', write: true }); /** `On start → Properties{…} on self`, its `width` output then stored in `seen`. */ function onSelf(ports: DeclaredPort[], params: PropertyBag = SPRITE) { const fixture = makeProject(); const behavior = makeBehavior(fixture, 'B', ['seen']); const edit = graphEditor(fixture, behavior); const start = edit.addNode('OnStart'); const self = edit.addNode('Self'); const props = edit.properties(ports, params); const store = edit.variables(['seen'], { write: true }); edit.wire([ [start, 'exec', props, 'exec'], [self, 'object', props, 'object'], [props, 'exec', store, 'exec'], [props, 'width', store, 'seen'], ]); return { fixture, behavior, props }; } function run(behavior: BehaviorAsset, fixture: ReturnType, props: PropertyBag) { const self = { ...fakeEntity('self'), props: { ...props } }; const ctx = fakeContext({ self, variables: { seen: 0 } }); new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx); return { props: self.props, seen: ctx.variables.seen }; } describe('a node that reads and writes properties of an object', () => { it("writes an unconnected input as its property's default, and reads it back after", () => { const { fixture, behavior } = onSelf([set('width'), set('color')]); expect(run(behavior, fixture, { width: 10, color: 0 })).toEqual({ props: { width: 100, color: 0xffffff }, seen: 100, }); }); it('writes the value typed on the node once there is one', () => { const { fixture, behavior, props } = onSelf([set('width')]); props.params.width = 64; expect(run(behavior, fixture, { width: 10 }).props.width).toBe(64); }); it('reads a property at the moment it is asked, so one node counts it up', () => { const fixture = makeProject(); const behavior = makeBehavior(fixture, 'B'); const edit = graphEditor(fixture, behavior); const start = edit.addNode('OnStart'); const self = edit.addNode('Self'); const coins = edit.properties([set('coins')]); const add = edit.addNode('Add', { b: 1 }); edit.wire([ [start, 'exec', coins, 'exec'], [self, 'object', coins, 'object'], [coins, 'coins', add, 'a'], [add, 'result', coins, 'coins'], ]); const self_ = { ...fakeEntity('self'), props: { coins: 5 } }; const interpreter = new GraphInterpreter(behavior.graph, graphLookup(fixture.project)); for (let i = 0; i < 2; i++) interpreter.fire('OnStart', fakeContext({ self: self_ })); expect(self_.props.coins).toBe(7); }); it('switches an object off and, left unconnected, on – what Set enabled did', () => { const switched = (typed: PropertyBag, before: boolean) => { const fixture = makeProject(); const behavior = makeBehavior(fixture, 'B'); const edit = graphEditor(fixture, behavior); const start = edit.addNode('OnStart'); const self = edit.addNode('Self'); const node = edit.properties([{ key: 'enabled', kind: 'boolean', write: true }], { [OBJECT_TYPE_PARAM]: 'container', }); Object.assign(node.params, typed); edit.wire([ [start, 'exec', node, 'exec'], [self, 'object', node, 'object'], ]); return run(behavior, fixture, { enabled: before }).props.enabled; }; expect([switched({ enabled: false }, true), switched({}, false)]).toEqual([false, true]); }); it('has flow pins only while a property is set, and they sit above the object input', () => { const { behavior, props } = onSelf([set('width'), { key: 'height', kind: 'number' }]); const keys = () => { const def = resolveDef(props, behavior.graph, NO_LOOKUP)!; return { inputs: def.inputs.map((p) => p.key), outputs: def.outputs.map((p) => p.key) }; }; expect(keys()).toEqual({ inputs: ['exec', 'object', 'width'], outputs: ['exec', 'width', 'height'] }); props.ports = props.ports!.map(({ write: _, ...p }) => p); expect(keys()).toEqual({ inputs: ['object'], outputs: ['width', 'height'] }); }); }); describe('what a port carries, by the type of the node', () => { const width: DeclaredPort = { key: 'width', kind: 'number' }; it("takes the def of its own type, not another type's that shares the key", () => { expect(propertyPort(width, SPRITE)).toMatchObject({ default: 100, field: { min: 1 } }); expect(propertyPort(width, { [OBJECT_TYPE_PARAM]: 'camera' })).toMatchObject({ default: 640, field: { decimals: 0 }, }); }); it('edits a typed-in value with the field of that property', () => { const text = { [OBJECT_TYPE_PARAM]: 'text' }; expect(inlineInputDef(propertyPort({ key: 'color', kind: 'number' }, text)).kind).toBe('color'); expect(inlineInputDef(propertyPort({ key: 'align', kind: 'text' }, text)).kind).toBe('enum'); // A kind the property does not travel as (a hand-edited file) is left a plain port. expect(propertyPort({ key: 'width', kind: 'text' }, SPRITE).field).toBeUndefined(); }); it('keeps a port under Any plain, at the zero of its kind, whatever its name', () => { expect(propertyPort(width, {})).toMatchObject({ default: 0 }); expect(propertyPort(width, {}).field).toBeUndefined(); expect(propertyPort({ key: 'ready', kind: 'boolean' }, {}).default).toBe(false); }); it('shows its type as a setting row, and the behavior only under that type', () => { const fixture = makeProject(); const edit = graphEditor(fixture, makeBehavior(fixture, 'B')); const hidden = (params: PropertyBag) => { const node = edit.properties([], params); return resolveDef(node, { nodes: [node], edges: [] }, NO_LOOKUP)!.hiddenParams?.(node.params); }; expect(hidden(SPRITE)).toEqual([OBJECT_BEHAVIOR_PARAM]); expect(hidden({})).toEqual([OBJECT_BEHAVIOR_PARAM]); expect(hidden({ [OBJECT_TYPE_PARAM]: 'behavior' })).toEqual([]); }); it('is titled Properties while open and says what it does once collapsed', () => { const fixture = makeProject(); const behavior = makeBehavior(fixture, 'B'); const edit = graphEditor(fixture, behavior); const width = { key: 'width', kind: 'number' } as const; const nodes = [ edit.properties([set('width'), set('height')], SPRITE), edit.properties([width, { key: 'height', kind: 'number' }], SPRITE), edit.properties([set('width')], SPRITE), edit.properties([width], SPRITE), edit.properties([], SPRITE), ]; const titles = () => nodes.map((node) => resolveDef(node, behavior.graph, NO_LOOKUP)!.label); expect(new Set(titles())).toEqual(new Set(['Properties'])); fixture.stack.execute( new CollapseNodesCommand( behavior.id, nodes.map((node) => node.id), true, ), ); expect(titles()).toEqual([ 'Set 2 properties', 'Get 2 properties', 'Set width', 'Get width', 'No properties', ]); }); }); describe('the keys a port cannot take', () => { it('keeps the object input and the settings their own', () => { const { fixture, behavior, props } = onSelf([set('width')]); for (const key of ['object', OBJECT_TYPE_PARAM, OBJECT_BEHAVIOR_PARAM]) { expect(() => fixture.stack.execute(new AddPortCommand(behavior.id, props.id, { key, kind: 'number' })), ).toThrow(CommandError); expect(() => fixture.stack.execute(new RenamePortCommand(behavior.id, props.id, 'width', key))).toThrow( CommandError, ); } }); it('drops a port from a file that would take one of them', () => { const { fixture, behavior, props } = onSelf([set('width')]); props.ports = [ ...props.ports!, { key: 'object', kind: 'number' }, { key: OBJECT_TYPE_PARAM, kind: 'text' }, ]; const loaded = roundTrip(fixture.project); const asset = loaded.assets.find((a) => a.id === behavior.id) as BehaviorAsset; const node = asset.graph.nodes.find((n) => n.id === props.id)!; expect((node.ports ?? []).map((p) => p.key)).toEqual(['width']); }); }); describe('a renamed variable', () => { it('pulls along the ports of Properties nodes pointing at its behavior, in every graph', () => { const fixture = makeProject(); const game = makeBehavior(fixture, 'Game', ['coins']); const player = makeBehavior(fixture, 'Player', ['coins']); const edit = graphEditor(fixture, player); const pointing = { [OBJECT_TYPE_PARAM]: 'behavior', [OBJECT_BEHAVIOR_PARAM]: game.id }; const ofGame = edit.properties([set('coins')], { ...pointing, coins: 3 }); const any = edit.properties([set('coins')]); const ofPlayer = edit.properties([set('coins')], { ...pointing, [OBJECT_BEHAVIOR_PARAM]: player.id }); const log = edit.addNode('Log'); edit.connect(ofGame, 'coins', log, 'message'); const keys = () => [ofGame, any, ofPlayer].map((n) => n.ports!.map((p) => p.key).join()); fixture.stack.execute(new RenameVariableCommand(game.id, 'coins', 'gold')); expect(keys()).toEqual(['gold', 'coins', 'coins']); expect(ofGame.params).toMatchObject({ gold: 3 }); expect(player.graph.edges.map((e) => `${e.from.port}>${e.to.port}`)).toEqual(['gold>message']); fixture.stack.undo(); expect(keys()).toEqual(['coins', 'coins', 'coins']); expect(ofGame.params).toMatchObject({ coins: 3 }); }); it('leaves a port alone whose new key the node already has', () => { const fixture = makeProject(); const game = makeBehavior(fixture, 'Game', ['coins']); const pointing = { [OBJECT_TYPE_PARAM]: 'behavior', [OBJECT_BEHAVIOR_PARAM]: game.id }; // `gold` is the port of a variable deleted since. const node = graphEditor(fixture, game).properties([set('coins'), set('gold')], pointing); fixture.stack.execute(new RenameVariableCommand(game.id, 'coins', 'gold')); expect(node.ports!.map((p) => p.key)).toEqual(['coins', 'gold']); }); });