Reading one field of a struct needed its own Expand node, even where the graph already knows the struct type. A split output shows the fields directly on the node, so a Find … Match test becomes a single Equals and the wires keep running left to right. - Right-clicking an output pin offers "Split into <Type> fields" when the type is known and "Split as …" for every other struct asset. The output is replaced by one pin per field with the field's kind. - The type is known for typed struct variables, typed Pack/Expand, fields of another split and item of For each, Find, Match and Item at over a typed list (portSplits.ts). - Right-clicking a field pin offers "Recombine". Splitting drops the edges of the whole output, recombining drops the field edges. One undo step brings them back. - The split is stored as GraphNode.splits and saved with the graph. Copy and paste carry it along. - Field pins follow the struct asset like a typed Expand. A new field gets a pin, a removed field loses pin and edge, a renamed field keeps its edge. Deleting the asset keeps the last fields. The delete dialog counts splits, so it now says "node(s)" instead of "Pack/Expand node(s)". - Renaming a variable carries its split and the field edges along. - At run time the node still produces the whole value. A field pin reads its field like Expand, a missing field reads as empty. - Limits: outputs only, one level, behavior graphs only. Tests: - ./check.sh: 185 files, 2706 tests, CHECK EXIT 0 - e2e GM_STEPS=1-6,54,55: ALL PASSED, 303 checks - New port-splits.test.ts with 12 tests. e2e 6z-vi with 3 checks using a real mouse: split Find.item, wire item.Name into the test and Match.item.Amount into the log (logs 7), recombine. - Counter-checks: without field reads in the interpreter, without rename in the split, without moving edges on rename and without cleanup on field removal one unit test fails each. In the browser, without field reads nothing is logged and without type inference the split entry is missing. - Check total in README: 1520. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
10 KiB
TypeScript
219 lines
10 KiB
TypeScript
/**
|
|
* Split outputs: a struct output shown as one output per field of a Struct asset. The fields follow
|
|
* the asset like a typed `Expand` does, the node still produces the whole value, and reading a field
|
|
* port picks the field out of it.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
CreateAssetCommand,
|
|
DeleteAssetCommand,
|
|
SetAssetPropertyCommand,
|
|
} from '../../src/core/commands/assetCommands';
|
|
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
|
|
import {
|
|
SetListItemTypeCommand,
|
|
SetStructTypeCommand,
|
|
SplitOutputCommand,
|
|
WithStructTypes,
|
|
} from '../../src/core/commands/structTypeCommands';
|
|
import {
|
|
AddVariableCommand,
|
|
RemoveVariableCommand,
|
|
RenameVariableCommand,
|
|
} from '../../src/core/commands/variableCommands';
|
|
import { edgesOutOf, FIND, GraphInterpreter, type GraphNode, resolveDef } from '../../src/core/graph';
|
|
import type { BehaviorAsset, StructAsset } from '../../src/core/model/Asset';
|
|
import { graphLookup } from '../../src/core/model/graphLookup';
|
|
import { outputStructType, structTypeUsages } from '../../src/core/model/structTypes';
|
|
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
|
import { fakeContext, graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers';
|
|
|
|
/** `Loot` = { Name: "Coin", Amount: 1 } */
|
|
function lootType(f: ProjectFixture): StructAsset {
|
|
const create = new CreateAssetCommand('struct', 'Loot');
|
|
f.stack.execute(create);
|
|
const id = create.asset.id;
|
|
f.stack.execute(new AddVariableCommand(id, 'Name', 'text'));
|
|
f.stack.execute(new AddVariableCommand(id, 'Amount', 'number'));
|
|
f.stack.execute(new SetAssetPropertyCommand(id, 'Name', 'Coin'));
|
|
return create.asset as StructAsset;
|
|
}
|
|
|
|
/**
|
|
* A behavior with a list `bag` of `Loot` (typed unless told otherwise) and On start → Find over it,
|
|
* the test `item == "Gem"` wired from the Find's `item` through an Equals into the Match.
|
|
*/
|
|
function finder(typed = true) {
|
|
const f = makeProject();
|
|
const loot = lootType(f);
|
|
const behavior = makeBehavior(f, 'Seeker');
|
|
f.stack.execute(new AddVariableCommand(behavior.id, 'bag', 'list'));
|
|
if (typed)
|
|
f.stack.execute(new SetListItemTypeCommand({ ownerId: behavior.id, key: 'bag', path: [] }, loot.id));
|
|
const edit = graphEditor(f, behavior);
|
|
const add = new AddNodeCommand(behavior.id, FIND, 0, 0);
|
|
f.stack.execute(add);
|
|
const find = add.node;
|
|
const match = add.closing!;
|
|
const start = edit.addNode('OnStart');
|
|
const get = edit.variables(['bag']);
|
|
const equals = edit.addNode('Equals', { b: 'Gem' });
|
|
edit.wire([
|
|
[start, 'exec', find, 'exec'],
|
|
[get, 'bag', find, 'list'],
|
|
[find, 'item', equals, 'a'],
|
|
[equals, 'result', match, 'match'],
|
|
]);
|
|
return { f, loot, behavior, edit, find, match, equals };
|
|
}
|
|
|
|
const split = (f: ProjectFixture, b: BehaviorAsset, node: GraphNode, port: string, typeId: string) =>
|
|
f.stack.execute(new SplitOutputCommand(b.id, node.id, port, typeId));
|
|
|
|
const outputs = (f: ProjectFixture, b: BehaviorAsset, node: GraphNode) =>
|
|
resolveDef(node, b.graph, graphLookup(f.project))!.outputs.map((p) => `${p.key}:${p.type}`);
|
|
|
|
describe('splitting an output', () => {
|
|
it('knows the type an item of a typed list carries, at Find and at Match', () => {
|
|
const { f, behavior, find, match, loot } = finder();
|
|
expect(outputStructType(f.project, behavior.graph, behavior.id, find, 'item')).toBe(loot.id);
|
|
expect(outputStructType(f.project, behavior.graph, behavior.id, match, 'item')).toBe(loot.id);
|
|
expect(outputStructType(f.project, behavior.graph, behavior.id, find, 'index')).toBe('');
|
|
});
|
|
|
|
it('knows nothing of an item of a mixed list', () => {
|
|
const { f, behavior, find } = finder(false);
|
|
expect(outputStructType(f.project, behavior.graph, behavior.id, find, 'item')).toBe('');
|
|
});
|
|
|
|
it('knows a struct variable declared with a type, and the value of a typed Pack', () => {
|
|
const f = makeProject();
|
|
const loot = lootType(f);
|
|
const behavior = makeBehavior(f);
|
|
f.stack.execute(new AddVariableCommand(behavior.id, 'drop', 'struct'));
|
|
const target = { kind: 'variable', ownerId: behavior.id, key: 'drop', path: [] } as const;
|
|
f.stack.execute(new SetStructTypeCommand(target, loot.id));
|
|
const edit = graphEditor(f, behavior);
|
|
const get = edit.variables(['drop']);
|
|
const pack = edit.addNode('PackStruct', { structType: loot.id });
|
|
expect(outputStructType(f.project, behavior.graph, behavior.id, get, 'drop')).toBe(loot.id);
|
|
expect(outputStructType(f.project, behavior.graph, behavior.id, pack, 'value')).toBe(loot.id);
|
|
});
|
|
|
|
it('puts one port per field where the output stood, typed as the field, and drops its edges', () => {
|
|
const { f, behavior, find, loot } = finder();
|
|
expect(outputs(f, behavior, find)).toEqual(['item:any', 'index:number']);
|
|
split(f, behavior, find, 'item', loot.id);
|
|
expect(outputs(f, behavior, find)).toEqual(['item.Name:string', 'item.Amount:number', 'index:number']);
|
|
expect(edgesOutOf(behavior.graph, find.id, 'item')).toEqual([]);
|
|
f.stack.undo();
|
|
expect(outputs(f, behavior, find)).toEqual(['item:any', 'index:number']);
|
|
expect(edgesOutOf(behavior.graph, find.id, 'item')).toHaveLength(1);
|
|
});
|
|
|
|
it('reads a field port as that field of the value the node produced', () => {
|
|
const { f, behavior, edit, find, match, equals, loot } = finder();
|
|
split(f, behavior, find, 'item', loot.id);
|
|
split(f, behavior, match, 'item', loot.id);
|
|
const log = edit.addNode('Log');
|
|
edit.wire([
|
|
[find, 'item.Name', equals, 'a'],
|
|
[match, 'found', log, 'exec'],
|
|
[match, 'item.Amount', log, 'message'],
|
|
]);
|
|
const bag = [
|
|
{ Name: 'Coin', Amount: 1 },
|
|
{ Name: 'Gem', Amount: 7 },
|
|
];
|
|
const ctx = fakeContext({ variables: { bag } });
|
|
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
|
|
expect(ctx.logs).toEqual(['7']);
|
|
});
|
|
|
|
it('refuses an output that holds no struct, and one split already', () => {
|
|
const { f, behavior, find, loot } = finder();
|
|
expect(() => split(f, behavior, find, 'index', loot.id)).toThrow('cannot be split');
|
|
split(f, behavior, find, 'item', loot.id);
|
|
expect(() => split(f, behavior, find, 'item', loot.id)).toThrow('cannot be split');
|
|
});
|
|
|
|
it('recombines: the whole output comes back, the edges on its fields go', () => {
|
|
const { f, behavior, find, equals, edit, loot } = finder();
|
|
split(f, behavior, find, 'item', loot.id);
|
|
edit.connect(find, 'item.Name', equals, 'a');
|
|
split(f, behavior, find, 'item', '');
|
|
expect(find.splits).toBeUndefined();
|
|
expect(outputs(f, behavior, find)).toEqual(['item:any', 'index:number']);
|
|
expect(behavior.graph.edges.some((e) => e.from.port.startsWith('item.'))).toBe(false);
|
|
f.stack.undo();
|
|
expect(edgesOutOf(behavior.graph, find.id, 'item.Name')).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('a split output following its Struct asset', () => {
|
|
it('moves the edge of a renamed field to the new field port, in one undo step', () => {
|
|
const { f, behavior, find, equals, edit, loot } = finder();
|
|
split(f, behavior, find, 'item', loot.id);
|
|
edit.connect(find, 'item.Name', equals, 'a');
|
|
f.stack.execute(
|
|
new WithStructTypes(new RenameVariableCommand(loot.id, 'Name', 'Title'), {
|
|
structType: loot.id,
|
|
path: [],
|
|
from: 'Name',
|
|
to: 'Title',
|
|
}),
|
|
);
|
|
expect(outputs(f, behavior, find)).toEqual(['item.Title:string', 'item.Amount:number', 'index:number']);
|
|
expect(edgesOutOf(behavior.graph, find.id, 'item.Title')).toHaveLength(1);
|
|
f.stack.undo();
|
|
expect(edgesOutOf(behavior.graph, find.id, 'item.Name')).toHaveLength(1);
|
|
expect(outputs(f, behavior, find)[0]).toBe('item.Name:string');
|
|
});
|
|
|
|
it('gets a new field and loses a removed one together with its edge', () => {
|
|
const { f, behavior, find, equals, edit, loot } = finder();
|
|
split(f, behavior, find, 'item', loot.id);
|
|
edit.connect(find, 'item.Name', equals, 'a');
|
|
f.stack.execute(new WithStructTypes(new AddVariableCommand(loot.id, 'Rare', 'boolean')));
|
|
expect(outputs(f, behavior, find)).toContain('item.Rare:boolean');
|
|
f.stack.execute(new WithStructTypes(new RemoveVariableCommand(loot.id, 'Name')));
|
|
expect(outputs(f, behavior, find)).toEqual(['item.Amount:number', 'item.Rare:boolean', 'index:number']);
|
|
expect(behavior.graph.edges.some((e) => e.from.port === 'item.Name')).toBe(false);
|
|
});
|
|
|
|
it('keeps its last fields when the type is deleted, and the delete dialog counts it', () => {
|
|
const { f, behavior, find, loot } = finder();
|
|
split(f, behavior, find, 'item', loot.id);
|
|
expect(structTypeUsages(f.project, loot.id).nodes).toBe(1);
|
|
f.stack.execute(new DeleteAssetCommand(loot.id));
|
|
expect(outputs(f, behavior, find)).toEqual(['item.Name:string', 'item.Amount:number', 'index:number']);
|
|
});
|
|
|
|
it('survives saving and loading', () => {
|
|
const { f, find, loot } = finder();
|
|
const behavior = f.project.assets.find((a) => a.name === 'Seeker') as BehaviorAsset;
|
|
split(f, behavior, find, 'item', loot.id);
|
|
const loaded = deserializeProject(serializeProject(f.project));
|
|
const again = loaded.assets.find((a) => a.id === behavior.id) as BehaviorAsset;
|
|
expect(again.graph.nodes.find((n) => n.id === find.id)?.splits).toEqual(find.splits);
|
|
});
|
|
|
|
it('follows its variable when that is renamed, field edges included', () => {
|
|
const f = makeProject();
|
|
const loot = lootType(f);
|
|
const behavior = makeBehavior(f);
|
|
f.stack.execute(new AddVariableCommand(behavior.id, 'drop', 'struct'));
|
|
const edit = graphEditor(f, behavior);
|
|
const get = edit.variables(['drop']);
|
|
const log = edit.addNode('Log');
|
|
split(f, behavior, get, 'drop', loot.id);
|
|
edit.connect(get, 'drop.Name', log, 'message');
|
|
f.stack.execute(new RenameVariableCommand(behavior.id, 'drop', 'loot'));
|
|
expect(get.splits?.[0]?.port).toBe('loot');
|
|
expect(edgesOutOf(behavior.graph, get.id, 'loot.Name')).toHaveLength(1);
|
|
f.stack.undo();
|
|
expect(get.splits?.[0]?.port).toBe('drop');
|
|
expect(edgesOutOf(behavior.graph, get.id, 'drop.Name')).toHaveLength(1);
|
|
});
|
|
});
|