import { describe, expect, it } from 'vitest'; import { AddVariableCommand } from '../../src/core/commands/variableCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import { sameValue, textOf } from '../../src/core/model/Properties'; import { findAsset, objectHolders, type Project } from '../../src/core/model/Project'; import { deserializeProject } from '../../src/core/store/serialize'; import { coerceField, coerceValue, variableDef } from '../../src/core/properties/PropertySchema'; import { deviatesFromAsset } from '../../src/core/properties/resolve'; import { asBoolean, asEntity, asStruct, writeTyped } from '../../src/core/graph'; import { fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers'; const findObject = (project: Project, id: string) => objectHolders(project) .flatMap((holder) => holder.objects) .find((o) => o.id === id); describe('a list as a value', () => { it('survives saving and loading – with its items, not as a string', () => { const f = makeSpriteScene(); const behavior = makeBehavior(f, 'B'); f.stack.execute(new AddVariableCommand(behavior.id, 'grid', 'list')); const object = place(f, behavior.id, { x: 0, y: 0 }); f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'grid', [1, 0, 2])); const loaded = roundTrip(f.project); const stored = findObject(loaded, object)?.overrides.grid; expect(stored).toEqual([1, 0, 2]); }); it('survives inside a container instance too, where no schema is there to coerce it', () => { // Instance edits are read from an untyped slot, so a primitives-only filter would drop the list silently. const loaded = deserializeProject( { name: 'P', activeSceneId: 's1', assets: [ { id: 's1', type: 'scene', objects: [ { id: 'i1', assetId: 'c1', position: {}, instanceEdits: { b1: { overrides: { grid: [3, 4] } } }, }, ], }, { id: 'c1', type: 'container', objects: [] }, ], }, 'p1', ); expect(findObject(loaded, 'i1')?.instanceEdits.b1?.overrides.grid).toEqual([3, 4]); }); it('is compared by its items, so reloading does not turn every list into an override', () => { // The same items in a fresh array: a reference compare would call this a deviation on every load. expect(sameValue([1, 2, 3], [1, 2, 3])).toBe(true); expect(sameValue([1, 2, 3], [1, 2])).toBe(false); expect(sameValue([1, 2, 3], [1, 9, 3])).toBe(false); }); it('an override that only repeats the default is not one', () => { const f = makeSpriteScene(); const behavior = makeBehavior(f, 'B'); f.stack.execute(new AddVariableCommand(behavior.id, 'row', 'list')); const asset = findAsset(f.project, behavior.id)!; asset.props.row = [1, 2]; const object = place(f, behavior.id, { x: 0, y: 0 }); f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'row', [1, 2])); const placed = f.object(object); expect(deviatesFromAsset(placed, asset, 'row')).toBe(false); f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'row', [1, 3])); expect(deviatesFromAsset(placed, asset, 'row')).toBe(true); }); it('coerces a loaded item to its declared kind, kind by kind', () => { expect(coerceValue(variableDef('n', 'list', [], 'number'), ['1', 2, 'x'])).toEqual([1, 2, 0]); expect(coerceValue(variableDef('t', 'list', [], 'text'), [1, true])).toEqual(['1', 'true']); // Spelling has to count here: Boolean('false') would be true. expect(coerceValue(variableDef('b', 'list', [], 'boolean'), ['true', 'false', '0', ''])).toEqual([ true, false, false, false, ]); expect(coerceValue(variableDef('c', 'list', [], 'color'), ['#ff0000', 0x00ff00])).toEqual([ 0xff0000, 0x00ff00, ]); }); it('a value that is not a list falls back to the default rather than becoming one', () => { const def = variableDef('grid', 'list', [7], 'number'); expect(coerceValue(def, 'nonsense')).toEqual([7]); expect(coerceValue(def, ['1', 2])).toEqual([1, 2]); }); }); describe('a struct as a value', () => { it('compares by content, one level deep, and tells a different shape apart', () => { expect(sameValue({ x: 1, tags: ['a'] }, { x: 1, tags: ['a'] })).toBe(true); expect(sameValue({ x: 1, tags: ['a'] }, { x: 1, tags: ['b'] })).toBe(false); expect(sameValue({ x: 1 }, { x: 1, y: 2 })).toBe(false); expect(sameValue({ x: 1 }, [1])).toBe(false); }); it('reads as text field by field, never as "[object Object]"', () => { expect(textOf({ x: 1, tags: ['a', 'b'] })).toBe('x: 1, tags: a, b'); }); it('is not a live entity, and a live entity is not one', () => { const handle = fakeEntity('Player'); expect(asStruct(handle)).toEqual({}); expect(asStruct({ x: 1 })).toEqual({ x: 1 }); expect(asEntity({ x: 1 })).toBeNull(); expect(asEntity(handle)).toBe(handle); }); /** What lets one node set some values of a bundle without erasing the rest of it. */ it('keeps the fields of the slot it is written into, filling only the ones that arrive', () => { const bag = { piece: { x: 1, y: 2, name: 'T' } }; writeTyped(bag, 'piece', { y: 9, gone: 5 }); expect(bag.piece).toEqual({ x: 1, y: 9, name: 'T' }); }); it('converts each field to the type that field already held', () => { const bag = { piece: { count: 0, on: false, label: '' } }; writeTyped(bag, 'piece', { count: '7', on: 'yes', label: 42 }); expect(bag.piece).toEqual({ count: 7, on: true, label: '42' }); }); it('a non-struct written into a struct slot leaves the declared fields standing', () => { const bag = { piece: { x: 1 } }; writeTyped(bag, 'piece', 5); expect(bag.piece).toEqual({ x: 1 }); }); }); describe('a yes/no written as text', () => { it('reads its spelling the same in a property, a struct field and a graph', () => { const spellings = ['false', ' No ', '0', '', 'true', 'yes']; const expected = [false, false, false, false, true, true]; const def = variableDef('on', 'boolean', true); expect(spellings.map((s) => coerceValue(def, s))).toEqual(expected); expect(spellings.map((s) => coerceField(s, 'boolean'))).toEqual(expected); expect(spellings.map((s) => asBoolean(s))).toEqual(expected); }); });