Make texture and colour full kinds and drop the load-time migrations

- `Texture` and `Color` can be picked as a variable kind, a struct field
  kind and a declared port kind; a new `Texture` node hands a picture id
  into the graph. Declared, a texture is its bare id – the `$texture` tag
  stays a list-only thing.
- The `String` node is labelled "Text" like its kind; the type id stays.
- Whole-number inputs (`Item at`, `Set item`, `Sub list`, `Fill`,
  `Repeat`, `Substring`, `To text`) show no decimals (`int()` in shared.ts).
- All server projects are reseeded, so `legacyNodes.ts`,
  `migrateDeclaredLists` and the `ownerId` fallback go, with their tests.
- Note `reseed-because-of-list` moved to done/; new note `refactor-types`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-12 14:06:14 +02:00
1 parent 11c900c998
commit 8a7185948d
39 files changed
+378 -765

No files matched your search

+1 -5
View File
@@ -156,14 +156,11 @@ describe('scene hierarchy', () => {
expect(findObject(findScene(deserializeProject(raw), sceneId)!, logic)!.parentId).toBe('');
});
it('the loader accepts legacy ownerId, drops dangling parents and breaks cycles', () => {
it('the loader drops dangling parents and breaks cycles', () => {
const { project, stack, sceneId, a, b, c } = threeBoxes();
stack.execute(new SetParentCommand(sceneId, b, a));
const raw = serializeProject(project) as { assets: { objects?: Record<string, unknown>[] }[] };
const objects = raw.assets[0]!.objects!;
// Legacy documents stored the link as `ownerId`.
delete objects[1]!.parentId;
objects[1]!.ownerId = a;
// A cycle a → c → a and a dangling reference are both cleared.
objects[0]!.parentId = c;
objects[2]!.parentId = a;
@@ -172,7 +169,6 @@ describe('scene hierarchy', () => {
expect(parentOf(b)).toBe(a);
expect([parentOf(a), parentOf(c)].filter(Boolean)).toHaveLength(1); // one link of the cycle survives
objects[1]!.parentId = 'ghost';
delete objects[1]!.ownerId;
expect(findScene(deserializeProject(raw), sceneId)!.objects[1]!.parentId).toBe('');
});
});
-222
View File
@@ -1,222 +0,0 @@
import { describe, expect, it } from 'vitest';
import { GraphInterpreter, OBJECT_BEHAVIOR_PARAM, OBJECT_TYPE_PARAM } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
import { findAsset } from '../../src/core/model/Project';
import type { PropertyDef } from '../../src/core/properties/PropertySchema';
import { pinPropertyTarget } from '../../src/core/store/legacyNodes';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { fakeContext, fakeEntity, makeBehavior, makeProject } from '../helpers';
type RawNode = { id: string; type: string; params?: Record<string, unknown>; ports?: unknown[] };
type RawEdge = [from: string, fromPort: string, to: string, toPort: string];
/** A saved project whose behavior "B" holds `nodes` and `edges` exactly as an older editor wrote them. */
function loadOld(nodes: RawNode[], edges: RawEdge[]) {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', ['seen']);
const game = makeBehavior(fixture, 'Game', ['coins']);
const raw = serializeProject(fixture.project) as { assets: Record<string, unknown>[] };
raw.assets.find((a) => a.id === behavior.id)!.graph = {
nodes: nodes.map((n) => ({ x: 0, y: 0, params: {}, ...n })),
edges: edges.map(([from, fromPort, to, toPort], i) => ({
id: `e${i}`,
from: { node: from, port: fromPort },
to: { node: to, port: toPort },
})),
};
const project = deserializeProject(raw);
return { project, game, behavior: findAsset(project, behavior.id) as BehaviorAsset };
}
const node = (behavior: BehaviorAsset, id: string) => behavior.graph.nodes.find((n) => n.id === id)!;
const edges = (behavior: BehaviorAsset) =>
behavior.graph.edges.map((e) => `${e.from.node}.${e.from.port}>${e.to.node}.${e.to.port}`);
describe('property nodes of an older file', () => {
it('load as Properties nodes that compute what the old graph computed', () => {
const { project, behavior } = loadOld(
[
{ id: 'start', type: 'OnStart' },
{ id: 'self', type: 'Self' },
{ id: 'paint', type: 'SetProperty', params: { property: 'color', value: 0xff0000 } },
{
id: 'size',
type: 'SetProperties',
params: { width: 40 },
ports: [{ key: 'width', kind: 'number' }],
},
{ id: 'get', type: 'GetProperty', params: { property: 'width' } },
{ id: 'store', type: 'Variables', ports: [{ key: 'seen', kind: 'number', write: true }] },
],
[
['start', 'exec', 'paint', 'exec'],
['self', 'object', 'paint', 'object'],
['paint', 'exec', 'size', 'exec'],
['self', 'object', 'size', 'object'],
['size', 'exec', 'store', 'exec'],
['self', 'object', 'get', 'object'],
['get', 'value', 'store', 'seen'],
],
);
expect(['paint', 'size', 'get'].map((id) => node(behavior, id).type)).toEqual([
'Properties',
'Properties',
'Properties',
]);
expect(edges(behavior)).toContain('get.width>store.seen');
const self = { ...fakeEntity('self'), props: { width: 10, color: 0 } };
const ctx = fakeContext({ self, variables: { seen: 0 } });
new GraphInterpreter(behavior.graph, behaviorLookup(project)).fire('OnStart', ctx);
expect({ ...self.props, seen: ctx.variables.seen }).toEqual({ width: 40, color: 0xff0000, seen: 40 });
});
it('keep whether they wrote and their typed-in value, under the kind of the property', () => {
const { behavior, game } = loadOld(
[
{ id: 'paint', type: 'SetProperty', params: { property: 'color', value: 7 } },
{ id: 'coins', type: 'SetProperty', params: { property: 'coins', value: 0 } },
{ id: 'speed', type: 'GetProperty', params: { property: 'speed' } },
],
[],
);
expect(node(behavior, 'paint')).toMatchObject({
params: { [OBJECT_TYPE_PARAM]: 'sprite', color: 7 },
ports: [{ key: 'color', kind: 'number', write: true }],
});
expect(node(behavior, 'coins')).toMatchObject({
params: { [OBJECT_TYPE_PARAM]: 'behavior', [OBJECT_BEHAVIOR_PARAM]: game.id, coins: 0 },
ports: [{ key: 'coins', kind: 'number', write: true }],
});
// Nothing declares `speed`, so nothing says its kind.
expect(node(behavior, 'speed').ports).toEqual([{ key: 'speed', kind: 'text' }]);
expect(node(behavior, 'speed').params).toEqual({});
});
it('turn Set enabled into Properties writing enabled, on unless told otherwise', () => {
const { project, behavior } = loadOld(
[
{ id: 'start', type: 'OnStart' },
{ id: 'self', type: 'Self' },
{ id: 'screen', type: 'ObjectByName', params: { name: 'screen' } },
{ id: 'no', type: 'Boolean', params: { value: false } },
{ id: 'hide', type: 'SetEnabled' },
{ id: 'show', type: 'SetEnabled', params: { enabled: false } },
],
[
['start', 'exec', 'hide', 'exec'],
['self', 'object', 'hide', 'object'],
['no', 'value', 'hide', 'enabled'],
['hide', 'exec', 'show', 'exec'],
['screen', 'object', 'show', 'object'],
],
);
expect(node(behavior, 'show')).toMatchObject({
type: 'Properties',
params: { enabled: false },
ports: [{ key: 'enabled', kind: 'boolean', write: true }],
});
expect(node(behavior, 'hide').params).toEqual({ enabled: true });
expect(edges(behavior)).toEqual([
'start.exec>hide.exec',
'self.object>hide.object',
'no.value>hide.enabled',
'hide.exec>show.exec',
'screen.object>show.object',
]);
const self = { ...fakeEntity('self'), props: { enabled: true } };
const screen = { ...fakeEntity('screen'), props: { enabled: true } };
const ctx = fakeContext({ self, findByName: (name) => (name === 'screen' ? screen : null) });
new GraphInterpreter(behavior.graph, behaviorLookup(project)).fire('OnStart', ctx);
expect([self.props.enabled, screen.props.enabled]).toEqual([false, false]);
});
it('turn Set text into Text properties writing text, empty unless told otherwise', () => {
const { project, behavior } = loadOld(
[
{ id: 'start', type: 'OnStart' },
{ id: 'a', type: 'ObjectByName', params: { name: 'a' } },
{ id: 'b', type: 'ObjectByName', params: { name: 'b' } },
{ id: 'c', type: 'ObjectByName', params: { name: 'c' } },
{ id: 'num', type: 'Number', params: { value: 12 } },
{ id: 'blank', type: 'SetText' },
{ id: 'typed', type: 'SetText', params: { text: 'Go' } },
{ id: 'fed', type: 'SetText' },
],
[
['start', 'exec', 'blank', 'exec'],
['a', 'object', 'blank', 'object'],
['blank', 'exec', 'typed', 'exec'],
['b', 'object', 'typed', 'object'],
['typed', 'exec', 'fed', 'exec'],
['c', 'object', 'fed', 'object'],
['num', 'value', 'fed', 'text'],
],
);
expect(node(behavior, 'typed')).toMatchObject({
type: 'Properties',
params: { [OBJECT_TYPE_PARAM]: 'text', text: 'Go' },
ports: [{ key: 'text', kind: 'text', write: true }],
});
expect(node(behavior, 'blank').params).toEqual({ [OBJECT_TYPE_PARAM]: 'text', text: '' });
expect(edges(behavior)).toContain('num.value>fed.text');
const texts = Object.fromEntries(
['a', 'b', 'c'].map((name) => [name, { ...fakeEntity(name), props: { text: 'old' } }]),
);
const ctx = fakeContext({ findByName: (name) => texts[name] ?? null });
new GraphInterpreter(behavior.graph, behaviorLookup(project)).fire('OnStart', ctx);
expect(['a', 'b', 'c'].map((name) => texts[name]!.props.text)).toEqual(['', 'Go', '12']);
});
it('keep only the object input of one that named no property', () => {
const { behavior } = loadOld(
[
{ id: 'start', type: 'OnStart' },
{ id: 'self', type: 'Self' },
{ id: 'num', type: 'Number' },
{ id: 'blank', type: 'SetProperty', params: { value: 5 } },
],
[
['start', 'exec', 'blank', 'exec'],
['self', 'object', 'blank', 'object'],
['num', 'value', 'blank', 'value'],
],
);
expect(node(behavior, 'blank')).toMatchObject({ type: 'Properties', params: {} });
expect(node(behavior, 'blank').ports).toBeUndefined();
expect(edges(behavior)).toEqual(['self.object>blank.object']);
});
});
describe('the type an old node is pinned to', () => {
const variable = (key: string): PropertyDef => ({ key, label: key, kind: 'number', default: 0 });
const behaviors = new Map([
['game', [variable('coins'), variable('lives')]],
['hud', [variable('lives')]],
]);
const pinned = (keys: string[]) => pinPropertyTarget(keys, behaviors).params;
it('is the first object type holding every key', () => {
expect(pinned(['color'])).toEqual({ [OBJECT_TYPE_PARAM]: 'sprite' });
expect(pinned(['color', 'align'])).toEqual({ [OBJECT_TYPE_PARAM]: 'text' });
expect(pinned(['width', 'zoom'])).toEqual({ [OBJECT_TYPE_PARAM]: 'camera' });
});
it('else the one behavior declaring them all', () => {
expect(pinned(['coins', 'lives'])).toEqual({
[OBJECT_TYPE_PARAM]: 'behavior',
[OBJECT_BEHAVIOR_PARAM]: 'game',
});
});
it('else Any: every object has it, nothing declares it, two behaviors do, or nothing to go by', () => {
expect(pinPropertyTarget(['enabled'], behaviors)).toMatchObject({
params: {},
defs: [{ kind: 'boolean' }],
});
expect(pinned(['enabled', 'color'])).toEqual({ [OBJECT_TYPE_PARAM]: 'sprite' });
expect(pinned(['speed'])).toEqual({});
expect(pinned(['lives'])).toEqual({});
expect(pinned([])).toEqual({});
});
});
-132
View File
@@ -1,132 +0,0 @@
import { describe, expect, it } from 'vitest';
import { AddVariableCommand } from '../../src/core/commands/variableCommands';
import { GraphInterpreter } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
import { findAsset } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { fakeContext, makeBehavior, makeProject } from '../helpers';
type RawNode = { id: string; type: string; params?: Record<string, unknown>; ports?: unknown[] };
type RawEdge = [from: string, fromPort: string, to: string, toPort: string];
/** A saved project whose behavior holds `nodes` and `edges` exactly as an older editor wrote them. */
function loadOld(nodes: RawNode[], edges: RawEdge[], variables = ['px', 'py', 'sum']) {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'B', variables);
fixture.stack.execute(new AddVariableCommand(behavior.id, 'piece', 'struct'));
fixture.stack.execute(new AddVariableCommand(behavior.id, 'flag', 'boolean'));
const raw = serializeProject(fixture.project) as { assets: Record<string, unknown>[] };
raw.assets.find((a) => a.id === behavior.id)!.graph = {
nodes: nodes.map((n) => ({ x: 0, y: 0, params: {}, ...n })),
edges: edges.map(([from, fromPort, to, toPort], i) => ({
id: `e${i}`,
from: { node: from, port: fromPort },
to: { node: to, port: toPort },
})),
};
const project = deserializeProject(raw);
return { project, behavior: findAsset(project, behavior.id) as BehaviorAsset };
}
const node = (behavior: BehaviorAsset, id: string) => behavior.graph.nodes.find((n) => n.id === id)!;
const edges = (behavior: BehaviorAsset) =>
behavior.graph.edges.map((e) => `${e.from.node}.${e.from.port}>${e.to.node}.${e.to.port}`);
describe('variable nodes of an older file', () => {
it('load as Variables nodes that compute what the old graph computed', () => {
const { project, behavior } = loadOld(
[
{ id: 'start', type: 'OnStart' },
{ id: 'set', type: 'SetVariables', params: { px: 7 }, ports: [{ key: 'px', kind: 'number' }] },
{ id: 'y', type: 'SetVariable', params: { variable: 'py', value: 9 } },
{ id: 'get', type: 'GetVariable', params: { variable: 'px' } },
{ id: 'both', type: 'GetVariables', ports: [{ key: 'py', kind: 'number' }] },
{ id: 'add', type: 'Add' },
{ id: 'sum', type: 'Variable', params: { variable: 'sum' } },
],
[
['start', 'exec', 'set', 'exec'],
['set', 'exec', 'y', 'exec'],
['y', 'exec', 'sum', 'exec'],
['get', 'value', 'add', 'a'],
['both', 'py', 'add', 'b'],
['add', 'result', 'sum', 'value'],
],
);
expect(behavior.graph.nodes.map((n) => n.type)).toEqual([
'OnStart',
'Variables',
'Variables',
'Variables',
'Variables',
'Add',
'Variables',
]);
expect(edges(behavior)).toEqual([
'start.exec>set.exec',
'set.exec>y.exec',
'y.exec>sum.exec',
'get.px>add.a',
'both.py>add.b',
'add.result>sum.sum',
]);
const ctx = fakeContext({ variables: { px: 0, py: 0, sum: 0 } });
new GraphInterpreter(behavior.graph, behaviorLookup(project)).fire('OnStart', ctx);
expect(ctx.variables).toEqual({ px: 7, py: 9, sum: 16 });
});
it('keep whether they wrote, their typed-in value, and the kind of the variable', () => {
const { behavior } = loadOld(
[
{ id: 'get', type: 'GetVariable', params: { variable: 'flag' } },
{ id: 'set', type: 'SetVariable', params: { variable: 'px', value: 3 } },
{ id: 'plural', type: 'SetVariables', params: { py: 4 }, ports: [{ key: 'py', kind: 'number' }] },
{ id: 'gone', type: 'Variable', params: { variable: 'nope' } },
],
[],
);
expect(node(behavior, 'get').ports).toEqual([{ key: 'flag', kind: 'boolean' }]);
expect(node(behavior, 'set')).toMatchObject({
params: { px: 3 },
ports: [{ key: 'px', kind: 'number', write: true }],
});
expect(node(behavior, 'plural')).toMatchObject({
params: { py: 4 },
ports: [{ key: 'py', kind: 'number', write: true }],
});
// A variable the behavior does not declare keeps its port, as text; the inspector marks it.
expect(node(behavior, 'gone').ports).toEqual([{ key: 'nope', kind: 'text', write: true }]);
expect(node(behavior, 'get').params).toEqual({});
});
it('give a struct variable a struct port, so an Expand can still take it apart', () => {
const { behavior } = loadOld([{ id: 'get', type: 'GetVariable', params: { variable: 'piece' } }], []);
expect(node(behavior, 'get').ports).toEqual([{ key: 'piece', kind: 'struct' }]);
});
it('drop every edge of a node that never had a variable picked, flow included', () => {
const { behavior } = loadOld(
[
{ id: 'start', type: 'OnStart' },
{ id: 'empty', type: 'SetVariable' },
{ id: 'add', type: 'Add' },
],
[
['start', 'exec', 'empty', 'exec'],
['add', 'result', 'empty', 'value'],
],
);
expect(node(behavior, 'empty').ports).toBeUndefined();
expect(edges(behavior)).toEqual([]);
});
it('are left alone once they are Variables nodes', () => {
const { project, behavior } = loadOld(
[{ id: 'get', type: 'GetVariable', params: { variable: 'px' } }],
[],
);
const again = findAsset(deserializeProject(serializeProject(project)), behavior.id) as BehaviorAsset;
expect(again.graph).toEqual(behavior.graph);
});
});
+11 -71
View File
@@ -1,23 +1,16 @@
/**
* 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.
* A list declares nothing about what it holds: every item carries its own kind, read back off the
* value – and a color or a texture says so, or it would be a plain number or text.
*/
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 { findAsset } 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();
@@ -71,13 +64,13 @@ describe('the kind of one list item', () => {
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', () => {
/** 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 back = findAsset(roundTrip(f.project), behaviorId)?.props.stops as PropertyValue[];
expect(back).toEqual([{ pic: 'frame-26', n: 1 }]);
expect(back).toEqual([{ pic: textureItem('frame-26'), n: 1 }]);
expect(structItemFields(back[0]).map((field) => `${field.key}:${field.kind}`)).toEqual([
'pic:text',
'pic:texture',
'n:number',
]);
});
@@ -131,62 +124,9 @@ describe('a list in a node parameter, where nothing is declared', () => {
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)]);
it('and one inside a struct item as well', () => {
expect(paramOf([{ pic: textureItem('frame-26'), n: 1 }])).toEqual([
{ pic: textureItem('frame-26'), n: 1 },
]);
});
});
+4
View File
@@ -231,6 +231,10 @@ const CASES: Record<string, Case[]> = {
{ name: 'outputs its setting', params: { value: 'hello' }, outputs: { value: 'hello' } },
{ name: 'unset is empty', outputs: { value: '' } },
],
Texture: [
{ name: 'outputs the id it was set to', params: { value: 'fr-0-1' }, outputs: { value: 'fr-0-1' } },
{ name: 'unset is no picture', outputs: { value: '' } },
],
Add: [
{ name: 'adds', inputs: { a: 2, b: 3 }, outputs: { result: 5 } },
{ name: 'numeric text is coerced', inputs: { a: '2', b: 3 }, outputs: { result: 5 } },
+13 -11
View File
@@ -6,13 +6,7 @@
import { describe, expect, it } from 'vitest';
import { AddPortCommand, AddPortsCommand } from '../../src/core/commands/portCommands';
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
import {
type DeclaredPortKind,
fieldPortKind,
missingFieldPorts,
resolveDef,
structShapeOf,
} from '../../src/core/graph';
import { declaredPortType, missingFieldPorts, resolveDef, structShapeOf } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { behaviorLookup } from '../../src/core/model/behaviorPrograms';
import type { StructFieldDef } from '../../src/core/properties/PropertySchema';
@@ -65,11 +59,19 @@ describe('the shape a variable declares', () => {
expect(structShapeOf(varOf(behavior, 'names'))).toBeUndefined();
});
/** A color is stored as a number and the graph has no port kind for it – every other kind is itself. */
it('reads a color field as a number port and a list field as a list port', () => {
expect(PIECE.map(fieldPortKind)).toEqual<DeclaredPortKind[]>([
/** A field's kind is its port kind; a color port then travels as a number, since the graph has none. */
it('gives each field a port of its own kind, a color travelling as a number', () => {
const ports = missingFieldPorts({ id: 'n', type: 'PackStruct', x: 0, y: 0, params: {} }, PIECE);
expect(ports.map((p) => `${p.key}:${p.kind}`)).toEqual([
'shape:number',
'name:text',
'held:boolean',
'tint:color',
'cells:list',
]);
expect(ports.map((p) => declaredPortType(p.kind))).toEqual([
'number',
'text',
'string',
'boolean',
'number',
'list',
+151
View File
@@ -0,0 +1,151 @@
/**
* A color and a texture are kinds of their own wherever a kind is picked – of a variable, of a struct
* field, of a declared port – while on a data edge either is still the number or the text it wraps.
*/
import { describe, expect, it } from 'vitest';
import { AddStructFieldCommand, AddVariableCommand } from '../../src/core/commands/variableCommands';
import {
declaredPortType,
inlineInputDef,
isPortOf,
OBJECT_TYPE_PARAM,
portDefOf,
portKindOf,
propertyPort,
withInlineDefault,
} from '../../src/core/graph';
import { BUILT_IN_NODES } from '../../src/core/graph/nodes';
import { textureItem } 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';
const COLOR: PropertyDef = { key: 'color', label: 'Color', kind: 'color', default: 0 };
const SPRITE = { [OBJECT_TYPE_PARAM]: 'sprite' };
describe('a variable of kind color or texture', () => {
it('declares its default as the number or the id, empty when nothing is given', () => {
expect(variableDef('pic', 'texture', 'fr-0-1')).toEqual({
key: 'pic',
label: 'pic',
kind: 'texture',
default: 'fr-0-1',
});
expect(variableDef('tint', 'color', '#ff0000').default).toBe(0xff0000);
expect(variableDef('pic', 'texture').default).toBe('');
expect(variableDef('tint', 'color').default).toBe(0);
});
it('survives a save and load with its kind', () => {
const f = makeProject();
const behavior = makeBehavior(f, 'B');
f.stack.execute(new AddVariableCommand(behavior.id, 'pic', 'texture'));
f.stack.execute(new AddVariableCommand(behavior.id, 'tint', 'color'));
findAsset(f.project, behavior.id)!.props.pic = 'fr-0-1';
const back = findAsset(roundTrip(f.project), behavior.id);
expect(back?.type === 'behavior' && back.variables.map((v) => `${v.key}:${v.kind}`)).toEqual([
'pic:texture',
'tint:color',
]);
expect(back?.props).toMatchObject({ pic: 'fr-0-1', tint: 0 });
});
});
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({
pic: 'fr-0-1',
});
expect(structOf([{ key: 'pic', kind: 'texture' }], undefined)).toEqual({ pic: '' });
});
it('is added to a struct variable like any other field', () => {
const f = makeProject();
const behavior = makeBehavior(f, 'B');
f.stack.execute(new AddVariableCommand(behavior.id, 'look', 'struct'));
f.stack.execute(new AddStructFieldCommand(behavior.id, 'look', { key: 'pic', kind: 'texture' }));
findAsset(f.project, behavior.id)!.props.look = { pic: 'fr-0-1' };
expect(findAsset(roundTrip(f.project), behavior.id)?.props.look).toEqual({ pic: 'fr-0-1' });
});
});
describe('a declared port of kind color or texture', () => {
it('is the kind of the property it names, travelling as a number or a text', () => {
expect(portKindOf(COLOR)).toBe('color');
expect(portKindOf({ key: 'pic', label: 'pic', kind: 'texture', default: '' })).toBe('texture');
expect(portKindOf({ key: 'align', label: 'align', kind: 'enum', default: 'left', options: [] })).toBe(
'text',
);
expect(declaredPortType('color')).toBe('number');
expect(declaredPortType('texture')).toBe('string');
});
it('carries the field that edits it, so a typed-in value gets the picker', () => {
const pic = withInlineDefault(portDefOf({ key: 'pic', kind: 'texture' }));
expect(pic).toMatchObject({ type: 'string', default: '' });
expect(inlineInputDef(pic).kind).toBe('texture');
const tint = withInlineDefault(portDefOf({ key: 'tint', kind: 'color' }));
expect(tint).toMatchObject({ type: 'number', default: 0 });
expect(inlineInputDef(tint).kind).toBe('color');
expect(portDefOf({ key: 'n', kind: 'number' }).field).toBeUndefined();
});
/** A file from before the kind existed declares a sprite's `color` port as a number; it still is one. */
it('names a property when it travels the way the property does', () => {
expect(isPortOf({ key: 'color', kind: 'color' }, COLOR)).toBe(true);
expect(isPortOf({ key: 'color', kind: 'number' }, COLOR)).toBe(true);
expect(isPortOf({ key: 'color', kind: 'text' }, COLOR)).toBe(false);
expect(propertyPort({ key: 'color', kind: 'color' }, SPRITE).field?.kind).toBe('color');
});
});
describe('the literal nodes', () => {
/** The palette and the kind picker say the same word: a text is "Text", nowhere "String". */
it('are labelled by the kind they hand out', () => {
const label = (type: string) => BUILT_IN_NODES.find((d) => d.type === type)?.label;
expect(['Number', 'Boolean', 'String', 'Texture', 'List'].map(label)).toEqual([
'Number',
'Boolean',
'Text',
'Texture',
'List',
]);
});
});
describe('the whole-number inputs', () => {
const input = (type: string, key: string) =>
BUILT_IN_NODES.find((d) => d.type === type)!.inputs.find((p) => p.key === key)!;
it('show no decimals, since the node floors what arrives', () => {
const whole = [
['ItemAt', 'index'],
['SetItem', 'index'],
['SubList', 'start'],
['SubList', 'count'],
['Fill', 'count'],
['Repeat', 'times'],
['Substring', 'start'],
['Substring', 'count'],
['ToText', 'decimals'],
] as const;
for (const [type, key] of whole) {
const def = inlineInputDef(input(type, key));
expect(def.kind === 'number' && def.decimals, `${type}.${key}`).toBe(0);
}
});
it('keep their default on the port and in the field alike', () => {
for (const [type, key, value] of [
['SubList', 'count', 1],
['Fill', 'count', 1],
['Repeat', 'times', 1],
['Substring', 'count', -1],
['ItemAt', 'index', 0],
] as const) {
const port = input(type, key);
expect([port.default, inlineInputDef(port).default], `${type}.${key}`).toEqual([value, value]);
}
});
});
+3 -4
View File
@@ -257,11 +257,10 @@ describe('a list a struct field holds', () => {
/**
* And because they are dropped, the tab must not offer them: an item added here would vanish on
* the next write, which reads as the menu entry doing nothing at all. A texture goes the same
* way – flat, it would keep only its id and stop being a picture.
* the next write, which reads as the menu entry doing nothing at all.
*/
it('offers the flat kinds only, where the whole list has all six', () => {
expect(list().kinds).toEqual(['number', 'text', 'boolean', 'color']);
it('offers the flat kinds only, where the whole list has struct as well', () => {
expect(list().kinds).toEqual(['number', 'text', 'boolean', 'color', 'texture']);
const whole = resolveList(f.state, { kind: 'asset', assetId: f.behavior.id, key: 'nums' })!;
expect(whole.kinds).toEqual(['number', 'text', 'boolean', 'color', 'texture', 'struct']);
});