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, 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. */ const run = (def: { run?: unknown }, inputs: Record) => (def.run as (ctx: unknown, i: Record) => { outputs: Record })({}, inputs) .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) => { 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 }]); }); }); /** * 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')], }); }); }); 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]) }); }); });