A variable could only be a number, a text or a yes/no, so every set of values had to be smuggled through a string - which is where Tetris got its unreadable "03066326048763714" colours from. A list is now a value of its own, with an item kind (number, text, yes/no, colour) that decides how the inspector reads the one comma-separated line it is typed as, and eight nodes to work with: List, Fill, Count, Item at, Set item, Sub list, Join lists, Index of. Two traps came with it. An array is typeof 'object', so every place that told a value from an entity had to learn the difference, or a list would have been passed around as a game object. And a value was compared by identity in three places - with lists that marks everything as an overridden property after each reload, so those comparisons now compare contents. There is no list of objects: a list is written to the project file and an object is a live handle that no reload survives. A set of objects stays a list of names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
97 lines
4.2 KiB
TypeScript
97 lines
4.2 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import { sameValue } from '../../src/core/model/Properties';
|
||
import { findAsset, objectHolders, type Project } from '../../src/core/model/Project';
|
||
import { deserializeProject } from '../../src/core/store/serialize';
|
||
import { coerceValue, listFromText, listToText, variableDef } from '../../src/core/properties/PropertySchema';
|
||
import { deviatesFromAsset } from '../../src/core/properties/resolve';
|
||
import { 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: the old filter kept number/string/boolean and
|
||
// dropped everything else, which would have swallowed the list without a word.
|
||
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('reads and writes as one comma-separated line, item kind by item kind', () => {
|
||
expect(listFromText('1, 2, 3', 'number')).toEqual([1, 2, 3]);
|
||
expect(listFromText('', 'number')).toEqual([]);
|
||
expect(listFromText('a, b', 'text')).toEqual(['a', 'b']);
|
||
// Spelling has to count here: Boolean('false') would be true.
|
||
expect(listFromText('true, false, 0', 'boolean')).toEqual([true, false, false]);
|
||
expect(listFromText('#ff0000, #00ff00', 'color')).toEqual([0xff0000, 0x00ff00]);
|
||
expect(listToText([0xff0000], 'color')).toBe('#ff0000');
|
||
expect(listToText([1, 2], 'number')).toBe('1, 2');
|
||
});
|
||
|
||
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]);
|
||
});
|
||
});
|