Files
DECNET/decnet_web/src/components/MazeNET/useMazeContextMenu.test.ts
anti f33a011900 refactor(decnet_web/MazeNET): extract useMazeContextMenu
Lift the context-menu builder out of the page shell. The hook
owns ctxMenu open/close state and exposes one builder per
surface (node / net / edge / canvas); the actual operations come
in via callbacks so the page keeps its optimistic-patch logic
unchanged.

- New MazeNET/useMazeContextMenu.tsx
- useMazeContextMenu.test.ts covers menu lifecycle (open/close),
  node-menu items, observed-entity locking, internet-net
  delete-disabled, canvas-menu Add subnet/DMZ items, and the
  edge-menu Remove invocation.
- Wiring into MazeNET.tsx lands next.
2026-05-09 05:34:58 -04:00

133 lines
4.7 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { describe, it, expect, vi } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { useMazeContextMenu } from './useMazeContextMenu';
import type { Net, MazeNode } from './types';
import type { UseTopologyEditor } from './useTopologyEditor';
const noop = () => {};
const noopAsync = async () => {};
const stubEditor = (): UseTopologyEditor => ({
inFlight: 0,
addLan: vi.fn(),
updateLanRow: vi.fn(),
deleteLan: vi.fn(),
addDeckyToLan: vi.fn(),
updateDecky: vi.fn(),
deleteDecky: vi.fn(),
attachEdge: vi.fn(),
detachEdge: vi.fn(),
} as unknown as UseTopologyEditor);
const fakeMouse = (overrides: Partial<MouseEvent> = {}): React.MouseEvent => ({
clientX: 100,
clientY: 200,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...overrides,
} as unknown as React.MouseEvent);
const subnet: Net = {
id: 'lan-1', name: 'lan-corp', label: 'CORP',
cidr: '10.0.0.0/24', kind: 'subnet', x: 0, y: 0, w: 300, h: 240,
};
const internet: Net = {
id: 'lan-www', name: 'internet', label: 'INTERNET',
cidr: '0.0.0.0/0', kind: 'internet', x: 0, y: 0, w: 300, h: 240,
};
const decky: MazeNode = {
kind: 'decky', id: 'd1', name: 'decoy-01', netId: 'lan-1',
archetype: 'workstation', services: ['ssh'], status: 'idle', x: 0, y: 0,
};
const observed: MazeNode = {
kind: 'observed', id: 'obs-1', label: '1.2.3.4', netId: 'lan-www',
x: 0, y: 0,
};
const baseArgs = () => ({
nets: [subnet, internet],
nodes: [decky, observed],
services: [
{ slug: 'http', name: 'HTTP', proto: 'tcp', port: 80, icon: 'globe', risk: 'medium' as const },
],
archetypes: [
{ slug: 'workstation', name: 'Workstation', services: ['ssh'], icon: 'monitor' },
],
topologyId: 'topo-1',
setSelection: vi.fn(),
setNodes: vi.fn(),
canvasRef: { current: null as HTMLDivElement | null } as React.RefObject<HTMLDivElement | null>,
pan: { x: 0, y: 0 },
editor: stubEditor(),
flashErr: vi.fn(),
onPaletteDrop: vi.fn(),
removeNet: vi.fn(),
removeNode: vi.fn(),
removeEdge: vi.fn(),
duplicateNode: vi.fn(),
addServiceToNode: vi.fn(),
});
describe('useMazeContextMenu', () => {
it('starts with no menu open and clears on closeMenu', () => {
const { result } = renderHook(() => useMazeContextMenu(baseArgs()));
expect(result.current.ctxMenu).toBeNull();
act(() => result.current.onNodeContextMenu('d1')(fakeMouse()));
expect(result.current.ctxMenu).not.toBeNull();
expect(result.current.ctxMenu?.x).toBe(100);
expect(result.current.ctxMenu?.items.length).toBeGreaterThan(0);
act(() => result.current.closeMenu());
expect(result.current.ctxMenu).toBeNull();
});
it('node context menu offers add-service / mutate / duplicate / delete', () => {
const { result } = renderHook(() => useMazeContextMenu(baseArgs()));
act(() => result.current.onNodeContextMenu('d1')(fakeMouse()));
const labels = result.current.ctxMenu?.items.map((i) => i.label);
expect(labels).toContain('Add service…');
expect(labels).toContain('Force mutate');
expect(labels).toContain('Duplicate decky');
expect(labels).toContain('Delete decky');
});
it('observed entities lock duplicate + delete', () => {
const { result } = renderHook(() => useMazeContextMenu(baseArgs()));
act(() => result.current.onNodeContextMenu('obs-1')(fakeMouse()));
const dup = result.current.ctxMenu?.items.find((i) => i.label === 'Duplicate decky');
const del = result.current.ctxMenu?.items.find((i) => i.label === 'Delete decky');
expect(dup?.disabled).toBe(true);
expect(del?.disabled).toBe(true);
});
it('internet network cannot be deleted', () => {
const { result } = renderHook(() => useMazeContextMenu(baseArgs()));
act(() => result.current.onNetContextMenu('lan-www')(fakeMouse()));
const del = result.current.ctxMenu?.items.find((i) =>
typeof i.label === 'string' && i.label.startsWith('Delete'),
);
expect(del?.disabled).toBe(true);
});
it('canvas context menu provides Add subnet / Add DMZ', () => {
const { result } = renderHook(() => useMazeContextMenu(baseArgs()));
act(() => result.current.onCanvasContextMenu(fakeMouse()));
const labels = result.current.ctxMenu?.items.map((i) => i.label);
expect(labels).toEqual(['Add subnet here', 'Add DMZ here']);
});
it('edge context menu invokes removeEdge on click', () => {
const args = baseArgs();
const { result } = renderHook(() => useMazeContextMenu(args));
act(() => result.current.onEdgeContextMenu('e1')(fakeMouse()));
const rm = result.current.ctxMenu?.items[0];
expect(rm?.label).toBe('Remove edge');
rm?.onClick?.();
expect(args.removeEdge).toHaveBeenCalledWith('e1');
});
});