Files
feuerwerk/tests/core/find-match.test.ts
T
adminandClaude Opus 5.5 2aaca01929 Find the first matching item with a Find … Match block (#255)
Searching a list with For each needs the test result wired back into
break on the left of the loop node. The wires then run across the node
and the graph reads badly. Find … Match keeps the whole search flowing
left to right, while the test in between stays a free graph.

- Find takes exec and list and offers item and index. It sets them to
  each element in turn and has no exec output of its own.
- Match reads its bool input match once per element. The first true
  value continues at found with item and index of the hit. No hit or an
  empty list continues at not found with an empty item and index -1.
- The pair is derived from the wiring and stored nowhere. A Match
  belongs to the Find whose item or index its test is built from. Copy,
  paste and undo therefore need no extra code (findBlock.ts).
- An unpaired Find or Match, two Matches on one Find and a Match fed
  by two Finds show a warning on the node. At run time the flow ends at
  an unpaired Find and the console says so.
- Adding Find from the palette places its Match 480 px to the right in
  the same undo step.
- The graph draws a quiet frame behind Find, Match and the nodes of the
  test. Literals feeding the test from outside stay outside the frame.
- The test consists of data nodes only, like the break of For each.

Tests:
- ./check.sh: 184 files, 2694 tests, CHECK EXIT 0
- e2e GM_STEPS=1-6: ALL PASSED, 272 checks
- New find-match.test.ts with 12 tests, 2 catalog cases, e2e 6z-v
  with 3 checks using a real mouse.
- Counter-checks: ignoring the test fails 4 unit tests, no ambiguity
  check fails 2, framing everything fails 1. In the browser, no frame
  and no Match from the palette each fail their check.
- Check total in README: 1517.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:33:25 +02:00

210 lines
8.1 KiB
TypeScript

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