Files
homelable/frontend/src/components/modals/__tests__/CustomStyleModal.test.tsx
T
Pouzor 485d2f2b04 feat: configurable edge line style + width per type and per edge
Edge render (solid/dashed/dotted) and stroke width were hardcoded per
edge type. Expose both as user settings.

- Custom Style modal (Edges): line-style buttons, 1-4x width slider,
  live preview; left-list swatch renders the actual line.
- Per-edge EdgeModal: same controls; line style follows the type preset
  live until overridden.
- Renderer applies line_style/width_mult over BASE_STYLES (width scales
  markers + animation overlays); unset keeps the type default look.
- Persist line_style/width_mult through serializer, canvas save, and the
  edges API (new nullable columns, idempotent migration).

ha-relevant: yes
2026-07-05 14:11:01 +02:00

227 lines
11 KiB
TypeScript

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('groups node types under category headers (incl. Zigbee and Z-Wave)', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
expect(screen.getByText('Hardware')).toBeDefined()
expect(screen.getByText('Zigbee')).toBeDefined()
expect(screen.getByText('Z-Wave')).toBeDefined()
// A Z-Wave node type is selectable from its category.
fireEvent.click(screen.getByRole('button', { name: /Z-Wave Controller/ }))
expect(screen.getByText(/Apply to existing Z-Wave Controller/)).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('initialNodeType preselects that type editor on open (NodeModal shortcut)', () => {
render(<CustomStyleModal open initialNodeType="switch" onClose={vi.fn()} />)
// Editor for Switch is shown immediately, no manual selection needed.
expect(screen.getByText(/Apply to existing Switch/)).toBeDefined()
expect(screen.queryByText(/Select a node type/)).toBeNull()
})
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('edge editor exposes Start/End marker pickers defaulting to none', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Edges' }))
fireEvent.click(screen.getByRole('button', { name: /Ethernet/ }))
const startNone = screen.getByRole('button', { name: 'Start marker none' })
const endNone = screen.getByRole('button', { name: 'End marker none' })
expect(startNone.getAttribute('aria-pressed')).toBe('true')
expect(endNone.getAttribute('aria-pressed')).toBe('true')
})
it('picking an End shape feeds arrowEnd to applyTypeEdgeStyle', () => {
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: 'End marker diamond' }))
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ }))
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowEnd).toBe('diamond')
expect(applyTypeEdgeStyle.mock.calls[0][1].arrowStart).toBe('none')
})
it('picking a line style + width feeds lineStyle/widthMult to applyTypeEdgeStyle', () => {
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: 'Dotted' }))
fireEvent.change(screen.getByRole('slider', { name: 'Line width multiplier' }), { target: { value: '3' } })
fireEvent.click(screen.getByRole('button', { name: /Apply to existing Ethernet/ }))
expect(applyTypeEdgeStyle.mock.calls[0][1].lineStyle).toBe('dotted')
expect(applyTypeEdgeStyle.mock.calls[0][1].widthMult).toBe(3)
})
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')
})
it('shows per-side default connection-point inputs in the node editor', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
expect(screen.getByText('Default connection points')).toBeDefined()
expect(screen.getByLabelText('Top default connection points')).toBeDefined()
expect(screen.getByLabelText('Left default connection points')).toBeDefined()
})
it('defaults per-side inputs to 1 (top/bottom) and 0 (left/right)', () => {
render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
expect((screen.getByLabelText('Top default connection points') as HTMLInputElement).value).toBe('1')
expect((screen.getByLabelText('Left default connection points') as HTMLInputElement).value).toBe('0')
})
it('editing a per-side default persists via setCustomStyle on Save', () => {
const onClose = vi.fn()
useCanvasStore.setState({ markUnsaved: vi.fn() })
const setCustomStyle = vi.spyOn(useThemeStore.getState(), 'setCustomStyle')
render(<CustomStyleModal open onClose={onClose} />)
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
fireEvent.change(screen.getByLabelText('Left default connection points'), { target: { value: '3' } })
fireEvent.click(screen.getByRole('button', { name: 'Save Custom Style' }))
expect(setCustomStyle).toHaveBeenCalled()
const saved = setCustomStyle.mock.calls.at(-1)?.[0]
expect(saved?.nodes.router?.leftHandles).toBe(3)
})
it('resets abandoned edits when reopened after cancel (mounted parent)', () => {
// Parent keeps the modal mounted and only toggles `open`, so the reset must
// happen on the open-prop edge, not via Radix onOpenChange.
const { rerender } = render(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
const widthInput = screen.getAllByRole('spinbutton')[0]
fireEvent.change(widthInput, { target: { value: '250' } })
expect((widthInput as HTMLInputElement).value).toBe('250')
// Cancel = parent flips open → false, then later → true again.
rerender(<CustomStyleModal open={false} onClose={vi.fn()} />)
rerender(<CustomStyleModal open onClose={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Router' }))
const reopenedWidth = screen.getAllByRole('spinbutton')[0]
// Draft was reset to saved style (default width 0) — edit did not leak.
expect((reopenedWidth as HTMLInputElement).value).toBe('0')
})
})