diff --git a/README.md b/README.md index 0e1e57f9..61581599 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, 1514 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1517 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/commands/graphCommands.ts b/src/core/commands/graphCommands.ts index 773a0d1d..169a404c 100644 --- a/src/core/commands/graphCommands.ts +++ b/src/core/commands/graphCommands.ts @@ -22,6 +22,8 @@ import { SetBagValueCommand } from './SetBagValueCommand'; export class AddNodeCommand implements Command { readonly label: string; readonly node: GraphNode; + /** The end of the block the node opens (`Find` → `Match`), added beside it in the same step. */ + readonly closing: GraphNode | null; constructor( private readonly assetId: string, @@ -40,6 +42,16 @@ export class AddNodeCommand implements Command { y: Math.round(y), params: { ...defaultsOf(def?.params ?? []), ...params }, }; + const opens = def?.opens; + this.closing = opens + ? { + id: newId(), + type: opens.type, + x: this.node.x + opens.dx, + y: this.node.y, + params: defaultsOf(nodeDef(opens.type)?.params ?? []), + } + : null; this.label = def ? `Add ${def.label}` : `Add ${type}`; } @@ -51,11 +63,13 @@ export class AddNodeCommand implements Command { if (isTerminalType(this.node.type) && terminalNode(graph, this.node.type)) throw new CommandError(`This graph already has a ${nodeDef(this.node.type)?.label} node`); graph.nodes.push(this.node); + if (this.closing) graph.nodes.push(this.closing); } undo(project: Project): void { const graph = requireGraph(project, this.assetId); graph.nodes = removeById(graph.nodes, this.node.id); + if (this.closing) graph.nodes = removeById(graph.nodes, this.closing.id); } } diff --git a/src/core/graph/GraphInterpreter.ts b/src/core/graph/GraphInterpreter.ts index 86b07238..ac3db3a6 100644 --- a/src/core/graph/GraphInterpreter.ts +++ b/src/core/graph/GraphInterpreter.ts @@ -1,3 +1,4 @@ +import { blockProblem, MATCH_INPUT, matchOf } from './findBlock'; import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph'; import { type GraphLookup, NO_LOOKUP } from './lookup'; import { BEHAVIOR_END, callEntries, resolveDef, terminalNode } from './nested'; @@ -159,6 +160,7 @@ export class GraphInterpreter { this.trace?.onNode(node.id); if (this.trace?.onValues) this.record(node.id, def.read ? this.readAll(node, def, ctx) : produced); if (result.repeat) this.loop(node, result.repeat, ctx, depth); + if (result.find) this.search(node, result.find, ctx, depth); const next = result.next === undefined ? [] : Array.isArray(result.next) ? result.next : [result.next]; for (const port of next) this.follow(node, port, ctx, depth); } @@ -247,6 +249,41 @@ export class GraphInterpreter { this.frame.outputCache.set(node.id, { ...after, [repeat.stopped]: stopped }); } + /** + * A Find's rounds: its `item` and `index` are set to one element after the other, and the Match + * closing the block reads its test anew each time, the way a loop's stop input is read. The first + * true one ends the search. Both nodes then hold the hit, and the flow goes on from the Match. + */ + private search(find: GraphNode, values: readonly Value[], ctx: NodeContext, depth: number): void { + const match = matchOf(this.frame.graph, find.id); + const test = match && this.def(match)?.inputs.find((p) => p.key === MATCH_INPUT); + if (!match || !test) { + ctx.log(`Find: ${blockProblem(this.frame.graph, find) ?? 'no Match closes it'}`); + return; + } + let hit = -1; + for (let round = 0; round < values.length; round++) { + if (++this.steps > MAX_STEPS_PER_FIRE) + throw new Error('Behavior graph exceeded the step limit (endless loop?)'); + this.frame.dataCache.clear(); + const roundOutputs = { item: values[round] ?? null, index: round }; + this.frame.outputCache.set(find.id, roundOutputs); + this.record(find.id, roundOutputs); + if (asBoolean(this.inputValue(match, test, ctx))) { + hit = round; + break; + } + } + this.frame.dataCache.clear(); + const found = hit >= 0; + const outputs = found ? { item: values[hit] ?? null, index: hit } : { item: null, index: -1 }; + this.frame.outputCache.set(find.id, outputs); + this.frame.outputCache.set(match.id, outputs); + this.trace?.onNode(match.id); + this.record(match.id, outputs); + this.follow(match, found ? 'found' : 'notFound', ctx, depth); + } + /** Resolves every data input: connected → pulled from the source, else the inline param. */ private collectInputs(node: GraphNode, ctx: NodeContext): Record { const inputs: Record = {}; diff --git a/src/core/graph/NodeRegistry.ts b/src/core/graph/NodeRegistry.ts index 9e6c82f2..80228d21 100644 --- a/src/core/graph/NodeRegistry.ts +++ b/src/core/graph/NodeRegistry.ts @@ -70,6 +70,11 @@ export interface RunResult { repeat?: RepeatResult; /** Runs another behavior before continuing along `next`; its results join `outputs`. */ call?: CallResult; + /** + * The elements a `Find` tests one by one against the `Match` closing its block. The flow goes on + * from that Match, not from the Find. + */ + find?: readonly Value[]; } /** What a node may compute its ports from: itself, the graph it sits in, and the project around it. */ @@ -115,6 +120,8 @@ export interface NodeDef { * between whole states, not as signal flowing left to right into a particular socket. */ transitionEdges?: true; + /** The node that comes along when this one is added, `dx` to the right: the end of its block. */ + opens?: { type: string; dx: number }; /** * Ports this node computes beyond the fixed and the declared ones – how many outputs a Sequence * offers, which values the behavior a call node names takes. `resolveDef` applies it. diff --git a/src/core/graph/findBlock.ts b/src/core/graph/findBlock.ts new file mode 100644 index 00000000..60d06a04 --- /dev/null +++ b/src/core/graph/findBlock.ts @@ -0,0 +1,84 @@ +import { findNode, type Graph, type GraphEdge, type GraphNode } from './Graph'; +import { nodeDef } from './NodeRegistry'; + +/* + * A `Find` … `Match` block is not stored anywhere: it is the wiring. A Match closes the one Find its + * test is built from – whatever reaches its `match` input through data edges and comes from that + * Find's `item` or `index`. So copying, pasting and undo need nothing of their own. + */ + +export const FIND = 'Find'; +export const MATCH = 'Match'; +/** The boolean input a `Match` reads its test from. */ +export const MATCH_INPUT = 'match'; + +function isExecPort(type: string | undefined, key: string, side: 'inputs' | 'outputs'): boolean { + return (type && nodeDef(type)?.[side].find((p) => p.key === key)?.type) === 'exec'; +} + +function carriesData(graph: Graph, edge: GraphEdge): boolean { + const from = findNode(graph, edge.from.node)?.type; + const to = findNode(graph, edge.to.node)?.type; + return !isExecPort(from, edge.from.port, 'outputs') && !isExecPort(to, edge.to.port, 'inputs'); +} + +/** Nodes feeding `nodeId` through data edges, directly or through others. A path ends at a Find. */ +function feeders(graph: Graph, nodeId: string): Set { + const seen = new Set(); + const todo = [nodeId]; + while (todo.length > 0) { + const id = todo.pop()!; + for (const edge of graph.edges) { + if (edge.to.node !== id || seen.has(edge.from.node) || !carriesData(graph, edge)) continue; + seen.add(edge.from.node); + if (findNode(graph, edge.from.node)?.type !== FIND) todo.push(edge.from.node); + } + } + return seen; +} + +/** The Finds a Match's test is built from. */ +export function findsOf(graph: Graph, match: GraphNode): GraphNode[] { + return [...feeders(graph, match.id)] + .map((id) => findNode(graph, id)) + .filter((node): node is GraphNode => node?.type === FIND); +} + +/** The Matches whose test is built from this Find, alone or together with another. */ +function matchesOf(graph: Graph, findId: string): GraphNode[] { + return graph.nodes.filter((n) => n.type === MATCH && findsOf(graph, n).some((f) => f.id === findId)); +} + +/** The Match closing a Find's block: the only one testing it, and testing nothing else. */ +export function matchOf(graph: Graph, findId: string): GraphNode | null { + const matches = matchesOf(graph, findId); + const match = matches.length === 1 ? matches[0]! : null; + return match && findsOf(graph, match).length === 1 ? match : null; +} + +/** Every node of a closed block: the Find, the nodes its item or index runs through, the Match. */ +export function blockOf(graph: Graph, find: GraphNode): string[] | null { + const match = matchOf(graph, find.id); + if (!match) return null; + const between = [...feeders(graph, match.id)].filter( + (id) => id !== find.id && feeders(graph, id).has(find.id), + ); + return [find.id, ...between, match.id]; +} + +/** What keeps a Find or a Match from forming a block, or null. */ +export function blockProblem(graph: Graph, node: GraphNode): string | null { + if (node.type === FIND) { + const matches = matchesOf(graph, node.id); + if (matches.length === 0) return 'No Match tests its item'; + if (matches.length > 1) return `${matches.length} Matches test its item`; + return matchOf(graph, node.id) ? null : 'Its Match tests another Find'; + } + if (node.type === MATCH) { + const finds = findsOf(graph, node); + if (finds.length === 0) return 'match is not built from a Find'; + if (finds.length > 1) return `match is built from ${finds.length} Finds`; + return matchesOf(graph, finds[0]!.id).length > 1 ? 'Another Match tests its Find' : null; + } + return null; +} diff --git a/src/core/graph/index.ts b/src/core/graph/index.ts index 305546ec..919dddd8 100644 --- a/src/core/graph/index.ts +++ b/src/core/graph/index.ts @@ -4,6 +4,7 @@ import './nodes'; export * from './dropTarget'; export * from './Graph'; export * from './lookup'; +export * from './findBlock'; export * from './nested'; export * from './NodeContext'; export * from './NodeRegistry'; diff --git a/src/core/graph/nodes/flow/Find.ts b/src/core/graph/nodes/flow/Find.ts new file mode 100644 index 00000000..075def36 --- /dev/null +++ b/src/core/graph/nodes/flow/Find.ts @@ -0,0 +1,19 @@ +import { FIND, MATCH } from '../../findBlock'; +import type { NodeDef } from '../../NodeRegistry'; +import { asList } from '../../values'; +import { any, exec, list, num } from '../shared'; + +export const Find: NodeDef = { + type: FIND, + label: 'Find', + category: 'flow', + description: + 'Opens a search block, closed by a Match. "item" and "index" go through one element after the ' + + 'other; whatever is built from them into the Match\'s "match" is the test. The first element it ' + + 'is true for continues at "found" of the Match, none at "not found".', + inputs: [exec(), list('list')], + outputs: [any('item'), num('index')], + params: [], + opens: { type: MATCH, dx: 480 }, + run: (_ctx, inputs) => ({ outputs: { item: null, index: -1 }, find: asList(inputs.list) }), +}; diff --git a/src/core/graph/nodes/flow/Match.ts b/src/core/graph/nodes/flow/Match.ts new file mode 100644 index 00000000..86c42b01 --- /dev/null +++ b/src/core/graph/nodes/flow/Match.ts @@ -0,0 +1,16 @@ +import { MATCH, MATCH_INPUT } from '../../findBlock'; +import type { NodeDef } from '../../NodeRegistry'; +import { any, bool, exec, num } from '../shared'; + +/** Never runs itself: the Find its test is built from hands it the hit and continues from here. */ +export const Match: NodeDef = { + type: MATCH, + label: 'Match', + category: 'flow', + description: + 'Closes the search block of the Find its "match" is built from. Continues at "found" with the ' + + 'first element "match" was true for, or at "not found" with no item and index -1.', + inputs: [{ ...bool(MATCH_INPUT), inline: false }], + outputs: [exec('found', 'found'), any('item'), num('index'), exec('notFound', 'not found')], + params: [], +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 59fbac3c..09922b41 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -114,7 +114,9 @@ import { OnUpdate } from './events/OnUpdate'; import { BehaviorEnd } from './flow/BehaviorEnd'; import { BehaviorStart } from './flow/BehaviorStart'; import { Branch } from './flow/Branch'; +import { Find } from './flow/Find'; import { ForEach } from './flow/ForEach'; +import { Match } from './flow/Match'; import { Repeat } from './flow/Repeat'; import { RunBehavior } from './flow/RunBehavior'; import { Sequence } from './flow/Sequence'; @@ -153,6 +155,8 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ Sequence, Repeat, ForEach, + Find, + Match, RunBehavior, StateBranch, SetState, diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 1fab33be..49d9327f 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -46,6 +46,7 @@ export function graphHooks({ state, editor }: HookContext) { 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), + blockFrames: () => graphEditor.canvas.blockFrames(), 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/GraphCanvas.ts b/src/editor/graph/GraphCanvas.ts index bbd1331e..e650063a 100644 --- a/src/editor/graph/GraphCanvas.ts +++ b/src/editor/graph/GraphCanvas.ts @@ -6,6 +6,7 @@ import { AddAnnotationCommand, type AnnotationBox } from '../../core/commands/an import { type AnnotationKind, BEHAVIOR_PARAM, + blockProblem, callProblem, drawnDef, edgesInto, @@ -48,6 +49,7 @@ import { type Box, boundsOf, boxesInBand, boxesWithinBand, frameBoxes } from '.. import { annotationBoxes, boxOf } from './annotationDrag'; import { AnnotationLayer } from './annotationLayer'; import { ANNOTATION_TITLE_HEIGHT, type AnnotationView, RESIZE_HANDLE } from './AnnotationView'; +import { type BlockFrame, blockFrames, drawBlockFrames } from './blockFrames'; import { EdgeLayer } from './EdgeLayer'; import { DRAG_THRESHOLD, GraphDrags } from './graphDrags'; import type { GraphCanvasHost } from './graphHost'; @@ -86,6 +88,8 @@ export class GraphCanvas extends Widget { private readonly clip = new Graphics(); private readonly canvas = new Container(); private readonly annotationLayer = new AnnotationLayer((view, e) => this.onAnnotationPointerDown(view, e)); + private readonly blockLayer = new Graphics(); + private drawnBlocks: BlockFrame[] = []; private readonly edgeLayer = new EdgeLayer(); private readonly nodeLayer = new Container(); private readonly tempEdge = new Graphics(); @@ -148,7 +152,13 @@ export class GraphCanvas extends Widget { }; this.wiring = new GraphWiring(host); this.drags = new GraphDrags(host); - this.canvas.addChild(this.annotationLayer, this.edgeLayer, this.nodeLayer, this.tempEdge); + this.canvas.addChild( + this.annotationLayer, + this.blockLayer, + this.edgeLayer, + this.nodeLayer, + this.tempEdge, + ); this.addChild(this.bg, this.grid, this.canvas, this.band, this.hint, this.clip); this.canvas.mask = this.clip; this.cursor = 'default'; @@ -271,7 +281,8 @@ export class GraphCanvas extends Widget { } const problem = (compilerProblems ? (compilerProblems[node.id] ?? null) : callProblem(node, lookup, asset.id)) ?? - ghostProblem(node, asset.graph, resolved); + ghostProblem(node, asset.graph, resolved) ?? + blockProblem(asset.graph, node); view.sync(node, asset.graph, this.state.project, def, problem); } for (const [id, view] of this.views) { @@ -796,6 +807,12 @@ export class GraphCanvas extends Widget { view.redraw(); } + this.drawnBlocks = blockFrames(asset.graph, (id) => { + const view = this.views.get(id); + return view ? { id, x: view.x, y: view.y, w: view.w, h: view.h } : null; + }); + drawBlockFrames(this.blockLayer, this.drawnBlocks); + this.edgeLayer.draw( asset.graph.edges, this.views, @@ -847,6 +864,11 @@ export class GraphCanvas extends Widget { return this.wiring.edgeShown; } + /** The frames the last redraw put behind the `Find` … `Match` blocks, in graph units. */ + blockFrames(): BlockFrame[] { + return this.drawnBlocks; + } + /** Whether the last redraw stroked this edge as a curve. */ edgeDrawn(edgeId: string): boolean { return this.edgeLayer.wasStroked(edgeId); diff --git a/src/editor/graph/blockFrames.ts b/src/editor/graph/blockFrames.ts new file mode 100644 index 00000000..c71eb78c --- /dev/null +++ b/src/editor/graph/blockFrames.ts @@ -0,0 +1,43 @@ +import type { Graphics } from 'pixi.js'; +import { blockOf, FIND } from '../../core/graph/findBlock'; +import type { Graph } from '../../core/graph/Graph'; +import { Theme } from '../../ui/Theme'; +import { type Bounds, type Box, boundsOf } from '../framing'; + +/** Room between a block's frame and the nodes it holds. */ +export const BLOCK_PAD = 14; + +export interface BlockFrame { + find: string; + bounds: Bounds; +} + +/** One frame per closed `Find` … `Match` block, around every node of it that is drawn. */ +export function blockFrames(graph: Graph, boxOf: (id: string) => Box | null): BlockFrame[] { + const frames: BlockFrame[] = []; + for (const node of graph.nodes) { + if (node.type !== FIND) continue; + const boxes = (blockOf(graph, node) ?? []).map(boxOf).filter((b): b is Box => b !== null); + const b = boundsOf(boxes); + if (!b) continue; + frames.push({ + find: node.id, + bounds: { + minX: b.minX - BLOCK_PAD, + minY: b.minY - BLOCK_PAD, + maxX: b.maxX + BLOCK_PAD, + maxY: b.maxY + BLOCK_PAD, + }, + }); + } + return frames; +} + +export function drawBlockFrames(g: Graphics, frames: readonly BlockFrame[]): void { + const color = Theme.colors.categoryFlow; + g.clear(); + for (const { bounds: b } of frames) + g.roundRect(b.minX, b.minY, b.maxX - b.minX, b.maxY - b.minY, 8) + .fill({ color, alpha: 0.1 }) + .stroke({ width: 1, color, alpha: 0.6 }); +} diff --git a/tests/core/find-match.test.ts b/tests/core/find-match.test.ts new file mode 100644 index 00000000..2ace6b15 --- /dev/null +++ b/tests/core/find-match.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest'; +import { AddNodeCommand } from '../../src/core/commands/graphCommands'; +import { PasteGraphCommand } from '../../src/core/commands/pasteCommands'; +import { AddVariableCommand } from '../../src/core/commands/variableCommands'; +import { + blockOf, + blockProblem, + FIND, + findNode, + type GraphNode, + GraphInterpreter, + MATCH, + matchOf, +} from '../../src/core/graph'; +import type { PropertyValue } from '../../src/core/model/Properties'; +import { blockFrames, BLOCK_PAD } from '../../src/editor/graph/blockFrames'; +import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers'; + +/** A behavior with a list variable `names` and a Find over it, the Match it brought along beside it. */ +function block() { + const fixture = makeProject(); + const behavior = makeBehavior(fixture); + fixture.stack.execute(new AddVariableCommand(behavior.id, 'names', 'list')); + const edit = graphEditor(fixture, behavior); + const add = new AddNodeCommand(behavior.id, FIND, 0, 0); + fixture.stack.execute(add); + const find = add.node; + const match = add.closing!; + return { ...fixture, behavior, edit, find, match }; +} + +/** + * On start: Find over `names`, the test wired from `item` (or `index`) through an Equals into the + * Match. "found" logs item and index of the hit, "not found" logs "none" and the index it hands on. + */ +function search(names: readonly PropertyValue[], wanted: PropertyValue, by: 'item' | 'index' = 'item') { + const { behavior, edit, find, match } = block(); + const start = edit.addNode('OnStart'); + const get = edit.variables(['names']); + const equals = edit.addNode('Equals', { b: wanted }); + const hit = edit.addNode('Log'); + const miss = edit.addNode('Log'); + const line = edit.addNode('Join'); + const none = edit.addNode('Join', { a: 'none' }); + edit.wire([ + [start, 'exec', find, 'exec'], + [get, 'names', find, 'list'], + [find, by, equals, 'a'], + [equals, 'result', match, 'match'], + [match, 'found', hit, 'exec'], + [match, 'item', line, 'a'], + [match, 'index', line, 'b'], + [line, 'text', hit, 'message'], + [match, 'notFound', miss, 'exec'], + [match, 'index', none, 'b'], + [none, 'text', miss, 'message'], + ]); + const ctx = fakeContext({ variables: { names: [...names] } }); + new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + return ctx.logs; +} + +describe('Find … Match at run time', () => { + it('goes on at found with the first element the test is true for', () => { + expect(search(['a', 'b', 'c', 'b'], 'b')).toEqual(['b1']); + }); + + it('reads the test anew for every element, not only for the first', () => { + expect(search(['a', 'b', 'c'], 'c')).toEqual(['c2']); + }); + + it('takes a test built from index as well', () => { + expect(search(['a', 'b', 'c'], 1, 'index')).toEqual(['b1']); + }); + + it('goes on at not found with index -1 when nothing matches', () => { + expect(search(['a', 'b'], 'x')).toEqual(['none-1']); + }); + + it('goes on at not found for an empty list', () => { + expect(search([], 'a')).toEqual(['none-1']); + }); + + it('reports a Find no Match tests, and nothing runs after it', () => { + const { behavior, edit, find, match } = block(); + const start = edit.addNode('OnStart'); + const after = edit.addNode('Log', { message: 'after' }); + edit.wire([ + [start, 'exec', find, 'exec'], + [match, 'found', after, 'exec'], + [match, 'notFound', after, 'exec'], + ]); + const ctx = fakeContext({ variables: { names: ['a'] } }); + new GraphInterpreter(behavior.graph).fire('OnStart', ctx); + expect(ctx.logs).toEqual(['Find: No Match tests its item']); + }); +}); + +describe('the block is the wiring', () => { + it('adding a Find brings its Match beside it, and one undo takes both', () => { + const { behavior, stack, find, match } = block(); + expect(match.type).toBe(MATCH); + expect(match.x).toBeGreaterThan(find.x); + expect(match.y).toBe(find.y); + expect(behavior.graph.nodes.map((n) => n.type)).toEqual([FIND, MATCH]); + stack.undo(); + expect(behavior.graph.nodes).toEqual([]); + }); + + it('pairs a Match with the Find its test is built from; the block is what lies between', () => { + const { behavior, edit, find, match } = block(); + const expand = edit.addNode('Not'); + const equals = edit.addNode('Equals'); + const wanted = edit.addNode('String', { value: 'x' }); + expect(matchOf(behavior.graph, find.id)).toBeNull(); + expect(blockProblem(behavior.graph, find)).toBe('No Match tests its item'); + expect(blockProblem(behavior.graph, match)).toBe('match is not built from a Find'); + edit.wire([ + [find, 'item', expand, 'value'], + [expand, 'result', equals, 'a'], + [wanted, 'value', equals, 'b'], + [equals, 'result', match, 'match'], + ]); + expect(matchOf(behavior.graph, find.id)?.id).toBe(match.id); + // The literal feeds the test but does not come from the Find: it stays outside the frame. + const ids = blockOf(behavior.graph, find)!; + expect([ids[0], ids.at(-1)]).toEqual([find.id, match.id]); + expect(ids.slice(1, -1).sort()).toEqual([equals.id, expand.id].sort()); + expect(blockProblem(behavior.graph, find)).toBeNull(); + expect(blockProblem(behavior.graph, match)).toBeNull(); + }); + + it('a second Match on the same Find closes nothing, and both say so', () => { + const { behavior, edit, find, match } = block(); + const other = edit.addNode(MATCH); + const one = edit.addNode('Not'); + const two = edit.addNode('Not'); + edit.wire([ + [find, 'item', one, 'value'], + [one, 'result', match, 'match'], + [find, 'item', two, 'value'], + [two, 'result', other, 'match'], + ]); + expect(matchOf(behavior.graph, find.id)).toBeNull(); + expect(blockProblem(behavior.graph, find)).toBe('2 Matches test its item'); + expect(blockProblem(behavior.graph, match)).toBe('Another Match tests its Find'); + }); + + it('a Match built from two Finds closes neither', () => { + const { behavior, edit, find, match } = block(); + const second = edit.addNode(FIND); + const and = edit.addNode('And'); + edit.wire([ + [find, 'item', and, 'a'], + [second, 'item', and, 'b'], + [and, 'result', match, 'match'], + ]); + expect(matchOf(behavior.graph, find.id)).toBeNull(); + expect(blockProblem(behavior.graph, match)).toBe('match is built from 2 Finds'); + expect(blockProblem(behavior.graph, find)).toBe('Its Match tests another Find'); + }); + + it('a pasted copy forms a block of its own, paired with its own Find', () => { + const { behavior, stack, edit, find, match } = block(); + const not = edit.addNode('Not'); + edit.wire([ + [find, 'item', not, 'value'], + [not, 'result', match, 'match'], + ]); + const nodes = behavior.graph.nodes.map((n) => structuredClone(n)); + const edges = behavior.graph.edges.map((e) => structuredClone(e)); + const paste = new PasteGraphCommand( + behavior.id, + { kind: 'graph', nodes, edges, annotations: [] }, + { x: 0, y: 400 }, + ); + stack.execute(paste); + const [copyFind, copyMatch] = paste.nodeIds.map((id) => findNode(behavior.graph, id)!); + expect(matchOf(behavior.graph, find.id)?.id).toBe(match.id); + expect(matchOf(behavior.graph, copyFind!.id)?.id).toBe(copyMatch!.id); + }); +}); + +describe('block frames', () => { + it('frame the nodes of a closed block with room to spare, and nothing else', () => { + const { behavior, edit, find, match } = block(); + const not = edit.addNode('Not'); + const stray = edit.addNode('Not'); + const box = (node: GraphNode, x: number, y: number) => ({ id: node.id, x, y, w: 100, h: 50 }); + const boxes = new Map([ + [find.id, box(find, 0, 0)], + [not.id, box(not, 200, 60)], + [match.id, box(match, 400, 30)], + [stray.id, box(stray, 900, 900)], + ]); + const at = (id: string) => boxes.get(id) ?? null; + expect(blockFrames(behavior.graph, at)).toEqual([]); + edit.wire([ + [find, 'item', not, 'value'], + [not, 'result', match, 'match'], + ]); + expect(blockFrames(behavior.graph, at)).toEqual([ + { + find: find.id, + bounds: { minX: -BLOCK_PAD, minY: -BLOCK_PAD, maxX: 500 + BLOCK_PAD, maxY: 110 + BLOCK_PAD }, + }, + ]); + }); +}); diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index 9b9a32cc..c4be77e1 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -80,6 +80,8 @@ describe('node registry', () => { else if (def.category === 'state') expect(def.run).toBeUndefined(); // A shader node is compiled to GLSL and a particle node to a function: neither is ever run. else if (def.category === 'shader' || def.category === 'particles') expect('emit' in def).toBe(true); + // The end of a block runs as part of the node that opens it (`Find` … `Match`). + else if (defs.some((d) => d.opens?.type === def.type)) expect(def.run).toBeUndefined(); // Everything else does one of the two: a step (`run`), or an answer whenever asked (`read`). else expect(typeof (def.run ?? def.read)).toBe('function'); expect(keys.every((k) => k.length > 0)).toBe(true); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index e5dd18a4..b37b5aa9 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -40,6 +40,8 @@ interface Case { repeat?: RunResult['repeat'] | null; /** Expected nested-behavior call; `null` asserts the node calls nothing. */ call?: RunResult['call'] | null; + /** Expected elements to test against a Match; `null` asserts the node searches nothing. */ + find?: RunResult['find'] | null; /** The project around the graph – for a node whose step is written in another asset. */ lookup?: GraphLookup; /** Assertions on what the node did to the context or to the handles it was given. */ @@ -377,6 +379,18 @@ const CASES: Record = { }, ], + // The rounds, the test and where the flow goes on are the interpreter's: see find-match.test.ts. + Find: [ + { + name: 'hands the elements to test and goes on from its Match, not from itself', + inputs: { list: ['a', 'b'] }, + outputs: { item: null, index: -1 }, + next: null, + find: ['a', 'b'], + }, + { name: 'no list tests nothing', inputs: { list: null }, find: [] }, + ], + // The rounds themselves and the break are the interpreter's: see "For each" in graph.test.ts. ForEach: [ { @@ -1690,16 +1704,21 @@ const EVENT_NODES: Record = { OnStateExited: 'state-machine.test.ts', }; +/** A node that ends the block another one opens never runs itself – the opener runs it (`Match`). */ +const closesBlock = (type: string) => BUILT_IN_NODES.some((def) => def.opens?.type === type); + describe('node catalog', () => { it('covers every built-in node', () => { const uncovered = BUILT_IN_NODES.filter((def) => - (def.run ?? def.read) ? !CASES[def.type] : !EVENT_NODES[def.type], + (def.run ?? def.read) ? !CASES[def.type] : !EVENT_NODES[def.type] && !closesBlock(def.type), ).map((def) => def.type); expect(uncovered).toEqual([]); }); it('lists exactly the nodes that neither run nor read as events', () => { - const runless = BUILT_IN_NODES.filter((def) => !def.run && !def.read).map((def) => def.type); + const runless = BUILT_IN_NODES.filter((def) => !def.run && !def.read && !closesBlock(def.type)).map( + (def) => def.type, + ); expect(Object.keys(EVENT_NODES).sort()).toEqual(runless.sort()); }); @@ -1735,6 +1754,7 @@ for (const [type, cases] of Object.entries(CASES)) { if (c.next !== undefined) expect(result.next ?? null).toEqual(c.next); if (c.repeat !== undefined) expect(result.repeat ?? null).toEqual(c.repeat); if (c.call !== undefined) expect(result.call ?? null).toEqual(c.call); + if (c.find !== undefined) expect(result.find ?? null).toEqual(c.find); c.after?.(ctx); }); } diff --git a/tools/e2e/06-graph.mjs b/tools/e2e/06-graph.mjs index f30e79d7..eb1b6d44 100644 --- a/tools/e2e/06-graph.mjs +++ b/tools/e2e/06-graph.mjs @@ -1102,6 +1102,7 @@ export async function graph(t) { await lerpNode(); await forEachNode(); + await findMatchNodes(); await mutate(`__gm.state.select({ kind: 'asset', assetId: 'mover' });`); @@ -1246,4 +1247,127 @@ export async function graph(t) { scene.objects = scene.objects.filter((o) => o.id !== 'fe-holder'); `); } + // 6z-v. Find … Match: typed into the palette, Find arrives with its Match beside it. The test in + // between is dragged into the Match with the mouse, which closes the block and frames it. + // Run, it finds the first loot named "Gold" and goes on at found with that one. + async function findMatchNodes() { + const loots = [{ Name: 'Axe' }, { Name: 'Gold' }, { Name: 'Bow' }, { Name: 'Gold' }]; + await mutate(` + const p = __gm.state.project; + p.assets.push({ + id: 'findy', + type: 'behavior', + name: 'Findy', + props: { enabled: true, runInEditor: false, loots: ${JSON.stringify(loots)} }, + variables: [{ key: 'loots', label: 'loots', kind: 'list', default: ${JSON.stringify(loots)} }], + graph: { nodes: [], edges: [] }, + }); + __gm.state.select({ kind: 'asset', assetId: 'findy' }); + `); + await showTab('graph'); + const spot = await json('__gm.graph.emptySpot()'); + await click(spot.x, spot.y, 'right'); + await typeText('find'); + await press('Enter'); + const [find, match] = (await asset('findy')).graph.nodes; + const findTypes = find ? await json(`__gm.graph.portTypes(${JSON.stringify(find.id)})`) : null; + const matchTypes = match ? await json(`__gm.graph.portTypes(${JSON.stringify(match.id)})`) : null; + const lonely = find ? await json(`__gm.graph.nodeProblem(${JSON.stringify(find.id)})`) : null; + check( + 'typing "find" into the palette adds Find with its Match beside it, both ports as planned', + find?.type === 'Find' && + match?.type === 'Match' && + match.x > find.x && + match.y === find.y && + JSON.stringify(Object.keys(findTypes?.outputs ?? {})) === JSON.stringify(['item', 'index']) && + JSON.stringify(Object.keys(matchTypes?.inputs ?? {})) === JSON.stringify(['match']) && + JSON.stringify(Object.keys(matchTypes?.outputs ?? {})) === + JSON.stringify(['found', 'item', 'index', 'notFound']) && + lonely === 'No Match tests its item', + JSON.stringify({ find, match, findTypes, matchTypes, lonely }), + ); + if (!find || !match) return; + const f = JSON.stringify(find.id); + const m = JSON.stringify(match.id); + await mutate(` + const p = __gm.state.project; + const behavior = p.assets.find((a) => a.id === 'findy'); + const node = (id, type, x, y, params = {}, ports) => ({ id, type, x, y, params, ...(ports ? { ports } : {}) }); + const edge = (id, from, fromPort, to, toPort) => + ({ id, from: { node: from, port: fromPort }, to: { node: to, port: toPort } }); + const at = behavior.graph.nodes.find((n) => n.id === ${f}); + behavior.graph.nodes.push( + node('fm-start', 'OnStart', at.x - 240, at.y - 60), + node('fm-loots', 'Variables', at.x - 240, at.y + 80, {}, [{ key: 'loots', kind: 'list' }]), + node('fm-expand', 'ExpandStruct', at.x + 200, at.y + 90, {}, [{ key: 'Name', kind: 'text' }]), + node('fm-equals', 'Equals', at.x + 200, at.y + 200, { b: 'Gold' }), + node('fm-found', 'Log', at.x + 700, at.y - 60), + node('fm-name', 'ExpandStruct', at.x + 700, at.y + 120, {}, [{ key: 'Name', kind: 'text' }]), + node('fm-join', 'Join', at.x + 900, at.y + 120), + ); + behavior.graph.edges.push( + edge('fm-e1', 'fm-start', 'exec', ${f}, 'exec'), + edge('fm-e2', 'fm-loots', 'loots', ${f}, 'list'), + edge('fm-e3', ${f}, 'item', 'fm-expand', 'value'), + edge('fm-e4', 'fm-expand', 'Name', 'fm-equals', 'a'), + edge('fm-e5', ${m}, 'found', 'fm-found', 'exec'), + edge('fm-e6', ${m}, 'item', 'fm-name', 'value'), + edge('fm-e9', 'fm-name', 'Name', 'fm-join', 'a'), + edge('fm-e7', ${m}, 'index', 'fm-join', 'b'), + edge('fm-e8', 'fm-join', 'text', 'fm-found', 'message'), + ); + const scene = __gm.state.activeScene; + const holder = structuredClone(scene.objects[0]); + Object.assign(holder, { id: 'fm-holder', assetId: 'findy', parentId: '', name: 'Find holder', + position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {} }); + delete holder.groupId; + scene.objects.push(holder); + `); + // With the console open the graph is short: fit it, or the ports lie under the console. + await clickText('Fit'); + const framesBefore = await json('__gm.graph.blockFrames()'); + const from = await json(`__gm.graph.portAt('fm-equals', 'result', true)`); + const to = await json(`__gm.graph.portAt(${m}, 'match', false)`); + if (from && to) await drag(from.x, from.y, to.x, to.y, 16); + const wired = (await asset('findy')).graph; + const frames = await json('__gm.graph.blockFrames()'); + const inside = (id) => { + const n = wired.nodes.find((node) => node.id === id); + const b = frames[0]?.bounds; + return !!n && !!b && n.x > b.minX && n.x < b.maxX && n.y > b.minY && n.y < b.maxY; + }; + const problems = [await json(`__gm.graph.nodeProblem(${f})`), await json(`__gm.graph.nodeProblem(${m})`)]; + check( + 'dragging the test into match closes the block: one frame around Find, Expand, Equals and Match only', + framesBefore.length === 0 && + wired.edges.some( + (e) => e.from.node === 'fm-equals' && e.to.node === match.id && e.to.port === 'match', + ) && + frames.length === 1 && + [find.id, 'fm-expand', 'fm-equals', match.id].every(inside) && + !['fm-start', 'fm-loots', 'fm-found', 'fm-name', 'fm-join'].some(inside) && + problems.every((problem) => problem === null), + JSON.stringify({ from, to, framesBefore, frames, problems }), + ); + await shot('24-find-match'); + const logFrom = t.consoleLog.length; + await t.play(); + await sleep(600); + await stop(); + const logged = t.consoleLog + .slice(logFrom) + .filter((line) => line.startsWith('[behavior Find holder]')) + .map((line) => line.split(']').pop().trim()); + check( + 'run, the search goes on at found with the first "Gold", index 1', + JSON.stringify(logged) === JSON.stringify(['Gold1']), + JSON.stringify(logged), + ); + await mutate(` + const p = __gm.state.project; + p.assets = p.assets.filter((a) => a.id !== 'findy'); + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => o.id !== 'fm-holder'); + `); + } }