import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { DetailPanel } from '../DetailPanel' import * as canvasStore from '@/stores/canvasStore' import type { NodeData } from '@/types' import type { Node } from '@xyflow/react' vi.mock('@/stores/canvasStore', async (importActual) => ({ ...(await importActual()), useCanvasStore: vi.fn(), })) function makeNode(data: Partial): Node { return { id: 'n1', type: data.type ?? 'server', position: { x: 0, y: 0 }, data: { label: 'Test Node', type: 'server', status: 'online', services: [], ...data, }, } } function setupStore(nodeData: Partial = {}, serviceStatuses: Record = {}) { const state = { nodes: [makeNode(nodeData)], selectedNodeId: 'n1', selectedNodeIds: [], setSelectedNode: vi.fn(), deleteNode: vi.fn(), updateNode: vi.fn(), snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), setNodeSize: vi.fn(), serviceStatuses, } // Support both the bare destructure call and the selector-based call. vi.mocked(canvasStore.useCanvasStore).mockImplementation( ((sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state)) as unknown as typeof canvasStore.useCanvasStore, ) } describe('DetailPanel', () => { beforeEach(() => { vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ nodes: [], selectedNodeId: null, selectedNodeIds: [], setSelectedNode: vi.fn(), deleteNode: vi.fn(), updateNode: vi.fn(), snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), } as unknown as ReturnType) }) it('renders nothing when no node is selected', () => { const { container } = render() expect(container.firstChild).toBeNull() }) it('renders node label and status', () => { setupStore({ label: 'My Server', status: 'online' }) render() expect(screen.getByText('My Server')).toBeDefined() expect(screen.getByText('online')).toBeDefined() }) it('renders nothing for groupRect nodes', () => { setupStore({ type: 'groupRect', label: 'Zone' }) const { container } = render() expect(container.firstChild).toBeNull() }) describe('Properties section', () => { it('renders empty state when no properties', () => { setupStore({ properties: [] }) render() expect(screen.getByText(/No properties/)).toBeDefined() }) it('renders properties with key and value', () => { setupStore({ properties: [ { key: 'CPU Model', value: 'i7-12700K', icon: 'Cpu', visible: true }, { key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: false }, ], }) render() expect(screen.getByText('CPU Model')).toBeDefined() // Value is rendered with a middle-dot prefix: "· 32 GB" expect(screen.getByText(/32 GB/)).toBeDefined() }) it('shows Properties count when properties exist', () => { setupStore({ properties: [ { key: 'CPU Model', value: 'i7', icon: null, visible: true }, { key: 'RAM', value: '16 GB', icon: null, visible: true }, ], }) render() expect(screen.getByText('Properties (2)')).toBeDefined() }) it('shows add form when Add is clicked', () => { setupStore({ properties: [] }) render() // There are multiple "Add" buttons (services + properties) — find the one after "Properties" const addButtons = screen.getAllByText('Add') fireEvent.click(addButtons[0]) // first Add = properties (rendered above services) expect(screen.getByPlaceholderText('Label (e.g. CPU Model)')).toBeDefined() }) it('calls updateNode with new property on Add confirm', () => { const updateNode = vi.fn() vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ nodes: [makeNode({ properties: [] })], selectedNodeId: 'n1', selectedNodeIds: [], setSelectedNode: vi.fn(), deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), } as unknown as ReturnType) render() const addButtons = screen.getAllByText('Add') fireEvent.click(addButtons[0]) // first Add = properties // Form is now open — fill key and value fireEvent.change(screen.getByPlaceholderText('Label (e.g. CPU Model)'), { target: { value: 'GPU' } }) fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'RTX 4090' } }) // The PropertyForm confirm button is labeled "Add" — use the form's confirm button fireEvent.keyDown(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { key: 'Enter' }) expect(updateNode).toHaveBeenCalledOnce() const [, payload] = updateNode.mock.calls[0] expect(payload.properties[0]).toMatchObject({ key: 'GPU', value: 'RTX 4090', visible: true }) }) it('calls updateNode with toggled visibility when eye button is clicked', () => { const updateNode = vi.fn() vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ nodes: [makeNode({ properties: [{ key: 'RAM', value: '32 GB', icon: 'MemoryStick', visible: true }] })], selectedNodeId: 'n1', selectedNodeIds: [], setSelectedNode: vi.fn(), deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), } as unknown as ReturnType) render() fireEvent.click(screen.getByTitle('Hide on node')) expect(updateNode).toHaveBeenCalledOnce() const [, payload] = updateNode.mock.calls[0] expect(payload.properties[0].visible).toBe(false) }) it('calls updateNode without the property when remove button is clicked', () => { const updateNode = vi.fn() vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ nodes: [makeNode({ properties: [{ key: 'GPU', value: 'RTX 4090', icon: null, visible: true }] })], selectedNodeId: 'n1', selectedNodeIds: [], setSelectedNode: vi.fn(), deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), } as unknown as ReturnType) render() fireEvent.click(screen.getByTitle('Remove property')) expect(updateNode).toHaveBeenCalledOnce() const [, payload] = updateNode.mock.calls[0] expect(payload.properties).toHaveLength(0) }) it('does not submit add form when key is empty', () => { const updateNode = vi.fn() vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ nodes: [makeNode({ properties: [] })], selectedNodeId: 'n1', selectedNodeIds: [], setSelectedNode: vi.fn(), deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), } as unknown as ReturnType) render() const addButtons = screen.getAllByText('Add') fireEvent.click(addButtons[0]) // Only fill value, leave key empty fireEvent.change(screen.getByPlaceholderText('Value (e.g. i7-12700K)'), { target: { value: 'some value' } }) const confirmButtons = screen.getAllByRole('button', { name: 'Add' }) fireEvent.click(confirmButtons[confirmButtons.length - 1]) expect(updateNode).not.toHaveBeenCalled() }) }) describe('Panel actions', () => { it('calls setSelectedNode(null) when close button is clicked', () => { const setSelectedNode = vi.fn() vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ nodes: [makeNode({})], selectedNodeId: 'n1', setSelectedNode, deleteNode: vi.fn(), updateNode: vi.fn(), snapshotHistory: vi.fn(), } as unknown as ReturnType) render() fireEvent.click(screen.getByLabelText('Close panel')) expect(setSelectedNode).toHaveBeenCalledWith(null) }) it('calls onEdit with node id when Edit button is clicked', () => { setupStore({}) const onEdit = vi.fn() render() fireEvent.click(screen.getByRole('button', { name: /edit/i })) expect(onEdit).toHaveBeenCalledWith('n1') }) it('calls snapshotHistory then deleteNode when delete confirmed', () => { const deleteNode = vi.fn() const snapshotHistory = vi.fn() vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ nodes: [makeNode({ label: 'My Server' })], selectedNodeId: 'n1', setSelectedNode: vi.fn(), deleteNode, updateNode: vi.fn(), snapshotHistory, } as unknown as ReturnType) vi.spyOn(window, 'confirm').mockReturnValue(true) render() fireEvent.click(screen.getByLabelText('Delete node')) expect(snapshotHistory).toHaveBeenCalledOnce() expect(deleteNode).toHaveBeenCalledWith('n1') }) it('does not call deleteNode or snapshotHistory when delete is cancelled', () => { const deleteNode = vi.fn() const snapshotHistory = vi.fn() vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ nodes: [makeNode({})], selectedNodeId: 'n1', setSelectedNode: vi.fn(), deleteNode, updateNode: vi.fn(), snapshotHistory, } as unknown as ReturnType) vi.spyOn(window, 'confirm').mockReturnValue(false) render() fireEvent.click(screen.getByLabelText('Delete node')) expect(snapshotHistory).not.toHaveBeenCalled() expect(deleteNode).not.toHaveBeenCalled() }) }) describe('Services — add/remove', () => { it('shows add form when Add is clicked', () => { setupStore({}) render() // Two "Add" buttons: first = properties, second = services const addButtons = screen.getAllByText('Add') fireEvent.click(addButtons[addButtons.length - 1]) expect(screen.getByPlaceholderText('Service name')).toBeDefined() }) it('calls updateNode with new service on Add confirm', () => { const updateNode = vi.fn() vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ nodes: [makeNode({})], selectedNodeId: 'n1', selectedNodeIds: [], setSelectedNode: vi.fn(), deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), } as unknown as ReturnType) render() // Two "Add" header buttons: first = properties, second = services const addHeaders = screen.getAllByText('Add') fireEvent.click(addHeaders[addHeaders.length - 1]) fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } }) fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } }) fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } }) fireEvent.keyDown(screen.getByPlaceholderText('Port'), { key: 'Enter' }) expect(updateNode).toHaveBeenCalledOnce() expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp', path: '/admin' }) }) it('allows adding a service without a port', () => { const updateNode = vi.fn() vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ nodes: [makeNode({ ip: '192.168.1.10:8080' })], selectedNodeId: 'n1', selectedNodeIds: [], setSelectedNode: vi.fn(), deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), } as unknown as ReturnType) render() const addHeaders = screen.getAllByText('Add') fireEvent.click(addHeaders[addHeaders.length - 1]) fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'health' } }) fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: 'healthz' } }) fireEvent.click(screen.getAllByRole('button', { name: 'Add' }).at(-1) as HTMLButtonElement) expect(updateNode).toHaveBeenCalledOnce() expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'health', protocol: 'tcp', path: 'healthz' }) expect(updateNode.mock.calls[0][1].services[0].port).toBeUndefined() }) it('calls updateNode without the removed service when X is clicked', () => { const updateNode = vi.fn() const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' } vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ nodes: [makeNode({ services: [svc] })], selectedNodeId: 'n1', setSelectedNode: vi.fn(), deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), } as unknown as ReturnType) render() fireEvent.click(screen.getByTitle('Remove service')) expect(updateNode).toHaveBeenCalledOnce() expect(updateNode.mock.calls[0][1].services).toHaveLength(0) }) it('does not crash when data.services is undefined', () => { vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ nodes: [makeNode({ services: undefined as unknown as [] })], selectedNodeId: 'n1', setSelectedNode: vi.fn(), deleteNode: vi.fn(), updateNode: vi.fn(), snapshotHistory: vi.fn(), } as unknown as ReturnType) expect(() => render()).not.toThrow() }) }) describe('Services — edit', () => { const svc = { port: 80, protocol: 'tcp' as const, service_name: 'nginx' } it('shows edit form pre-filled when pencil is clicked', () => { setupStore({ services: [{ ...svc, path: '/admin' }] }) render() // Hover to reveal edit button (fireEvent.mouseOver isn't needed — opacity is CSS only) const editBtn = screen.getByTitle('Edit service') fireEvent.click(editBtn) const nameInput = screen.getByPlaceholderText('Service name') as HTMLInputElement expect(nameInput.value).toBe('nginx') const portInput = screen.getByPlaceholderText('Port') as HTMLInputElement expect(portInput.value).toBe('80') const pathInput = screen.getByPlaceholderText('Path (/admin)') as HTMLInputElement expect(pathInput.value).toBe('/admin') }) it('calls updateNode with updated values on Save', () => { const updateNode = vi.fn() vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ nodes: [makeNode({ services: [svc] })], selectedNodeId: 'n1', setSelectedNode: vi.fn(), deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), } as unknown as ReturnType) render() fireEvent.click(screen.getByTitle('Edit service')) const nameInput = screen.getByPlaceholderText('Service name') fireEvent.change(nameInput, { target: { value: 'apache' } }) fireEvent.change(screen.getByPlaceholderText('Path (/admin)'), { target: { value: '/admin' } }) fireEvent.click(screen.getByRole('button', { name: 'Save' })) expect(updateNode).toHaveBeenCalledOnce() expect(updateNode.mock.calls[0][1].services[0].service_name).toBe('apache') expect(updateNode.mock.calls[0][1].services[0].port).toBe(80) expect(updateNode.mock.calls[0][1].services[0].path).toBe('/admin') }) it('cancels edit without updating', () => { const updateNode = vi.fn() vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ nodes: [makeNode({ services: [svc] })], selectedNodeId: 'n1', setSelectedNode: vi.fn(), deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), } as unknown as ReturnType) render() fireEvent.click(screen.getByTitle('Edit service')) fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) expect(updateNode).not.toHaveBeenCalled() expect(screen.getByText('nginx')).toBeDefined() }) }) describe('IP Address — clickable link', () => { it('renders a link for a single IP', () => { setupStore({ ip: '192.168.1.10' }) render() const link = screen.getByRole('link', { name: /192\.168\.1\.10/ }) expect(link).toBeDefined() expect(link.getAttribute('href')).toBe('http://192.168.1.10') expect(link.getAttribute('target')).toBe('_blank') }) it('renders no IP link when ip is absent', () => { setupStore({ ip: undefined }) render() expect(screen.queryByText('IP Address')).toBeNull() }) it('uses primary IP as href for comma-separated IPs', () => { setupStore({ ip: '192.168.1.10, 192.168.1.11' }) render() const link = screen.getByRole('link', { name: /192\.168\.1\.10/ }) expect(link.getAttribute('href')).toBe('http://192.168.1.10') }) it('displays full comma-separated IP string as link text', () => { setupStore({ ip: '192.168.1.10, 192.168.1.11' }) render() expect(screen.getByRole('link', { name: /192\.168\.1\.10/ })).toBeDefined() expect(screen.getByRole('link', { name: /192\.168\.1\.11/ })).toBeDefined() expect(screen.queryByText(',')).toBeNull() }) it('renders separate links for semicolon-separated IPs', () => { setupStore({ ip: '192.168.1.10; 192.168.1.11' }) render() expect(screen.getByRole('link', { name: /192\.168\.1\.10/ }).getAttribute('href')).toBe('http://192.168.1.10') expect(screen.getByRole('link', { name: /192\.168\.1\.11/ }).getAttribute('href')).toBe('http://192.168.1.11') }) it('renders separate links for newline-separated IPs', () => { setupStore({ ip: '192.168.1.10\n192.168.1.11' }) render() expect(screen.getByRole('link', { name: /192\.168\.1\.10/ }).getAttribute('href')).toBe('http://192.168.1.10') expect(screen.getByRole('link', { name: /192\.168\.1\.11/ }).getAttribute('href')).toBe('http://192.168.1.11') }) }) describe('ServiceBadge rendering', () => { it('renders service name and port/protocol label', () => { setupStore({ services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] }) render() expect(screen.getByText('nginx')).toBeDefined() expect(screen.getByText('8080/tcp')).toBeDefined() }) it('renders path label when path is set', () => { setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '/admin' }] }) render() expect(screen.getByText('/admin')).toBeDefined() }) it('renders no path text when path is empty', () => { setupStore({ services: [{ port: 80, protocol: 'tcp', service_name: 'web', path: '' }] }) render() expect(screen.queryByText('/')).toBeNull() }) it('renders port/protocol omitted when port is absent', () => { setupStore({ services: [{ protocol: 'tcp', service_name: 'health', path: '' }] }) render() expect(screen.getByText('health')).toBeDefined() expect(screen.queryByText(/\/tcp/)).toBeNull() }) it('renders service name as link when ip and port are set', () => { setupStore({ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] }) render() const link = screen.getByRole('link', { name: 'nginx' }) expect(link.getAttribute('href')).toContain('192.168.1.10') expect(link.getAttribute('target')).toBe('_blank') }) it('renders service name as plain text when no url can be built', () => { setupStore({ ip: undefined, services: [{ protocol: 'tcp', service_name: 'health', path: '' }] }) render() expect(screen.getByText('health').tagName).not.toBe('A') }) it('colors a categoryless but reachable web service blue, not grey', () => { setupStore({ ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] }) render() const link = screen.getByRole('link', { name: 'nginx' }) expect(link.style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web) }) it('keeps a categoryless unreachable service grey', () => { setupStore({ ip: undefined, services: [{ protocol: 'tcp', service_name: 'health', path: '' }] }) render() expect(screen.getByText('health').style.color).toBe('rgb(139, 148, 158)') // #8b949e }) it('respects an explicit category over the url fallback', () => { setupStore({ ip: '192.168.1.10', services: [{ port: 5432, protocol: 'tcp', service_name: 'pg', category: 'database', path: '' }] }) render() expect(screen.getByText('pg').style.color).toBe('rgb(168, 85, 247)') // #a855f7 (database) }) it('paints a service red when its live status is offline', () => { setupStore( { ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] }, { 'n1:8080/tcp': 'offline' }, ) render() expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(248, 81, 73)') // #f85149 }) it('keeps the category colour when the live status is online', () => { setupStore( { ip: '192.168.1.10', services: [{ port: 8080, protocol: 'tcp', service_name: 'nginx', path: '' }] }, { 'n1:8080/tcp': 'online' }, ) render() expect(screen.getByRole('link', { name: 'nginx' }).style.color).toBe('rgb(0, 212, 255)') // #00d4ff (web) }) }) describe('Last Seen formatting', () => { it('renders a valid date when last_seen has +00:00 offset (no Z)', () => { setupStore({ last_seen: '2026-05-10T17:54:38.221403+00:00' }) render() const row = screen.getByText('Last Seen').parentElement expect(row?.textContent).not.toMatch(/Invalid Date/) }) it('renders a valid date when last_seen ends with Z', () => { setupStore({ last_seen: '2026-05-10T17:54:38.221403Z' }) render() const row = screen.getByText('Last Seen').parentElement expect(row?.textContent).not.toMatch(/Invalid Date/) }) it('treats naive ISO strings as UTC', () => { setupStore({ last_seen: '2026-05-10T17:54:38' }) render() const row = screen.getByText('Last Seen').parentElement expect(row?.textContent).not.toMatch(/Invalid Date/) }) }) describe('Size section', () => { function setupSized(node: Partial>, setNodeSize = vi.fn()) { const state = { nodes: [{ ...makeNode({}), ...node }], selectedNodeId: 'n1', selectedNodeIds: [], setSelectedNode: vi.fn(), deleteNode: vi.fn(), updateNode: vi.fn(), snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), setNodeSize, serviceStatuses: {}, } vi.mocked(canvasStore.useCanvasStore).mockImplementation( ((sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state)) as unknown as typeof canvasStore.useCanvasStore, ) return setNodeSize } it('shows the current width/height, preferring explicit over measured', () => { setupSized({ width: 220, height: 130, measured: { width: 999, height: 999 } }) render() expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('220') expect((screen.getByLabelText('Height') as HTMLInputElement).value).toBe('130') }) it('falls back to the measured size before a resize', () => { setupSized({ measured: { width: 187, height: 73 } }) render() expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('187') expect((screen.getByLabelText('Height') as HTMLInputElement).value).toBe('73') }) it('commits a manual width on blur', () => { const setNodeSize = setupSized({ width: 200, height: 100 }) render() const w = screen.getByLabelText('Width') fireEvent.change(w, { target: { value: '260' } }) fireEvent.blur(w) expect(setNodeSize).toHaveBeenCalledWith('n1', { width: 260 }) }) it('reverts invalid input without committing', () => { const setNodeSize = setupSized({ width: 200, height: 100 }) render() const w = screen.getByLabelText('Width') as HTMLInputElement fireEvent.change(w, { target: { value: 'abc' } }) fireEvent.blur(w) expect(setNodeSize).not.toHaveBeenCalled() expect(w.value).toBe('200') }) it('resyncs the field when the node is resized by corner drag', () => { setupSized({ width: 200, height: 100 }) const { rerender } = render() expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('200') // Simulate a corner-drag resize updating the store dimension. setupSized({ width: 340, height: 100 }) rerender() expect((screen.getByLabelText('Width') as HTMLInputElement).value).toBe('340') }) }) })