/** * 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); }); });