M5-T06: add Energy device management UI and sidebar entry

This commit is contained in:
2026-06-22 13:52:33 +02:00
parent 702a1cec00
commit 2dc469274a
11 changed files with 2301 additions and 8 deletions
+241
View File
@@ -0,0 +1,241 @@
/**
* Tests for energy/DeviceForm.tsx
*
* Coverage:
* 1. Renders empty form in create mode (title "New Device").
* 2. Renders pre-filled form in edit mode (title "Edit Device"), shows UUID.
* 3. Validation: missing friendly_name shows error.
* 4. Profile dropdown loads options from GET /api/modbus/profiles.
* 5. Submit in create mode calls POST /api/modbus/devices.
* 6. Submit in edit mode calls PATCH /api/modbus/devices/{uuid}.
* 7. Cancel button invokes onClose without submitting.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
import { DeviceForm } from './DeviceForm'
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const DEVICE = {
uuid: 'abc-123',
friendly_name: 'SDM120 Main',
transport: 'tcp',
host: '192.168.1.100',
port: 502,
unit_id: 1,
profile: 'sdm120',
poll_interval_s: 5,
enabled: true,
last_poll_at: null,
last_poll_ok: null,
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
}
const PROFILES_RESP = {
profiles: [{ name: 'sdm120', description: 'Eastron SDM120 single-phase energy meter' }],
}
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
POST: (...args: unknown[]) => mockPost(...args),
PATCH: (...args: unknown[]) => mockPatch(...args),
DELETE: vi.fn(),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function setupProfilesMock() {
mockGet.mockImplementation((path: string) => {
if (path === '/api/modbus/profiles') {
return Promise.resolve({ data: PROFILES_RESP })
}
// modbus-devices for invalidation
return Promise.resolve({ data: { items: [], total: 0 } })
})
}
function renderCreateForm(onClose = vi.fn(), onSaved = vi.fn()) {
return renderWithProviders(<DeviceForm onClose={onClose} onSaved={onSaved} />, {
initialPath: '/',
})
}
function renderEditForm(onClose = vi.fn(), onSaved = vi.fn()) {
return renderWithProviders(<DeviceForm device={DEVICE} onClose={onClose} onSaved={onSaved} />, {
initialPath: '/',
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('DeviceForm — create mode', () => {
beforeEach(() => {
vi.clearAllMocks()
setupProfilesMock()
})
it('renders with title "New Device" and empty friendly-name input', async () => {
renderCreateForm()
await waitFor(() => {
expect(screen.getByTestId('device-form-modal')).toBeInTheDocument()
})
// Modal title
expect(screen.getByText('New Device')).toBeInTheDocument()
// UUID display should NOT be present in create mode
expect(screen.queryByTestId('device-uuid-display')).not.toBeInTheDocument()
})
it('loads profile options from GET /api/modbus/profiles', async () => {
renderCreateForm()
await waitFor(() => {
expect(screen.getByTestId('device-form')).toBeInTheDocument()
})
// Profile select should be rendered
expect(screen.getByTestId('device-profile')).toBeInTheDocument()
expect(mockGet).toHaveBeenCalledWith('/api/modbus/profiles')
})
it('shows validation error when friendly name is empty', async () => {
renderCreateForm()
await waitFor(() => expect(screen.getByTestId('device-form')).toBeInTheDocument())
fireEvent.submit(screen.getByTestId('device-form'))
await waitFor(() => {
expect(screen.getByTestId('device-form-error')).toBeInTheDocument()
})
expect(screen.getByTestId('device-form-error').textContent).toContain('Friendly name')
})
it('submit calls POST /api/modbus/devices with correct body (profile auto-selected)', async () => {
mockPost.mockResolvedValue({ data: DEVICE })
const onSaved = vi.fn()
renderCreateForm(vi.fn(), onSaved)
// Wait for profiles to load; sdm120 is auto-selected as first profile.
await waitFor(() => expect(screen.getByTestId('device-form')).toBeInTheDocument())
// Fill form (profile auto-selected to 'sdm120')
fireEvent.change(screen.getByTestId('device-friendly-name'), {
target: { value: 'My Meter' },
})
fireEvent.change(screen.getByTestId('device-host'), {
target: { value: '10.0.0.1' },
})
fireEvent.submit(screen.getByTestId('device-form'))
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
'/api/modbus/devices',
expect.objectContaining({
body: expect.objectContaining({
friendly_name: 'My Meter',
host: '10.0.0.1',
transport: 'tcp',
profile: 'sdm120',
}),
}),
)
})
})
it('cancel calls onClose without POST', async () => {
const onClose = vi.fn()
renderCreateForm(onClose)
await waitFor(() => expect(screen.getByTestId('device-form-cancel')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('device-form-cancel'))
expect(onClose).toHaveBeenCalledOnce()
expect(mockPost).not.toHaveBeenCalled()
})
})
describe('DeviceForm — edit mode', () => {
beforeEach(() => {
vi.clearAllMocks()
setupProfilesMock()
})
it('renders with title "Edit Device" and pre-filled friendly-name', async () => {
renderEditForm()
// Wait for modal to appear
await waitFor(() => {
expect(screen.getByTestId('device-form-modal')).toBeInTheDocument()
})
expect(screen.getByText('Edit Device')).toBeInTheDocument()
// Wait for form to appear (profiles must load first)
await waitFor(() => {
expect(screen.getByTestId('device-form')).toBeInTheDocument()
})
expect(screen.getByTestId('device-uuid-display').textContent).toContain('abc-123')
// Friendly name input should be pre-filled
const input = screen.getByTestId('device-friendly-name') as HTMLInputElement
expect(input.value).toBe('SDM120 Main')
})
it('submit calls PATCH /api/modbus/devices/{uuid}', async () => {
mockPatch.mockResolvedValue({ data: DEVICE })
renderEditForm()
// Wait for profiles to load so the form is visible
await waitFor(() => expect(screen.getByTestId('device-form')).toBeInTheDocument())
fireEvent.change(screen.getByTestId('device-friendly-name'), {
target: { value: 'SDM120 Updated' },
})
fireEvent.submit(screen.getByTestId('device-form'))
await waitFor(() => {
expect(mockPatch).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
params: { path: { uuid: 'abc-123' } },
body: expect.objectContaining({ friendly_name: 'SDM120 Updated' }),
})
})
})
})
+263
View File
@@ -0,0 +1,263 @@
/**
* DeviceForm — create / edit a Modbus device.
*
* Used for both new-device creation (no `device` prop) and editing an
* existing device (pass `device` prop with current values).
*
* Profile list is fetched from GET /api/modbus/profiles and rendered as a
* Select dropdown. All other fields are inline inputs.
*/
import { useState } from 'react'
import {
Modal,
Stack,
TextInput,
NumberInput,
Select,
Switch,
Button,
Group,
Alert,
Loader,
Center,
Text,
} from '@mantine/core'
import { useProfiles, useCreateDevice, useUpdateDevice } from './hooks'
import type { ModbusDevice, ModbusDeviceCreate, ModbusDeviceUpdate } from './hooks'
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface DeviceFormProps {
/** When provided, the form is in edit mode; otherwise create mode. */
device?: ModbusDevice
onClose: () => void
onSaved: () => void
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function DeviceForm({ device, onClose, onSaved }: DeviceFormProps) {
const isEdit = !!device
// Form state — initialise from device if editing.
const [friendlyName, setFriendlyName] = useState(device?.friendly_name ?? '')
const [host, setHost] = useState(device?.host ?? '')
const [port, setPort] = useState<number | string>(device?.port ?? 502)
const [unitId, setUnitId] = useState<number | string>(device?.unit_id ?? 1)
const [profile, setProfile] = useState<string | null>(device?.profile ?? null)
const [pollIntervalS, setPollIntervalS] = useState<number | string>(device?.poll_interval_s ?? 5)
const [enabled, setEnabled] = useState(device?.enabled ?? true)
const [error, setError] = useState<string | null>(null)
const profilesQuery = useProfiles()
const createMutation = useCreateDevice()
const updateMutation = useUpdateDevice()
const isBusy = createMutation.isPending || updateMutation.isPending
// Build select options from profiles response.
const profileOptions =
profilesQuery.data?.profiles.map((p) => ({
value: p.name,
label: `${p.name}${p.description}`,
})) ?? []
// In create mode, if no explicit selection has been made and profiles are loaded,
// derive a default from the first available profile. We avoid useEffect+setState
// (causes cascading renders) by computing the effective value here instead.
const effectiveProfile: string | null =
profile !== null
? profile
: !isEdit && profileOptions.length > 0
? profileOptions[0].value
: null
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
function validate(): string | null {
if (!friendlyName.trim()) return 'Friendly name is required.'
if (!host.trim()) return 'Host is required.'
const portNum = Number(port)
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
return 'Port must be an integer between 1 and 65535.'
const unitNum = Number(unitId)
if (!Number.isInteger(unitNum) || unitNum < 1 || unitNum > 247)
return 'Unit ID must be an integer between 1 and 247.'
if (!effectiveProfile) return 'Profile is required.'
const pollNum = Number(pollIntervalS)
if (!Number.isInteger(pollNum) || pollNum < 1)
return 'Poll interval must be a positive integer (seconds).'
return null
}
// ---------------------------------------------------------------------------
// Submit
// ---------------------------------------------------------------------------
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
const validationError = validate()
if (validationError) {
setError(validationError)
return
}
try {
if (isEdit && device) {
const body: ModbusDeviceUpdate = {
friendly_name: friendlyName.trim(),
host: host.trim(),
port: Number(port),
unit_id: Number(unitId),
profile: effectiveProfile!,
poll_interval_s: Number(pollIntervalS),
enabled,
}
await updateMutation.mutateAsync({ uuid: device.uuid, body })
} else {
const body: ModbusDeviceCreate = {
friendly_name: friendlyName.trim(),
transport: 'tcp',
host: host.trim(),
port: Number(port),
unit_id: Number(unitId),
profile: effectiveProfile!,
poll_interval_s: Number(pollIntervalS),
enabled,
}
await createMutation.mutateAsync(body)
}
onSaved()
onClose()
} catch {
setError('Failed to save device. Please try again.')
}
}
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<Modal
opened
onClose={onClose}
title={isEdit ? 'Edit Device' : 'New Device'}
size="md"
data-testid="device-form-modal"
>
{profilesQuery.isLoading && (
<Center py="md">
<Loader size="sm" />
</Center>
)}
{profilesQuery.isError && (
<Alert color="red" mb="sm" data-testid="profiles-load-error">
Failed to load profiles. Cannot create device.
</Alert>
)}
{!profilesQuery.isLoading && (
<form onSubmit={handleSubmit} data-testid="device-form">
<Stack gap="sm">
{isEdit && (
<Text size="xs" c="dimmed" data-testid="device-uuid-display">
UUID: {device!.uuid}
</Text>
)}
<TextInput
label="Friendly name"
required
value={friendlyName}
onChange={(e) => setFriendlyName(e.currentTarget.value)}
data-testid="device-friendly-name"
/>
<TextInput
label="Host"
description="Gateway IP address"
required
value={host}
onChange={(e) => setHost(e.currentTarget.value)}
data-testid="device-host"
/>
<NumberInput
label="Port"
required
min={1}
max={65535}
value={port}
onChange={(val) => setPort(val)}
data-testid="device-port"
/>
<NumberInput
label="Unit ID"
description="Modbus slave address (Meter ID on device panel)"
required
min={1}
max={247}
value={unitId}
onChange={(val) => setUnitId(val)}
data-testid="device-unit-id"
/>
<Select
label="Profile"
description="YAML device profile"
required
data={profileOptions}
value={effectiveProfile}
onChange={setProfile}
data-testid="device-profile"
/>
<NumberInput
label="Poll interval (seconds)"
required
min={1}
value={pollIntervalS}
onChange={(val) => setPollIntervalS(val)}
data-testid="device-poll-interval"
/>
<Switch
label="Enabled"
description="Whether this device is polled"
checked={enabled}
onChange={(e) => setEnabled(e.currentTarget.checked)}
data-testid="device-enabled"
/>
{error && (
<Alert color="red" data-testid="device-form-error">
{error}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} data-testid="device-form-cancel">
Cancel
</Button>
<Button type="submit" loading={isBusy} data-testid="device-form-submit">
{isEdit ? 'Save' : 'Create'}
</Button>
</Group>
</Stack>
</form>
)}
</Modal>
)
}
+214
View File
@@ -0,0 +1,214 @@
/**
* Tests for energy/hooks.ts — Modbus API wrappers.
*
* Coverage:
* 1. useDevices — calls GET /api/modbus/devices and returns data.
* 2. useProfiles — calls GET /api/modbus/profiles.
* 3. useCreateDevice — calls POST /api/modbus/devices with body.
* 4. useUpdateDevice — calls PATCH /api/modbus/devices/{uuid} with body.
* 5. useDeleteDevice — calls DELETE /api/modbus/devices/{uuid}.
* 6. useTestReadDevice — calls POST /api/modbus/devices/{uuid}/test (no body required).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, waitFor, act } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { ReactNode } from 'react'
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
const mockDelete = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
POST: (...args: unknown[]) => mockPost(...args),
PATCH: (...args: unknown[]) => mockPatch(...args),
DELETE: (...args: unknown[]) => mockDelete(...args),
},
ApiError: class ApiError extends Error {
status: number
body: unknown
constructor(status: number, body: unknown) {
super(`API error ${status}`)
this.name = 'ApiError'
this.status = status
this.body = body
}
},
registerLoginRedirect: vi.fn(),
}))
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const DEVICE = {
uuid: 'test-uuid-1',
friendly_name: 'SDM120 Main',
transport: 'tcp',
host: '192.168.1.100',
port: 502,
unit_id: 1,
profile: 'sdm120',
poll_interval_s: 5,
enabled: true,
last_poll_at: null,
last_poll_ok: null,
created_at: '2026-06-01T00:00:00Z',
updated_at: '2026-06-01T00:00:00Z',
}
// ---------------------------------------------------------------------------
// Wrapper
// ---------------------------------------------------------------------------
function makeWrapper() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>
}
return { qc, Wrapper }
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('useDevices', () => {
beforeEach(() => vi.clearAllMocks())
it('calls GET /api/modbus/devices and returns device list', async () => {
mockGet.mockResolvedValue({ data: { items: [DEVICE], total: 1 } })
const { Wrapper } = makeWrapper()
const { useDevices } = await import('./hooks')
const { result } = renderHook(() => useDevices(), { wrapper: Wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockGet).toHaveBeenCalledWith('/api/modbus/devices')
expect(result.current.data?.items).toHaveLength(1)
expect(result.current.data?.items[0].uuid).toBe('test-uuid-1')
})
})
describe('useProfiles', () => {
beforeEach(() => vi.clearAllMocks())
it('calls GET /api/modbus/profiles and returns profiles', async () => {
mockGet.mockResolvedValue({
data: { profiles: [{ name: 'sdm120', description: 'Eastron SDM120' }] },
})
const { Wrapper } = makeWrapper()
const { useProfiles } = await import('./hooks')
const { result } = renderHook(() => useProfiles(), { wrapper: Wrapper })
await waitFor(() => expect(result.current.isSuccess).toBe(true))
expect(mockGet).toHaveBeenCalledWith('/api/modbus/profiles')
expect(result.current.data?.profiles[0].name).toBe('sdm120')
})
})
describe('useCreateDevice', () => {
beforeEach(() => vi.clearAllMocks())
it('calls POST /api/modbus/devices with the provided body', async () => {
mockPost.mockResolvedValue({ data: DEVICE })
// LIST query to satisfy invalidation
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
const { Wrapper } = makeWrapper()
const { useCreateDevice } = await import('./hooks')
const { result } = renderHook(() => useCreateDevice(), { wrapper: Wrapper })
const body = {
friendly_name: 'SDM120 Main',
transport: 'tcp',
host: '192.168.1.100',
port: 502,
unit_id: 1,
profile: 'sdm120',
poll_interval_s: 5,
enabled: true,
}
await act(async () => {
await result.current.mutateAsync(body)
})
expect(mockPost).toHaveBeenCalledWith('/api/modbus/devices', { body })
})
})
describe('useUpdateDevice', () => {
beforeEach(() => vi.clearAllMocks())
it('calls PATCH /api/modbus/devices/{uuid} with uuid in path and body', async () => {
mockPatch.mockResolvedValue({ data: DEVICE })
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
const { Wrapper } = makeWrapper()
const { useUpdateDevice } = await import('./hooks')
const { result } = renderHook(() => useUpdateDevice(), { wrapper: Wrapper })
await act(async () => {
await result.current.mutateAsync({ uuid: 'test-uuid-1', body: { enabled: false } })
})
expect(mockPatch).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
params: { path: { uuid: 'test-uuid-1' } },
body: { enabled: false },
})
})
})
describe('useDeleteDevice', () => {
beforeEach(() => vi.clearAllMocks())
it('calls DELETE /api/modbus/devices/{uuid}', async () => {
mockDelete.mockResolvedValue({ data: null })
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
const { Wrapper } = makeWrapper()
const { useDeleteDevice } = await import('./hooks')
const { result } = renderHook(() => useDeleteDevice(), { wrapper: Wrapper })
await act(async () => {
await result.current.mutateAsync('test-uuid-1')
})
expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
params: { path: { uuid: 'test-uuid-1' } },
})
})
})
describe('useTestReadDevice', () => {
beforeEach(() => vi.clearAllMocks())
it('calls POST /api/modbus/devices/{uuid}/test with uuid in path', async () => {
mockPost.mockResolvedValue({
data: { ok: true, payload: { voltage: 230.2 } },
})
const { Wrapper } = makeWrapper()
const { useTestReadDevice } = await import('./hooks')
const { result } = renderHook(() => useTestReadDevice(), { wrapper: Wrapper })
await act(async () => {
await result.current.mutateAsync('test-uuid-1')
})
expect(mockPost).toHaveBeenCalledWith('/api/modbus/devices/{uuid}/test', {
params: { path: { uuid: 'test-uuid-1' } },
})
})
})
+114
View File
@@ -0,0 +1,114 @@
/**
* Energy / Modbus hooks — typed TanStack Query wrappers for /api/modbus/*.
*
* All write operations go through the typed apiClient (openapi-fetch), which
* injects CSRF via the csrfMiddleware already wired into the client.
*
* Query-key conventions:
* ['modbus-devices'] — device list
* ['modbus-device', uuid] — single device
* ['modbus-profiles'] — profile list (rarely changes)
*
* On success, mutations invalidate the device list so the UI refreshes.
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import apiClient from '../api/client'
import type { components } from '../api/schema.d.ts'
// ---------------------------------------------------------------------------
// Re-exported types for consumers
// ---------------------------------------------------------------------------
export type ModbusDevice = components['schemas']['ModbusDeviceResponse']
export type ModbusDeviceCreate = components['schemas']['ModbusDeviceCreate']
export type ModbusDeviceUpdate = components['schemas']['ModbusDeviceUpdate']
export type ProfileSummary = components['schemas']['ProfileSummary']
export type ModbusTestReadResponse = components['schemas']['ModbusTestReadResponse']
// ---------------------------------------------------------------------------
// Query: list all devices
// ---------------------------------------------------------------------------
export function useDevices() {
return useQuery({
queryKey: ['modbus-devices'],
queryFn: async () => {
const res = await apiClient.GET('/api/modbus/devices')
return res.data
},
})
}
// ---------------------------------------------------------------------------
// Query: list available profiles
// ---------------------------------------------------------------------------
export function useProfiles() {
return useQuery({
queryKey: ['modbus-profiles'],
queryFn: async () => {
const res = await apiClient.GET('/api/modbus/profiles')
return res.data
},
// Profiles are static — 5 min stale time.
staleTime: 5 * 60 * 1000,
})
}
// ---------------------------------------------------------------------------
// Mutation: create device
// ---------------------------------------------------------------------------
export function useCreateDevice() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: ModbusDeviceCreate) =>
apiClient.POST('/api/modbus/devices', { body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }),
})
}
// ---------------------------------------------------------------------------
// Mutation: update (PATCH) device
// ---------------------------------------------------------------------------
export function useUpdateDevice() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ uuid, body }: { uuid: string; body: ModbusDeviceUpdate }) =>
apiClient.PATCH('/api/modbus/devices/{uuid}', {
params: { path: { uuid } },
body,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }),
})
}
// ---------------------------------------------------------------------------
// Mutation: delete device
// ---------------------------------------------------------------------------
export function useDeleteDevice() {
const qc = useQueryClient()
return useMutation({
mutationFn: (uuid: string) =>
apiClient.DELETE('/api/modbus/devices/{uuid}', {
params: { path: { uuid } },
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['modbus-devices'] }),
})
}
// ---------------------------------------------------------------------------
// Mutation: test-read device (POST /devices/{uuid}/test, does NOT persist)
// ---------------------------------------------------------------------------
export function useTestReadDevice() {
return useMutation({
mutationFn: (uuid: string) =>
apiClient.POST('/api/modbus/devices/{uuid}/test', {
params: { path: { uuid } },
}),
})
}