A list no longer says what its items are. ListDef.items, ListDef.fields,
ListFieldDef.items and SetListItemsCommand are gone; the single seam is
src/core/properties/listItems.ts. JSON already tells number, text, boolean
and struct apart, so only two kinds need a tag: {"$color": n} and the new
{"$texture": id}. Field names cannot start with "$", so a tag never
collides with a struct field. On a data link a texture item arrives as
its bare id; the graph has no images.
List tab: the mark in front of each row is the kind picker, "+ Item"
asks for the kind, both are ordinary value edits (one undo step). A list
inside a struct field only offers the flat kinds because the depth rule
forbids a struct item there (ListAccess.kinds); the menu used to offer
it and silently do nothing.
Legacy lists that declared items: 'color' are tagged on load.
Actor demo: the four walk animations and four standing frames live in
two list variables of texture items indexed by facing (Item at), so the
graph can be rebuilt in the editor. The stand frames get their grid ids
back; 44 nodes instead of 46.
Also: the user's edits to on-start-during-play.md and the new note
reseed-because-of-list.md.
Verified: check.sh 794 tests, full e2e 631 checks, smokes 24/19/20.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
193 lines
8.5 KiB
TypeScript
193 lines
8.5 KiB
TypeScript
/**
|
||
* A list declares nothing about what it holds: every item carries its own kind. Two things matter –
|
||
* the kind is read back off the value (and a color says so, or it would be a plain number), and a
|
||
* project written under the old rule, where the list declared one kind for all of them, still loads
|
||
* with the values it had.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||
import { colorItem, type PropertyValue, textureItem } from '../../src/core/model/Properties';
|
||
import { findAsset, objectHolders, type Project } from '../../src/core/model/Project';
|
||
import { deserializeProject } from '../../src/core/store/serialize';
|
||
import { itemKindOf, itemValue, makeItem, structItemFields } from '../../src/core/properties/listItems';
|
||
import { makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
|
||
|
||
const findObject = (project: Project, id: string) =>
|
||
objectHolders(project)
|
||
.flatMap((holder) => holder.objects)
|
||
.find((o) => o.id === id);
|
||
|
||
/** 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, colorItem(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, colorItem(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([colorItem(0xff0000), 0xff0000]);
|
||
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
|
||
expect(back.map(itemKindOf)).toEqual(['color', 'number']);
|
||
expect(back.map(itemValue)).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, one of its frames or an animation.
|
||
*/
|
||
it('keeps a texture apart from the text beside it, and reads back as its id', () => {
|
||
const { f, behaviorId } = withList([textureItem('anim-walk'), 'anim-walk']);
|
||
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
|
||
expect(back.map(itemKindOf)).toEqual(['texture', 'text']);
|
||
expect(back.map(itemValue)).toEqual(['anim-walk', 'anim-walk']);
|
||
});
|
||
|
||
it('turns a text into a texture item and back without losing the id', () => {
|
||
expect(makeItem('texture', 'frame-26')).toEqual(textureItem('frame-26'));
|
||
expect(makeItem('text', textureItem('frame-26'))).toBe('frame-26');
|
||
});
|
||
|
||
/** A texture is a list item only: a flat position keeps the id, but as the text it looks like. */
|
||
it('degrades to its id inside a struct item, where no texture kind exists', () => {
|
||
const { f, behaviorId } = withList([{ pic: textureItem('frame-26'), n: 1 }]);
|
||
const back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
|
||
expect(back).toEqual([{ pic: 'frame-26', n: 1 }]);
|
||
expect(structItemFields(back[0]).map((field) => `${field.key}:${field.kind}`)).toEqual([
|
||
'pic:text',
|
||
'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'],
|
||
]);
|
||
});
|
||
|
||
it('drops a list inside a struct item, the one level too deep', () => {
|
||
const { f, behaviorId } = withList([{ x: 1, deep: [1, 2] }]);
|
||
expect(findAsset(roundTrip(f.project), behaviorId)?.props.stops).toEqual([{ x: 1 }]);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 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([textureItem('frame-26'), 'frame-26'])).toEqual([textureItem('frame-26'), 'frame-26']);
|
||
});
|
||
|
||
it('but degrades one inside a struct item to its id, where no texture kind exists', () => {
|
||
expect(paramOf([{ pic: textureItem('frame-26'), n: 1 }])).toEqual([{ pic: 'frame-26', n: 1 }]);
|
||
});
|
||
});
|
||
|
||
describe('a list that still declares its item kind', () => {
|
||
/** A project file from before the change: the declaration said `color`, the items were numbers. */
|
||
const loadDeclared = (items: string, values: unknown, fields?: unknown) =>
|
||
deserializeProject({
|
||
id: 'p',
|
||
assets: [
|
||
{
|
||
id: 'b',
|
||
type: 'behavior',
|
||
name: 'B',
|
||
props: { stops: values },
|
||
variables: [{ key: 'stops', label: 'stops', kind: 'list', default: values, items, fields }],
|
||
},
|
||
{
|
||
id: 's',
|
||
type: 'scene',
|
||
name: 'S',
|
||
objects: [{ id: 'o', assetId: 'b', overrides: { stops: values } }],
|
||
},
|
||
],
|
||
activeSceneId: 's',
|
||
});
|
||
|
||
it('tags the colors it held, in the default, the own value and the override', () => {
|
||
const project = loadDeclared('color', [0xff0000, '#00ff00']);
|
||
const asset = findAsset(project, 'b');
|
||
const tagged = [colorItem(0xff0000), colorItem(0x00ff00)];
|
||
expect(asset?.props.stops).toEqual(tagged);
|
||
expect(asset?.type === 'behavior' && asset.variables[0]?.default).toEqual(tagged);
|
||
expect(findObject(project, 'o')?.overrides.stops).toEqual(tagged);
|
||
});
|
||
|
||
it('converts the other kinds the same way, so a value stays what it was meant to be', () => {
|
||
expect(findAsset(loadDeclared('text', [1, true]), 'b')?.props.stops).toEqual(['1', 'true']);
|
||
expect(findAsset(loadDeclared('number', ['1', '2']), 'b')?.props.stops).toEqual([1, 2]);
|
||
expect(findAsset(loadDeclared('boolean', ['false', 'yes']), 'b')?.props.stops).toEqual([false, true]);
|
||
});
|
||
|
||
/** An item could be missing a declared field, and the declaration was what filled it in. */
|
||
it('fills a struct item from the shape the list declared', () => {
|
||
const fields = [
|
||
{ key: 'x', kind: 'number' },
|
||
{ key: 'y', kind: 'number' },
|
||
];
|
||
expect(findAsset(loadDeclared('struct', [{ x: 3 }], fields), 'b')?.props.stops).toEqual([{ x: 3, y: 0 }]);
|
||
});
|
||
|
||
it('and says nothing about its items once it has been read back out', () => {
|
||
const written = roundTrip(loadDeclared('color', [0xff0000]));
|
||
const asset = findAsset(written, 'b');
|
||
expect(asset?.type === 'behavior' && asset.variables[0]).not.toHaveProperty('items');
|
||
expect(asset?.props.stops).toEqual([colorItem(0xff0000)]);
|
||
});
|
||
});
|