Describe every value kind in one table instead of spreading it over the code
A kind like colour or texture used to be spelled out in five parallel lists, its port type, its empty value, its glyph, its label and its list tag - 22 literal places for texture alone. core/properties/kinds.ts now holds one row per kind and the rest is derived from it, so adding a kind is one row. The five literal nodes collapse into literals.ts, the six kind menus into one helper. Number, Text, Boolean is now the one order everywhere; the variable and port menus used to list Boolean before Text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8a7185948d
commit
3ed8b68ecf
39 files changed
+699
-565
No files matched your search
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* The kind table is the one place a kind is described. Everything a kind needs – its lists, its
|
||||
* name and mark, its empty value, its tag, its port – has to follow from the row alone, so each
|
||||
* check here walks the table rather than naming a kind.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import { declaredPortType, portDefOf } from '../../src/core/graph';
|
||||
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||||
import { tagOf, untag } from '../../src/core/model/Properties';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import {
|
||||
coerceField,
|
||||
DECLARED_PORT_KINDS,
|
||||
emptyOf,
|
||||
FIELD_KINDS,
|
||||
isKindWith,
|
||||
isTaggedKind,
|
||||
KIND,
|
||||
KINDS,
|
||||
kindLabel,
|
||||
kindMark,
|
||||
LIST_ITEM_KINDS,
|
||||
plainKindOf,
|
||||
STRUCT_FIELD_KINDS,
|
||||
VARIABLE_KINDS,
|
||||
} from '../../src/core/properties/kinds';
|
||||
import { itemKindOf, listItemOf, makeItem } from '../../src/core/properties/listItems';
|
||||
import { deserializeProject } from '../../src/core/store/serialize';
|
||||
import { makeBehavior, makeProject, roundTrip } from '../helpers';
|
||||
|
||||
const TAGGED = FIELD_KINDS.filter(isTaggedKind);
|
||||
/** A value of the type a tagged kind travels as, distinct from the kind's empty value. */
|
||||
const sampleOf = (kind: (typeof TAGGED)[number]) => (KIND[kind].travels === 'number' ? 0xff0000 : 'walk-up');
|
||||
|
||||
describe('the kind table', () => {
|
||||
it('has a word and a mark for every kind a user can pick, and no two marks alike', () => {
|
||||
const picked = new Set([...VARIABLE_KINDS, ...FIELD_KINDS, ...DECLARED_PORT_KINDS]);
|
||||
for (const kind of picked) {
|
||||
expect(kindLabel(kind), kind).not.toBe(kind);
|
||||
expect(kindMark(kind), kind).not.toBe('?');
|
||||
}
|
||||
expect(new Set([...picked].map(kindMark)).size).toBe(picked.size);
|
||||
});
|
||||
|
||||
it('derives the kind lists from the roles, the depth rules on top', () => {
|
||||
for (const row of KINDS) {
|
||||
expect(VARIABLE_KINDS.includes(row.kind as never), row.kind).toBe(isKindWith('variable', row.kind));
|
||||
expect(FIELD_KINDS.includes(row.kind as never), row.kind).toBe(isKindWith('field', row.kind));
|
||||
expect(DECLARED_PORT_KINDS.includes(row.kind as never), row.kind).toBe(isKindWith('port', row.kind));
|
||||
}
|
||||
expect(LIST_ITEM_KINDS).toEqual([...FIELD_KINDS, 'struct']);
|
||||
expect(STRUCT_FIELD_KINDS).toEqual([...FIELD_KINDS, 'list']);
|
||||
});
|
||||
|
||||
it('reads nothing as the empty value of a kind, and the empty value as itself', () => {
|
||||
for (const kind of FIELD_KINDS) {
|
||||
expect(coerceField(undefined, kind), kind).toBe(emptyOf(kind));
|
||||
expect(coerceField(emptyOf(kind), kind), kind).toBe(emptyOf(kind));
|
||||
}
|
||||
});
|
||||
|
||||
it('reads a bare value as the untagged kind travelling its way', () => {
|
||||
for (const kind of FIELD_KINDS) {
|
||||
if (isTaggedKind(kind)) continue;
|
||||
expect(plainKindOf(KIND[kind].travels), kind).toBe(kind);
|
||||
expect(itemKindOf(emptyOf(kind)), kind).toBe(kind);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('a tagged kind', () => {
|
||||
it('wraps what it reads, and reads back as itself', () => {
|
||||
for (const kind of TAGGED) {
|
||||
const item = makeItem(kind, sampleOf(kind));
|
||||
expect(tagOf(item), kind).toBe(kind);
|
||||
expect(itemKindOf(item), kind).toBe(kind);
|
||||
expect(untag(item), kind).toBe(sampleOf(kind));
|
||||
expect(listItemOf(item), kind).toEqual(item);
|
||||
// The bare value beside it is the plain kind – which is why the tag is needed at all.
|
||||
expect(itemKindOf(sampleOf(kind)), kind).toBe(plainKindOf(KIND[kind].travels));
|
||||
}
|
||||
});
|
||||
|
||||
it('survives a save and load in a list variable and in a node parameter', () => {
|
||||
for (const kind of TAGGED) {
|
||||
const item = makeItem(kind, sampleOf(kind));
|
||||
const f = makeProject();
|
||||
const behavior = makeBehavior(f, 'B');
|
||||
f.stack.execute(new AddVariableCommand(behavior.id, 'items', 'list'));
|
||||
findAsset(f.project, behavior.id)!.props.items = [item, sampleOf(kind)];
|
||||
expect(findAsset(roundTrip(f.project), behavior.id)?.props.items, kind).toEqual([item, sampleOf(kind)]);
|
||||
|
||||
const loaded = deserializeProject({
|
||||
id: 'p',
|
||||
assets: [
|
||||
{
|
||||
id: 'b',
|
||||
type: 'behavior',
|
||||
name: 'B',
|
||||
graph: { nodes: [{ id: 'n', type: 'List', x: 0, y: 0, params: { value: [item] } }], edges: [] },
|
||||
},
|
||||
],
|
||||
});
|
||||
const asset = findAsset(loaded, 'b');
|
||||
expect(asset?.type === 'behavior' && asset.graph.nodes[0]?.params.value, kind).toEqual([item]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('a declared port', () => {
|
||||
it('travels as the table says, and carries the field that edits it only when tagged', () => {
|
||||
for (const kind of DECLARED_PORT_KINDS) {
|
||||
expect(declaredPortType(kind), kind).toBe(KIND[kind].travels);
|
||||
expect(portDefOf({ key: 'p', kind }).field?.kind, kind).toBe(isTaggedKind(kind) ? kind : undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the literal nodes', () => {
|
||||
it('are one per kind: named, typed and edited as the kind they hand out', () => {
|
||||
for (const [type, kind] of [
|
||||
['Number', 'number'],
|
||||
['Boolean', 'boolean'],
|
||||
['String', 'text'],
|
||||
['Texture', 'texture'],
|
||||
['List', 'list'],
|
||||
] as const) {
|
||||
const def = BUILT_IN_NODES.find((d) => d.type === type)!;
|
||||
expect(def.label, type).toBe(kindLabel(kind));
|
||||
expect(def.outputs[0]?.type, type).toBe(KIND[kind].travels);
|
||||
expect(def.params[0]?.kind, type).toBe(kind);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,10 @@
|
||||
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 { 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, itemValue, makeItem, structItemFields } from '../../src/core/properties/listItems';
|
||||
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. */
|
||||
@@ -25,7 +25,7 @@ function withList(value: unknown = [1, 2]) {
|
||||
|
||||
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([
|
||||
expect([7, 'a', true, tagged('color', 0xff0000), { x: 1 }].map(itemKindOf)).toEqual([
|
||||
'number',
|
||||
'text',
|
||||
'boolean',
|
||||
@@ -35,17 +35,17 @@ describe('the kind of one list item', () => {
|
||||
});
|
||||
|
||||
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 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([colorItem(0xff0000), 0xff0000]);
|
||||
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(itemValue)).toEqual([0xff0000, 0xff0000]);
|
||||
expect(back.map(untag)).toEqual([0xff0000, 0xff0000]);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -53,22 +53,22 @@ describe('the kind of one list item', () => {
|
||||
* 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 { f, behaviorId } = withList([tagged('texture', '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']);
|
||||
expect(back.map(untag)).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');
|
||||
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: textureItem('frame-26'), n: 1 }]);
|
||||
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: textureItem('frame-26'), n: 1 }]);
|
||||
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',
|
||||
@@ -121,12 +121,15 @@ describe('a list in a node parameter, where nothing is declared', () => {
|
||||
};
|
||||
|
||||
it('keeps a tagged texture an item of its own', () => {
|
||||
expect(paramOf([textureItem('frame-26'), 'frame-26'])).toEqual([textureItem('frame-26'), 'frame-26']);
|
||||
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: textureItem('frame-26'), n: 1 }])).toEqual([
|
||||
{ pic: textureItem('frame-26'), n: 1 },
|
||||
expect(paramOf([{ pic: tagged('texture', 'frame-26'), n: 1 }])).toEqual([
|
||||
{ pic: tagged('texture', 'frame-26'), n: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { Fill } from '../../src/core/graph/nodes/data/Fill';
|
||||
import { IndexOf } from '../../src/core/graph/nodes/data/IndexOf';
|
||||
import { ItemAt } from '../../src/core/graph/nodes/data/ItemAt';
|
||||
import { SetItem } from '../../src/core/graph/nodes/data/SetItem';
|
||||
import { colorItem, sameValue, textOf, textureItem } from '../../src/core/model/Properties';
|
||||
import { sameValue, tagged, textOf } from '../../src/core/model/Properties';
|
||||
import { coerceValue, variableDef } from '../../src/core/properties/PropertySchema';
|
||||
import { makeBehavior, makeSpriteScene, roundTrip } from '../helpers';
|
||||
|
||||
@@ -99,24 +99,28 @@ describe('an untyped slot on load', () => {
|
||||
*/
|
||||
describe('the list nodes with tagged items', () => {
|
||||
it('hands a texture item out as the plain id it holds', () => {
|
||||
expect(run(ItemAt, { list: [textureItem('walk-up'), textureItem('fr-0-1')], index: 1 })).toEqual({
|
||||
expect(
|
||||
run(ItemAt, { list: [tagged('texture', 'walk-up'), tagged('texture', 'fr-0-1')], index: 1 }),
|
||||
).toEqual({
|
||||
item: 'fr-0-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('and a colour item as its number', () => {
|
||||
expect(run(ItemAt, { list: [colorItem(0xff0000)], index: 0 })).toEqual({ item: 0xff0000 });
|
||||
expect(run(ItemAt, { list: [tagged('color', 0xff0000)], index: 0 })).toEqual({ item: 0xff0000 });
|
||||
});
|
||||
|
||||
/** Or searching a list of pictures with the id a `Properties` node just read would never hit. */
|
||||
it('finds a texture item by the id beside it', () => {
|
||||
expect(run(IndexOf, { list: [textureItem('a'), textureItem('b')], value: 'b' })).toEqual({ index: 1 });
|
||||
expect(run(IndexOf, { list: [tagged('texture', 'a'), tagged('texture', 'b')], value: 'b' })).toEqual({
|
||||
index: 1,
|
||||
});
|
||||
});
|
||||
|
||||
/** The slot keeps its kind, so a list of pictures stays one after the graph has written to it. */
|
||||
it('keeps a texture slot a texture when the graph writes a plain id into it', () => {
|
||||
expect(run(SetItem, { list: [textureItem('a')], index: 0, value: 'b' })).toEqual({
|
||||
result: [textureItem('b')],
|
||||
expect(run(SetItem, { list: [tagged('texture', 'a')], index: 0, value: 'b' })).toEqual({
|
||||
result: [tagged('texture', 'b')],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { colorItem, sameValue, textOf } from '../../src/core/model/Properties';
|
||||
import { 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/listItems';
|
||||
import { coerceField } from '../../src/core/properties/kinds';
|
||||
import { deviatesFromAsset } from '../../src/core/properties/resolve';
|
||||
import { asBoolean, asEntity, asStruct, writeTyped } from '../../src/core/graph';
|
||||
import { fakeEntity, makeBehavior, makeSpriteScene, place, roundTrip } from '../helpers';
|
||||
@@ -81,7 +81,7 @@ describe('a list as a value', () => {
|
||||
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, [colorItem(0xff0000)])).toEqual([colorItem(0xff0000)]);
|
||||
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]);
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
withInlineDefault,
|
||||
} from '../../src/core/graph';
|
||||
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
|
||||
import { textureItem } from '../../src/core/model/Properties';
|
||||
import { tagged } from '../../src/core/model/Properties';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { type PropertyDef, structOf, variableDef } from '../../src/core/properties/PropertySchema';
|
||||
import { makeBehavior, makeProject, roundTrip } from '../helpers';
|
||||
@@ -54,7 +54,7 @@ describe('a variable of kind color or texture', () => {
|
||||
describe('a struct field of kind texture', () => {
|
||||
/** Declared, so the value needs no tag: the field says what it is, as a color field does. */
|
||||
it('holds the plain id, and starts empty', () => {
|
||||
expect(structOf([{ key: 'pic', kind: 'texture' }], { pic: textureItem('fr-0-1') })).toEqual({
|
||||
expect(structOf([{ key: 'pic', kind: 'texture' }], { pic: tagged('texture', 'fr-0-1') })).toEqual({
|
||||
pic: 'fr-0-1',
|
||||
});
|
||||
expect(structOf([{ key: 'pic', kind: 'texture' }], undefined)).toEqual({ pic: '' });
|
||||
|
||||
@@ -9,8 +9,9 @@ import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCom
|
||||
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
import { colorItem } from '../../src/core/model/Properties';
|
||||
import { tagged } from '../../src/core/model/Properties';
|
||||
import { createEmptyProject, findAsset } from '../../src/core/model/Project';
|
||||
import { FIELD_KINDS, LIST_ITEM_KINDS } from '../../src/core/properties/kinds';
|
||||
import { variableDef } from '../../src/core/properties/PropertySchema';
|
||||
import { resolveList } from '../../src/editor/list/slot';
|
||||
import { EditorState, type ListSlot } from '../../src/editor/state';
|
||||
@@ -83,8 +84,8 @@ describe('the address a list is edited through', () => {
|
||||
});
|
||||
|
||||
it('takes a whole list of mixed items as it is, here and on a node setting', () => {
|
||||
list().replace(['5', true, colorItem(0xff0000)]);
|
||||
expect(list().value).toEqual(['5', true, colorItem(0xff0000)]);
|
||||
list().replace(['5', true, tagged('color', 0xff0000)]);
|
||||
expect(list().value).toEqual(['5', true, tagged('color', 0xff0000)]);
|
||||
|
||||
const add = new AddNodeCommand(f.behavior.id, 'List', 0, 0);
|
||||
expect(f.state.execute(add)).toBe(true);
|
||||
@@ -260,8 +261,10 @@ describe('a list a struct field holds', () => {
|
||||
* the next write, which reads as the menu entry doing nothing at all.
|
||||
*/
|
||||
it('offers the flat kinds only, where the whole list has struct as well', () => {
|
||||
expect(list().kinds).toEqual(['number', 'text', 'boolean', 'color', 'texture']);
|
||||
expect(list().kinds).toEqual(FIELD_KINDS);
|
||||
expect(list().kinds).not.toContain('struct');
|
||||
const whole = resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums' })!;
|
||||
expect(whole.kinds).toEqual(['number', 'text', 'boolean', 'color', 'texture', 'struct']);
|
||||
expect(whole.kinds).toEqual(LIST_ITEM_KINDS);
|
||||
expect(whole.kinds).toContain('struct');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user