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
+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' } },
})
})
})