A single node now lists any number of variables. Every entry is an output that reads the current value whenever it is pulled; an entry switched to Set is also an input and is written when the flow arrives. Exec pins only exist while at least one entry writes, and all inputs are read before any write, so one node can swap two variables. Get/Set variable, Get/Set variables and Variable are gone. Old project files are converted on load: each old node becomes a Variables node with the same variables, edges and typed values. A written port without a typed value now shows the zero of its kind on the node body, matching what the inspector shows and what the run writes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
147 lines
6.9 KiB
TypeScript
147 lines
6.9 KiB
TypeScript
/**
|
||
* Get/Set properties: the plural of Get/Set property, built like Get/Set variables. A port naming a
|
||
* built-in property carries that property – its default and its field – while a custom name is a
|
||
* plain port for a variable of another behavior's object.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { CommandError } from '../../src/core/commands/Command';
|
||
import { AddPortCommand, RenamePortCommand } from '../../src/core/commands/portCommands';
|
||
import { RenameVariableCommand } from '../../src/core/commands/variableCommands';
|
||
import {
|
||
GET_PROPERTIES,
|
||
GraphInterpreter,
|
||
inlineInputDef,
|
||
propertyPort,
|
||
requireNodeDef,
|
||
resolveDef,
|
||
SET_PROPERTIES,
|
||
} from '../../src/core/graph';
|
||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
|
||
import { OBJECT_PROPERTIES, OBJECT_PROPERTY_CHOICES } from '../../src/core/properties/PropertySchema';
|
||
import { fakeContext, fakeEntity, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
|
||
|
||
/** Self's `width` and `color` are set with nothing wired into them, then read back into `seen`. */
|
||
function typedIn(params: Record<string, number> = {}) {
|
||
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 set = edit.addNode(SET_PROPERTIES, params);
|
||
const get = edit.addNode(GET_PROPERTIES);
|
||
const store = edit.variables(['seen'], { write: true });
|
||
for (const key of ['width', 'color'])
|
||
fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key, kind: 'number' }));
|
||
fixture.stack.execute(new AddPortCommand(behavior.id, get.id, { key: 'width', kind: 'number' }));
|
||
edit.wire([
|
||
[start, 'exec', set, 'exec'],
|
||
[self, 'object', set, 'object'],
|
||
[set, 'exec', store, 'exec'],
|
||
[self, 'object', get, 'object'],
|
||
[get, 'width', store, 'seen'],
|
||
]);
|
||
return { fixture, behavior, set, get };
|
||
}
|
||
|
||
function run(behavior: BehaviorAsset, fixture: ReturnType<typeof makeProject>) {
|
||
const self = { ...fakeEntity('self'), props: { width: 10, color: 0 } };
|
||
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 or writes several properties at once', () => {
|
||
it("writes an unconnected input as the property's own default, not as a bare 0", () => {
|
||
const { fixture, behavior } = typedIn();
|
||
expect(run(behavior, fixture)).toEqual({ props: { width: 100, color: 0xffffff }, seen: 100 });
|
||
});
|
||
|
||
it('writes the value typed on the node once there is one', () => {
|
||
const { fixture, behavior } = typedIn({ width: 64 });
|
||
expect(run(behavior, fixture).props.width).toBe(64);
|
||
});
|
||
|
||
it('edits a typed-in value with the field of the property it names', () => {
|
||
expect(inlineInputDef(propertyPort({ key: 'color', kind: 'number' })).kind).toBe('color');
|
||
expect(inlineInputDef(propertyPort({ key: 'align', kind: 'text' })).kind).toBe('enum');
|
||
expect(inlineInputDef(propertyPort({ key: 'speed', kind: 'number' })).kind).toBe('number');
|
||
// A kind the property does not travel as (a hand-edited file) is left a plain port.
|
||
expect(propertyPort({ key: 'width', kind: 'text' }).field).toBeUndefined();
|
||
});
|
||
|
||
it('gives a custom name the zero it writes untyped, so the node shows a value', () => {
|
||
expect(propertyPort({ key: 'speed', kind: 'number' }).default).toBe(0);
|
||
expect(propertyPort({ key: 'ready', kind: 'boolean' }).default).toBe(false);
|
||
});
|
||
|
||
it('keeps the fixed object input its own, so no declared port can take its key', () => {
|
||
const { fixture, behavior, set, get } = typedIn();
|
||
const add = (nodeId: string) =>
|
||
new AddPortCommand(behavior.id, nodeId, { key: 'object', kind: 'number' });
|
||
expect(() => fixture.stack.execute(add(set.id))).toThrow(CommandError);
|
||
expect(() =>
|
||
fixture.stack.execute(new RenamePortCommand(behavior.id, set.id, 'width', 'object')),
|
||
).toThrow(CommandError);
|
||
// Get properties has no *output* called object, so there the name is free.
|
||
fixture.stack.execute(add(get.id));
|
||
const keys = resolveDef(set, behavior.graph, () => null)!.inputs.map((p) => p.key);
|
||
expect(keys).toEqual(['exec', 'object', 'width', 'color']);
|
||
});
|
||
|
||
it('drops a port from a file that would shadow the object input', () => {
|
||
const { fixture, behavior, set } = typedIn();
|
||
set.ports = [...(set.ports ?? []), { key: 'object', kind: 'number' }];
|
||
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 === set.id)!;
|
||
expect((node.ports ?? []).map((p) => p.key)).toEqual(['width', 'color']);
|
||
});
|
||
|
||
it('does not follow a variable that happens to share a property name', () => {
|
||
const fixture = makeProject();
|
||
const behavior = makeBehavior(fixture, 'B', ['width']);
|
||
const set = graphEditor(fixture, behavior).addNode(SET_PROPERTIES);
|
||
fixture.stack.execute(new AddPortCommand(behavior.id, set.id, { key: 'width', kind: 'number' }));
|
||
fixture.stack.execute(new RenameVariableCommand(behavior.id, 'width', 'w'));
|
||
expect((set.ports ?? []).map((p) => p.key)).toEqual(['width']);
|
||
});
|
||
});
|
||
|
||
describe('the properties on offer', () => {
|
||
it('lists each key once, with the def of a sprite where types share it', () => {
|
||
const keys = OBJECT_PROPERTIES.map((d) => d.key);
|
||
expect(new Set(keys).size).toBe(keys.length);
|
||
expect(OBJECT_PROPERTIES.find((d) => d.key === 'width')).toMatchObject({ label: 'Width', min: 1 });
|
||
expect(keys).toEqual(expect.arrayContaining(['enabled', 'color', 'texture', 'text', 'zoom', 'radius']));
|
||
// The asset's own switches and a scene's background are not properties of a placed object.
|
||
expect(keys).not.toContain('runInEditor');
|
||
expect(keys).not.toContain('backgroundColor');
|
||
});
|
||
|
||
it('names each entry with the type it belongs to, a shared key once per type', () => {
|
||
const labels = OBJECT_PROPERTY_CHOICES.map((c) => c.label);
|
||
expect(labels).toEqual(
|
||
expect.arrayContaining([
|
||
'Object enabled',
|
||
'Sprite width',
|
||
'Collider width',
|
||
'Camera width',
|
||
'Text align',
|
||
]),
|
||
);
|
||
expect(new Set(labels).size).toBe(labels.length);
|
||
expect(OBJECT_PROPERTY_CHOICES.find((c) => c.label === 'Camera width')?.def.key).toBe('width');
|
||
});
|
||
|
||
it('offers the same entries as suggestions for the property a single Get/Set property names', () => {
|
||
for (const type of ['GetProperty', 'SetProperty']) {
|
||
const param = requireNodeDef(type).params.find((p) => p.key === 'property');
|
||
expect(param?.kind === 'text' && param.suggestions).toContainEqual({
|
||
value: 'width',
|
||
label: 'Camera width',
|
||
});
|
||
}
|
||
});
|
||
});
|