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') 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 = {}) { vi.mocked(canvasStore.useCanvasStore).mockReturnValue({ nodes: [makeNode(nodeData)], selectedNodeId: 'n1', selectedNodeIds: [], setSelectedNode: vi.fn(), deleteNode: vi.fn(), updateNode: vi.fn(), snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), } as unknown as ReturnType) } 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('Hardware section', () => { it('does not render hardware section when no hardware data', () => { setupStore({ label: 'Server' }) render() expect(screen.queryByText('Hardware')).toBeNull() }) it('renders hardware section when cpu_count is set', () => { setupStore({ cpu_count: 8 }) render() expect(screen.getByText('Hardware')).toBeDefined() expect(screen.getByText('8')).toBeDefined() }) it('renders cpu_model', () => { setupStore({ cpu_model: 'Intel Xeon E5-2680' }) render() expect(screen.getByText('Intel Xeon E5-2680')).toBeDefined() }) it('formats ram_gb in GB', () => { setupStore({ ram_gb: 32 }) render() expect(screen.getByText('32 GB')).toBeDefined() }) it('formats ram_gb >= 1024 as TB', () => { setupStore({ ram_gb: 2048 }) render() expect(screen.getByText('2 TB')).toBeDefined() }) it('formats disk_gb in GB', () => { setupStore({ disk_gb: 500 }) render() expect(screen.getByText('500 GB')).toBeDefined() }) it('formats disk_gb >= 1024 as TB', () => { setupStore({ disk_gb: 1536 }) render() expect(screen.getByText('1.5 TB')).toBeDefined() }) it('renders all hardware fields together', () => { setupStore({ cpu_count: 16, cpu_model: 'AMD EPYC', ram_gb: 128, disk_gb: 4096 }) render() expect(screen.getByText('Hardware')).toBeDefined() expect(screen.getByText('AMD EPYC')).toBeDefined() expect(screen.getByText('16')).toBeDefined() expect(screen.getByText('128 GB')).toBeDefined() expect(screen.getByText('4 TB')).toBeDefined() }) }) 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() fireEvent.click(screen.getByText('Add')) 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', setSelectedNode: vi.fn(), deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), } as unknown as ReturnType) render() fireEvent.click(screen.getByText('Add')) fireEvent.change(screen.getByPlaceholderText('Service name'), { target: { value: 'nginx' } }) fireEvent.change(screen.getByPlaceholderText('Port'), { target: { value: '80' } }) // Two "Add" buttons exist: the header toggle and the form confirm — pick the form's const addButtons = screen.getAllByRole('button', { name: 'Add' }) fireEvent.click(addButtons[addButtons.length - 1]) expect(updateNode).toHaveBeenCalledOnce() expect(updateNode.mock.calls[0][1].services[0]).toMatchObject({ service_name: 'nginx', port: 80, protocol: 'tcp' }) }) 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] }) 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') }) 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.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) }) 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() }) }) })