Files
feuerwerk/tests/core/folded-edge.test.ts
T
rootandClaude Opus 5 4b0919a224 Let a link be hidden, and shown again by selecting either end
A link can be folded away from the canvas: instead of a curve running clear
across the graph, each end grows a short stub with a mark naming the node at
the far side. The curve comes back while either of its two nodes is selected,
so following a connection is a click rather than a search, and a link carrying
a live pulse is always drawn so the trace keeps its thread.

Marks are collected per port, not per link, because several hidden links on one
port used to stack stubs, dots and labels on identical pixels. A bundle reads
"Move +1" and its dot selects every node the port feeds, as one undo step.

The whole thing is one optional flag on the edge; the interpreter never sees it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 22:19:59 +02:00

148 lines
6.5 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.
import { describe, expect, it } from 'vitest';
import { CommandError } from '../../src/core/commands/Command';
import { ConnectCommand, FoldEdgeCommand } from '../../src/core/commands/graphCommands';
import { GraphInterpreter } from '../../src/core/graph';
import type { BehaviorAsset } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
import { foldedEdgeShown, foldMarkKey, foldMarkLabel } from '../../src/editor/graph/folding';
import { fakeContext, graphEditor, makeBehavior, makeProject, roundTrip } from '../helpers';
/** "On start → Move(self, dx = 7)": two edges, so a fold can hide one and leave the other. */
function mover() {
const fixture = makeProject();
const behavior = makeBehavior(fixture, 'Mover');
const edit = graphEditor(fixture, behavior);
const start = edit.addNode('OnStart');
const self = edit.addNode('Self');
const move = edit.addNode('Move', { dx: 7 });
edit.wire([
[start, 'exec', move, 'exec'],
[self, 'object', move, 'object'],
]);
return { ...fixture, behavior, edit, nodes: { start, self, move } };
}
describe('folding an edge', () => {
it('changes nothing about what the graph does – the value still arrives', () => {
const { project, stack, behavior } = mover();
for (const edge of behavior.graph.edges) stack.execute(new FoldEdgeCommand(behavior.id, edge.id, true));
expect(behavior.graph.edges.every((e) => e.folded === true)).toBe(true);
const ctx = fakeContext();
new GraphInterpreter(behavior.graph).fire('OnStart', ctx);
expect(ctx.self.x).toBe(7);
// And the edges themselves are untouched apart from the flag.
expect(findAsset(project, behavior.id)).toBe(behavior);
expect(behavior.graph.edges).toHaveLength(2);
});
it('survives the save, and unfolding leaves no trace in the file', () => {
const { project, stack, behavior } = mover();
const [first, second] = behavior.graph.edges;
stack.execute(new FoldEdgeCommand(behavior.id, first!.id, true));
const loaded = findAsset(roundTrip(project), behavior.id) as BehaviorAsset;
expect(loaded.graph.edges.find((e) => e.id === first!.id)!.folded).toBe(true);
expect('folded' in loaded.graph.edges.find((e) => e.id === second!.id)!).toBe(false);
stack.execute(new FoldEdgeCommand(behavior.id, first!.id, false));
const again = findAsset(roundTrip(project), behavior.id) as BehaviorAsset;
expect(again.graph.edges.every((e) => !('folded' in e))).toBe(true);
});
it('is undoable, and undo restores the state the edge really had', () => {
const { stack, behavior } = mover();
const edge = behavior.graph.edges[0]!;
stack.execute(new FoldEdgeCommand(behavior.id, edge.id, true));
// Folding an already folded edge must not make undo unfold it.
stack.execute(new FoldEdgeCommand(behavior.id, edge.id, true));
stack.undo();
expect(edge.folded).toBe(true);
stack.undo();
expect(edge.folded).toBeUndefined();
});
it('reports a missing edge as a CommandError instead of throwing from the constructor', () => {
const { project, behavior } = mover();
const command = new FoldEdgeCommand(behavior.id, 'no-such-edge', true);
expect(command.label).toBe('Hide link');
expect(() => command.execute(project)).toThrow(CommandError);
});
it('belongs to the edge, not to the port: a replacement starts out shown', () => {
const { stack, behavior, edit, nodes } = mover();
const edge = behavior.graph.edges.find((e) => e.to.port === 'object')!;
stack.execute(new FoldEdgeCommand(behavior.id, edge.id, true));
const owner = edit.addNode('Owner');
stack.execute(
new ConnectCommand(
behavior.id,
{ node: owner.id, port: 'object' },
{ node: nodes.move.id, port: 'object' },
),
);
const now = behavior.graph.edges.find((e) => e.to.port === 'object')!;
expect(now.id).not.toBe(edge.id);
expect(now.folded).toBeUndefined();
// Undo puts the old edge back exactly as it was – folded.
stack.undo();
expect(behavior.graph.edges.find((e) => e.to.port === 'object')!.folded).toBe(true);
});
it('ignores a folded flag in the file that is not literally true', () => {
const { project, behavior } = mover();
const raw = serializeProject(project) as {
assets: { id: string; graph?: { edges: Record<string, unknown>[] } }[];
};
const edges = raw.assets.find((a) => a.id === behavior.id)!.graph!.edges;
edges[0]!.folded = 'yes';
edges[1]!.folded = false;
const loaded = findAsset(deserializeProject(raw), behavior.id) as BehaviorAsset;
expect(loaded.graph.edges.every((e) => !('folded' in e))).toBe(true);
});
});
describe('when a folded edge shows its curve anyway', () => {
const edge = { id: 'e', from: { node: 'a', port: 'out' }, to: { node: 'b', port: 'in' } };
it('stays hidden while neither end is selected', () => {
expect(foldedEdgeShown(edge, new Set(['c']), false)).toBe(false);
expect(foldedEdgeShown(edge, new Set(), false)).toBe(false);
});
it('comes back for either end, not just the source', () => {
expect(foldedEdgeShown(edge, new Set(['a']), false)).toBe(true);
expect(foldedEdgeShown(edge, new Set(['b']), false)).toBe(true);
});
it('always shows while a pulse is on it, so the live trace keeps its thread', () => {
expect(foldedEdgeShown(edge, new Set(), true)).toBe(true);
});
});
describe('several hidden links at one port', () => {
it('share a mark, while a different port or side gets its own', () => {
const out = foldMarkKey({ node: 'a', port: 'value' }, true);
expect(foldMarkKey({ node: 'a', port: 'value' }, true)).toBe(out);
expect(foldMarkKey({ node: 'a', port: 'other' }, true)).not.toBe(out);
expect(foldMarkKey({ node: 'b', port: 'value' }, true)).not.toBe(out);
// The same port on the other side of the node is a mark of its own.
expect(foldMarkKey({ node: 'a', port: 'value' }, false)).not.toBe(out);
});
it('cannot be confused by a node or port name that contains the separator', () => {
expect(foldMarkKey({ node: 'a b', port: 'c' }, true)).not.toBe(
foldMarkKey({ node: 'a', port: 'b c' }, true),
);
});
it('name one far node and count the rest, so nothing is written on top of anything', () => {
expect(foldMarkLabel(['Add'])).toBe('Add');
expect(foldMarkLabel(['Add', 'Compare'])).toBe('Add +1');
expect(foldMarkLabel(['Add', 'Compare', 'Multiply'])).toBe('Add +2');
expect(foldMarkLabel([])).toBe('');
});
});