/** * 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'], ['StateMachine', 'statemachine'], ['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); } }); });