Loop over a list and stop at the first match with For each (#249)
Finding the first item that matches took Count, Repeat, Item at and a Branch, and the loop could not stop once it had found something. A graph has no lambdas, so the predicate is an input that is read every round. - For each runs body once per element, with item and index set, and goes on at done. - Its break input is read anew before every round, after item is set. Once it is true the loop ends there and that round's body does not run. - done sees the matching item and index with stopped true. Without a match it sees no item, index -1 and stopped false, the OrDefault. - RepeatResult has optional items, stopWhen, stopped and exhausted. GraphInterpreter.loop reads them, Repeat runs as before. Tests: - ./check.sh: 180 files, 2623 tests, CHECK EXIT 0 - e2e GM_STEPS=1-6: ALL PASSED, 269 checks - 5 interpreter tests in graph.test.ts, 2 catalog cases - e2e 6z-iv: the palette creates the node, and a search for Name == "Gold" in Axe, Gold, Bow, Gold logs "1true" - Counter-checks: without the break check 2 unit tests fail, and with break not read over its edge 6z-iv fails - This commit alone: tsc and the three touched unit files pass (349 tests) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a5ae21481b
commit
cb8a2d029a
8 files changed
+253
-3
No files matched your search
@@ -24,6 +24,7 @@ import {
|
||||
portsCompatible,
|
||||
} from '../../src/core/graph';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import type { PropertyValue } from '../../src/core/model/Properties';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import {
|
||||
@@ -390,6 +391,68 @@ describe('Repeat', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `For each` over the list variable `names`, "break" = (item == wanted). The body logs the item it
|
||||
* runs for, done logs item, index and stopped run together, so the logs show every round and where the loop ended.
|
||||
*/
|
||||
describe('For each', () => {
|
||||
function search(names: readonly PropertyValue[], wanted: string | null) {
|
||||
const fixture = emptyBehavior();
|
||||
const { edit, behavior, stack } = fixture;
|
||||
stack.execute(new AddVariableCommand(behavior.id, 'names', 'list'));
|
||||
const start = edit.addNode('OnStart');
|
||||
const each = edit.addNode('ForEach');
|
||||
const get = edit.variables(['names']);
|
||||
const body = edit.addNode('Log');
|
||||
const done = edit.addNode('Log');
|
||||
const line = edit.addNode('Join');
|
||||
const tail = edit.addNode('Join');
|
||||
edit.wire([
|
||||
[start, 'exec', each, 'exec'],
|
||||
[get, 'names', each, 'list'],
|
||||
[each, 'body', body, 'exec'],
|
||||
[each, 'item', body, 'message'],
|
||||
[each, 'done', done, 'exec'],
|
||||
[each, 'item', line, 'a'],
|
||||
[each, 'index', tail, 'a'],
|
||||
[each, 'stopped', tail, 'b'],
|
||||
[tail, 'text', line, 'b'],
|
||||
[line, 'text', done, 'message'],
|
||||
]);
|
||||
if (wanted !== null) {
|
||||
const equals = edit.addNode('Equals', { b: wanted });
|
||||
edit.wire([
|
||||
[each, 'item', equals, 'a'],
|
||||
[equals, 'result', each, 'break'],
|
||||
]);
|
||||
}
|
||||
const ctx = fakeContext({ variables: { names: [...names] } });
|
||||
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
|
||||
return ctx.logs;
|
||||
}
|
||||
|
||||
it('runs the body once per element, each round seeing its own item', () => {
|
||||
expect(search(['a', 'b', 'c'], null)).toEqual(['a', 'b', 'c', '-1false']);
|
||||
});
|
||||
|
||||
it('stops at the first element the break is true for, and done sees that one', () => {
|
||||
// The body of the matching round does not run: "b" is logged by done only.
|
||||
expect(search(['a', 'b', 'c', 'b'], 'b')).toEqual(['a', 'b1true']);
|
||||
});
|
||||
|
||||
it('reads the break anew every round: it is true for the element at hand, not for the first one', () => {
|
||||
expect(search(['a', 'b', 'c'], 'c')).toEqual(['a', 'b', 'c2true']);
|
||||
});
|
||||
|
||||
it('without a match runs every round and done sees no item, index -1', () => {
|
||||
expect(search(['a', 'b'], 'x')).toEqual(['a', 'b', '-1false']);
|
||||
});
|
||||
|
||||
it('runs nothing for an empty list and goes straight to done', () => {
|
||||
expect(search([], 'a')).toEqual(['-1false']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('graph commands', () => {
|
||||
it('rejects incompatible, self and duplicate connections', () => {
|
||||
const {
|
||||
|
||||
@@ -377,6 +377,38 @@ const CASES: Record<string, Case[]> = {
|
||||
},
|
||||
],
|
||||
|
||||
// The rounds themselves and the break are the interpreter's: see "For each" in graph.test.ts.
|
||||
ForEach: [
|
||||
{
|
||||
name: 'asks for one body round per element, handing out the elements as item',
|
||||
inputs: { list: ['a', 'b'] },
|
||||
outputs: { item: null, index: -1, stopped: false },
|
||||
repeat: {
|
||||
port: 'body',
|
||||
index: 'index',
|
||||
times: 2,
|
||||
items: { port: 'item', values: ['a', 'b'] },
|
||||
stopWhen: 'break',
|
||||
stopped: 'stopped',
|
||||
exhausted: { item: null, index: -1, stopped: false },
|
||||
},
|
||||
next: 'done',
|
||||
},
|
||||
{
|
||||
name: 'no list runs nothing',
|
||||
inputs: { list: null },
|
||||
repeat: {
|
||||
port: 'body',
|
||||
index: 'index',
|
||||
times: 0,
|
||||
items: { port: 'item', values: [] },
|
||||
stopWhen: 'break',
|
||||
stopped: 'stopped',
|
||||
exhausted: { item: null, index: -1, stopped: false },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
// Reading needs the interpreter, which asks `read` at the moment: see variable-node.test.ts.
|
||||
Variables: [
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user