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 }, }, ]); }); });