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'); }