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:
1 parent
11c900c998
commit
8a7185948d
39 files changed
+378
-765
No files matched your search
@@ -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('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user