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>
136 lines
5.3 KiB
TypeScript
136 lines
5.3 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|
||
});
|
||
});
|