Files
feuerwerk/tests/core/graph-annotations.test.ts
adminandClaude Opus 5 b9f1a9d319
game-maker / suite (push) Successful in 6m13s
A graph that grew past one screen needs somewhere to say what a part of it is for (#38)
The graph could hold any number of nodes and said nothing about which of them
belonged together. Groups and comments are two separate things, as decided in
the issue: a group is a titled box that carries the nodes it encloses, a
comment is free text that carries nothing.

Both live as optional arrays on Graph, not as registry nodes. A comment that
were a real node would run through the interpreter, show up in the drop
palettes and be compiled into a shader — the wrong place for a piece of text.
As arrays on Graph they exist in all four graph kinds at once, and a document
without them means exactly "no groups", so no format bump.

Membership is computed when the box is grabbed, never stored: the center of a
node inside the box travels with it, and a node dragged out is simply out. A
box inside a box travels with the outer one, its own nodes included. A group
is grabbed by its title bar only — its body stays transparent to the pointer,
or a rubber band could never be drawn across the nodes it holds. Deleting a
box deletes the box; the nodes stay where they are, because a box that
silently took ten nodes with it would be the most expensive surprise in the
feature.

The color is a free picker, and the title color follows from it: relative
luminance per WCAG, threshold at 0.179 where the two contrast ratios meet —
not 0.5, which would pick white on mid grey where black reads better.

Resizing snaps the far edge to the grid, not the width, so the corner that is
not being dragged stays put. The minimum size rises in whole steps for that
reason: 80 is no multiple of 24, so a box squeezed to the stop would have sat
off-grid in exactly the mode meant to keep it on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:24:34 +02:00

161 lines
7.0 KiB
TypeScript
Raw Permalink 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.
/**
* A graph that has grown needs somewhere to say what a corner of it is for. Two things say it: a
* group, a titled box that takes what it encloses along when it is moved, and a comment, a card of
* free text that frames nothing. Both are drawing only – no interpreter ever reads one – and both
* survive a save, which is the whole point of writing them down.
*/
import { describe, expect, it } from 'vitest';
import {
AddAnnotationCommand,
RemoveAnnotationCommand,
SetAnnotationBoxCommand,
SetAnnotationColorCommand,
SetAnnotationTextCommand,
} from '../../src/core/commands/annotationCommands';
import { AddNodeCommand } from '../../src/core/commands/graphCommands';
import { annotationsOf, findAnnotation, type Graph } from '../../src/core/graph';
import { findGraphAsset } from '../../src/core/model/Project';
import { boxesCentredIn } from '../../src/editor/framing';
import { luminance, readableTextColor } from '../../src/ui/color';
import { makeBehavior, makeProject, roundTrip } from '../helpers';
function fixture() {
const f = makeProject();
const behavior = makeBehavior(f, 'B');
const graphOf = (): Graph => findGraphAsset(f.project, behavior.id)!.graph;
return { ...f, behaviorId: behavior.id, graphOf };
}
describe('a group and a comment', () => {
it('are two lists of the same shape, and an untouched graph holds neither', () => {
const f = fixture();
expect(f.graphOf().groups).toBeUndefined();
expect(f.graphOf().notes).toBeUndefined();
const group = new AddAnnotationCommand(f.behaviorId, 'group', { x: 10, y: 20, w: 300, h: 200 }, 'Setup');
const note = new AddAnnotationCommand(f.behaviorId, 'note', { x: 0, y: 0, w: 200, h: 90 }, 'Careful');
f.stack.execute(group);
f.stack.execute(note);
expect(annotationsOf(f.graphOf(), 'group').map((a) => a.text)).toEqual(['Setup']);
expect(annotationsOf(f.graphOf(), 'note').map((a) => a.text)).toEqual(['Careful']);
// Found by id alone: the selection knows an id, not which of the two lists holds it.
expect(findAnnotation(f.graphOf(), note.annotation.id)?.kind).toBe('note');
expect(group.label).toBe('Add group');
expect(note.label).toBe('Add comment');
});
it('are refused a box smaller than the minimum, wherever the numbers come from', () => {
const f = fixture();
const add = new AddAnnotationCommand(f.behaviorId, 'group', { x: 0, y: 0, w: 4, h: 4 }, 'Tiny');
f.stack.execute(add);
const box = findAnnotation(f.graphOf(), add.annotation.id)!.annotation;
expect(box.w).toBeGreaterThanOrEqual(80);
expect(box.h).toBeGreaterThanOrEqual(48);
f.stack.execute(
new SetAnnotationBoxCommand(f.behaviorId, 'group', add.annotation.id, {
x: 5,
y: 5,
w: 1,
h: 1,
}),
);
expect(findAnnotation(f.graphOf(), add.annotation.id)!.annotation.w).toBeGreaterThanOrEqual(80);
});
it('take their text and colour back on undo, and a cleared colour means the default again', () => {
const f = fixture();
const add = new AddAnnotationCommand(f.behaviorId, 'group', { x: 0, y: 0, w: 200, h: 120 }, 'Setup');
f.stack.execute(add);
const id = add.annotation.id;
const current = () => findAnnotation(f.graphOf(), id)!.annotation;
f.stack.execute(new SetAnnotationTextCommand(f.behaviorId, 'group', id, 'Movement'));
expect(current().text).toBe('Movement');
f.stack.undo();
expect(current().text).toBe('Setup');
f.stack.execute(new SetAnnotationColorCommand(f.behaviorId, 'group', id, 0x336699));
expect(current().color).toBe(0x336699);
// Absent, not zero: black is a colour somebody could choose, "none" is not.
f.stack.execute(new SetAnnotationColorCommand(f.behaviorId, 'group', id, null));
expect(current().color).toBeUndefined();
f.stack.undo();
expect(current().color).toBe(0x336699);
});
it('give the nodes back when deleted – the box goes, what it framed stays', () => {
const f = fixture();
const node = new AddNodeCommand(f.behaviorId, 'OnStart', 40, 40);
f.stack.execute(node);
const add = new AddAnnotationCommand(f.behaviorId, 'group', { x: 0, y: 0, w: 300, h: 200 }, 'Setup');
f.stack.execute(add);
f.stack.execute(new RemoveAnnotationCommand(f.behaviorId, 'group', add.annotation.id));
expect(annotationsOf(f.graphOf(), 'group')).toHaveLength(0);
expect(f.graphOf().nodes.map((n) => n.id)).toEqual([node.node.id]);
f.stack.undo();
expect(annotationsOf(f.graphOf(), 'group').map((a) => a.text)).toEqual(['Setup']);
});
it('survive a save, and a file that invents fields or repeats an id does not', () => {
const f = fixture();
const add = new AddAnnotationCommand(f.behaviorId, 'group', { x: 10, y: 20, w: 300, h: 200 }, 'Setup');
f.stack.execute(add);
f.stack.execute(new SetAnnotationColorCommand(f.behaviorId, 'group', add.annotation.id, 0x336699));
const back = roundTrip(f.project);
const group = annotationsOf(findGraphAsset(back, f.behaviorId)!.graph, 'group')[0]!;
expect(group).toEqual({
id: add.annotation.id,
x: 10,
y: 20,
w: 300,
h: 200,
text: 'Setup',
color: 0x336699,
});
// Straight from an untrusted file: the second box repeats the id, the third invents a key.
const graph = findGraphAsset(f.project, f.behaviorId)!.graph;
graph.groups = [
{ id: 'g1', x: 0, y: 0, w: 200, h: 120, text: 'One' },
{ id: 'g1', x: 0, y: 0, w: 200, h: 120, text: 'Again' },
{ id: 'g2', x: 0, y: 0, w: 200, h: 120, text: 'Two', danger: 'yes' },
] as never;
const cleaned = annotationsOf(findGraphAsset(roundTrip(f.project), f.behaviorId)!.graph, 'group');
expect(cleaned.map((a) => a.text)).toEqual(['One', 'Two']);
expect(cleaned[1]).not.toHaveProperty('danger');
});
});
describe('what a group carries', () => {
const box = { x: 0, y: 0, w: 100, h: 100 };
it('is what the eye says: the node whose centre lies inside, overlap or not', () => {
const inside = { id: 'in', x: 10, y: 10, w: 40, h: 20 };
// Its left edge is in the box, its centre is not – the eye calls it "next to the group".
const touching = { id: 'touch', x: 90, y: 10, w: 60, h: 20 };
const outside = { id: 'out', x: 400, y: 400, w: 40, h: 20 };
expect(boxesCentredIn([inside, touching, outside], box)).toEqual(['in']);
});
it('includes a box sitting exactly on the border, so nothing falls between the two answers', () => {
expect(boxesCentredIn([{ id: 'edge', x: 80, y: 80, w: 40, h: 40 }], box)).toEqual(['edge']);
});
});
describe('the text colour of a box', () => {
it('is the one that reads on it, which is what lets the picker run free', () => {
expect(readableTextColor(0x000000)).toBe(0xffffff);
expect(readableTextColor(0xffffff)).toBe(0x000000);
// The two colours a free picker makes dangerous: a saturated yellow is bright, a blue is not.
expect(readableTextColor(0xffff00)).toBe(0x000000);
expect(readableTextColor(0x0000ff)).toBe(0xffffff);
expect(luminance(0x000000)).toBe(0);
expect(luminance(0xffffff)).toBeCloseTo(1, 5);
});
});