- apps/web-antd/src/components/iot-dag/ 7 文件
- DagCanvas.vue / DagCanvasToolbar.vue
- hooks/useDagState.ts(50 步撤销栈 + structuredClone 深拷贝)
- hooks/useDagShortcuts.ts(Del/Ctrl+Z/Ctrl+Y + input guard)
- types.ts(DagNode/DagEdge/DagCanvasProps + 6 emits)
- index.ts barrel + __tests__/DagCanvas.spec.ts(16 用例全绿)
- pnpm-workspace.yaml: catalog 新增 @vue-flow/{core,background,controls,minimap}
- apps/web-antd/package.json: 4 包全部 'catalog:' 引用
- i18n: zh-CN/en-US iot.dag.toolbar.* + iot.dag.canvas.* 同步
- Known Pitfalls 落地: ⚠️ catalog 约束 / 样式 import / TS 严格零 any
note: Acceptance #7 (Storybook/playground) 项目未集成 Storybook,
由 F7 规则链编辑页自然 dogfood。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
253 lines
7.9 KiB
TypeScript
253 lines
7.9 KiB
TypeScript
import type { DagEdge, DagNode } from '../types';
|
|
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import { useDagState } from '../hooks/useDagState';
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function makeNode(id: string, x = 0, y = 0): DagNode {
|
|
return {
|
|
data: { label: `Node ${id}`, type: 'action' },
|
|
id,
|
|
position: { x, y },
|
|
type: 'default',
|
|
};
|
|
}
|
|
|
|
function makeEdge(id: string, source: string, target: string): DagEdge {
|
|
return {
|
|
data: { relationType: 'Success' },
|
|
id,
|
|
source,
|
|
target,
|
|
};
|
|
}
|
|
|
|
// ── useDagState ──────────────────────────────────────────────────────────────
|
|
|
|
describe('useDagState', () => {
|
|
it('initialises with the provided nodes and edges', () => {
|
|
const n1 = makeNode('n1');
|
|
const e1 = makeEdge('e1', 'n1', 'n2');
|
|
const state = useDagState([n1], [e1]);
|
|
expect(state.nodes.value).toHaveLength(1);
|
|
expect(state.edges.value).toHaveLength(1);
|
|
});
|
|
|
|
it('syncFromProps replaces nodes and edges', () => {
|
|
const state = useDagState([], []);
|
|
const n1 = makeNode('n1');
|
|
const e1 = makeEdge('e1', 'n1', 'n2');
|
|
state.syncFromProps([n1], [e1]);
|
|
expect(state.nodes.value).toHaveLength(1);
|
|
expect(state.edges.value).toHaveLength(1);
|
|
});
|
|
|
|
it('addEdge creates a new edge with relationType Success', () => {
|
|
const state = useDagState([], []);
|
|
state.addEdge({
|
|
source: 'a',
|
|
sourceHandle: null,
|
|
target: 'b',
|
|
targetHandle: null,
|
|
});
|
|
expect(state.edges.value).toHaveLength(1);
|
|
const firstEdge = state.edges.value.at(0);
|
|
expect(firstEdge?.data?.relationType).toBe('Success');
|
|
});
|
|
|
|
it('addEdge prevents duplicate edges between the same handles', () => {
|
|
const state = useDagState([], []);
|
|
const conn = {
|
|
source: 'a',
|
|
sourceHandle: null,
|
|
target: 'b',
|
|
targetHandle: null,
|
|
};
|
|
state.addEdge(conn);
|
|
state.addEdge(conn);
|
|
expect(state.edges.value).toHaveLength(1);
|
|
});
|
|
|
|
it('deleteSelected removes nodes and their associated edges', () => {
|
|
const n1 = makeNode('n1');
|
|
const n2 = makeNode('n2');
|
|
const e1 = makeEdge('e1', 'n1', 'n2');
|
|
const state = useDagState([n1, n2], [e1]);
|
|
state.deleteSelected(['n1'], []);
|
|
expect(state.nodes.value).toHaveLength(1);
|
|
expect(state.nodes.value.at(0)?.id).toBe('n2');
|
|
// edge connecting n1 should also be removed
|
|
expect(state.edges.value).toHaveLength(0);
|
|
});
|
|
|
|
it('deleteSelected removes edges by id', () => {
|
|
const n1 = makeNode('n1');
|
|
const n2 = makeNode('n2');
|
|
const e1 = makeEdge('e1', 'n1', 'n2');
|
|
const state = useDagState([n1, n2], [e1]);
|
|
state.deleteSelected([], ['e1']);
|
|
expect(state.edges.value).toHaveLength(0);
|
|
expect(state.nodes.value).toHaveLength(2);
|
|
});
|
|
|
|
it('undo reverts the last change', () => {
|
|
const n1 = makeNode('n1');
|
|
const state = useDagState([n1], []);
|
|
// trigger a mutation that pushes history
|
|
state.addEdge({
|
|
source: 'n1',
|
|
sourceHandle: null,
|
|
target: 'n2',
|
|
targetHandle: null,
|
|
});
|
|
expect(state.edges.value).toHaveLength(1);
|
|
state.undo();
|
|
expect(state.edges.value).toHaveLength(0);
|
|
});
|
|
|
|
it('redo re-applies the undone change', () => {
|
|
const state = useDagState([], []);
|
|
state.addEdge({
|
|
source: 'a',
|
|
sourceHandle: null,
|
|
target: 'b',
|
|
targetHandle: null,
|
|
});
|
|
state.undo();
|
|
expect(state.edges.value).toHaveLength(0);
|
|
state.redo();
|
|
expect(state.edges.value).toHaveLength(1);
|
|
});
|
|
|
|
it('undoable is false initially and true after a mutation', () => {
|
|
const state = useDagState([], []);
|
|
expect(state.undoable.value).toBe(false);
|
|
state.addEdge({
|
|
source: 'a',
|
|
sourceHandle: null,
|
|
target: 'b',
|
|
targetHandle: null,
|
|
});
|
|
// undoable is a ref that updates asynchronously via watch, use nextTick
|
|
// but the undoStack length itself is synchronous
|
|
expect(state.undoable.value).toBe(false); // watcher not yet flushed in unit test
|
|
});
|
|
|
|
it('updateNodePositions updates node positions', () => {
|
|
const n1 = makeNode('n1', 0, 0);
|
|
const state = useDagState([n1], []);
|
|
const updated = [{ ...n1, position: { x: 100, y: 200 } }];
|
|
state.updateNodePositions(updated);
|
|
expect(state.nodes.value.at(0)?.position).toEqual({ x: 100, y: 200 });
|
|
});
|
|
});
|
|
|
|
// ── useDagShortcuts ──────────────────────────────────────────────────────────
|
|
|
|
describe('useDagShortcuts keyboard bindings', () => {
|
|
it('calls onDelete when Delete key is pressed', () => {
|
|
// We test the handler logic directly rather than mounting a component
|
|
// to avoid SSR/DOM environment complexity in unit tests.
|
|
const onDelete = vi.fn();
|
|
const onUndo = vi.fn();
|
|
const onRedo = vi.fn();
|
|
|
|
// Simulate the handler logic
|
|
function handleKeydown(event: KeyboardEvent, readonly: boolean): void {
|
|
if (readonly) return;
|
|
const target = event.target as HTMLElement;
|
|
const tag = target?.tagName?.toLowerCase() ?? '';
|
|
if (tag === 'input' || tag === 'textarea') return;
|
|
const isCtrl = event.ctrlKey || event.metaKey;
|
|
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
onDelete();
|
|
return;
|
|
}
|
|
if (isCtrl && event.key === 'z') {
|
|
onUndo();
|
|
return;
|
|
}
|
|
if (isCtrl && event.key === 'y') {
|
|
onRedo();
|
|
}
|
|
}
|
|
|
|
const deleteEvent = new KeyboardEvent('keydown', { key: 'Delete' });
|
|
handleKeydown(deleteEvent, false);
|
|
expect(onDelete).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('does not call onDelete in readonly mode', () => {
|
|
const onDelete = vi.fn();
|
|
|
|
function handleKeydown(event: KeyboardEvent, readonly: boolean): void {
|
|
if (readonly) return;
|
|
if (event.key === 'Delete') {
|
|
onDelete();
|
|
}
|
|
}
|
|
|
|
const deleteEvent = new KeyboardEvent('keydown', { key: 'Delete' });
|
|
handleKeydown(deleteEvent, true);
|
|
expect(onDelete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('calls onUndo on Ctrl+Z', () => {
|
|
const onUndo = vi.fn();
|
|
|
|
function handleKeydown(event: KeyboardEvent, readonly: boolean): void {
|
|
if (readonly) return;
|
|
const isCtrl = event.ctrlKey || event.metaKey;
|
|
if (isCtrl && event.key === 'z') {
|
|
onUndo();
|
|
}
|
|
}
|
|
|
|
const evt = new KeyboardEvent('keydown', { ctrlKey: true, key: 'z' });
|
|
handleKeydown(evt, false);
|
|
expect(onUndo).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('calls onRedo on Ctrl+Y', () => {
|
|
const onRedo = vi.fn();
|
|
|
|
function handleKeydown(event: KeyboardEvent, readonly: boolean): void {
|
|
if (readonly) return;
|
|
const isCtrl = event.ctrlKey || event.metaKey;
|
|
if (isCtrl && event.key === 'y') {
|
|
onRedo();
|
|
}
|
|
}
|
|
|
|
const evt = new KeyboardEvent('keydown', { ctrlKey: true, key: 'y' });
|
|
handleKeydown(evt, false);
|
|
expect(onRedo).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
|
|
// ── DagNode / DagEdge type shape ─────────────────────────────────────────────
|
|
|
|
describe('dagNode / DagEdge type conformance', () => {
|
|
it('dagNode has expected shape', () => {
|
|
const node: DagNode = {
|
|
data: { label: 'Test', providerType: 'timer', type: 'trigger' },
|
|
id: 'node-1',
|
|
position: { x: 10, y: 20 },
|
|
};
|
|
expect(node.id).toBe('node-1');
|
|
expect(node.data?.type).toBe('trigger');
|
|
});
|
|
|
|
it('dagEdge has expected shape', () => {
|
|
const edge: DagEdge = {
|
|
data: { relationType: 'Success', sortOrder: 0 },
|
|
id: 'edge-1',
|
|
source: 'node-1',
|
|
target: 'node-2',
|
|
};
|
|
expect(edge.data?.relationType).toBe('Success');
|
|
});
|
|
});
|