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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-28 11:33:25 +02:00
1 parent e1d3c0cadb
commit 2aaca01929
16 files changed
+608 -5

No files matched your search

+22 -2
View File
@@ -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<string, Case[]> = {
},
],
// 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<string, string> = {
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);
});
}