/** * @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 = {}): 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, 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'); }); });