Collapsed, the node reads "Set 2 variables", "Get width" or "No properties"; expanded it is always "Variables" / "Properties". The Properties node shows its object type (and behavior) as a line under the title instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
249 lines
11 KiB
TypeScript
249 lines
11 KiB
TypeScript
/**
|
||
* 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_BEHAVIORS,
|
||
OBJECT_BEHAVIOR_PARAM,
|
||
OBJECT_TYPE_PARAM,
|
||
propertyPort,
|
||
resolveDef,
|
||
} from '../../src/core/graph';
|
||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||
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<typeof makeProject>, props: PropertyBag) {
|
||
const self = { ...fakeEntity('self'), props: { ...props } };
|
||
const ctx = fakeContext({ self, variables: { seen: 0 } });
|
||
new GraphInterpreter(behavior.graph, behaviorLookup(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, behaviorLookup(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_BEHAVIORS)!;
|
||
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_BEHAVIORS)!.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_BEHAVIORS)!.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']);
|
||
});
|
||
});
|