diff --git a/README.md b/README.md index e5d11d12..975a64b2 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,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, 1671 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1673 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/nested.ts b/src/core/graph/nested.ts index ce80ce91..696e7322 100644 --- a/src/core/graph/nested.ts +++ b/src/core/graph/nested.ts @@ -145,7 +145,7 @@ 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 { - const def = shapedDef(node, graph, lookup, true); + const def = shapedDef(node, graph, lookup, newTrail()); return def && splitDef(def, node); } @@ -158,24 +158,41 @@ function splitDef(def: NodeDef, node: GraphNode): NodeDef { * produces its wholes as always; reading a field port picks the field out of one (`splitField`). */ export function runDef(node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef | undefined { - return shapedDef(node, graph, lookup, true); + return shapedDef(node, graph, lookup, newTrail()); } /** - * `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). + * One question about a shared group, followed through the nodes of that kind in front of it (#210). + * `open` holds the nodes still being asked: reaching one again is a data cycle, and it then reports + * the `any` it was registered with, which ends the circle. `done` keeps each answer, so a node + * feeding both inputs of the next is asked once and not once per path. */ -function shapedDef( - node: GraphNode, - graph: Graph, - lookup: GraphLookup, - inherit: boolean, -): NodeDef | undefined { +interface Trail { + open: Set; + done: Map; +} + +function newTrail(): Trail { + return { open: new Set(), done: new Map() }; +} + +function shapedDef(node: GraphNode, graph: Graph, lookup: GraphLookup, trail: Trail): NodeDef | undefined { const def = nodeDef(node.type); 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 shared = def.sharedType; + if (!shared || trail.open.has(node.id)) return ownShape(def, node, graph, lookup); + const known = trail.done.get(node.id); + if (known) return known; + trail.open.add(node.id); + const typed = withSharedType(ownShape(def, node, graph, lookup), shared, node, graph, lookup, trail); + trail.open.delete(node.id); + trail.done.set(node.id, typed); + return typed; +} + +/** The ports of this node before any shared group has its say. */ +function ownShape(def: NodeDef, node: GraphNode, graph: Graph, lookup: GraphLookup): NodeDef { + if (!def.declaresPorts && !def.derivePorts) return def; const own = (side: 'inputs' | 'outputs') => declaredOn(def, node.ports ?? [], side).map((p) => def.declaredPort ? def.declaredPort(p, node, graph, lookup) : portDefOf(p), @@ -185,13 +202,12 @@ function shapedDef( const ports = [...def[key], ...(grown[key] ?? []), ...own(key)]; return grown.inOrder ? ports : execFirst(ports); }; - const resolved = { + return { ...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. */ @@ -201,8 +217,9 @@ function withSharedType( node: GraphNode, graph: Graph, lookup: GraphLookup, + trail: Trail, ): NodeDef { - const type = agreedType(group.inputs.map((key) => feedingType(node, key, graph, lookup, false))); + const type = agreedType(group.inputs.map((key) => feedingType(node, key, graph, lookup, trail))); if (type === 'any') return def; return { ...def, @@ -211,21 +228,20 @@ function withSharedType( } /** - * 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. + * What the edge into that input carries, or undefined while nothing is connected to it: the type + * the feeding node resolves to, which is what the edge itself is drawn in. */ function feedingType( node: GraphNode, key: string, graph: Graph, lookup: GraphLookup, - inherit: boolean, + trail: Trail, ): 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); + const def = from && shapedDef(from, graph, lookup, trail); return def && splitDef(def, from).outputs.find((p) => p.key === edge.from.port)?.type; } @@ -236,9 +252,10 @@ function feedingType( */ function paintedInputs(node: GraphNode, graph: Graph, def: NodeDef, lookup: GraphLookup): PortDef[] | null { if (!def.inputs.some((port) => port.type === 'any' && !port.ghost)) return null; + const trail = newTrail(); return def.inputs.map((port) => { if (port.type !== 'any' || port.ghost) return port; - const fed = feedingType(node, port.key, graph, lookup, true); + const fed = feedingType(node, port.key, graph, lookup, trail); return fed === undefined || fed === 'any' || fed === 'exec' ? port : { ...port, paintType: fed }; }); } diff --git a/tests/core/shared-port-type.test.ts b/tests/core/shared-port-type.test.ts index 2657136f..bd183c1e 100644 --- a/tests/core/shared-port-type.test.ts +++ b/tests/core/shared-port-type.test.ts @@ -100,21 +100,6 @@ describe('First not empty, whose a, b and value carry the same value', () => { 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'); @@ -167,6 +152,184 @@ describe('Select, whose false, true and value carry the same value', () => { }); }); +type Fixture = ReturnType; + +/** `count` First not empty in a row: the first fed by two literals, each next one by the one before. */ +function chain(f: Fixture, count: number, literal: string, kind = 'FirstNotEmpty'): GraphNode[] { + const [a, b] = kind === 'Select' ? ['false', 'true'] : ['a', 'b']; + const nodes = Array.from({ length: count }, () => f.edit.addNode(kind)); + f.edit.connect(f.edit.addNode(literal), 'value', nodes[0]!, a); + f.edit.connect(f.edit.addNode(literal), 'value', nodes[0]!, b); + for (let i = 1; i < count; i++) { + f.edit.connect(nodes[i - 1]!, 'value', nodes[i]!, a); + f.edit.connect(nodes[i - 1]!, 'value', nodes[i]!, b); + } + return nodes; +} + +/** Two First not empty feeding each other, each with a number on its other input. */ +function circle(f: Fixture): [GraphNode, GraphNode] { + const first = f.edit.addNode('FirstNotEmpty'); + const second = f.edit.addNode('FirstNotEmpty'); + f.edit.wire([ + [f.edit.addNode('Number'), 'value', first, 'b'], + [f.edit.addNode('Number'), 'value', second, 'b'], + [first, 'value', second, 'a'], + [second, 'value', first, 'a'], + ]); + return [first, second]; +} + +describe('a type handed on through several nodes with a shared group (#210)', () => { + it('reaches the second node of a chain', () => { + const f = fixture(); + const [first, second] = chain(f, 2, 'Number'); + expect(typeOf(f.behavior, first!, 'value', f)).toBe('number'); + expect(typeOf(f.behavior, second!, 'value', f)).toBe('number'); + }); + + it('reaches the third, and keeps every input on the way any', () => { + const f = fixture(); + const nodes = chain(f, 3, 'String'); + expect(nodes.map((n) => typeOf(f.behavior, n, 'value', f))).toEqual(['string', 'string', 'string']); + expect(nodes.map((n) => typeOf(f.behavior, n, 'a', f, 'inputs'))).toEqual(['any', 'any', 'any']); + }); + + it('passes from one kind of node to another', () => { + const f = fixture(); + const [pick] = chain(f, 1, 'Animation'); + const select = f.edit.addNode('Select'); + f.edit.wire([ + [pick!, 'value', select, 'false'], + [f.edit.addNode('Animation'), 'value', select, 'true'], + ]); + const animation = typeOf(f.behavior, pick!, 'value', f); + expect(animation).not.toBe('any'); + expect(typeOf(f.behavior, select, 'value', f)).toBe(animation); + }); + + it('is any behind a node whose inputs disagree, and untouched in front of it', () => { + const f = fixture(); + const nodes = chain(f, 4, 'Number'); + // The second node now hears a number and a text. + f.edit.connect(f.edit.addNode('String'), 'value', nodes[1]!, 'b'); + expect(nodes.map((n) => typeOf(f.behavior, n, 'value', f))).toEqual(['number', 'any', 'any', 'any']); + }); + + it('is any behind a node with an input left open', () => { + 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'], + [first, 'value', second, 'a'], + [first, 'value', second, 'b'], + ]); + expect(typeOf(f.behavior, second, 'value', f)).toBe('any'); + }); + + it('falls back to any all along the chain when an edge at its front is undone', () => { + const f = fixture(); + const nodes = Array.from({ length: 3 }, () => f.edit.addNode('FirstNotEmpty')); + f.edit.wire([ + [nodes[0]!, 'value', nodes[1]!, 'a'], + [nodes[0]!, 'value', nodes[1]!, 'b'], + [nodes[1]!, 'value', nodes[2]!, 'a'], + [nodes[1]!, 'value', nodes[2]!, 'b'], + [f.edit.addNode('Number'), 'value', nodes[0]!, 'a'], + [f.edit.addNode('Number'), 'value', nodes[0]!, 'b'], + ]); + expect(nodes.map((n) => typeOf(f.behavior, n, 'value', f))).toEqual(['number', 'number', 'number']); + + f.stack.undo(); + expect(nodes.map((n) => typeOf(f.behavior, n, 'value', f))).toEqual(['any', 'any', 'any']); + + f.stack.redo(); + expect(nodes.map((n) => typeOf(f.behavior, n, 'value', f))).toEqual(['number', 'number', 'number']); + }); + + it('ends a circle of two with any instead of asking forever', () => { + const f = fixture(); + const [first, second] = circle(f); + expect(typeOf(f.behavior, first, 'value', f)).toBe('any'); + expect(typeOf(f.behavior, second, 'value', f)).toBe('any'); + }); + + it('ends a node feeding itself with any', () => { + const f = fixture(); + const node = f.edit.addNode('FirstNotEmpty'); + f.edit.connect(f.edit.addNode('Number'), 'value', node, 'b'); + // No command lays this edge, a file written by hand can still carry it. + f.behavior.graph.edges.push({ + id: 'loop', + from: { node: node.id, port: 'value' }, + to: { node: node.id, port: 'a' }, + }); + expect(typeOf(f.behavior, node, 'value', f)).toBe('any'); + }); + + it('is any behind a circle as well', () => { + const f = fixture(); + const [, second] = circle(f); + const behind = f.edit.addNode('FirstNotEmpty'); + f.edit.wire([ + [second, 'value', behind, 'a'], + [f.edit.addNode('Number'), 'value', behind, 'b'], + ]); + expect(typeOf(f.behavior, behind, 'value', f)).toBe('any'); + }); + + it('makes an edge further down the chain invalid, and leaves it in place', () => { + const f = fixture(); + const first = f.edit.addNode('FirstNotEmpty'); + const second = f.edit.addNode('FirstNotEmpty'); + const sub = f.edit.addNode('Substring'); + f.edit.wire([ + [second, 'value', sub, 'text'], + [first, 'value', second, 'a'], + [first, 'value', second, 'b'], + ]); + const lookup = () => graphLookup(f.project); + const out = () => + portTypeOf(f.behavior.graph, { nodeId: second.id, port: 'value', isOutput: true }, lookup())!; + const inp = () => + portTypeOf(f.behavior.graph, { nodeId: sub.id, port: 'text', isOutput: false }, lookup())!; + expect(portsCompatible(out(), inp())).toBe(true); + + f.edit.wire([ + [f.edit.addNode('Number'), 'value', first, 'a'], + [f.edit.addNode('Number'), 'value', first, 'b'], + ]); + expect(portsCompatible(out(), inp())).toBe(false); + expect(f.behavior.graph.edges.filter((e) => e.to.node === sub.id)).toHaveLength(1); + }); + + it('asks a node feeding both inputs of the next only once, however long the chain', () => { + const f = fixture(); + // Asked once per path, the last of 40 would be asked 2^40 times and this test would never end. + const nodes = chain(f, 40, 'Number', 'Select'); + expect(typeOf(f.behavior, nodes[39]!, 'value', f)).toBe('number'); + }); + + it('resolves every node of a chain of 50 well within a frame', () => { + const f = fixture(); + const nodes = chain(f, 50, 'Number'); + const lookup = graphLookup(f.project); + const all = () => { + for (const node of f.behavior.graph.nodes) + drawnDef(node, f.behavior.graph, resolveDef(node, f.behavior.graph, lookup)!, lookup); + }; + all(); + const start = performance.now(); + for (let i = 0; i < 20; i++) all(); + const each = (performance.now() - start) / 20; + expect(typeOf(f.behavior, nodes[49]!, 'value', f)).toBe('number'); + // What the canvas does on every change to the project. Measured at 10 ms on the slowest host, + // against 2 ms before chains; the bound only has to catch a walk that grows with every path. + expect(each).toBeLessThan(200); + }); +}); + /** 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); @@ -223,11 +386,25 @@ describe('an any input painted as what flows into it', () => { [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. + // The edge is drawn in number, so the pin it ends on is too. Its other input is still open. expect(inputPin(f.behavior, second, 'a', f).paintType).toBe('number'); expect(typeOf(f.behavior, second, 'value', f)).toBe('any'); }); + it('follows a type handed on through a chain, two nodes back', () => { + const f = fixture(); + const nodes = chain(f, 3, 'Number'); + expect(inputPin(f.behavior, nodes[2]!, 'a', f).paintType).toBe('number'); + expect(inputPin(f.behavior, nodes[2]!, 'b', f).paintType).toBe('number'); + }); + + it('stays grey on every pin of a circle', () => { + const f = fixture(); + const [first, second] = circle(f); + expect(inputPin(f.behavior, first, 'a', f).paintType).toBeUndefined(); + expect(inputPin(f.behavior, second, 'a', f).paintType).toBeUndefined(); + }); + it('leaves an input with a type of its own alone', () => { const f = fixture(); const sub = f.edit.addNode('Substring'); diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index 216f77d1..1914b7ce 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -1050,6 +1050,41 @@ export async function graph(t) { JSON.stringify({ pinColors, wanted }), ); await shot('20-input-pin-colours'); + + // A second node of the kind behind the first (#210): the type is handed on through the chain, + // and taken back all along it once the front of the chain disagrees again. + await mutate(` + __gm.state.project.assets + .find((a) => a.id === 'picky') + .graph.nodes.push({ id: 'p-next', type: 'FirstNotEmpty', x: 300, y: 330, params: {} }); + `); + const nextTypes = async () => json(`__gm.graph.portTypes('p-next')`); + await wire('p-pick', 'value', 'p-next', 'a'); + await wire('p-pick', 'value', 'p-next', 'b'); + const chainOpen = await nextTypes(); + await wire('p-a', 'value', 'p-pick', 'a'); + const chainTyped = await nextTypes(); + check( + 'a second node of the kind behind the first takes the type the first one agreed on', + chainOpen.outputs.value === 'any' && + (await pickTypes()).outputs.value === 'number' && + chainTyped.outputs.value === 'number' && + chainTyped.inputs.a === 'any' && + chainTyped.paint.a === 'number' && + chainTyped.paint.b === 'number', + JSON.stringify({ chainOpen, chainTyped }), + ); + await shot('20b-shared-type-chain'); + await wire('p-text', 'value', 'p-pick', 'a'); + const chainMixed = await nextTypes(); + check( + 'a text plugged into the first opens the whole chain up again', + (await pickTypes()).outputs.value === 'any' && + chainMixed.outputs.value === 'any' && + chainMixed.paint.a === 'any' && + chainMixed.paint.b === 'any', + JSON.stringify(chainMixed), + ); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => a.id !== 'picky');