test(frontend): cover api client, ProxmoxGroupNode, CustomStyleModal

This commit is contained in:
Pouzor
2026-05-14 13:25:41 +02:00
parent aa17edf1d0
commit 373960f6ea
3 changed files with 448 additions and 0 deletions
@@ -0,0 +1,111 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render } from '@testing-library/react'
import { ReactFlowProvider } from '@xyflow/react'
import { ProxmoxGroupNode } from '../ProxmoxGroupNode'
import { useCanvasStore } from '@/stores/canvasStore'
import { useThemeStore } from '@/stores/themeStore'
import type { NodeData, NodeProperty } from '@/types'
import type { NodeProps, Node } from '@xyflow/react'
function renderNode(data: Partial<NodeData> = {}, selected = false) {
const fullData: NodeData = {
label: 'pve-01',
type: 'proxmox',
status: 'online',
services: [],
...data,
}
const props = {
id: 'p1',
data: fullData,
selected,
type: 'proxmox',
zIndex: 0,
isConnectable: true,
xPos: 0,
yPos: 0,
dragging: false,
deletable: true,
draggable: true,
selectable: true,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
width: 300,
height: 200,
dragHandle: undefined,
parentId: undefined,
sourcePosition: undefined,
targetPosition: undefined,
} as unknown as NodeProps<Node<NodeData>>
return render(
<ReactFlowProvider>
<ProxmoxGroupNode {...props} />
</ReactFlowProvider>
)
}
describe('ProxmoxGroupNode', () => {
beforeEach(() => {
useCanvasStore.setState({ hideIp: false })
useThemeStore.setState({ activeTheme: 'default' })
})
it('renders the node label', () => {
const { getByText } = renderNode({ label: 'My Proxmox' })
expect(getByText('My Proxmox')).toBeDefined()
})
it('renders ip when provided', () => {
const { getByText } = renderNode({ ip: '192.168.1.10' })
expect(getByText('192.168.1.10')).toBeDefined()
})
it('renders multiple ips when comma separated', () => {
const { getByText } = renderNode({ ip: '10.0.0.1, 10.0.0.2' })
expect(getByText('10.0.0.1')).toBeDefined()
expect(getByText('10.0.0.2')).toBeDefined()
})
it('masks ip when hideIp is enabled in store', () => {
useCanvasStore.setState({ hideIp: true })
const { queryByText } = renderNode({ ip: '192.168.1.10' })
expect(queryByText('192.168.1.10')).toBeNull()
})
it('renders visible properties only', () => {
const properties: NodeProperty[] = [
{ key: 'CPU', value: '16 cores', icon: null, visible: true },
{ key: 'Hidden', value: 'should-not-show', icon: null, visible: false },
]
const { getByText, queryByText } = renderNode({ properties })
expect(getByText('CPU')).toBeDefined()
expect(getByText(/16 cores/)).toBeDefined()
expect(queryByText('Hidden')).toBeNull()
expect(queryByText(/should-not-show/)).toBeNull()
})
it('renders status dot with title matching status', () => {
const { container } = renderNode({ status: 'offline' })
const dot = container.querySelector('[title="offline"]')
expect(dot).not.toBeNull()
})
it('container_mode === false renders as BaseNode (no resizer group border)', () => {
const { container } = renderNode({ container_mode: false })
// NodeResizer should not be present when not group-rendered
expect(container.querySelector('.react-flow__resize-control')).toBeNull()
})
it('container_mode default renders the group border container', () => {
const { container } = renderNode({})
// Group border div has rounded-xl border-2 classes
expect(container.querySelector('.rounded-xl.border-2')).not.toBeNull()
})
it('renders cluster handles in both modes', () => {
const { container: groupC } = renderNode({})
expect(groupC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
const { container: nodeC } = renderNode({ container_mode: false })
expect(nodeC.querySelectorAll('[title="Same cluster"]').length).toBeGreaterThanOrEqual(2)
})
})
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { CustomStyleModal } from '../CustomStyleModal'
import { useThemeStore } from '@/stores/themeStore'
import { useCanvasStore } from '@/stores/canvasStore'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }))
import { toast } from 'sonner'
describe('CustomStyleModal', () => {
beforeEach(() => {
useThemeStore.setState({ customStyle: { nodes: {}, edges: {} } })
useCanvasStore.setState({ hasUnsavedChanges: false })
vi.mocked(toast.success).mockReset()
})
it('renders nothing when closed', () => {
const { container } = render(<CustomStyleModal open={false} onClose={vi.fn()} />)
expect(container.querySelector('[role="dialog"]')).toBeNull()
})
it('renders title and tabs', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
expect(screen.getByText('Custom Style Editor')).toBeDefined()
expect(screen.getByRole('button', { name: 'Nodes' })).toBeDefined()
expect(screen.getByRole('button', { name: 'Edges' })).toBeDefined()
})
it('starts with empty selection placeholder', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
expect(screen.getByText(/Select a node type/)).toBeDefined()
})
it('switches to edges tab and shows the right placeholder', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
expect(screen.getByText(/edge type from the list/i)).toBeDefined()
})
it('selecting a node type opens the node editor', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
expect(screen.getByText(/Apply to existing/)).toBeDefined()
expect(screen.getByText('Default size')).toBeDefined()
})
it('selecting an edge type opens the edge editor with path style buttons', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
expect(screen.getByRole('button', { name: 'Bezier' })).toBeDefined()
expect(screen.getByRole('button', { name: 'Smooth' })).toBeDefined()
})
it('Apply-to-existing node button calls store and toasts', () => {
const applyTypeNodeStyle = vi.fn()
useCanvasStore.setState({ applyTypeNodeStyle })
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Router/ }))
expect(applyTypeNodeStyle).toHaveBeenCalledOnce()
expect(applyTypeNodeStyle.mock.calls[0][0]).toBe('router')
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Router'))
})
it('Apply-to-existing edge button calls store and toasts', () => {
const applyTypeEdgeStyle = vi.fn()
useCanvasStore.setState({ applyTypeEdgeStyle })
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ }))
expect(applyTypeEdgeStyle).toHaveBeenCalledOnce()
expect(applyTypeEdgeStyle.mock.calls[0][0]).toBe('ethernet')
})
it('Save Custom Style sets customStyle, marks unsaved, closes, toasts', () => {
const onClose = vi.fn()
const markUnsaved = vi.fn()
useCanvasStore.setState({ markUnsaved })
render(<CustomStyleModal open onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Save Custom Style' }))
expect(markUnsaved).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
expect(toast.success).toHaveBeenCalledWith(expect.stringContaining('Custom style saved'))
})
it('Apply All to Canvas calls applyAllCustomStyles, markUnsaved, closes', () => {
const onClose = vi.fn()
const markUnsaved = vi.fn()
const applyAllCustomStyles = vi.fn()
useCanvasStore.setState({ markUnsaved, applyAllCustomStyles })
render(<CustomStyleModal open onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Apply All to Canvas' }))
expect(applyAllCustomStyles).toHaveBeenCalledOnce()
expect(markUnsaved).toHaveBeenCalledOnce()
expect(onClose).toHaveBeenCalledOnce()
})
it('Cancel button closes without saving', () => {
const onClose = vi.fn()
const markUnsaved = vi.fn()
useCanvasStore.setState({ markUnsaved })
render(<CustomStyleModal open onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(onClose).toHaveBeenCalledOnce()
expect(markUnsaved).not.toHaveBeenCalled()
})
it('editing path style updates the edge draft', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
const smoothBtn = screen.getByRole('button', { name: 'Smooth' })
fireEvent.click(smoothBtn)
// The clicked button should now be styled selected (cyan border)
expect(smoothBtn.getAttribute('style')).toContain('rgb(0, 212, 255)')
})
it('changing width input updates node draft', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
const widthInputs = screen.getAllByRole('spinbutton')
fireEvent.change(widthInputs[0], { target: { value: '250' } })
expect((widthInputs[0] as HTMLInputElement).value).toBe('250')
})
})