Query objects in bulk and filter, count or test a list (#259)
The object queries only ever returned one object: Object by name, Child by name and Find all stop at the first hit. Getting every coin, every child or every item that passes a test needed a For each with its own bookkeeping. These nodes return the whole set as a list of Object, so For each and Find can use item straight away. - Objects by name returns every scene object with that name in scene order. - Children by name returns every match below object, nearer levels first, the same order as the singular node. - Children returns the direct children, with recursive all of them level by level. - Objects of asset returns every instance of an object asset or container by the same rule as Is asset, spawned ones included. - All four take a snapshot. Destroyed objects are never in it, disabled ones are, as with the singular nodes. - Find has a Mode setting: First, All, Any and Count. First is the default and hides the row, so existing graphs look the same. - Match follows the mode of its paired Find: done with items and indices, done with any, or done with count. An unpaired Match shows First, since the pairing only comes from the wiring. - First and Any stop at the first hit, All and Count walk the list. - Changing the mode drops edges at outputs the Match no longer has, in one undo step. SetNodeParamCommand now cleans the whole graph, but only edges that were valid before the change. - The palette entry "filter" adds a Find set to All with its Match. Found on the way: - After adding the filter entry, "find" + Enter picked Filter. The entry has node type Find, counted as an exact hit, and the tie went to the alphabet. The search now ranks by the entry id. e2e 6z-v caught it. Tests: - ./check.sh: 188 files, 2746 tests, CHECK EXIT 0. - find-modes.test.ts with 11 tests, 11 catalog cases for the four nodes, the asset input guard now expects 3. - e2e 6z-vii with 3 checks: filter from the palette, Count picked in the inspector drops the items edge, a run logs 2, 2, 2, 3, 4, "0, 2". - GM_STEPS=1-6: all passed, 278 checks. - Counter-checks fail as expected: every mode stopping early (2), Match ignoring the mode (6), cleanup on the own node only (1), no recursion (2), no inOrder (2), old ranking (1). In the browser a Match ignoring the mode fails both editor checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f84defb9b6
commit
4e6aeb5f72
22 files changed
+671
-35
No files matched your search
@@ -126,7 +126,7 @@ describe('node asset inputs', () => {
|
||||
const params = BUILT_IN_NODES.flatMap((def) => def.inputs)
|
||||
.filter((p) => p.key === 'asset')
|
||||
.map((p) => p.field!);
|
||||
expect(params.map((p) => p.key)).toEqual(['asset', 'asset']);
|
||||
expect(params.map((p) => p.key)).toEqual(['asset', 'asset', 'asset']);
|
||||
for (const param of params) {
|
||||
expect(param.kind).toBe('asset');
|
||||
expect(param.kind === 'asset' && param.assetTypes).toBeUndefined();
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AddNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
|
||||
import { requireNodeDef } from '../../src/core/commands/require';
|
||||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import {
|
||||
FIND,
|
||||
FIND_MODE,
|
||||
type FindMode,
|
||||
GraphInterpreter,
|
||||
resolveDef,
|
||||
searchOutcome,
|
||||
} from '../../src/core/graph';
|
||||
import { graphLookup } from '../../src/core/model/graphLookup';
|
||||
import type { PropertyValue } from '../../src/core/model/Properties';
|
||||
import { paletteEntries, rankedEntries } from '../../src/editor/graph/paletteEntries';
|
||||
import { fakeContext, graphEditor, makeBehavior, makeProject } from '../helpers';
|
||||
|
||||
/** A behavior with a list variable `names`, a Find over it testing `item == wanted`, and its Match. */
|
||||
function block(wanted: PropertyValue = 'b', mode?: FindMode) {
|
||||
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, mode ? { [FIND_MODE]: mode } : {});
|
||||
fixture.stack.execute(add);
|
||||
const find = add.node;
|
||||
const match = add.closing!;
|
||||
const start = edit.addNode('OnStart');
|
||||
const get = edit.variables(['names']);
|
||||
const equals = edit.addNode('Equals', { b: wanted });
|
||||
edit.wire([
|
||||
[start, 'exec', find, 'exec'],
|
||||
[get, 'names', find, 'list'],
|
||||
[find, 'item', equals, 'a'],
|
||||
[equals, 'result', match, 'match'],
|
||||
]);
|
||||
const outputs = () =>
|
||||
resolveDef(match, behavior.graph, graphLookup(fixture.project))!.outputs.map((p) => p.key);
|
||||
const setMode = (value: FindMode) =>
|
||||
fixture.stack.execute(new SetNodeParamCommand(behavior.id, find.id, FIND_MODE, value));
|
||||
return { ...fixture, behavior, edit, find, match, outputs, setMode };
|
||||
}
|
||||
|
||||
/** Runs a block in `mode` over `names`, logging each of the Match's `ports` after `done`. */
|
||||
function answer(mode: FindMode, names: readonly PropertyValue[], ports: string[]): string[] {
|
||||
const { behavior, edit, match } = block('b', mode);
|
||||
let from = { node: match, port: 'done' };
|
||||
for (const port of ports) {
|
||||
const log = edit.addNode('Log');
|
||||
edit.wire([
|
||||
[from.node, from.port, log, 'exec'],
|
||||
[match, port, log, 'message'],
|
||||
]);
|
||||
from = { node: log, port: 'exec' };
|
||||
}
|
||||
const ctx = fakeContext({ variables: { names: [...names] } });
|
||||
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
|
||||
return ctx.logs;
|
||||
}
|
||||
|
||||
describe('Find modes at run time (#259)', () => {
|
||||
it('All hands on every item the test is true for, and their indices', () => {
|
||||
expect(answer('all', ['a', 'b', 'c', 'b'], ['items', 'indices'])).toEqual(['b, b', '1, 3']);
|
||||
});
|
||||
|
||||
it('All with no hit goes on at done with two empty lists', () => {
|
||||
expect(answer('all', ['a', 'c'], ['items', 'indices'])).toEqual(['', '']);
|
||||
});
|
||||
|
||||
it('Any says whether there is one', () => {
|
||||
expect(answer('any', ['a', 'b'], ['any'])).toEqual(['true']);
|
||||
expect(answer('any', ['a', 'c'], ['any'])).toEqual(['false']);
|
||||
expect(answer('any', [], ['any'])).toEqual(['false']);
|
||||
});
|
||||
|
||||
it('Count counts every hit', () => {
|
||||
expect(answer('count', ['b', 'a', 'b', 'b'], ['count'])).toEqual(['3']);
|
||||
expect(answer('count', [], ['count'])).toEqual(['0']);
|
||||
});
|
||||
|
||||
it('First and Any stop at the first hit, All and Count look at every element', () => {
|
||||
const values = ['x', 'y', 'z'];
|
||||
expect(searchOutcome('first', values, [1])).toEqual({
|
||||
next: 'found',
|
||||
match: { item: 'y', index: 1 },
|
||||
find: { item: 'y', index: 1 },
|
||||
});
|
||||
expect(searchOutcome('first', values, []).next).toBe('notFound');
|
||||
expect(searchOutcome('all', values, [0, 2])).toEqual({
|
||||
next: 'done',
|
||||
match: { items: ['x', 'z'], indices: [0, 2] },
|
||||
find: { item: null, index: -1 },
|
||||
});
|
||||
expect(searchOutcome('any', values, [2]).match).toEqual({ any: true });
|
||||
expect(searchOutcome('count', values, [0, 1]).match).toEqual({ count: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Match follows its Find', () => {
|
||||
it('has the outputs of its Find’s mode, in the order they are drawn', () => {
|
||||
const { outputs, setMode } = block();
|
||||
expect(outputs()).toEqual(['found', 'item', 'index', 'notFound']);
|
||||
setMode('all');
|
||||
expect(outputs()).toEqual(['done', 'items', 'indices']);
|
||||
setMode('any');
|
||||
expect(outputs()).toEqual(['done', 'any']);
|
||||
setMode('count');
|
||||
expect(outputs()).toEqual(['done', 'count']);
|
||||
});
|
||||
|
||||
it('a Find saved without a mode is First, and First leaves the setting off the node', () => {
|
||||
const { find, outputs } = block();
|
||||
delete find.params[FIND_MODE];
|
||||
expect(outputs()).toEqual(['found', 'item', 'index', 'notFound']);
|
||||
const hidden = requireNodeDef(FIND).hiddenParams!;
|
||||
expect(hidden(find.params)).toEqual([FIND_MODE]);
|
||||
expect(hidden({ [FIND_MODE]: 'all' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('switching the mode drops the Match’s edges on outputs that are gone; undo brings them back', () => {
|
||||
const { behavior, edit, match, setMode, stack } = block();
|
||||
const hit = edit.addNode('Log');
|
||||
const other = edit.addNode('Log');
|
||||
edit.wire([
|
||||
[match, 'found', hit, 'exec'],
|
||||
[match, 'item', hit, 'message'],
|
||||
]);
|
||||
const before = behavior.graph.edges.length;
|
||||
setMode('all');
|
||||
expect(behavior.graph.edges.length).toBe(before - 2);
|
||||
edit.wire([[match, 'done', other, 'exec']]);
|
||||
setMode('count');
|
||||
// `done` is there in both modes, so its edge stays.
|
||||
expect(behavior.graph.edges.some((e) => e.from.node === match.id && e.from.port === 'done')).toBe(true);
|
||||
stack.undo();
|
||||
stack.undo();
|
||||
stack.undo();
|
||||
expect(behavior.graph.edges.length).toBe(before);
|
||||
expect(behavior.graph.edges.some((e) => e.from.port === 'found')).toBe(true);
|
||||
});
|
||||
|
||||
it('an edge that already hung on a gone port elsewhere is left to show as a ghost', () => {
|
||||
const { behavior, edit, find, stack } = block();
|
||||
const log = edit.addNode('Log');
|
||||
const ghost = { id: 'ghost', from: { node: log.id, port: 'gone' }, to: { node: log.id, port: 'exec' } };
|
||||
behavior.graph.edges.push(ghost);
|
||||
stack.execute(new SetNodeParamCommand(behavior.id, find.id, FIND_MODE, 'all'));
|
||||
expect(behavior.graph.edges).toContain(ghost);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Filter entry', () => {
|
||||
it('is a Find set to All under a name of its own', () => {
|
||||
const { project, behavior } = block();
|
||||
const filter = paletteEntries(project, behavior).find((e) => e.def.label === 'Filter')!;
|
||||
expect(filter.def.type).toBe(FIND);
|
||||
expect(filter.params).toEqual({ [FIND_MODE]: 'all' });
|
||||
const add = new AddNodeCommand(behavior.id, filter.def.type, 0, 0, filter.params);
|
||||
expect(add.node.params[FIND_MODE]).toBe('all');
|
||||
expect(add.closing).not.toBeNull();
|
||||
});
|
||||
|
||||
it('"filter" finds it first, "find" still the plain Find', () => {
|
||||
const { project, behavior } = block();
|
||||
const first = (query: string) => rankedEntries(paletteEntries(project, behavior), query)[0]!;
|
||||
expect([first('filter').def.label, first('filter').params]).toEqual(['Filter', { [FIND_MODE]: 'all' }]);
|
||||
expect([first('find').def.label, first('find').params]).toEqual(['Find', {}]);
|
||||
});
|
||||
});
|
||||
@@ -129,6 +129,14 @@ const pointer = (overrides: Partial<NodeContext['pointer']>): Partial<NodeContex
|
||||
pointer: { ...NO_POINTER, ...overrides },
|
||||
});
|
||||
|
||||
/** Player › (Arm › Hitbox #2), Hitbox #1 – the arm comes first in scene order – and a lone Coin. */
|
||||
const hero = fakeEntity('Player');
|
||||
const arm = { ...fakeEntity('Arm'), parent: hero };
|
||||
const deepBox = { ...fakeEntity('Hitbox'), id: 'hitbox-2', parent: arm };
|
||||
const nearBox = { ...fakeEntity('Hitbox'), id: 'hitbox-1', parent: hero };
|
||||
const coins = [fakeEntity('Coin', 0, 'coin'), { ...fakeEntity('Coin', 0, 'coin'), id: 'coin-2' }];
|
||||
const scene: Partial<NodeContext> = { objects: () => [hero, arm, deepBox, nearBox, ...coins] };
|
||||
|
||||
const CASES: Record<string, Case[]> = {
|
||||
Branch: [
|
||||
{ name: 'true takes then', inputs: { condition: true }, next: 'then' },
|
||||
@@ -500,6 +508,75 @@ const CASES: Record<string, Case[]> = {
|
||||
},
|
||||
{ name: 'no object is null', inputs: { object: null, name: 'Hitbox' }, outputs: { child: null } },
|
||||
],
|
||||
ObjectsByName: [
|
||||
{
|
||||
name: 'every object so named, in scene order',
|
||||
inputs: { name: 'Hitbox' },
|
||||
context: scene,
|
||||
outputs: { objects: [deepBox, nearBox] },
|
||||
},
|
||||
{
|
||||
name: 'none so named is the empty list',
|
||||
inputs: { name: 'Ghost' },
|
||||
context: scene,
|
||||
outputs: { objects: [] },
|
||||
},
|
||||
],
|
||||
ChildrenByName: [
|
||||
{
|
||||
name: 'every one below, the nearer level first',
|
||||
inputs: { object: hero, name: 'Hitbox' },
|
||||
context: scene,
|
||||
outputs: { children: [nearBox, deepBox] },
|
||||
},
|
||||
{
|
||||
name: 'nothing above or beside the object',
|
||||
inputs: { object: arm, name: 'Hitbox' },
|
||||
context: scene,
|
||||
outputs: { children: [deepBox] },
|
||||
},
|
||||
{
|
||||
name: 'no object is the empty list',
|
||||
inputs: { object: null, name: 'Hitbox' },
|
||||
context: scene,
|
||||
outputs: { children: [] },
|
||||
},
|
||||
],
|
||||
Children: [
|
||||
{
|
||||
name: 'the direct children only',
|
||||
inputs: { object: hero, recursive: false },
|
||||
context: scene,
|
||||
outputs: { children: [arm, nearBox] },
|
||||
},
|
||||
{
|
||||
name: 'recursive: level by level',
|
||||
inputs: { object: hero, recursive: true },
|
||||
context: scene,
|
||||
outputs: { children: [arm, nearBox, deepBox] },
|
||||
},
|
||||
{
|
||||
name: 'a leaf has none',
|
||||
inputs: { object: deepBox, recursive: true },
|
||||
context: scene,
|
||||
outputs: { children: [] },
|
||||
},
|
||||
{ name: 'no object has none', inputs: { object: null }, context: scene, outputs: { children: [] } },
|
||||
],
|
||||
ObjectsOfAsset: [
|
||||
{
|
||||
name: 'every instance of the asset',
|
||||
inputs: { asset: 'coin' },
|
||||
context: scene,
|
||||
outputs: { objects: coins },
|
||||
},
|
||||
{
|
||||
name: 'no asset chosen is the empty list',
|
||||
inputs: { asset: '' },
|
||||
context: scene,
|
||||
outputs: { objects: [] },
|
||||
},
|
||||
],
|
||||
NameOf: [
|
||||
{ name: 'reads the name', inputs: { object: fakeEntity('Coin') }, outputs: { name: 'Coin' } },
|
||||
{ name: 'no object is the empty name', inputs: { object: null }, outputs: { name: '' } },
|
||||
|
||||
Reference in new issue
Block a user