191 lines
7.9 KiB
TypeScript
191 lines
7.9 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import { type PropertyBag, sameValue, tagged, textOf } from '../../src/core/model/Properties';
|
||
import { findAsset, objectHolders, type Project } from '../../src/core/model/Project';
|
||
import { deserializeProject } from '../../src/core/store/serialize';
|
||
import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
|
||
import { coerceField } from '../../src/core/properties/kinds';
|
||
import { deviatesFromAsset } from '../../src/core/properties/resolve';
|
||
import {
|
||
asBoolean,
|
||
asEntity,
|
||
asList,
|
||
asNumber,
|
||
asString,
|
||
asStruct,
|
||
storedList,
|
||
type Value,
|
||
writeTyped,
|
||
} from '../../src/core/graph';
|
||
import { IndexOf } from '../../src/core/graph/nodes/data/IndexOf';
|
||
import { NO_LOOKUP } from '../../src/core/graph/lookup';
|
||
import { fakeContext, 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(f.project, placed, asset, 'row')).toBe(false);
|
||
|
||
f.stack.execute(new SetOverrideCommand(f.sceneId, object, 'row', [1, 3]));
|
||
expect(deviatesFromAsset(f.project, placed, asset, 'row')).toBe(true);
|
||
});
|
||
|
||
it('keeps a loaded item as the kind it is, kind by kind', () => {
|
||
const def = variableDef('v', 'list', []);
|
||
expect(coerceValue(def, ['1', 2, true])).toEqual(['1', 2, true]);
|
||
expect(coerceValue(def, [tagged('color', 0xff0000)])).toEqual([tagged('color', 0xff0000)]);
|
||
// A colour only keeps its kind through the tag; as a bare number it is one.
|
||
expect(coerceValue(def, [0xff0000])).toEqual([0xff0000]);
|
||
});
|
||
|
||
it('a value that is not a list falls back to the default rather than becoming one', () => {
|
||
const def = variableDef('grid', 'list', [7]);
|
||
expect(coerceValue(def, 'nonsense')).toEqual([7]);
|
||
expect(coerceValue(def, ['1', 2])).toEqual(['1', 2]);
|
||
});
|
||
});
|
||
|
||
describe('a list carrying objects (#157)', () => {
|
||
const hero = fakeEntity('Hero', 10);
|
||
const crate = fakeEntity('Crate', 20);
|
||
|
||
it('hands the objects on as they are, and a lone object reads as a list of one', () => {
|
||
expect(asList([hero, crate])).toEqual([hero, crate]);
|
||
expect(asEntity(asList(hero)[0])).toBe(hero);
|
||
expect(storedList([hero, 3, crate])).toEqual(['Hero', 3, 'Crate']);
|
||
});
|
||
|
||
it('never lets a live object into a stored value', () => {
|
||
// The one door into a PropertyBag: whatever arrives is narrowed to what a stored list may hold.
|
||
const bag: PropertyBag = { seen: ['x'] };
|
||
writeTyped(bag, 'seen', [hero, crate]);
|
||
expect(bag.seen).toEqual(['Hero', 'Crate']);
|
||
expect(JSON.parse(JSON.stringify(bag))).toEqual({ seen: ['Hero', 'Crate'] });
|
||
});
|
||
|
||
it('reads as text by the names of its objects, not by what they are made of', () => {
|
||
expect(asString([hero, 3, crate])).toBe('Hero, 3, Crate');
|
||
});
|
||
|
||
it('is searched by identity, since two objects have no content to compare', () => {
|
||
const indexOf = (list: Value, value: Value) =>
|
||
asNumber(IndexOf.run!(fakeContext(), { list, value }, {}, IndexOf, 'exec', NO_LOOKUP).outputs?.index);
|
||
expect(indexOf([hero, crate], crate)).toBe(1);
|
||
expect(indexOf([hero, crate], fakeEntity('Crate', 20))).toBe(-1);
|
||
// A name is not the object of that name, and neither stands in for the other.
|
||
expect(indexOf([hero, crate], 'Crate')).toBe(-1);
|
||
expect(indexOf(['Hero', 'Crate'], crate)).toBe(-1);
|
||
});
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|