From d98c2898b13a3deb7de5488f15923a859a6bdb75 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Tue, 22 Sep 2026 19:58:30 +0200 Subject: [PATCH] A new node takes the first of two values that is not empty (#205) A graph that reads an optional value needed a chain of Equals and Branch just to fall back on a default. The new node does that in one step: it passes a through unless a is empty, in which case b comes out. Both ports are any, so it works for numbers, text, lists, structs and objects alike. - `First not empty` sits in the data category with two any inputs `a` and `b` and one any output `value`. Are both empty, b comes out, so the second input doubles as the default. - `isEmptyValue` in values.ts holds the rule as a pure function: nothing connected, 0, an empty text, false, an empty list, and a struct whose fields are all empty. - The struct case is what covers a position: behavior graphs have no vector type of their own, so (0, 0) arrives as a struct. `{x: 0, y: 2}` therefore counts as set, `{x: 0, y: 0}` as empty. Findings: - A real 0 or false on a is passed over. That follows from counting 0 as empty and is written down in the issue. - The output stays any. Ports that inherit their type from an input do not exist in the graph; `Item at` has the same shape. Tests: - ./check.sh: 142 files, 2104 tests, exit 0 (was 2094). - 10 new catalog cases: a set, a unconnected, 0, "", false, an empty list, a position (0, 0), a struct with one field set, an object, and both empty. - Counter-check: with structs never empty and 0 a real value exactly those two cases fail, the rest stays green. Originals restored and compared with cmp, then rebuilt. - No e2e check: single data nodes have no e2e coverage here, the palette fills itself from BUILT_IN_NODES. Co-Authored-By: Claude Opus 5 --- src/core/graph/nodes/data/FirstNotEmpty.ts | 14 +++++++++++++ src/core/graph/nodes/index.ts | 2 ++ src/core/graph/values.ts | 12 +++++++++++ tests/core/node-catalog.test.ts | 24 ++++++++++++++++++++++ 4 files changed, 52 insertions(+) create mode 100644 src/core/graph/nodes/data/FirstNotEmpty.ts diff --git a/src/core/graph/nodes/data/FirstNotEmpty.ts b/src/core/graph/nodes/data/FirstNotEmpty.ts new file mode 100644 index 00000000..e65e335e --- /dev/null +++ b/src/core/graph/nodes/data/FirstNotEmpty.ts @@ -0,0 +1,14 @@ +import type { NodeDef } from '../../NodeRegistry'; +import { isEmptyValue } from '../../values'; +import { any } from '../shared'; + +export const FirstNotEmpty: NodeDef = { + type: 'FirstNotEmpty', + label: 'First not empty', + category: 'data', + description: 'a, unless it is empty (nothing, 0, "", false, an empty list), then b.', + inputs: [any('a'), any('b')], + outputs: [any('value')], + params: [], + run: (_ctx, inputs) => ({ outputs: { value: (isEmptyValue(inputs.a) ? inputs.b : inputs.a) ?? null } }), +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 77befa3e..22968ca6 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -34,6 +34,7 @@ import { Distance } from './data/Distance'; import { Divide } from './data/Divide'; import { Equals } from './data/Equals'; import { FindText } from './data/FindText'; +import { FirstNotEmpty } from './data/FirstNotEmpty'; import { Floor } from './data/Floor'; import { GetPosition } from './data/GetPosition'; import { IsAsset } from './data/IsAsset'; @@ -192,6 +193,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ FindText, Equals, Compare, + FirstNotEmpty, And, Or, Not, diff --git a/src/core/graph/values.ts b/src/core/graph/values.ts index 62041ee8..7ec35307 100644 --- a/src/core/graph/values.ts +++ b/src/core/graph/values.ts @@ -91,6 +91,18 @@ export function asString(value: Value | undefined): string { return textOf(isValueList(value) ? storedList(value) : value); } +/** + * Whether a value is the empty one of its kind: no object, 0, '', false, an empty list, or a struct + * whose fields are all empty – which is how a position (0, 0) arrives. + */ +export function isEmptyValue(value: Value | undefined): boolean { + if (value === null || value === undefined) return true; + if (isEntity(value)) return false; + if (isValueList(value)) return value.length === 0; + if (isStructValue(value)) return Object.values(value).every(isEmptyValue); + return value === 0 || value === '' || value === false; +} + export function asEntity(value: Value | undefined): EntityHandle | null { return isEntity(value) ? value : null; } diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index 21a267c5..a4aa7dc0 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -694,6 +694,30 @@ const CASES: Record = { outputs: { result: true }, }, ], + FirstNotEmpty: [ + { + name: 'a is taken when it has a value', + inputs: { a: 'Hero', b: 'Nobody' }, + outputs: { value: 'Hero' }, + }, + { name: 'an unconnected a gives b', inputs: { b: 5 }, outputs: { value: 5 } }, + { name: '0 counts as empty', inputs: { a: 0, b: 5 }, outputs: { value: 5 } }, + { name: 'empty text counts as empty', inputs: { a: '', b: 'x' }, outputs: { value: 'x' } }, + { name: 'false counts as empty', inputs: { a: false, b: true }, outputs: { value: true } }, + { name: 'an empty list counts as empty', inputs: { a: [], b: [1] }, outputs: { value: [1] } }, + { name: 'a position (0, 0) counts as empty', inputs: { a: { x: 0, y: 0 }, b: 3 }, outputs: { value: 3 } }, + { + name: 'a struct with one value set is kept', + inputs: { a: { x: 0, y: 2 }, b: 3 }, + outputs: { value: { x: 0, y: 2 } }, + }, + { + name: 'an object is never empty', + inputs: { a: fakeEntity('Coin'), b: 1 }, + outputs: { value: fakeEntity('Coin') }, + }, + { name: 'both empty gives b', inputs: { a: '', b: 0 }, outputs: { value: 0 } }, + ], And: [ { name: 'both true', inputs: { a: true, b: true }, outputs: { result: true } }, { name: 'one false', inputs: { a: true, b: false }, outputs: { result: false } },