/** * A list declares nothing about what it holds: every item carries its own kind, read back off the * value – and a color or a texture says so, or it would be a plain number or text. */ import { describe, expect, it } from 'vitest'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { AddVariableCommand } from '../../src/core/commands/variableCommands'; import { type PropertyValue, tagged, untag } from '../../src/core/model/Properties'; import { findAsset } from '../../src/core/model/Project'; import { deserializeProject } from '../../src/core/store/serialize'; import { itemKindOf, makeItem, structItemFields } from '../../src/core/properties/listItems'; import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers'; /** A behavior with a list variable `stops`, placed once with an override of its own. */ function withList(value: unknown = [1, 2]) { const f = makeSpriteScene(); const behavior = makeBehavior(f, 'B'); f.stack.execute(new AddVariableCommand(behavior.id, 'stops', 'list')); const asset = findAsset(f.project, behavior.id)!; asset.props.stops = value as never; const object = place(f, behavior.id, { x: 0, y: 0 }); f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'stops', [3])); return { f, behaviorId: behavior.id, object }; } describe('the kind of one list item', () => { it('is read off the item itself, a color through its tag', () => { expect([7, 'a', true, tagged('color', 0xff0000), { x: 1 }].map(itemKindOf)).toEqual([ 'number', 'text', 'boolean', 'color', 'struct', ]); }); it('lets one list hold all of them at once, through a write and a read back', () => { const mixed = [7, 'tee', true, tagged('color', 0xff0000), { x: 1, y: 'two' }]; const { f, behaviorId } = withList(mixed); expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual(mixed); }); /** The one kind JSON cannot tell apart from a number, which is why it is tagged at all. */ it('keeps a color apart from the number beside it', () => { const { f, behaviorId } = withList([tagged('color', 0xff0000), 0xff0000]); const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[]; expect(back.map(itemKindOf)).toEqual(['color', 'number']); expect(back.map(untag)).toEqual([0xff0000, 0xff0000]); }); /** * The second kind JSON cannot tell apart on its own: an asset id is a text like any other. What a * sprite wears is one id whether it names a texture or one of its frames. */ it('keeps a texture apart from the text beside it, and reads back as its id', () => { const { f, behaviorId } = withList([tagged('texture', 'tex-walk'), 'tex-walk']); const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[]; expect(back.map(itemKindOf)).toEqual(['texture', 'text']); expect(back.map(untag)).toEqual(['tex-walk', 'tex-walk']); }); it('turns a text into a texture item and back without losing the id', () => { expect(makeItem('texture', 'frame-26')).toEqual(tagged('texture', 'frame-26')); expect(makeItem('text', tagged('texture', 'frame-26'))).toBe('frame-26'); }); /** A struct item's field is undeclared too, so a texture there keeps its tag, like a color does. */ it('stays a texture inside a struct item', () => { const { f, behaviorId } = withList([{ pic: tagged('texture', 'frame-26'), n: 1 }]); const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[]; expect(back).toEqual([{ pic: tagged('texture', 'frame-26'), n: 1 }]); expect(structItemFields(back[0]).map((field) => `${field.key}:${field.kind}`)).toEqual([ 'pic:texture', 'n:number', ]); }); it('changes for one item without touching its neighbours', () => { const { f, behaviorId } = withList([1, 2]); const asset = findAsset(f.project, behaviorId)!; const list = asset.props.stops as PropertyValue[]; asset.props.stops = [makeItem('text', list[0]), list[1]] as never; expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual(['1', 2]); }); it('gives a struct item a shape of its own, each field with its own kind', () => { const { f, behaviorId } = withList([{ x: 1, label: 'a' }, { x: 2 }]); const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[]; expect(back.map((item) => structItemFields(item).map((field) => `${field.key}:${field.kind}`))).toEqual([ ['x:number', 'label:text'], ['x:number'], ]); }); /** Once dropped as one level too deep; since #239 nothing bounds the depth. */ it('keeps a list inside a struct item, and a list in a list', () => { const { f, behaviorId } = withList([{ x: 1, deep: [1, [2, tagged('color', 0xff0000)]] }, [[3]]]); expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual([ { x: 1, deep: [1, [2, tagged('color', 0xff0000)]] }, [[3]], ]); }); }); /** * A node parameter meets no schema on the way in – `asStoredValue` is the only rule it passes. It is * therefore the one place where the store itself has to know the tags. */ describe('a list in a node parameter, where nothing is declared', () => { const loadParam = (value: unknown) => deserializeProject({ id: 'p', assets: [ { id: 'b', type: 'behavior', name: 'B', graph: { nodes: [{ id: 'n', type: 'List', x: 0, y: 0, params: { value } }], edges: [] }, }, ], }); const paramOf = (value: unknown) => { const asset = findAsset(loadParam(value), 'b'); return asset?.type === 'behavior' ? asset.graph.nodes[0]?.params.value : undefined; }; it('keeps a tagged texture an item of its own', () => { expect(paramOf([tagged('texture', 'frame-26'), 'frame-26'])).toEqual([ tagged('texture', 'frame-26'), 'frame-26', ]); }); it('and one inside a struct item as well', () => { expect(paramOf([{ pic: tagged('texture', 'frame-26'), n: 1 }])).toEqual([ { pic: tagged('texture', 'frame-26'), n: 1 }, ]); }); });