A list of structs used to let every item carry its own fields. Adding an item meant typing the same fields again, and nothing kept the items in step with the struct asset from #253. A list can now name a struct type for its items, and the asset keeps them in shape. - A list variable and a list field get the row Items with Mixed, every struct asset and Make struct type. Make struct type collects every field any item has, so no item loses a field. - A typed item carries its type under the key `$struct` (ITEM_TYPE_KEY). listItems does not count it as a field. - A list that holds anything but structs refuses a type, also when the offending item sits in an override. The console names the list and the count, and the list stays as it was. - In a typed list `+ Item` adds an item with the defaults, without the kind menu. Field names are locked, values stay editable. - The rows of a typed item take their kind from the struct asset (declaredKind in InlineList.ts). An asset field shows the asset picker instead of a text field, as reported in the issue. - A struct item in a mixed list gets its own Type row. Switching a list back to Mixed keeps the type on each item. - syncStructTypes brings marked items into shape and carries renames into them, also inside overrides and nested lists. One undo step. - The delete dialog of a struct asset also counts typed lists. - The game never sees the key. World and sharedState strip it with withoutItemTypes and bagWithoutItemTypes, so a struct stays a plain object at runtime. - Add item and Set item get the setting Type. With a type they only accept a struct with exactly its field names (fitsType in SetItem.ts). Anything else is left out and logged. - The item indent from #252 is back to its old width, the narrow one squeezed the value fields. Not done: writing a variable does not check the type, and the List node offers no type. Found on the way: a test helper in list-of-structs.test.ts called nodes without params. It now passes the same arguments as the interpreter. Tests: - ./check.sh: 183 files, 2679 tests, CHECK EXIT 0 - e2e GM_STEPS=1,21,22,32,54,55: ALL PASSED, 150 checks - e2e GM_STEPS=1-6,21,22,32,54,55 before the follow-up: 404 checks - New: tests/core/typed-lists.test.ts, e2e module 55 with 13 checks - Counter-checks bite in unit and browser, originals restored by cmp Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
188 lines
8.0 KiB
TypeScript
188 lines
8.0 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { type GraphLookup, NO_LOOKUP } from '../../src/core/graph/lookup';
|
||
import { AddItem } from '../../src/core/graph/nodes/data/AddItem';
|
||
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, tagged, textOf } from '../../src/core/model/Properties';
|
||
import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
|
||
import { makeBehavior, makeSpriteScene, roundTrip } from '../helpers';
|
||
|
||
const cells = (...pairs: [number, number][]) => pairs.map(([x, y]) => ({ x, y }));
|
||
|
||
/** A `list` variable holding struct items, the way a seeder writes it. */
|
||
const shapesDef = (value: unknown) => variableDef('shapes', 'list', value as never);
|
||
|
||
/** The node interface only needs `run`; nothing here reads the context, and no node has a setting. */
|
||
const run = (def: { run?: unknown }, inputs: Record<string, unknown>) =>
|
||
(
|
||
def.run as (
|
||
ctx: unknown,
|
||
i: Record<string, unknown>,
|
||
params: Record<string, unknown>,
|
||
def: unknown,
|
||
entry: string,
|
||
lookup: GraphLookup,
|
||
) => { outputs: Record<string, unknown> }
|
||
)({}, inputs, {}, def, '', NO_LOOKUP).outputs;
|
||
|
||
describe('a list whose items are structs', () => {
|
||
/** Each item keeps the fields it carries: there is no declaration left to fill or drop any. */
|
||
it('keeps every item the shape it arrived with', () => {
|
||
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 }, { x: 4, y: 5, z: 9 }]);
|
||
});
|
||
|
||
it('keeps a field value as the kind it is, text included', () => {
|
||
expect(coerceValue(shapesDef([]), [{ x: '3', y: 4 }])).toEqual([{ x: '3', y: 4 }]);
|
||
});
|
||
|
||
it('leaves a number beside them a number, since an item answers for itself', () => {
|
||
expect(coerceValue(shapesDef([]), [7, { x: 1, y: 2 }])).toEqual([7, { 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).toBe('list');
|
||
expect(def?.default).toEqual(cells([0, 1], [1, 1]));
|
||
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 });
|
||
});
|
||
|
||
/** Both were dropped while the depth was bounded; since #239 they come back as stored. */
|
||
it('keeps a struct inside a struct', () => {
|
||
expect(paramsAfterLoad({ v: { inner: { x: 1, deeper: { y: 2 } }, n: 1 } }).v).toEqual({
|
||
inner: { x: 1, deeper: { y: 2 } },
|
||
n: 1,
|
||
});
|
||
});
|
||
|
||
it('keeps a list inside a list item, and drops only what is no value at all', () => {
|
||
expect(paramsAfterLoad({ items: [{ deep: [1, [2, null]], x: 1 }, [null, 3]] }).items).toEqual([
|
||
{ deep: [1, [2]], x: 1 },
|
||
[3],
|
||
]);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* A tagged item is an editor thing: the graph has no colours and no pictures, only numbers and
|
||
* texts. Reading one out therefore hands over what it wraps – the id a sprite can be made to wear.
|
||
*/
|
||
describe('the list nodes with tagged items', () => {
|
||
it('hands a texture item out as the plain id it holds', () => {
|
||
expect(
|
||
run(ItemAt, { list: [tagged('texture', 'walk-up'), tagged('texture', 'fr-0-1')], index: 1 }),
|
||
).toEqual({
|
||
item: 'fr-0-1',
|
||
});
|
||
});
|
||
|
||
it('and a colour item as its number', () => {
|
||
expect(run(ItemAt, { list: [tagged('color', 0xff0000)], index: 0 })).toEqual({ item: 0xff0000 });
|
||
});
|
||
|
||
/** Or searching a list of pictures with the id a `Properties` node just read would never hit. */
|
||
it('finds a texture item by the id beside it', () => {
|
||
expect(run(IndexOf, { list: [tagged('texture', 'a'), tagged('texture', 'b')], value: 'b' })).toEqual({
|
||
index: 1,
|
||
});
|
||
});
|
||
|
||
/** The slot keeps its kind, so a list of pictures stays one after the graph has written to it. */
|
||
it('keeps a texture slot a texture when the graph writes a plain id into it', () => {
|
||
expect(run(SetItem, { list: [tagged('texture', 'a')], index: 0, value: 'b' })).toEqual({
|
||
result: [tagged('texture', 'b')],
|
||
});
|
||
});
|
||
|
||
it('adds a plain id to a list of pictures as a picture', () => {
|
||
expect(run(AddItem, { list: [tagged('texture', 'a')], value: 'b', index: -1 })).toEqual({
|
||
result: [tagged('texture', 'a'), tagged('texture', 'b')],
|
||
});
|
||
});
|
||
});
|
||
|
||
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 });
|
||
});
|
||
|
||
/** `Join lists` would take the struct apart into its field values. */
|
||
it('adds a struct as one whole item', () => {
|
||
expect(run(AddItem, { list: cells([1, 2]), value: { x: 3, y: 4 }, index: -1 })).toEqual({
|
||
result: cells([1, 2], [3, 4]),
|
||
});
|
||
});
|
||
|
||
it('adds a struct to an empty list as one whole item', () => {
|
||
expect(run(AddItem, { list: [], value: { x: 3, y: 4 }, index: -1 })).toEqual({ result: cells([3, 4]) });
|
||
});
|
||
|
||
it('leaves a number a number beside structs, since a mixed list has no kind to keep', () => {
|
||
expect(run(AddItem, { list: [7, { x: 1, y: 2 }], value: 8, index: -1 })).toEqual({
|
||
result: [7, { x: 1, y: 2 }, 8],
|
||
});
|
||
});
|
||
|
||
it('fills a list with a struct', () => {
|
||
expect(run(Fill, { value: { x: 1, y: 2 }, count: 2 })).toEqual({ result: cells([1, 2], [1, 2]) });
|
||
});
|
||
});
|