Files
feuerwerk/tests/core/kinds.test.ts
T
adminandClaude Opus 5.5 0f6e628616 Check the declared type when a graph writes a typed variable (#271)
A list variable with an item type only kept its shape in the editor.
A graph could write any list into it at runtime, and the List node had
no type at all. Now the declaration decides what a write may store.

- Writing a list into a typed list variable leaves out every item that
  does not have exactly the fields of the type. The rest is written and
  the console names each item that was left out, like Add item does.
- The same rule holds for typed list fields inside a struct variable
  and for lists inside typed items, at any depth.
- A typed struct variable refuses a struct with a field the type does
  not have, and a value that is no struct. A struct with missing fields
  still merges in as before, so a partial Pack keeps working.
- The check runs in the Variables node (own variables and Variables
  assets) and in the Properties node when it sets a behavior variable
  of another object.
- Ports learn their declaration through GraphLookup: what a variable
  declares about its type, and which behavior owns a graph. The rule
  itself lives in the new src/core/graph/typedValues.ts, fitsType moved
  there from SetItem.
- The List node gets a Type setting. With a type its items are shaped
  and marked, follow the struct asset, count when the type is deleted
  and offer Split into fields in For each. The mark never reaches the
  running game.
- A type on a List node holding something other than structs is
  refused with a console line.
- Setting the List node back to None removes the marks, unlike a list
  variable. A node list has no per-item type row, so marked items could
  never be freed there. Undo brings everything back.
- tests/core/kinds.test.ts looked for the value as the first parameter
  of a literal. Type now comes first on the List node, so the test
  finds the value by its key.

Tests:
- Gate: 198 files, 2869 tests (2856 + 13), CHECK EXIT 0
- e2e 55: 19 checks (+55j write at runtime, +2 for 55k List node)
- e2e 54: 16 checks, smokes 24 / 19 / 21
- Five mutations, each fails between 1 and 4 tests
- e2e total 1606 is computed (1603 + 3), no full run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:08:22 +02:00

142 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
NESTED_KINDS,
plainKindOf,
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(NESTED_KINDS).toEqual([...FIELD_KINDS, 'list', 'struct']);
});
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.find((p) => p.key === 'value')?.kind, type).toBe(kind);
}
});
});