diff --git a/src/core/graph/nodes/data/FirstNotEmpty.ts b/src/core/graph/nodes/data/FirstNotEmpty.ts index e65e335e..bf604cb7 100644 --- a/src/core/graph/nodes/data/FirstNotEmpty.ts +++ b/src/core/graph/nodes/data/FirstNotEmpty.ts @@ -1,14 +1,38 @@ +import { textOf } from '../../../model/Properties'; import type { NodeDef } from '../../NodeRegistry'; -import { isEmptyValue } from '../../values'; +import { type EmptyMode, isEmptyValue } from '../../values'; import { any } from '../shared'; +const MODES: Record = { + any: 'any', + nothing: 'nothing', + null: 'null', +}; + 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.', + description: 'a, unless it is empty, then b. The setting says what is empty.', inputs: [any('a'), any('b')], outputs: [any('value')], - params: [], - run: (_ctx, inputs) => ({ outputs: { value: (isEmptyValue(inputs.a) ? inputs.b : inputs.a) ?? null } }), + params: [ + { + key: 'empty', + label: 'Empty means', + kind: 'enum', + default: 'any', + options: [ + { value: 'any', label: 'Any empty value' }, + { value: 'nothing', label: 'Nothing there (0 and false count)' }, + { value: 'null', label: 'Only a missing value' }, + ], + }, + ], + run: (_ctx, inputs, params) => { + // The fallback matches the param's declared default, so a node with no setting behaves + // like a freshly created one. + const mode = MODES[textOf(params.empty)] ?? 'any'; + return { outputs: { value: (isEmptyValue(inputs.a, mode) ? inputs.b : inputs.a) ?? null } }; + }, }; diff --git a/src/core/graph/values.ts b/src/core/graph/values.ts index 7ec35307..a927ba8d 100644 --- a/src/core/graph/values.ts +++ b/src/core/graph/values.ts @@ -92,14 +92,22 @@ export function asString(value: Value | undefined): string { } /** - * 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. + * How much counts as empty: every empty value of its kind, only the ones that carry nothing (0 and + * false are values then), or nothing but a missing one. */ -export function isEmptyValue(value: Value | undefined): boolean { +export type EmptyMode = 'any' | 'nothing' | 'null'; + +/** + * 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. `mode` narrows that down. + */ +export function isEmptyValue(value: Value | undefined, mode: EmptyMode = 'any'): boolean { if (value === null || value === undefined) return true; + if (mode === 'null') return false; if (isEntity(value)) return false; if (isValueList(value)) return value.length === 0; - if (isStructValue(value)) return Object.values(value).every(isEmptyValue); + if (isStructValue(value)) return Object.values(value).every((field) => isEmptyValue(field, mode)); + if (mode === 'nothing') return value === ''; return value === 0 || value === '' || value === false; } diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index a4aa7dc0..e6cc6a58 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -717,6 +717,60 @@ const CASES: Record = { outputs: { value: fakeEntity('Coin') }, }, { name: 'both empty gives b', inputs: { a: '', b: 0 }, outputs: { value: 0 } }, + { + name: '"nothing there" keeps a 0', + inputs: { a: 0, b: 5 }, + params: { empty: 'nothing' }, + outputs: { value: 0 }, + }, + { + name: '"nothing there" keeps a false', + inputs: { a: false, b: true }, + params: { empty: 'nothing' }, + outputs: { value: false }, + }, + { + name: '"nothing there" still falls back on empty text', + inputs: { a: '', b: 'x' }, + params: { empty: 'nothing' }, + outputs: { value: 'x' }, + }, + { + name: '"nothing there" still falls back on an empty list', + inputs: { a: [], b: [1] }, + params: { empty: 'nothing' }, + outputs: { value: [1] }, + }, + { + name: '"nothing there" keeps a position (0, 0)', + inputs: { a: { x: 0, y: 0 }, b: 3 }, + params: { empty: 'nothing' }, + outputs: { value: { x: 0, y: 0 } }, + }, + { + name: '"only a missing value" keeps an empty text', + inputs: { a: '', b: 'x' }, + params: { empty: 'null' }, + outputs: { value: '' }, + }, + { + name: '"only a missing value" keeps an empty list', + inputs: { a: [], b: [1] }, + params: { empty: 'null' }, + outputs: { value: [] }, + }, + { + name: '"only a missing value" still falls back on an unconnected a', + inputs: { b: 5 }, + params: { empty: 'null' }, + outputs: { value: 5 }, + }, + { + name: 'an unknown setting behaves like the default', + inputs: { a: 0, b: 5 }, + params: { empty: 'nonsense' }, + outputs: { value: 5 }, + }, ], And: [ { name: 'both true', inputs: { a: true, b: true }, outputs: { result: true } },