Files
feuerwerk/tests/core/list-of-structs.test.ts
T
rootandClaude Opus 5 f60358c951 Let a list hold structs, and read or write variables in bundles
A list item may now be a struct. The depth is bounded in the declaration
rather than by a comment: FieldKind (number|text|boolean|color) says what a
struct field holds, ListItemKind adds struct on top and says what a list
holds, so list -> struct -> primitive is the deepest storable shape and
sameValue stays finite. A struct item renders inline in the List tab, which
is why it needs no path in the slot address.

Four paths silently assumed a list item was a primitive: sameValue compared
items with ===, textOf would have printed [object Object], asListItem turned
a struct into text on Set item and Fill, and Index of used indexOf, which is
reference equality and could never have found a struct.

Tetris now runs on bundles. shapes is 112 {x, y} structs instead of 224 loose
numbers, piece and the candidate are structs, so Next move hands back a whole
candidate and each strand packs only the field it changes, since mergeStruct
leaves the rest standing. Declared behavior ports drop 49 -> 35, the master's
variables 18 -> 13, its graph 84/94 -> 69/76 nodes/edges.

Get variables and Set variables declare their ports from a list of variable
names, the way Pack and Expand already do, so one node can read or write
several at once. Renaming a variable rewires them, because there the port key
is the variable name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 14:01:56 +02:00

127 lines
5.5 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { Fill } from '../../src/core/graph/nodes/data/Fill';
import { IndexOf } from '../../src/core/graph/nodes/data/IndexOf';
import { ItemAt } from '../../src/core/graph/nodes/data/ItemAt';
import { SetItem } from '../../src/core/graph/nodes/data/SetItem';
import { sameValue, textOf } from '../../src/core/model/Properties';
import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
import { makeBehavior, makeSpriteScene, roundTrip } from '../helpers';
const XY = [
{ key: 'x', kind: 'number' },
{ key: 'y', kind: 'number' },
] as const;
const cells = (...pairs: [number, number][]) => pairs.map(([x, y]) => ({ x, y }));
/** A `list` variable whose items are structs, declared the way a seeder writes it. */
const shapesDef = (value: unknown) => variableDef('shapes', 'list', value as never, 'struct', XY);
/** The node interface only needs `run`; nothing here reads the context. */
const run = (def: { run?: unknown }, inputs: Record<string, unknown>) =>
(def.run as (ctx: unknown, i: Record<string, unknown>) => { outputs: Record<string, unknown> })({}, inputs)
.outputs;
describe('a list whose items are structs', () => {
it('forces every item into the declared shape, filling and dropping fields', () => {
const def = shapesDef([{ x: 1, y: 2 }, { x: 3 }, { x: 4, y: 5, z: 9 }]);
expect(def.default).toEqual([
{ x: 1, y: 2 },
{ x: 3, y: 0 },
{ x: 4, y: 5 },
]);
});
it('coerces the field values themselves, so text out of a file becomes numbers', () => {
expect(coerceValue(shapesDef([]), [{ x: '3', y: '4' }])).toEqual([{ x: 3, y: 4 }]);
});
it('makes a non-struct item into the empty shape rather than dropping the item', () => {
expect(coerceValue(shapesDef([]), [7, { x: 1, y: 2 }])).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 2 },
]);
});
it('survives a save and load with its items intact', () => {
const f = makeSpriteScene();
const behavior = makeBehavior(f, 'B');
const asset = f.project.assets.find((a) => a.id === behavior.id);
if (asset?.type !== 'behavior') throw new Error('behavior expected');
asset.variables = [shapesDef(cells([0, 1], [1, 1]))];
asset.props.shapes = cells([2, 3], [4, 5]);
const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id);
if (loaded?.type !== 'behavior') throw new Error('behavior expected');
const def = loaded.variables.find((v) => v.key === 'shapes');
expect(def?.kind === 'list' ? def.items : '').toBe('struct');
expect(def?.kind === 'list' ? def.fields : []).toEqual([...XY]);
expect(loaded.props.shapes).toEqual(cells([2, 3], [4, 5]));
});
it('compares by content, so an unchanged list is not reported as an override', () => {
expect(sameValue(cells([1, 2]), cells([1, 2]))).toBe(true);
expect(sameValue(cells([1, 2]), cells([1, 3]))).toBe(false);
expect(sameValue(cells([1, 2]), cells([1, 2], [3, 4]))).toBe(false);
});
it('reads as its items rather than as "[object Object]"', () => {
expect(textOf(cells([1, 2], [3, 4]))).toBe('x: 1, y: 2, x: 3, y: 4');
});
});
/**
* Node params have no schema to coerce against, so the loader alone decides how deep a value may
* be. The bound has to be the model's own: a list of structs of primitives, and no further.
*/
describe('an untyped slot on load', () => {
const paramsAfterLoad = (params: Record<string, unknown>) => {
const f = makeSpriteScene();
const behavior = makeBehavior(f, 'B');
const asset = f.project.assets.find((a) => a.id === behavior.id);
if (asset?.type !== 'behavior') throw new Error('behavior expected');
asset.graph.nodes.push({ id: 'n1', type: 'List', x: 0, y: 0, params: params as never });
const loaded = roundTrip(f.project).assets.find((a) => a.id === behavior.id);
if (loaded?.type !== 'behavior') throw new Error('behavior expected');
return loaded.graph.nodes.find((n) => n.id === 'n1')?.params ?? {};
};
it('keeps a list of structs', () => {
expect(paramsAfterLoad({ items: cells([1, 2], [3, 4]) }).items).toEqual(cells([1, 2], [3, 4]));
});
it('still keeps a struct holding a flat list, which it always did', () => {
expect(paramsAfterLoad({ v: { tags: ['a', 'b'], n: 1 } }).v).toEqual({ tags: ['a', 'b'], n: 1 });
});
it('drops a struct inside a struct, which no declaration can ask for', () => {
expect(paramsAfterLoad({ v: { inner: { x: 1 }, n: 1 } }).v).toEqual({ n: 1 });
});
it('drops a list inside a list item, the level below what a field may hold', () => {
expect(paramsAfterLoad({ items: [{ deep: [1, 2], x: 1 }] }).items).toEqual([{ x: 1 }]);
});
});
describe('the list nodes with struct items', () => {
it('hands a whole item out of the list', () => {
expect(run(ItemAt, { list: cells([1, 2], [3, 4]), index: 1 })).toEqual({ item: { x: 3, y: 4 } });
});
it('puts a struct back into a list instead of turning it into text', () => {
expect(run(SetItem, { list: cells([1, 2], [3, 4]), index: 0, value: { x: 9, y: 9 } })).toEqual({
result: cells([9, 9], [3, 4]),
});
});
it('finds a struct by its content, since two equal structs are never the same object', () => {
expect(run(IndexOf, { list: cells([1, 2], [3, 4]), value: { x: 3, y: 4 } })).toEqual({ index: 1 });
expect(run(IndexOf, { list: cells([1, 2]), value: { x: 9, y: 9 } })).toEqual({ index: -1 });
});
it('fills a list with a struct', () => {
expect(run(Fill, { value: { x: 1, y: 2 }, count: 2 })).toEqual({ result: cells([1, 2], [1, 2]) });
});
});