Give unconnected node inputs the default the node shows (#234)

A new node handed null to every unconnected input that had no port
default, even a Bool. The inspector showed false, 0, "" or an empty
list at the same time, so what the node read was not what the editor
said. 97 of 135 inputs of the built-in nodes were affected.

- inlineValue() falls back to the default the inspector shows. One
  place covers every node, also future ones and saved projects.
- A default list is handed out as a fresh copy per read, so a node
  that edits its input in place cannot change the shared default.
- The node shows the value it reads: an empty text or list as
  (empty), an object or struct input without a link as (null).
  Live values while playing use the same words.
- A key input without a key keeps showing a dash, like the key field
  in the inspector.
- Visible changes in old projects: To text without input gives ""
  instead of "0", Select and First not empty give "" instead of null.

Tests:
- 8 new unit tests in tests/core/inline-defaults.test.ts, among them
  one over all built-in nodes that no typed-in input reads null.
- The e2e check in module 6 reads the rows of an unlinked Name of
  node. The check sum stays the same.
- ./check.sh: 167 files, 2434 tests, exit 0. e2e 6: 117 checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-26 14:35:43 +02:00
1 parent 8b44f97c35
commit 1d18862ed6
4 files changed
+149 -10

No files matched your search

+21 -4
View File
@@ -1,4 +1,4 @@
import type { PropertyBag, PropertyValue } from '../model/Properties';
import { isList, type PropertyBag, type PropertyValue } from '../model/Properties';
import { isKindWith, plainKindOf } from '../properties/kinds';
import { flatDef, type PropertyDef } from '../properties/PropertySchema';
import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph';
@@ -11,7 +11,7 @@ export interface PortDef {
key: string;
label: string;
type: PortType;
/** Inline value used while the input is unconnected and the node has no own value (default 0/''/false). */
/** Inline value used while the input is unconnected and the node has no own value (else 0/''/false). */
default?: PropertyValue;
/** The property this port carries; its typed-in value is edited as that property (color, enum, …). */
field?: PropertyDef;
@@ -200,7 +200,24 @@ export function inlineInputDef(port: PortDef): PropertyDef {
return def.kind === 'number' ? { ...def, step: 1 } : def;
}
/** The value an unconnected input carries: the node's own inline value, else the port default. */
/**
* The value an unconnected input carries: the node's own inline value, else the default the
* inspector shows for it – `false`, `0`, `""`, an empty list –, so what the node reads is what the
* editor says it reads. Only an input with nothing to type in (object, struct, exec) is `null`.
*/
export function inlineValue(node: { params: PropertyBag }, port: PortDef): Value {
return node.params[port.key] ?? port.default ?? null;
return node.params[port.key] ?? port.default ?? shownDefault(port);
}
/** Cached per port: the interpreter asks for it on every read of an unconnected input. */
const shownDefaults = new WeakMap<PortDef, PropertyValue | null>();
function shownDefault(port: PortDef): Value {
let value = shownDefaults.get(port);
if (value === undefined) {
value = isInlinePort(port) ? inlineInputDef(port).default : null;
shownDefaults.set(port, value);
}
// A list is handed out fresh: a node that edits its input in place must not edit every other one.
return isList(value) ? [...value] : value;
}
+20 -6
View File
@@ -184,10 +184,20 @@ export class NodeView extends Widget {
.map((p) => p.key),
);
this.inlineTexts = new Map();
// Every free input shows what it carries, its default too: an untouched `And` reads "false",
// not nothing, empty text reads "(empty)" – the same value the node computes with. A free
// object or struct has nothing to type in and carries null, which it says as well.
for (const port of inlineInputs(this.def)) {
const value = inlineValue(node, port);
if (!this.connected.has(port.key) && value !== null && value !== '')
this.inlineTexts.set(port.key, formatField(inlineInputDef(port), value, project));
if (this.connected.has(port.key)) continue;
const text = formatField(inlineInputDef(port), inlineValue(node, port), project);
this.inlineTexts.set(port.key, text === '' ? EMPTY_TEXT : text);
}
for (const port of this.def.inputs) {
if (
(port.type === 'object' || port.type === 'struct') &&
edgesInto(graph, node.id, port.key).length === 0
)
this.inlineTexts.set(port.key, NULL_TEXT);
}
this.linked = {
inputs: graph.edges.some((e) => e.to.node === node.id),
@@ -489,9 +499,13 @@ function formatField(def: PropertyDef, value: Value, project: Project | null): s
return referenceLabel(def, project, asString(value)) ?? formatValue(value);
}
/** How a node says "no value" and "empty value" – for inputs and live outputs alike. */
const NULL_TEXT = '(null)';
const EMPTY_TEXT = '(empty)';
function formatLive(output: PortDef, value: Value, project: Project | null): string {
if (value === null) return '—';
if (value === '') return '""';
if (value === null) return NULL_TEXT;
if (value === '') return EMPTY_TEXT;
return output.field ? formatField(output.field, value, project) : formatValue(value);
}
@@ -500,7 +514,7 @@ function formatValue(value: Value): string {
if (typeof value === 'boolean') return value ? 'true' : 'false';
// A list can hold hundreds of items – shown whole it would stretch the node across the graph.
if (isList(value)) {
if (value.length === 0) return 'empty';
if (value.length === 0) return EMPTY_TEXT;
const head = value.slice(0, 4).map(formatValue).join(', ');
return value.length > 4 ? `${head}, … (${value.length})` : head;
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, expect, it } from 'vitest';
import {
GraphInterpreter,
inlineInputDef,
inlineInputs,
inlineValue,
type PortDef,
} from '../../src/core/graph';
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
import { graphLookup } from '../../src/core/model/graphLookup';
import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers';
const untouched = { params: {} };
/** What a flow logs when `build` hangs a value in front of `To text` → `Log`. */
function logged(
build: (
edit: ReturnType<typeof graphEditor>,
) => [ReturnType<ReturnType<typeof graphEditor>['addNode']>, string],
) {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', []);
const edit = graphEditor(fixture, behavior);
const start = edit.addNode('OnStart');
const log = edit.addNode('Log');
const text = edit.addNode('ToText');
const [source, port] = build(edit);
edit.wire([
[start, 'exec', log, 'exec'],
[text, 'text', log, 'message'],
...(source === text ? [] : ([[source, port, text, 'value']] as const)),
]);
const ctx = fakeContext();
new GraphInterpreter(behavior.graph, graphLookup(fixture.project)).fire('OnStart', ctx);
return ctx.logs;
}
describe('the value of an unconnected input', () => {
it('is, on every built-in node, the default the inspector shows – never null', () => {
const wrong: string[] = [];
for (const def of BUILT_IN_NODES) {
for (const port of inlineInputs(def)) {
const value = inlineValue(untouched, port);
const shown = inlineInputDef(port).default;
if (value === null || JSON.stringify(value) !== JSON.stringify(shown))
wrong.push(`${def.type}.${port.key}: ${JSON.stringify(value)} vs ${JSON.stringify(shown)}`);
}
}
expect(wrong).toEqual([]);
});
it('is the zero of its kind: false, 0, "" and an empty list', () => {
const port = (type: PortDef['type']): PortDef => ({ key: 'p', label: 'p', type });
expect(
['boolean', 'number', 'string', 'any', 'list'].map((t) =>
inlineValue(untouched, port(t as PortDef['type'])),
),
).toEqual([false, 0, '', '', []]);
});
it('stays null where nothing can be typed in: an object is a live handle', () => {
expect(inlineValue(untouched, { key: 'o', label: 'o', type: 'object' })).toBeNull();
expect(inlineValue(untouched, { key: 'n', label: 'n', type: 'number', inline: false })).toBeNull();
});
it('takes the node’s own value first, then the port default', () => {
const port: PortDef = { key: 'n', label: 'n', type: 'number', default: 5 };
expect(inlineValue(untouched, port)).toBe(5);
expect(inlineValue({ params: { n: 7 } }, port)).toBe(7);
});
it('hands out a fresh empty list each time, so editing one in place touches no other', () => {
const port: PortDef = { key: 'l', label: 'l', type: 'list' };
const first = inlineValue(untouched, port) as unknown[];
first.push(1);
expect(inlineValue(untouched, port)).toEqual([]);
});
});
describe('what a flow computes from an untouched node', () => {
it('turns empty text into "", not "0"', () => {
expect(logged((edit) => [edit.addNode('ToText'), 'text'])).toEqual(['']);
});
it('lets Select and First not empty pass on "" instead of nothing', () => {
expect(logged((edit) => [edit.addNode('Select'), 'value'])).toEqual(['']);
expect(logged((edit) => [edit.addNode('FirstNotEmpty'), 'value'])).toEqual(['']);
});
it('still reads a typed-in number as that number', () => {
expect(logged((edit) => [edit.addNode('Number', { value: 3 }), 'value'])).toEqual(['3']);
});
});
+15
View File
@@ -946,6 +946,7 @@ export async function graph(t) {
{ id: 'p-text', type: 'String', x: 0, y: 300, params: { value: 'hi' } },
{ id: 'p-pick', type: 'FirstNotEmpty', x: 300, y: 60, params: {} },
{ id: 'p-sub', type: 'Substring', x: 600, y: 60, params: {} },
{ id: 'p-name', type: 'NameOf', x: 600, y: 300, params: {} },
],
edges: [],
},
@@ -959,6 +960,20 @@ export async function graph(t) {
(await pickTypes()).outputs.value === 'any',
JSON.stringify(await pickTypes()),
);
// An untouched input shows the value the node computes with: empty text reads "(empty)", a
// number 0, and an object with nothing plugged in "(null)".
const untouched = {
pick: await json(`__gm.graph.nodeInputs('p-pick')`),
sub: await json(`__gm.graph.nodeInputs('p-sub')`),
name: await json(`__gm.graph.nodeInputs('p-name')`),
};
check(
'untouched inputs show their default beside the name: (empty), 0 and (null) for an object',
JSON.stringify(untouched.pick) === JSON.stringify(['a (empty)', 'b (empty)']) &&
JSON.stringify(untouched.sub) === JSON.stringify(['text (empty)', 'start 0', 'count -1']) &&
JSON.stringify(untouched.name) === JSON.stringify(['object (null)']),
JSON.stringify(untouched),
);
const wire = async (fromNode, fromPort, toNode, toPort) => {
const from = await json(