From d7a2521ca76a1eb3109ed70e708ffbad29b2f0eb Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Tue, 22 Sep 2026 21:15:21 +0200 Subject: [PATCH] A port can take its type from what is plugged into it (#207) Until now every port of "First not empty" was grey and typeless, so the editor could not tell you what actually travels through the node. The output is now typed as soon as both inputs agree, and every any-input is painted in the colour of its own edge. Nothing changes at runtime. - A NodeDef can declare which of its ports carry the same value (`sharedType: { inputs, outputs }`). The rule itself lives as a pure function `agreedType` in `src/core/graph/portTypes.ts`. - Only the output inherits. It becomes the shared type when every input of the group is connected and they all carry the same type, otherwise it stays `any`. - The inputs stay `any` on purpose. `canConnect` checks against the port type and a busy input is replaced by the new edge, so a typed `a` could never be re-plugged with a text without disconnecting first. - Chains do not propagate. When looking up what feeds an input, a node of the same kind reports the `any` it is registered with, so two of them in a row stay untyped and a data cycle cannot ask itself in a circle. - An edge that the new type makes invalid stays where it is and turns red. `EdgeLayer` already paints a mismatching edge with `danger`, nothing is disconnected on its own. - New `PortDef.paintType`: colour only, never truth. It is set in `drawnDef` and read in `NodeView.redraw`, so `canConnect`, the interpreter and the compiler keep seeing `any`. Every connected any-input is painted, not just members of a `sharedType` group; ghost ports keep their hollow red look. - The pin now matches the edge that ends in it, because both take the colour from the resolved output type of the neighbour. Mixed sources therefore show two colours, which explains why the output stays grey. - New hooks `__gm.graph.portTypes(id)`, `__gm.graph.edgeColor(id)` and `__gm.themeColor(name)`. `portTypes` reports type and paint colour separately so the type checks do not silently become colour checks. Findings: - `resolveDef` only appends derived ports to the registered ones, so a fixed port cannot be retyped that way -- it would appear twice. The shared type is therefore resolved before the ports are built. - Answer 5 of the issue (inline field follows the type) has no case left under variant A: an unconnected input stays `any` and keeps its text field. - `Item at`, `Join` and `Equals` do not profit: `Item at` has no any-input, the other two have fixed outputs. The next candidates are the shader arithmetic nodes, which were left alone. Tests: - ./check.sh -> 143 files, 2127 tests, exit 0 (was 142 / 2113) - GM_STEPS=1-6 fresh -> ALL PASSED, 245 checks (was 242), 9 of them new - 14 new unit tests in tests/core/shared-port-type.test.ts - 9 new e2e checks in block 6z, one of them a pixel check straight on the pin via pixelAt + themeColor - Counter-probes all bite: filtering unconnected inputs out of `agreedType` fails 3 unit tests, ignoring `inherit` fails 1, killing the derivation in the bundle fails exactly the 2 expected e2e checks, and never setting `paintType` fails 4 unit tests plus the 3 pin checks (#bdbdbd instead of #e6a8ff / #6fcf97) - Screenshot read: `a` pink, `b` green, output `value` grey, edges in the same colours - Check total 1302 (1299 + 3), 1293 was measured by CI at 1a3babf Co-Authored-By: Claude Opus 5 --- README.md | 2 +- src/core/graph/NodeRegistry.ts | 13 ++ src/core/graph/index.ts | 1 + src/core/graph/nested.ts | 101 +++++++++- src/core/graph/nodes/data/FirstNotEmpty.ts | 2 + src/core/graph/portTypes.ts | 21 +++ src/editor/debugHook.ts | 7 + src/editor/graph/EdgeLayer.ts | 11 +- src/editor/graph/GraphCanvas.ts | 29 ++- src/editor/graph/NodeView.ts | 3 +- src/ui/Theme.ts | 2 +- tests/core/ghost-ports.test.ts | 8 +- tests/core/shared-port-type.test.ts | 205 +++++++++++++++++++++ tools/e2e/06-graph.mjs | 113 ++++++++++++ 14 files changed, 499 insertions(+), 19 deletions(-) create mode 100644 src/core/graph/portTypes.ts create mode 100644 tests/core/shared-port-type.test.ts diff --git a/README.md b/README.md index 793ca2f4..f16d14ca 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1293 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1302 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index d4b612f5..40809b8c 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -4,6 +4,7 @@ import { flatDef, type PropertyDef } from '../properties/PropertySchema'; import type { DeclaredPort, Graph, GraphNode, PortType } from './Graph'; import type { GraphLookup } from './lookup'; import type { NodeContext } from './NodeContext'; +import type { SharedPortGroup } from './portTypes'; import { asList, storedList, type Value } from './values'; export interface PortDef { @@ -18,6 +19,12 @@ export interface PortDef { inline?: boolean; /** Not a port of the definition: a key only an edge still names, drawn so that edge can be seen. */ ghost?: true; + /** + * Drawing only: the colour an `any` input is painted in, taken from the edge that feeds it (#207). + * Its `type` stays `any`, so the port keeps taking everything and can be swapped at any time – + * only the pin says what currently flows in. Set by `drawnDef`, read by `NodeView`, nowhere else. + */ + paintType?: PortType; } /** @@ -102,6 +109,12 @@ export interface NodeDef { * offers, which values the behavior a call node names takes. `resolveDef` applies it. */ derivePorts?(source: PortSource): DerivedShape; + /** + * Ports carrying one and the same value: the outputs take the type the inputs agree on, so an + * `any` output is drawn, validated and edited as what actually flows through it (#207). + * `resolveDef` applies it; only the outputs change, the inputs stay `any` and take everything. + */ + sharedType?: SharedPortGroup; /** * Outputs readable at any moment, even while the node's exec input has not fired in this pulse. * Never cached, so a read after a write sees the new value. `input` pulls one input on demand: diff --git a/src/core/graph/index.ts b/src/core/graph/index.ts index 7b6fdd3e..3bade121 100644 --- a/src/core/graph/index.ts +++ b/src/core/graph/index.ts @@ -7,6 +7,7 @@ export * from './lookup'; export * from './nested'; export * from './NodeContext'; export * from './NodeRegistry'; +export * from './portTypes'; export * from './propertyPorts'; export * from './savedValues'; export * from './sharedState'; diff --git a/src/core/graph/nested.ts b/src/core/graph/nested.ts index 5a64ddef..eab794c2 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -1,9 +1,19 @@ import { type PropertyBag, textOf } from '../model/Properties'; import { isTaggedKind } from '../properties/kinds'; import { flatDef, isValidFieldName, type PropertyDef } from '../properties/PropertySchema'; -import { type DeclaredPort, declaredPortType, type Graph, type GraphEdge, type GraphNode } from './Graph'; +import { + type DeclaredPort, + declaredPortType, + edgesInto, + findNode, + type Graph, + type GraphEdge, + type GraphNode, + type PortType, +} from './Graph'; import type { GraphLookup } from './lookup'; import { nodeDef, type NodeDef, type PortDef } from './NodeRegistry'; +import { agreedType, type SharedPortGroup } from './portTypes'; export const BEHAVIOR_START = 'BehaviorStart'; export const BEHAVIOR_END = 'BehaviorEnd'; @@ -107,15 +117,86 @@ export function declaredPorts(graph: Graph | undefined, type: string): PortDef[] * type declares ports per node (`declaresPorts`) or computes them (`derivePorts`). */ export function resolveDef(node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef | undefined { + return shapedDef(node, graph, lookup, true); +} + +/** + * `inherit` is off while answering what feeds a shared group: a node of that kind then reports the + * `any` it was registered with, so two of them in a row cannot ask each other in circles (#207). + */ +function shapedDef( + node: GraphNode, + graph: Graph, + lookup: GraphLookup, + inherit: boolean, +): NodeDef | undefined { const def = nodeDef(node.type); - if (!def || (!def.declaresPorts && !def.derivePorts)) return def; + if (!def) return undefined; + const shared = inherit ? def.sharedType : undefined; + if (!def.declaresPorts && !def.derivePorts) + return shared ? withSharedType(def, shared, node, graph, lookup) : def; const own = (side: 'inputs' | 'outputs') => declaredOn(def, node.ports ?? [], side).map((p) => def.declaredPort ? def.declaredPort(p, node) : portDefOf(p), ); const grown = def.derivePorts?.({ node, graph, lookup }) ?? {}; const side = (key: 'inputs' | 'outputs') => execFirst([...def[key], ...(grown[key] ?? []), ...own(key)]); - return { ...def, label: grown.label ?? def.label, inputs: side('inputs'), outputs: side('outputs') }; + const resolved = { + ...def, + label: grown.label ?? def.label, + inputs: side('inputs'), + outputs: side('outputs'), + }; + return shared ? withSharedType(resolved, shared, node, graph, lookup) : resolved; +} + +/** The group's outputs, retyped to what its inputs agree on; `any` leaves the definition alone. */ +function withSharedType( + def: NodeDef, + group: SharedPortGroup, + node: GraphNode, + graph: Graph, + lookup: GraphLookup, +): NodeDef { + const type = agreedType(group.inputs.map((key) => feedingType(node, key, graph, lookup, false))); + if (type === 'any') return def; + return { + ...def, + outputs: def.outputs.map((p) => (group.outputs.includes(p.key) ? { ...p, type } : p)), + }; +} + +/** + * What the edge into that input carries, or undefined while nothing is connected to it. `inherit` + * says whether a shared group on the *feeding* node may have its say: off while a group asks what + * feeds it, on when only the drawn colour is wanted, which is what the edge itself is drawn in. + */ +function feedingType( + node: GraphNode, + key: string, + graph: Graph, + lookup: GraphLookup, + inherit: boolean, +): PortType | undefined { + const edge = edgesInto(graph, node.id, key)[0]; + if (!edge) return undefined; + const from = findNode(graph, edge.from.node); + const def = from && shapedDef(from, graph, lookup, inherit); + return def?.outputs.find((p) => p.key === edge.from.port)?.type; +} + +/** + * `any` inputs painted as what flows into them: an unconnected one stays grey, a connected one takes + * the colour of its own edge, so a group feeding two different types is visible as two colours. + * Only `paintType` is set – the port's `type` stays `any`, so `canConnect` keeps taking everything. + */ +function paintedInputs(node: GraphNode, graph: Graph, def: NodeDef, lookup: GraphLookup): PortDef[] | null { + if (!def.inputs.some((port) => port.type === 'any' && !port.ghost)) return null; + return def.inputs.map((port) => { + if (port.type !== 'any' || port.ghost) return port; + const fed = feedingType(node, port.key, graph, lookup, true); + return fed === undefined || fed === 'any' || fed === 'exec' ? port : { ...port, paintType: fed }; + }); } /** A derived exec pin belongs at the top, above the fixed data ports too. */ @@ -150,18 +231,20 @@ function ghostPort(key: string): PortDef { } /** - * The definition as the canvas draws it: the real ports, then the keys only edges still name. The - * ghosts go last and skip `execFirst`, so no real row moves because of one – and they stay out of - * `resolveDef`, which the interpreter, the compilers and `ConnectCommand` read as the truth. + * The definition as the canvas draws it: the real ports with their pin colours (`paintedInputs`), + * then the keys only edges still name. The ghosts go last and skip `execFirst`, so no real row moves + * because of one – and none of this reaches `resolveDef`, which the interpreter, the compilers and + * `ConnectCommand` read as the truth. */ -export function drawnDef(node: GraphNode, graph: Graph, def: NodeDef): NodeDef { +export function drawnDef(node: GraphNode, graph: Graph, def: NodeDef, lookup: GraphLookup): NodeDef { + const painted = paintedInputs(node, graph, def, lookup); const ends = ghostEnds(node, graph, def); - if (ends.length === 0) return def; + if (ends.length === 0) return painted ? { ...def, inputs: painted } : def; const side = (isOutput: boolean) => [...new Set(ends.filter((e) => e.isOutput === isOutput).map((e) => e.key))].map(ghostPort); return { ...def, - inputs: [...def.inputs, ...side(false)], + inputs: [...(painted ?? def.inputs), ...side(false)], outputs: [...def.outputs, ...side(true)], }; } diff --git a/src/core/graph/nodes/data/FirstNotEmpty.ts b/src/core/graph/nodes/data/FirstNotEmpty.ts index bf604cb7..0bc59075 100644 --- a/src/core/graph/nodes/data/FirstNotEmpty.ts +++ b/src/core/graph/nodes/data/FirstNotEmpty.ts @@ -16,6 +16,8 @@ export const FirstNotEmpty: NodeDef = { description: 'a, unless it is empty, then b. The setting says what is empty.', inputs: [any('a'), any('b')], outputs: [any('value')], + // Whatever goes in comes out: with both inputs on the same type, `value` is that type too (#207). + sharedType: { inputs: ['a', 'b'], outputs: ['value'] }, params: [ { key: 'empty', diff --git a/src/core/graph/portTypes.ts b/src/core/graph/portTypes.ts new file mode 100644 index 00000000..92d81a33 --- /dev/null +++ b/src/core/graph/portTypes.ts @@ -0,0 +1,21 @@ +import type { PortType } from './Graph'; + +/** + * Ports of one node that carry the same value: the outputs take the type the inputs agree on + * (#207). Inputs keep their own type, so a connected one can always be swapped for another type. + */ +export interface SharedPortGroup { + inputs: string[]; + outputs: string[]; +} + +/** + * The type a group agrees on, given what feeds each of its inputs (`undefined`: nothing does). + * `any` unless every input is connected and all of them carry the same, known type – an unconnected + * input could still become anything, so its group says nothing yet. + */ +export function agreedType(sources: readonly (PortType | undefined)[]): PortType { + const first = sources[0]; + if (sources.length === 0 || first === undefined || first === 'any' || first === 'exec') return 'any'; + return sources.every((type) => type === first) ? first : 'any'; +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index b010a78d..4c5795bb 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -39,6 +39,7 @@ import { currentGradientBar, currentShapeList } from './inspector/shapeSection'; import type { SnapToggle } from './graph/SnapToggle'; import type { EditorState, TilemapTool } from './state'; import { rectOf, textProbe } from './textProbe'; +import { Theme } from '../ui/Theme'; /** * Installs `window.__gm`, which the check scripts in tools/ use to find widgets by their @@ -218,6 +219,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E }, /** What a panel dragged shut keeps: its header. Follows the theme, so checks need not. */ panelHeader: Panel.HEADER_HEIGHT, + /** A theme colour as a hex string, so a check names the colour instead of its current value. */ + themeColor: (name: keyof typeof Theme.colors) => `#${Theme.colors[name].toString(16).padStart(6, '0')}`, inspectorInfo: () => inspector.debugInfo(), textColor, overlayTexts, @@ -354,6 +357,10 @@ export function installDebugHook(app: Application, state: EditorState, editor: E dropTarget: () => graphEditor.canvas.pendingDropTarget(), danglingEdge: () => graphEditor.canvas.danglingEdgeVisible(), edgeDrawn: (edgeId: string) => graphEditor.canvas.edgeDrawn(edgeId), + /** The colour it was stroked in: `danger` is an edge whose two ends stopped fitting (#207). */ + edgeColor: (edgeId: string) => graphEditor.canvas.edgeColor(edgeId), + /** The types the node's ports are drawn with, after a shared group had its say (#207). */ + portTypes: (nodeId: string) => graphEditor.canvas.portTypes(nodeId), foldMark: (edgeId: string, isOutput: boolean) => graphEditor.canvas.foldMarkGlobal(edgeId, isOutput), foldMarks: () => graphEditor.canvas.foldMarksDrawn(), // The canvas, not the whole tab: a centre measured through the header sits above the diff --git a/src/editor/graph/EdgeLayer.ts b/src/editor/graph/EdgeLayer.ts index db80ab9a..a63a0082 100644 --- a/src/editor/graph/EdgeLayer.ts +++ b/src/editor/graph/EdgeLayer.ts @@ -67,8 +67,8 @@ export class EdgeLayer extends Container { private labelCount = 0; private ends: FoldMarkEnd[] = []; private marks: FoldMark[] = []; - /** Edges the last draw really stroked as a curve – a hidden one is missing from it. */ - private readonly stroked = new Set(); + /** Edges the last draw really stroked as a curve, with the colour it used – a hidden one is missing. */ + private readonly stroked = new Map(); private beadsDrawn: { edgeId: string; progress: number; x: number; y: number }[] = []; /** Sideways shift per transition, so two between the same states do not lie on top of each other. */ private offsets = new Map(); @@ -120,6 +120,11 @@ export class EdgeLayer extends Container { return this.stroked.has(edgeId); } + /** The colour the last draw gave that edge – `danger` is one whose two ends no longer fit. */ + strokeColor(edgeId: string): number | null { + return this.stroked.get(edgeId) ?? null; + } + drawnBeads(): readonly { edgeId: string; progress: number; x: number; y: number }[] { return this.beadsDrawn; } @@ -184,7 +189,7 @@ export class EdgeLayer extends Container { loop ? loopPoint(loop, 0.5) : { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 }, loop ? { x: 1, y: 0 } : (direction(start, end) ?? { x: 1, y: 0 }), ); - this.stroked.add(edge.id); + this.stroked.set(edge.id, color); for (const progress of beads) { const { x, y } = loop ? loopPoint(loop, progress) diff --git a/src/editor/graph/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index 00b2f371..17e49bf4 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -29,6 +29,7 @@ import { type GraphNode, type GraphLookup, MIN_ANNOTATION_SIZE, + type PortDef, type PortSide, type PortType, portTypeOf, @@ -305,7 +306,7 @@ export class GraphCanvas extends Widget { for (const node of asset.graph.nodes) { const resolved = resolveDef(node, asset.graph, lookup); if (!resolved) continue; - const def = drawnDef(node, asset.graph, resolved); + const def = drawnDef(node, asset.graph, resolved, lookup); seen.add(node.id); let view = this.views.get(node.id); if (!view || view.node !== node) { @@ -1198,6 +1199,32 @@ export class GraphCanvas extends Widget { return this.edgeLayer.wasStroked(edgeId); } + /** As a hex string, so a test can name the colour it expects: `danger` marks an edge gone invalid. */ + edgeColor(edgeId: string): string | null { + const color = this.edgeLayer.strokeColor(edgeId); + return color === null ? null : `#${color.toString(16).padStart(6, '0')}`; + } + + /** + * The port types the node is drawn with – what a shared group (#207) makes of its inputs. `paint` + * is what an input's pin is *coloured* as, which can differ: the type stays `any`, the pin follows + * the edge feeding it. + */ + portTypes(nodeId: string): { + inputs: Record; + outputs: Record; + paint: Record; + } | null { + const view = this.views.get(nodeId); + if (!view) return null; + const side = (ports: readonly PortDef[]) => Object.fromEntries(ports.map((p) => [p.key, p.type])); + return { + inputs: side(view.def.inputs), + outputs: side(view.def.outputs), + paint: Object.fromEntries(view.def.inputs.map((p) => [p.key, p.paintType ?? p.type])), + }; + } + /** The name drawn on a transition, or null when nothing is drawn on it. */ transitionLabelDrawn(edgeId: string): string | null { return this.edgeLayer.drawnTransitionLabels().find((l) => l.edgeId === edgeId)?.text ?? null; diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index 9da4de13..066987a7 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -445,7 +445,8 @@ export class NodeView extends Widget { .closePath() .fill(portColor('exec')); } else { - this.ports.circle(x, y, r).fill(portColor(port.type)); + // `paintType`: an `any` input takes the colour of the edge feeding it, its type stays `any`. + this.ports.circle(x, y, r).fill(portColor(port.paintType ?? port.type)); this.ports.circle(x, y, r).stroke({ width: 1, color: c.node }); } } diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index 15270b92..8a863039 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -5,7 +5,7 @@ export const Theme = { // Both ship with the build (`fonts.ts`); the rest only covers glyphs outside their Latin subset. font: '"Source Sans 3", "Segoe UI", Roboto, Helvetica, Arial, sans-serif', monoFont: '"Source Code Pro", Consolas, "Courier New", monospace', - fontSize: 12, + fontSize: 13, smallFontSize: 11, viewportLabelSize: 10, rowHeight: 22, diff --git a/tests/core/ghost-ports.test.ts b/tests/core/ghost-ports.test.ts index 227dcc65..7d7776af 100644 --- a/tests/core/ghost-ports.test.ts +++ b/tests/core/ghost-ports.test.ts @@ -52,7 +52,7 @@ function setup() { const real = resolveDef(node, asset.graph, graphLookup(f.project))!; return { real, - drawn: drawnDef(node, asset.graph, real), + drawn: drawnDef(node, asset.graph, real, graphLookup(f.project)), problem: ghostProblem(node, asset.graph, real), }; }; @@ -120,10 +120,12 @@ describe('an edge on a port that is gone', () => { edit.connect(number, 'value', call, 'in1'); const node = () => caller.graph.nodes.find((n) => n.id === call.id)!; const real = () => resolveDef(node(), caller.graph, graphLookup(f.project))!; - expect(drawnDef(node(), caller.graph, real()).inputs.filter((p) => p.ghost)).toHaveLength(0); + expect( + drawnDef(node(), caller.graph, real(), graphLookup(f.project)).inputs.filter((p) => p.ghost), + ).toHaveLength(0); f.stack.execute(new RemoveNodeCommand(callee.id, start.id)); expect( - drawnDef(node(), caller.graph, real()) + drawnDef(node(), caller.graph, real(), graphLookup(f.project)) .inputs.filter((p) => p.ghost) .map((p) => p.key), ).toEqual(['in1']); diff --git a/tests/core/shared-port-type.test.ts b/tests/core/shared-port-type.test.ts new file mode 100644 index 00000000..44ef342d --- /dev/null +++ b/tests/core/shared-port-type.test.ts @@ -0,0 +1,205 @@ +/** + * Ports carrying one value (`NodeDef.sharedType`, #207): the outputs take the type the inputs agree + * on, the inputs stay `any`. Only the editor sees this – the interpreter passes `Value` either way. + */ +import { describe, expect, it } from 'vitest'; +import { + agreedType, + canConnect, + drawnDef, + type GraphNode, + type PortDef, + portsCompatible, + portTypeOf, + resolveDef, +} from '../../src/core/graph'; +import { graphLookup } from '../../src/core/model/graphLookup'; +import type { BehaviorAsset } from '../../src/core/model/Asset'; +import { graphEditor, makeBehavior, makeProject, type ProjectFixture } from '../helpers'; + +function fixture() { + const project = makeProject(); + const behavior = makeBehavior(project, 'B'); + return { ...project, behavior, edit: graphEditor(project, behavior) }; +} + +const typeOf = ( + behavior: BehaviorAsset, + node: GraphNode, + key: string, + fixture: ProjectFixture, + side: 'inputs' | 'outputs' = 'outputs', +) => resolveDef(node, behavior.graph, graphLookup(fixture.project))![side].find((p) => p.key === key)!.type; + +describe('the type a group of ports agrees on', () => { + it('is any unless every source is known and the same', () => { + expect(agreedType(['number', 'number'])).toBe('number'); + expect(agreedType(['string'])).toBe('string'); + expect(agreedType(['number', 'string'])).toBe('any'); + expect(agreedType(['number', undefined])).toBe('any'); + expect(agreedType([undefined, undefined])).toBe('any'); + expect(agreedType(['any', 'any'])).toBe('any'); + expect(agreedType(['exec', 'exec'])).toBe('any'); + expect(agreedType([])).toBe('any'); + }); +}); + +describe('First not empty, whose a, b and value carry the same value', () => { + it('hands out any while nothing is connected', () => { + const f = fixture(); + const node = f.edit.addNode('FirstNotEmpty'); + expect(typeOf(f.behavior, node, 'value', f)).toBe('any'); + }); + + it('takes the type once both inputs carry it', () => { + const f = fixture(); + const node = f.edit.addNode('FirstNotEmpty'); + f.edit.connect(f.edit.addNode('Number'), 'value', node, 'a'); + // One input says nothing yet: the other could still become anything. + expect(typeOf(f.behavior, node, 'value', f)).toBe('any'); + + f.edit.connect(f.edit.addNode('Number'), 'value', node, 'b'); + expect(typeOf(f.behavior, node, 'value', f)).toBe('number'); + }); + + it('falls back to any when the two inputs disagree', () => { + const f = fixture(); + const node = f.edit.addNode('FirstNotEmpty'); + f.edit.connect(f.edit.addNode('Number'), 'value', node, 'a'); + f.edit.connect(f.edit.addNode('String'), 'value', node, 'b'); + expect(typeOf(f.behavior, node, 'value', f)).toBe('any'); + }); + + it('is any again as soon as one of the edges is undone', () => { + const f = fixture(); + const node = f.edit.addNode('FirstNotEmpty'); + f.edit.connect(f.edit.addNode('Self'), 'object', node, 'a'); + f.edit.connect(f.edit.addNode('Self'), 'object', node, 'b'); + expect(typeOf(f.behavior, node, 'value', f)).toBe('object'); + + f.stack.undo(); + expect(typeOf(f.behavior, node, 'value', f)).toBe('any'); + }); + + it('keeps its inputs any, so a connected one can be swapped for another type', () => { + const f = fixture(); + const node = f.edit.addNode('FirstNotEmpty'); + f.edit.wire([ + [f.edit.addNode('Number'), 'value', node, 'a'], + [f.edit.addNode('Number'), 'value', node, 'b'], + ]); + expect(typeOf(f.behavior, node, 'a', f, 'inputs')).toBe('any'); + + const text = f.edit.addNode('String'); + const may = canConnect( + f.behavior.graph, + { nodeId: text.id, port: 'value', isOutput: true }, + { nodeId: node.id, port: 'a', isOutput: false }, + graphLookup(f.project), + ); + expect(may).toBe(true); + }); + + it('does not pass the type on through a second one of its kind', () => { + const f = fixture(); + const first = f.edit.addNode('FirstNotEmpty'); + const second = f.edit.addNode('FirstNotEmpty'); + f.edit.wire([ + [f.edit.addNode('Number'), 'value', first, 'a'], + [f.edit.addNode('Number'), 'value', first, 'b'], + [first, 'value', second, 'a'], + [first, 'value', second, 'b'], + ]); + expect(typeOf(f.behavior, first, 'value', f)).toBe('number'); + // A chain would have to resolve its neighbour to answer, and two of them would ask in circles. + expect(typeOf(f.behavior, second, 'value', f)).toBe('any'); + }); + + it('leaves an edge that the new type makes invalid in place, for the canvas to mark', () => { + const f = fixture(); + const node = f.edit.addNode('FirstNotEmpty'); + const join = f.edit.addNode('Substring'); + f.edit.connect(node, 'value', join, 'text'); + f.edit.wire([ + [f.edit.addNode('Number'), 'value', node, 'a'], + [f.edit.addNode('Number'), 'value', node, 'b'], + ]); + + expect(f.behavior.graph.edges.filter((e) => e.to.node === join.id)).toHaveLength(1); + const lookup = graphLookup(f.project); + const out = portTypeOf(f.behavior.graph, { nodeId: node.id, port: 'value', isOutput: true }, lookup)!; + const inp = portTypeOf(f.behavior.graph, { nodeId: join.id, port: 'text', isOutput: false }, lookup)!; + expect(portsCompatible(out, inp)).toBe(false); + }); +}); + +/** As the canvas draws it: `drawnDef` is the only place a pin colour is decided. */ +const inputPin = (behavior: BehaviorAsset, node: GraphNode, key: string, f: ProjectFixture): PortDef => { + const lookup = graphLookup(f.project); + const real = resolveDef(node, behavior.graph, lookup)!; + return drawnDef(node, behavior.graph, real, lookup).inputs.find((p) => p.key === key)!; +}; + +describe('an any input painted as what flows into it', () => { + it('stays grey while nothing is connected', () => { + const f = fixture(); + const node = f.edit.addNode('FirstNotEmpty'); + expect(inputPin(f.behavior, node, 'a', f).paintType).toBeUndefined(); + }); + + it('takes the colour of its own edge while its type stays any', () => { + const f = fixture(); + const node = f.edit.addNode('FirstNotEmpty'); + f.edit.connect(f.edit.addNode('Number'), 'value', node, 'a'); + const pin = inputPin(f.behavior, node, 'a', f); + expect(pin.paintType).toBe('number'); + // The type is what canConnect reads: painted or not, the port still takes everything. + expect(pin.type).toBe('any'); + }); + + it('paints each input on its own, so a disagreeing pair shows two colours', () => { + const f = fixture(); + const node = f.edit.addNode('FirstNotEmpty'); + f.edit.wire([ + [f.edit.addNode('Number'), 'value', node, 'a'], + [f.edit.addNode('String'), 'value', node, 'b'], + ]); + expect(inputPin(f.behavior, node, 'a', f).paintType).toBe('number'); + expect(inputPin(f.behavior, node, 'b', f).paintType).toBe('string'); + // Exactly the case in which the output says nothing – the pins say why. + expect(typeOf(f.behavior, node, 'value', f)).toBe('any'); + }); + + it('is grey again once the edge is undone', () => { + const f = fixture(); + const node = f.edit.addNode('FirstNotEmpty'); + f.edit.connect(f.edit.addNode('Self'), 'object', node, 'a'); + expect(inputPin(f.behavior, node, 'a', f).paintType).toBe('object'); + + f.stack.undo(); + expect(inputPin(f.behavior, node, 'a', f).paintType).toBeUndefined(); + }); + + it('follows the colour the edge itself has, even where the output inherited it', () => { + const f = fixture(); + const first = f.edit.addNode('FirstNotEmpty'); + const second = f.edit.addNode('FirstNotEmpty'); + f.edit.wire([ + [f.edit.addNode('Number'), 'value', first, 'a'], + [f.edit.addNode('Number'), 'value', first, 'b'], + [first, 'value', second, 'a'], + ]); + // The edge is drawn in number, so the pin it ends on is too – the type is not passed on. + expect(inputPin(f.behavior, second, 'a', f).paintType).toBe('number'); + expect(typeOf(f.behavior, second, 'value', f)).toBe('any'); + }); + + it('leaves an input with a type of its own alone', () => { + const f = fixture(); + const sub = f.edit.addNode('Substring'); + f.edit.connect(f.edit.addNode('String'), 'value', sub, 'text'); + const pin = inputPin(f.behavior, sub, 'text', f); + expect(pin.type).toBe('string'); + expect(pin.paintType).toBeUndefined(); + }); +}); diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index 04a8c483..e456beb1 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -16,6 +16,7 @@ export async function graph(t) { drag, mouse, locate, + mutate, typeText, press, holdKey, @@ -928,5 +929,117 @@ export async function graph(t) { await click(reopen.x, reopen.y); check('expanding leaves no trace in the file', !('collapsed' in (await savedMove()))); + // 6z. Ports carrying one value (#207): the output takes the type both inputs agree on, the + // inputs stay open for everything, and an edge the new type breaks is drawn as broken. + await mutate(` + const p = __gm.state.project; + p.assets.push({ + id: 'picky', + type: 'behavior', + name: 'Picky', + props: { enabled: true, runInEditor: false }, + variables: [], + graph: { + nodes: [ + { id: 'p-a', type: 'Number', x: 0, y: 0, params: { value: 1 } }, + { id: 'p-b', type: 'Number', x: 0, y: 150, params: { value: 2 } }, + { 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: {} }, + ], + edges: [], + }, + }); + __gm.state.select({ kind: 'asset', assetId: 'picky' }); + `); + await showTab('graph'); + const pickTypes = async () => json(`__gm.graph.portTypes('p-pick')`); + check( + 'a group with nothing plugged in stays any', + (await pickTypes()).outputs.value === 'any', + JSON.stringify(await pickTypes()), + ); + + const wire = async (fromNode, fromPort, toNode, toPort) => { + const from = await json( + `__gm.graph.portAt(${JSON.stringify(fromNode)}, ${JSON.stringify(fromPort)}, true)`, + ); + const to = await json(`__gm.graph.portAt(${JSON.stringify(toNode)}, ${JSON.stringify(toPort)}, false)`); + await drag(from.x, from.y, to.x, to.y, 16); + }; + // Laid while the output is still any, so a number later makes it a text input fed by a number. + await wire('p-pick', 'value', 'p-sub', 'text'); + const toSub = async () => (await asset('picky')).graph.edges.find((e) => e.to.node === 'p-sub'); + const outEdge = await toSub(); + const edgeColor = async () => json(`__gm.graph.edgeColor(${JSON.stringify(outEdge?.id ?? '')})`); + const danger = await json(`__gm.themeColor('danger')`); + check( + 'an any output connects to a text input and is drawn as a working link', + outEdge !== undefined && (await edgeColor()) !== danger, + JSON.stringify({ edge: Boolean(outEdge), color: await edgeColor(), danger }), + ); + + await wire('p-a', 'value', 'p-pick', 'a'); + check( + 'one input alone says nothing: the other could still become anything', + (await pickTypes()).outputs.value === 'any', + JSON.stringify(await pickTypes()), + ); + + await wire('p-b', 'value', 'p-pick', 'b'); + const typed = await pickTypes(); + check( + 'with both inputs on a number the output is a number, the inputs stay any', + typed.outputs.value === 'number' && typed.inputs.a === 'any' && typed.inputs.b === 'any', + JSON.stringify(typed), + ); + check( + 'both input pins are painted as the number that flows in, though their type is any', + typed.paint.a === 'number' && typed.paint.b === 'number', + JSON.stringify(typed), + ); + check( + 'the edge the new type breaks stays and is drawn in the danger colour', + (await toSub()) !== undefined && (await edgeColor()) === danger, + JSON.stringify({ color: await edgeColor(), danger }), + ); + + await shot('19-shared-port-types'); + + // An input that keeps its `any` is one that can be re-plugged without unplugging first. + await wire('p-text', 'value', 'p-pick', 'a'); + const mixed = await pickTypes(); + check( + 'a connected input takes another type, and the group opens up again', + mixed.outputs.value === 'any' && (await edgeColor()) !== danger, + JSON.stringify({ types: mixed, color: await edgeColor() }), + ); + check( + 'inputs fed by different types are painted apart, which is why the output says nothing', + mixed.paint.a === 'string' && mixed.paint.b === 'number' && mixed.inputs.a === 'any', + JSON.stringify(mixed), + ); + // Read off the canvas: the colour is on the pin, not only in the definition. + const pinPixel = async (port) => { + const at = await json(`__gm.graph.portAt('p-pick', ${JSON.stringify(port)}, false)`); + return json(`__gm.pixelAt(${Math.round(at.x)}, ${Math.round(at.y)})`); + }; + const pinColors = { a: await pinPixel('a'), b: await pinPixel('b') }; + const wanted = { + a: await json(`__gm.themeColor('portString')`), + b: await json(`__gm.themeColor('portNumber')`), + }; + check( + 'the two pins are drawn in those two colours', + pinColors.a === wanted.a && pinColors.b === wanted.b, + JSON.stringify({ pinColors, wanted }), + ); + await shot('20-input-pin-colours'); + await mutate(` + const p = __gm.state.project; + p.assets = p.assets.filter((a) => a.id !== 'picky'); + __gm.state.select({ kind: 'asset', assetId: 'mover' }); + `); + await showTab('scene'); }