Let a Sequence grow an output as you connect the last one
The Outputs setting is gone. A Sequence now keeps one free output below the last connected one: nothing plugged in is one output, connecting the spare grows a new one, unplugging the bottom edge shrinks it back. It counts the last *connected* port rather than the number of edges, so unplugging a middle one leaves a gap instead of silently dropping an edge. Removing the setting also removes a bug it caused. Typing in the Outputs field did nothing and the arrow keys did nothing, while dragging the value worked: onInput fires per keystroke, the preview writes into node.params, that emits 'project', and Inspector.refreshValues() sees the node's signature change -- the port list is part of it -- so it rebuilds and destroys the row being typed in. A drag survives because it keeps receiving globalpointermove on the old widget. refreshValues() has no focus guard where refreshLive() does; the trap is still there for the next typed field that changes a node's shape. Ports that depend on the edges around a node mean derivePorts needs the graph, not just the params, so resolveDef and portSignature take it too. And a node whose ports are derived cannot know how many it has from its type, so run is handed the definition resolved for it.
This commit is contained in:
1 parent
b6a609ae24
commit
3c76106fd2
15 files changed
+159
-111
No files matched your search
@@ -6,7 +6,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||||
import { type NodeContext, type RunResult, requireNodeDef, type Value } from '../../src/core/graph';
|
||||
import type { PropertyBag } from '../../src/core/model/Properties';
|
||||
import { fakeContext, type FakeContext, fakeEntity, fakeHandle, NO_POINTER } from '../helpers';
|
||||
import { fakeContext, type FakeContext, fakeEntity, fakeHandle, NO_POINTER, runDef } from '../helpers';
|
||||
|
||||
interface Case {
|
||||
/** What the case demonstrates – becomes the failure message. */
|
||||
@@ -16,6 +16,8 @@ interface Case {
|
||||
context?: Partial<NodeContext>;
|
||||
/** Expected data outputs, compared exactly. */
|
||||
outputs?: Record<string, Value>;
|
||||
/** Exec outputs the node would have grown in a graph – only for types whose ports are derived. */
|
||||
derivedOutputs?: string[];
|
||||
/** Expected exec continuation; `null` asserts the flow ends here. */
|
||||
next?: string | string[] | null;
|
||||
/** Expected loop request; `null` asserts the node does not loop. */
|
||||
@@ -28,7 +30,10 @@ interface Case {
|
||||
|
||||
const run = (type: string, c: Case): { result: RunResult; ctx: FakeContext } => {
|
||||
const ctx = fakeContext(c.context);
|
||||
return { result: requireNodeDef(type).run!(ctx, c.inputs ?? {}, c.params ?? {}), ctx };
|
||||
const registered = requireNodeDef(type);
|
||||
const grown = (c.derivedOutputs ?? []).map((key) => ({ key, label: key, type: 'exec' as const }));
|
||||
const def = grown.length > 0 ? { ...registered, outputs: [...registered.outputs, ...grown] } : registered;
|
||||
return { result: def.run!(ctx, c.inputs ?? {}, c.params ?? {}, def), ctx };
|
||||
};
|
||||
|
||||
const at = (name: string, x: number, y: number) => ({ ...fakeEntity(name), x, y });
|
||||
@@ -47,9 +52,8 @@ const CASES: Record<string, Case[]> = {
|
||||
{ name: '"false" as text is false', inputs: { condition: 'false' }, next: 'else' },
|
||||
],
|
||||
Sequence: [
|
||||
{ name: 'runs all three outputs in order', next: ['a', 'b', 'c'] },
|
||||
{ name: 'a raised count runs that many', params: { count: 5 }, next: ['a', 'b', 'c', 'd', 'e'] },
|
||||
{ name: 'two is the floor: one output is a plain link', params: { count: 0 }, next: ['a', 'b'] },
|
||||
{ name: 'on its own it has the one output, and follows it', next: ['a'] },
|
||||
{ name: 'runs every output it has grown, in order', derivedOutputs: ['b', 'c'], next: ['a', 'b', 'c'] },
|
||||
],
|
||||
BehaviorEnd: [
|
||||
{
|
||||
@@ -706,48 +710,44 @@ for (const [type, cases] of Object.entries(CASES)) {
|
||||
describe('action nodes write through to the object they are given', () => {
|
||||
it('Move applies dx/dy × scale × dt', () => {
|
||||
const target = at('m', 0, 0);
|
||||
requireNodeDef('Move').run!(fakeContext(), { object: target, dx: 1, dy: -2, scale: 100, dt: 0.5 }, {});
|
||||
runDef('Move', fakeContext(), { object: target, dx: 1, dy: -2, scale: 100, dt: 0.5 }, {});
|
||||
expect({ x: target.x, y: target.y }).toEqual({ x: 50, y: -100 });
|
||||
});
|
||||
|
||||
it('SetPosition places the object absolutely', () => {
|
||||
const target = at('p', 10, 10);
|
||||
requireNodeDef('SetPosition').run!(fakeContext(), { object: target, x: 30, y: -40 }, {});
|
||||
runDef('SetPosition', fakeContext(), { object: target, x: 30, y: -40 }, {});
|
||||
expect({ x: target.x, y: target.y }).toEqual({ x: 30, y: -40 });
|
||||
});
|
||||
|
||||
it('SetProperty keeps the type the property already had', () => {
|
||||
const target = fakeHandle({ width: 100 });
|
||||
requireNodeDef('SetProperty').run!(
|
||||
fakeContext(),
|
||||
{ object: target, value: '250' },
|
||||
{ property: 'width' },
|
||||
);
|
||||
runDef('SetProperty', fakeContext(), { object: target, value: '250' }, { property: 'width' });
|
||||
expect(target.props.width).toBe(250);
|
||||
});
|
||||
|
||||
it('SetProperty without a property name writes nothing', () => {
|
||||
const target = fakeHandle({ width: 100 });
|
||||
requireNodeDef('SetProperty').run!(fakeContext(), { object: target, value: 5 }, {});
|
||||
runDef('SetProperty', fakeContext(), { object: target, value: 5 }, {});
|
||||
expect(target.props).toEqual({ width: 100 });
|
||||
});
|
||||
|
||||
it('SetEnabled switches the object off', () => {
|
||||
const target = fakeHandle({ enabled: true });
|
||||
requireNodeDef('SetEnabled').run!(fakeContext(), { object: target, enabled: false }, {});
|
||||
runDef('SetEnabled', fakeContext(), { object: target, enabled: false }, {});
|
||||
expect(target.props.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('SetText writes the text property, stringifying numbers', () => {
|
||||
const target = fakeHandle({ text: 'a' });
|
||||
requireNodeDef('SetText').run!(fakeContext(), { object: target, text: 12 }, {});
|
||||
runDef('SetText', fakeContext(), { object: target, text: 12 }, {});
|
||||
expect(target.props.text).toBe('12');
|
||||
});
|
||||
|
||||
it('Destroy hands the object to the context', () => {
|
||||
const removed: string[] = [];
|
||||
const ctx = fakeContext({ destroy: (target) => removed.push(target.name) });
|
||||
requireNodeDef('Destroy').run!(ctx, { object: fakeEntity('Coin') }, {});
|
||||
runDef('Destroy', ctx, { object: fakeEntity('Coin') }, {});
|
||||
expect(removed).toEqual(['Coin']);
|
||||
});
|
||||
|
||||
@@ -758,7 +758,7 @@ describe('action nodes write through to the object they are given', () => {
|
||||
restarted = true;
|
||||
},
|
||||
});
|
||||
requireNodeDef('RestartGame').run!(ctx, {}, {});
|
||||
runDef('RestartGame', ctx, {}, {});
|
||||
expect(restarted).toBe(true);
|
||||
});
|
||||
|
||||
@@ -766,7 +766,7 @@ describe('action nodes write through to the object they are given', () => {
|
||||
const ctx = fakeContext();
|
||||
const draws = Array.from(
|
||||
{ length: 200 },
|
||||
() => requireNodeDef('Random').run!(ctx, { min: 5, max: 10 }, {}).outputs!.result as number,
|
||||
() => runDef('Random', ctx, { min: 5, max: 10 }, {}).outputs!.result as number,
|
||||
);
|
||||
expect(draws.every((n) => n >= 5 && n < 10)).toBe(true);
|
||||
// Not a constant: a generator stuck on one value would pass the range check above.
|
||||
|
||||
Reference in new issue
Block a user