An object could only live in a dedicated object variable, kept in slots
beside the variable bag. Struct fields and list items could not hold
one, so a spawned object packed into a struct or list was lost. This
replaces the slots with one mechanism for all three places.
- "No object" has one resting form everywhere: {"$object": ""}. A
running game can tell an object slot from an empty text that way.
- During play the object handle lives directly in the value, also
nested in structs and lists. objectSlots.ts and the special paths for
Keep and Properties are gone.
- liveObjects.ts strips objects to (null) at every exit: overrides the
editor run writes back to the scene, saved values of persistent
Variables assets, live values in the inspector, and the world
snapshot of the test hooks (objects as names there).
- A destroyed object reads as (null) through isAlive, alone, in a
struct field or as a list item.
- "+ Field" and "+ Item" offer Object. The inspector and the list tab
show a fixed (null) row without input.
- Lists from Raycast all stay objects when written to a variable.
Before they turned into names.
- A struct that only changes by an object writes no override. A list
that grows by an object writes [(null)], its length really changed.
Found on the way: CI at 98211a8 failed only in e2e 21d. #237 added
asset to the field kinds without updating that fixed expectation. 21d
now expects ...,animation,asset,object,struct.
No project on the server has object variables, so no old data is
affected.
Tests:
- ./check.sh: 169 files, 2475 tests, CHECK EXIT 0.
- e2e 6, 21, 23, 32: ALL PASSED, 213 checks.
- object-variable.test.ts rewritten: variable, struct field and list
item over several frames, destroyed objects, nothing in scene, file
or save, editor run reload, Keep.
- New e2e 21d-ii adds an Object item by click. 21d and 23k updated.
- 7 counter-probes, each fails 1 to 9 tests. Originals back via cmp.
- Not tested on its own: stripping of inspector live values and the
names in the test snapshot, the "+ Field -> Object" menu by click.
- e2e check total 1407, only calculated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
144 lines
5.9 KiB
TypeScript
144 lines
5.9 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 { NO_OBJECT, 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';
|
||
|
||
/** The tagged kinds that hold a value; an object's place is always empty at rest (see below). */
|
||
const TAGGED = FIELD_KINDS.filter((kind) => isTaggedKind(kind) && kind !== 'object');
|
||
/** 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', () => {
|
||
// The marks are held with the pictures that stand in for most of them (tests/ui/svg-icons.test.ts).
|
||
it('has a word for every kind a user can pick', () => {
|
||
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('no such kind')).toBe('?');
|
||
});
|
||
|
||
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('holds nothing but its place when it is an object, whatever it is handed', () => {
|
||
for (const value of ['Crate', 3, undefined]) expect(makeItem('object', value)).toEqual(NO_OBJECT);
|
||
expect(itemKindOf(NO_OBJECT)).toBe('object');
|
||
expect(listItemOf(NO_OBJECT)).toEqual(NO_OBJECT);
|
||
});
|
||
|
||
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);
|
||
// An object has nothing to type in, so its port has no field.
|
||
const edited = isTaggedKind(kind) && kind !== 'object';
|
||
expect(portDefOf({ key: 'p', kind }).field?.kind, kind).toBe(edited ? 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);
|
||
}
|
||
});
|
||
});
|