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
+1 -1
View File
@@ -363,7 +363,7 @@ Phase CMQTT / Discovery,依赖 B 的设备数据与 provider 接口)
- **Reviewer checklist**: 删除受 RESTRICT 保护、无批量删/清表路径;查询走 `(device_id, recorded_at)` 索引;test 端点确不落库;路径键用 `uuid` - **Reviewer checklist**: 删除受 RESTRICT 保护、无批量删/清表路径;查询走 `(device_id, recorded_at)` 索引;test 端点确不落库;路径键用 `uuid`
### M5-T06 — 前端:设备管理 UIEnergy 视图) ### M5-T06 — 前端:设备管理 UIEnergy 视图)
- **Status**: `todo` · **Depends**: M5-T01, M5-T05 - **Status**: `done` · **Depends**: M5-T01, M5-T05
- **Context**: 在侧栏加 Energy 入口与 `/energy` 路由;设备增删改 + 试读。 - **Context**: 在侧栏加 Energy 入口与 `/energy` 路由;设备增删改 + 试读。
- **Files**: `create frontend/src/pages/EnergyPage.tsx``frontend/src/energy/DeviceForm.tsx``frontend/src/energy/hooks.ts``modify frontend/src/App.tsx`(路由)、`frontend/src/components/AppSidebar.tsx`Energy 项);`create` 对应 `*.test.tsx` - **Files**: `create frontend/src/pages/EnergyPage.tsx``frontend/src/energy/DeviceForm.tsx``frontend/src/energy/hooks.ts``modify frontend/src/App.tsx`(路由)、`frontend/src/components/AppSidebar.tsx`Energy 项);`create` 对应 `*.test.tsx`
- **Steps**: `useQuery`/`useMutation``/api/modbus/devices` API;列表 + 新建/编辑表单(friendly_name/host/port/unit_id/profile 下拉/poll/enabled+ 删除二次确认(删失败 409 提示改用禁用);"试读"按钮调 `POST {uuid}/test` 显示结果。 - **Steps**: `useQuery`/`useMutation``/api/modbus/devices` API;列表 + 新建/编辑表单(friendly_name/host/port/unit_id/profile 下拉/poll/enabled+ 删除二次确认(删失败 409 提示改用禁用);"试读"按钮调 `POST {uuid}/test` 显示结果。
+3
View File
@@ -10,6 +10,7 @@
* / → ProtectedRoute → AppLayout → HomePage * / → ProtectedRoute → AppLayout → HomePage
* /config → ProtectedRoute → AppLayout → ConfigPage * /config → ProtectedRoute → AppLayout → ConfigPage
* /records → ProtectedRoute → AppLayout → RecordsPage * /records → ProtectedRoute → AppLayout → RecordsPage
* /energy → ProtectedRoute → AppLayout → EnergyPage
* *
* AppLayout renders a sidebar (AppSidebar) on the left; page content via <Outlet/> on the right. * AppLayout renders a sidebar (AppSidebar) on the left; page content via <Outlet/> on the right.
* /login and /change-password have no sidebar layout. * /login and /change-password have no sidebar layout.
@@ -29,6 +30,7 @@ import { LoginPage } from './pages/LoginPage'
import { HomePage } from './pages/HomePage' import { HomePage } from './pages/HomePage'
import { ConfigPage } from './pages/ConfigPage' import { ConfigPage } from './pages/ConfigPage'
import { RecordsPage } from './pages/RecordsPage' import { RecordsPage } from './pages/RecordsPage'
import { EnergyPage } from './pages/EnergyPage'
import { ChangePasswordPage } from './pages/ChangePasswordPage' import { ChangePasswordPage } from './pages/ChangePasswordPage'
import { AppSidebar } from './components/AppSidebar' import { AppSidebar } from './components/AppSidebar'
@@ -125,6 +127,7 @@ export default function App() {
<Route index element={<HomePage />} /> <Route index element={<HomePage />} />
<Route path="/config" element={<ConfigPage />} /> <Route path="/config" element={<ConfigPage />} />
<Route path="/records" element={<RecordsPage />} /> <Route path="/records" element={<RecordsPage />} />
<Route path="/energy" element={<EnergyPage />} />
</Route> </Route>
</Routes> </Routes>
</SessionProvider> </SessionProvider>
+703
View File
@@ -210,6 +210,199 @@ export interface paths {
patch: operations["patch_poo_api_poo__timestamp__patch"]; patch: operations["patch_poo_api_poo__timestamp__patch"];
trace?: never; trace?: never;
}; };
"/api/modbus/profiles": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Profiles
* @description List all available Modbus YAML profiles (name + description).
*
* Intended for the front-end's device-creation profile drop-down.
*/
get: operations["get_profiles_api_modbus_profiles_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/modbus/devices": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Devices
* @description Return all Modbus devices (no pagination — device counts stay small).
*/
get: operations["list_devices_api_modbus_devices_get"];
put?: never;
/**
* Create Device
* @description Create a new Modbus device.
*
* - Validates that the referenced ``profile`` exists; returns 422 if not.
* - Returns 201 with the created device on success.
*/
post: operations["create_device_api_modbus_devices_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/modbus/devices/{uuid}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Device
* @description Return a single Modbus device by UUID.
*/
get: operations["get_device_api_modbus_devices__uuid__get"];
put?: never;
post?: never;
/**
* Delete Device
* @description Delete a Modbus device.
*
* Returns 409 Conflict if the device has any associated readings; use
* ``enabled=false`` to disable it instead.
*
* **Application-layer safety**: the check is performed via an explicit
* SELECT COUNT query — not by relying on SQLite's FK RESTRICT constraint,
* which is not enforced at runtime in this project (no PRAGMA foreign_keys=ON).
*/
delete: operations["delete_device_api_modbus_devices__uuid__delete"];
options?: never;
head?: never;
/**
* Patch Device
* @description Partially update a Modbus device (including enable/disable).
*
* Only fields explicitly provided in the request body are updated.
* Providing ``profile`` triggers a profile-existence check (422 if unknown).
*/
patch: operations["patch_device_api_modbus_devices__uuid__patch"];
trace?: never;
};
"/api/modbus/devices/{uuid}/latest": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Latest Reading
* @description Return the most recent reading for a device.
*
* If no readings exist yet, returns ``{"found": false, "recorded_at": null,
* "payload": null}`` (200, not 404) so the front-end can distinguish
* "device exists but has no data" from "device not found".
*/
get: operations["get_latest_reading_api_modbus_devices__uuid__latest_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/modbus/devices/{uuid}/readings": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Readings
* @description Return time-range readings for a device.
*
* Results are ordered by ``recorded_at`` ascending and capped by ``limit``
* (max {_READINGS_LIMIT_MAX}) to prevent accidental full-table exports.
*
* The query uses the ``(device_id, recorded_at)`` composite index for
* efficient time-window scans.
*
* Query parameters:
* - ``start``: inclusive lower bound (ISO8601 datetime)
* - ``end``: inclusive upper bound (ISO8601 datetime)
* - ``limit``: max rows to return (default 500, max {_READINGS_LIMIT_MAX})
*/
get: operations["get_readings_api_modbus_devices__uuid__readings_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/modbus/devices/{uuid}/metrics": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Metrics
* @description Return the metric catalogue for a device's profile.
*
* Each entry has ``key``, ``label`` (derived from key if the profile has
* none — underscores → spaces, title-cased), ``unit``, and ``device_class``.
* This is the authoritative metadata source for front-end card labels and
* chart axis labels.
*/
get: operations["get_metrics_api_modbus_devices__uuid__metrics_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/modbus/devices/{uuid}/test": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Test Read
* @description Immediately read and decode the device once without persisting any data.
*
* Useful for validating gateway connectivity and Modbus address settings
* before relying on the background polling job.
*
* This is a write-class endpoint (it initiates network I/O on demand) and
* therefore requires a CSRF token. The response payload is never stored.
*/
post: operations["test_read_api_modbus_devices__uuid__test_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/session": { "/api/session": {
parameters: { parameters: {
query?: never; query?: never;
@@ -620,6 +813,202 @@ export interface components {
/** Totp Code */ /** Totp Code */
totp_code?: string | null; totp_code?: string | null;
}; };
/**
* MetricInfo
* @description Metadata for a single measurable quantity in a device's profile.
*/
MetricInfo: {
/** Key */
key: string;
/** Label */
label: string;
/** Unit */
unit: string;
/** Device Class */
device_class: string;
};
/**
* ModbusDeviceCreate
* @description Request body for POST /api/modbus/devices.
*/
ModbusDeviceCreate: {
/** Friendly Name */
friendly_name: string;
/**
* Transport
* @default tcp
*/
transport: string;
/** Host */
host: string;
/**
* Port
* @default 502
*/
port: number;
/**
* Unit Id
* @default 1
*/
unit_id: number;
/** Profile */
profile: string;
/**
* Poll Interval S
* @default 5
*/
poll_interval_s: number;
/**
* Enabled
* @default true
*/
enabled: boolean;
};
/**
* ModbusDeviceListResponse
* @description Response schema for listing Modbus devices.
*/
ModbusDeviceListResponse: {
/** Items */
items: components["schemas"]["ModbusDeviceResponse"][];
/** Total */
total: number;
};
/**
* ModbusDeviceResponse
* @description Response schema for a single Modbus device.
*/
ModbusDeviceResponse: {
/** Uuid */
uuid: string;
/** Friendly Name */
friendly_name: string;
/** Transport */
transport: string;
/** Host */
host: string;
/** Port */
port: number;
/** Unit Id */
unit_id: number;
/** Profile */
profile: string;
/** Poll Interval S */
poll_interval_s: number;
/** Enabled */
enabled: boolean;
/** Last Poll At */
last_poll_at: string | null;
/** Last Poll Ok */
last_poll_ok: boolean | null;
/**
* Created At
* Format: date-time
*/
created_at: string;
/**
* Updated At
* Format: date-time
*/
updated_at: string;
};
/**
* ModbusDeviceUpdate
* @description Request body for PATCH /api/modbus/devices/{uuid} — all fields optional.
*/
ModbusDeviceUpdate: {
/** Friendly Name */
friendly_name?: string | null;
/** Transport */
transport?: string | null;
/** Host */
host?: string | null;
/** Port */
port?: number | null;
/** Unit Id */
unit_id?: number | null;
/** Profile */
profile?: string | null;
/** Poll Interval S */
poll_interval_s?: number | null;
/** Enabled */
enabled?: boolean | null;
};
/**
* ModbusLatestResponse
* @description Response for the /latest endpoint.
*
* ``found`` is False and ``recorded_at``/``payload`` are None when the device
* has no readings yet. Callers should check ``found`` before using the values.
*/
ModbusLatestResponse: {
/** Found */
found: boolean;
/** Recorded At */
recorded_at: string | null;
/** Payload */
payload: {
[key: string]: unknown;
} | null;
};
/**
* ModbusMetricsResponse
* @description Response schema for GET /api/modbus/devices/{uuid}/metrics.
*/
ModbusMetricsResponse: {
/** Profile */
profile: string;
/** Metrics */
metrics: components["schemas"]["MetricInfo"][];
};
/**
* ModbusProfilesResponse
* @description Response schema for GET /api/modbus/profiles.
*/
ModbusProfilesResponse: {
/** Profiles */
profiles: components["schemas"]["ProfileSummary"][];
};
/**
* ModbusReadingResponse
* @description A single reading row: timestamp + decoded payload.
*/
ModbusReadingResponse: {
/**
* Recorded At
* Format: date-time
*/
recorded_at: string;
/** Payload */
payload: {
[key: string]: unknown;
};
};
/**
* ModbusReadingsResponse
* @description Response schema for the readings time-range endpoint.
*/
ModbusReadingsResponse: {
/** Items */
items: components["schemas"]["ModbusReadingResponse"][];
};
/**
* ModbusTestReadResponse
* @description Response for POST /api/modbus/devices/{uuid}/test.
*
* On success ``ok=True`` and ``payload`` contains the decoded values.
* On failure ``ok=False`` and ``error`` describes the problem.
*/
ModbusTestReadResponse: {
/** Ok */
ok: boolean;
/** Payload */
payload?: {
[key: string]: unknown;
} | null;
/** Error */
error?: string | null;
};
/** PasswordChangeRequest */ /** PasswordChangeRequest */
PasswordChangeRequest: { PasswordChangeRequest: {
/** Current Password */ /** Current Password */
@@ -661,6 +1050,16 @@ export interface components {
/** Longitude */ /** Longitude */
longitude?: number | null; longitude?: number | null;
}; };
/**
* ProfileSummary
* @description One entry in the GET /api/modbus/profiles response.
*/
ProfileSummary: {
/** Name */
name: string;
/** Description */
description: string;
};
/** PublicIPCheckResponse */ /** PublicIPCheckResponse */
PublicIPCheckResponse: { PublicIPCheckResponse: {
/** /**
@@ -1177,6 +1576,310 @@ export interface operations {
}; };
}; };
}; };
get_profiles_api_modbus_profiles_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ModbusProfilesResponse"];
};
};
};
};
list_devices_api_modbus_devices_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ModbusDeviceListResponse"];
};
};
};
};
create_device_api_modbus_devices_post: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ModbusDeviceCreate"];
};
};
responses: {
/** @description Successful Response */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ModbusDeviceResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_device_api_modbus_devices__uuid__get: {
parameters: {
query?: never;
header?: never;
path: {
uuid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ModbusDeviceResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_device_api_modbus_devices__uuid__delete: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path: {
uuid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
patch_device_api_modbus_devices__uuid__patch: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path: {
uuid: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ModbusDeviceUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ModbusDeviceResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_latest_reading_api_modbus_devices__uuid__latest_get: {
parameters: {
query?: never;
header?: never;
path: {
uuid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ModbusLatestResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_readings_api_modbus_devices__uuid__readings_get: {
parameters: {
query?: {
start?: string | null;
end?: string | null;
limit?: number;
};
header?: never;
path: {
uuid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ModbusReadingsResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_metrics_api_modbus_devices__uuid__metrics_get: {
parameters: {
query?: never;
header?: never;
path: {
uuid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ModbusMetricsResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
test_read_api_modbus_devices__uuid__test_post: {
parameters: {
query?: never;
header?: {
"X-CSRF-Token"?: string | null;
};
path: {
uuid: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ModbusTestReadResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_session_api_session_get: { get_session_api_session_get: {
parameters: { parameters: {
query?: never; query?: never;
+15 -3
View File
@@ -1,15 +1,16 @@
/** /**
* Tests for AppSidebar (M5-T01). * Tests for AppSidebar (M5-T01, updated M5-T06).
* *
* Strategy: render AppSidebar inside a minimal AppShell (required by * Strategy: render AppSidebar inside a minimal AppShell (required by
* AppShell.Navbar) + MemoryRouter so useLocation() works. * AppShell.Navbar) + MemoryRouter so useLocation() works.
* *
* Coverage: * Coverage:
* 1. All three nav items render (Home, Records, Config). * 1. All four nav items render (Home, Records, Energy, Config).
* 2. Home nav item is active when pathname is '/'. * 2. Home nav item is active when pathname is '/'.
* 3. Records nav item is active when pathname is '/records'. * 3. Records nav item is active when pathname is '/records'.
* 4. Config nav item is active when pathname is '/config'. * 4. Config nav item is active when pathname is '/config'.
* 5. Home is NOT active when on '/records'. * 5. Home is NOT active when on '/records'.
* 6. Energy nav item is active when on '/energy'.
*/ */
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
@@ -76,10 +77,11 @@ describe('AppSidebar', () => {
// 1. All nav items render // 1. All nav items render
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
it('renders Home, Records, and Config nav items', () => { it('renders Home, Records, Energy, and Config nav items', () => {
renderSidebar('/') renderSidebar('/')
expect(screen.getByTestId('nav-home')).toBeInTheDocument() expect(screen.getByTestId('nav-home')).toBeInTheDocument()
expect(screen.getByTestId('nav-records')).toBeInTheDocument() expect(screen.getByTestId('nav-records')).toBeInTheDocument()
expect(screen.getByTestId('nav-energy')).toBeInTheDocument()
expect(screen.getByTestId('nav-config')).toBeInTheDocument() expect(screen.getByTestId('nav-config')).toBeInTheDocument()
}) })
@@ -123,4 +125,14 @@ describe('AppSidebar', () => {
const homeLink = screen.getByTestId('nav-home') const homeLink = screen.getByTestId('nav-home')
expect(homeLink).not.toHaveAttribute('data-active', 'true') expect(homeLink).not.toHaveAttribute('data-active', 'true')
}) })
// -------------------------------------------------------------------------
// 6. Energy active on '/energy'
// -------------------------------------------------------------------------
it('marks Energy nav item as active when on "/energy"', () => {
renderSidebar('/energy')
const energyLink = screen.getByTestId('nav-energy')
expect(energyLink).toHaveAttribute('data-active', 'true')
})
}) })
+8 -4
View File
@@ -1,18 +1,16 @@
/** /**
* AppSidebar — vertical sidebar navigation for all protected pages. * AppSidebar — vertical sidebar navigation for all protected pages.
* *
* Nav items: Home / Records / Config * Nav items: Home / Records / Energy / Config
* Utilities: ColorSchemeToggle + LogoutButton (at bottom) * Utilities: ColorSchemeToggle + LogoutButton (at bottom)
* *
* Current route is highlighted via useLocation(). * Current route is highlighted via useLocation().
* Mobile: burger button toggles the navbar open/closed (handled by AppShell context). * Mobile: burger button toggles the navbar open/closed (handled by AppShell context).
*
* NOTE: Energy nav item is intentionally absent — added in M5-T06 once /energy exists.
*/ */
import { NavLink, Stack, Divider, Tooltip, ActionIcon, useMantineColorScheme, useComputedColorScheme, AppShell, Text, Group } from '@mantine/core' import { NavLink, Stack, Divider, Tooltip, ActionIcon, useMantineColorScheme, useComputedColorScheme, AppShell, Text, Group } from '@mantine/core'
import { Link, useLocation, useNavigate } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import { Home, List, Settings, Sun, Moon, LogOut } from 'react-feather' import { Home, List, Settings, Sun, Moon, LogOut, Zap } from 'react-feather'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import apiClient from '../api/client' import apiClient from '../api/client'
@@ -40,6 +38,12 @@ const NAV_ENTRIES: NavEntry[] = [
icon: <List size={18} />, icon: <List size={18} />,
testId: 'nav-records', testId: 'nav-records',
}, },
{
to: '/energy',
label: 'Energy',
icon: <Zap size={18} />,
testId: 'nav-energy',
},
{ {
to: '/config', to: '/config',
label: 'Config', label: 'Config',
+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 } },
}),
})
}
+343
View File
@@ -0,0 +1,343 @@
/**
* Tests for EnergyPage (M5-T06).
*
* Coverage:
* 1. Renders device list from GET /api/modbus/devices.
* 2. Empty state when no devices.
* 3. "New Device" button opens DeviceForm modal.
* 4. Edit button opens DeviceForm modal pre-filled with device data.
* 5. Delete button opens confirmation modal; confirm calls DELETE.
* 6. Delete 409 error shows friendly hint in confirmation modal.
* 7. Test-read button calls POST /api/modbus/devices/{uuid}/test and shows result.
* 8. Test-read failure shows error in modal.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
import { EnergyPage } from './EnergyPage'
// ---------------------------------------------------------------------------
// 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: '2026-06-22T10:00:00Z',
last_poll_ok: true,
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 mockDelete = 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: (...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(),
}))
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function setupDefaultMocks(devices = [DEVICE]) {
mockGet.mockImplementation((path: string) => {
if (path === '/api/modbus/devices') {
return Promise.resolve({ data: { items: devices, total: devices.length } })
}
if (path === '/api/modbus/profiles') {
return Promise.resolve({ data: PROFILES_RESP })
}
return Promise.resolve({ data: null })
})
}
function renderEnergy() {
return renderWithProviders(<EnergyPage />, { initialPath: '/energy' })
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('EnergyPage — device list', () => {
beforeEach(() => {
vi.clearAllMocks()
setupDefaultMocks()
})
it('renders device list from GET /api/modbus/devices', async () => {
renderEnergy()
await waitFor(() => {
expect(screen.getByTestId('devices-table')).toBeInTheDocument()
})
expect(screen.getByText('SDM120 Main')).toBeInTheDocument()
expect(screen.getByText('192.168.1.100')).toBeInTheDocument()
expect(screen.getByText('sdm120')).toBeInTheDocument()
})
it('shows empty state when no devices', async () => {
setupDefaultMocks([])
renderEnergy()
await waitFor(() => {
expect(screen.getByTestId('devices-empty')).toBeInTheDocument()
})
})
it('shows loading state initially', () => {
// Delay resolution so we can catch the loading state
mockGet.mockReturnValue(new Promise(() => {}))
renderEnergy()
expect(screen.getByTestId('energy-loading')).toBeInTheDocument()
})
})
describe('EnergyPage — create device', () => {
beforeEach(() => {
vi.clearAllMocks()
setupDefaultMocks()
})
it('opens DeviceForm modal when New Device is clicked', async () => {
renderEnergy()
await waitFor(() => {
expect(screen.getByTestId('device-new-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('device-new-button'))
// Wait for both modal AND the form (which appears after profiles load)
await waitFor(() => {
expect(screen.getByTestId('device-form-modal')).toBeInTheDocument()
})
await waitFor(() => {
expect(screen.getByTestId('device-form')).toBeInTheDocument()
})
expect(screen.getByTestId('device-friendly-name')).toBeInTheDocument()
expect(screen.getByTestId('device-host')).toBeInTheDocument()
})
it('submit on new device form calls POST /api/modbus/devices', async () => {
mockPost.mockResolvedValue({ data: DEVICE })
renderEnergy()
await waitFor(() => expect(screen.getByTestId('device-new-button')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('device-new-button'))
// Wait for profiles to load so the form is visible (profiles auto-select sdm120)
await waitFor(() => expect(screen.getByTestId('device-form')).toBeInTheDocument())
// Fill required fields (profile is auto-selected to 'sdm120')
fireEvent.change(screen.getByTestId('device-friendly-name'), {
target: { value: 'Test Device' },
})
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: 'Test Device',
host: '10.0.0.1',
profile: 'sdm120',
}),
}),
)
})
})
})
describe('EnergyPage — edit device', () => {
beforeEach(() => {
vi.clearAllMocks()
setupDefaultMocks()
})
it('opens DeviceForm modal with device data when Edit is clicked', async () => {
renderEnergy()
await waitFor(() => {
expect(screen.getByTestId(`device-edit-${DEVICE.uuid}`)).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(`device-edit-${DEVICE.uuid}`))
await waitFor(() => {
expect(screen.getByTestId('device-form-modal')).toBeInTheDocument()
})
// Wait for profiles to load so form body is visible
await waitFor(() => {
expect(screen.getByTestId('device-form')).toBeInTheDocument()
})
// UUID shown in edit mode
expect(screen.getByTestId('device-uuid-display').textContent).toContain(DEVICE.uuid)
})
})
describe('EnergyPage — delete device', () => {
beforeEach(() => {
vi.clearAllMocks()
setupDefaultMocks()
})
it('shows confirmation modal on Delete click', async () => {
renderEnergy()
await waitFor(() => {
expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
await waitFor(() => {
expect(screen.getByTestId('device-delete-modal')).toBeInTheDocument()
})
expect(screen.getByTestId('device-delete-message').textContent).toContain('SDM120 Main')
})
it('calls DELETE on confirm and closes modal', async () => {
mockDelete.mockResolvedValue({ data: null })
renderEnergy()
await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
await waitFor(() => expect(screen.getByTestId('device-delete-confirm')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('device-delete-confirm'))
await waitFor(() => {
expect(mockDelete).toHaveBeenCalledWith('/api/modbus/devices/{uuid}', {
params: { path: { uuid: DEVICE.uuid } },
})
})
})
it('cancel button closes confirmation modal', async () => {
renderEnergy()
await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
await waitFor(() => expect(screen.getByTestId('device-delete-cancel')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('device-delete-cancel'))
await waitFor(() => {
expect(screen.queryByTestId('device-delete-modal')).not.toBeInTheDocument()
})
})
it('shows friendly 409 hint when device has readings', async () => {
const { ApiError } = await import('../api/client')
mockDelete.mockRejectedValue(new ApiError(409, { detail: 'device has readings' }))
renderEnergy()
await waitFor(() => expect(screen.getByTestId(`device-delete-${DEVICE.uuid}`)).toBeInTheDocument())
fireEvent.click(screen.getByTestId(`device-delete-${DEVICE.uuid}`))
await waitFor(() => expect(screen.getByTestId('device-delete-confirm')).toBeInTheDocument())
fireEvent.click(screen.getByTestId('device-delete-confirm'))
await waitFor(() => {
expect(screen.getByTestId('device-delete-409-hint')).toBeInTheDocument()
})
})
})
describe('EnergyPage — test-read', () => {
beforeEach(() => {
vi.clearAllMocks()
setupDefaultMocks()
})
it('shows success payload modal after successful test-read', async () => {
mockPost.mockResolvedValue({
data: { ok: true, payload: { voltage: 230.2, current: 1.3 } },
})
renderEnergy()
await waitFor(() => {
expect(screen.getByTestId(`device-test-${DEVICE.uuid}`)).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(`device-test-${DEVICE.uuid}`))
await waitFor(() => {
expect(screen.getByTestId('test-read-modal')).toBeInTheDocument()
})
expect(screen.getByTestId('test-read-ok')).toBeInTheDocument()
expect(screen.getByTestId('test-read-payload').textContent).toContain('230.2')
})
it('shows error modal after failed test-read', async () => {
mockPost.mockResolvedValue({
data: { ok: false, error: 'Connection timed out' },
})
renderEnergy()
await waitFor(() => {
expect(screen.getByTestId(`device-test-${DEVICE.uuid}`)).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(`device-test-${DEVICE.uuid}`))
await waitFor(() => {
expect(screen.getByTestId('test-read-modal')).toBeInTheDocument()
})
expect(screen.getByTestId('test-read-error')).toBeInTheDocument()
expect(screen.getByTestId('test-read-error').textContent).toContain('Connection timed out')
})
})
+396
View File
@@ -0,0 +1,396 @@
/**
* EnergyPage — device management UI for the Energy (Modbus) domain.
*
* Features:
* - Device list with status indicators (enabled, last poll).
* - Create / edit via DeviceForm modal.
* - Delete with二次确认; 409 (has readings) → friendly prompt to disable instead.
* - "Test read" button per device — calls POST /devices/{uuid}/test, shows
* decoded payload or error without persisting to the database.
*
* Out of scope (T07): readings cards, trend charts, Recharts import.
*/
import { useState } from 'react'
import {
Container,
Title,
Table,
Button,
Group,
Text,
Loader,
Center,
Alert,
Stack,
Badge,
ScrollArea,
Modal,
Code,
Tooltip,
} from '@mantine/core'
import { useDevices, useDeleteDevice, useTestReadDevice } from '../energy/hooks'
import { DeviceForm } from '../energy/DeviceForm'
import type { ModbusDevice, ModbusTestReadResponse } from '../energy/hooks'
import { ApiError } from '../api/client'
// ---------------------------------------------------------------------------
// Delete confirmation modal (with 409 "disable instead" hint)
// ---------------------------------------------------------------------------
interface ConfirmDeleteProps {
device: ModbusDevice
onConfirm: () => void
onCancel: () => void
loading: boolean
/** Set when the delete failed with 409. */
has409Error: boolean
}
function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error }: ConfirmDeleteProps) {
return (
<Modal
opened
onClose={onCancel}
title="Confirm Delete"
size="sm"
data-testid="device-delete-modal"
>
<Stack gap="md">
<Text data-testid="device-delete-message">
Delete device <strong>{device.friendly_name}</strong>?
</Text>
{has409Error && (
<Alert color="orange" data-testid="device-delete-409-hint">
This device has existing readings and cannot be deleted. Consider{' '}
<strong>disabling</strong> it instead (click Edit uncheck Enabled).
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onCancel} data-testid="device-delete-cancel">
Cancel
</Button>
<Button
color="red"
loading={loading}
onClick={onConfirm}
data-testid="device-delete-confirm"
>
Delete
</Button>
</Group>
</Stack>
</Modal>
)
}
// ---------------------------------------------------------------------------
// Test-read result modal
// ---------------------------------------------------------------------------
interface TestResultModalProps {
result: ModbusTestReadResponse
deviceName: string
onClose: () => void
}
function TestResultModal({ result, deviceName, onClose }: TestResultModalProps) {
return (
<Modal
opened
onClose={onClose}
title={`Test read — ${deviceName}`}
size="md"
data-testid="test-read-modal"
>
{result.ok ? (
<Stack gap="sm">
<Badge color="green" data-testid="test-read-ok">Success</Badge>
<Code block data-testid="test-read-payload">
{JSON.stringify(result.payload, null, 2)}
</Code>
</Stack>
) : (
<Stack gap="sm">
<Badge color="red" data-testid="test-read-error-badge">Failed</Badge>
<Text c="red" data-testid="test-read-error">
{result.error ?? 'Unknown error'}
</Text>
</Stack>
)}
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onClose}>
Close
</Button>
</Group>
</Modal>
)
}
// ---------------------------------------------------------------------------
// Device row action: test-read button (self-contained per row)
// ---------------------------------------------------------------------------
interface TestReadButtonProps {
device: ModbusDevice
}
function TestReadButton({ device }: TestReadButtonProps) {
const testMutation = useTestReadDevice()
const [result, setResult] = useState<ModbusTestReadResponse | null>(null)
async function handleTest() {
setResult(null)
const res = await testMutation.mutateAsync(device.uuid)
if (res.data) {
setResult(res.data)
}
}
return (
<>
<Tooltip label="Immediately read device (not saved)" position="top">
<Button
size="xs"
variant="outline"
color="blue"
loading={testMutation.isPending}
onClick={handleTest}
data-testid={`device-test-${device.uuid}`}
>
Test read
</Button>
</Tooltip>
{result && (
<TestResultModal
result={result}
deviceName={device.friendly_name}
onClose={() => setResult(null)}
/>
)}
</>
)
}
// ---------------------------------------------------------------------------
// Device list table
// ---------------------------------------------------------------------------
interface DeviceTableProps {
devices: ModbusDevice[]
onEdit: (device: ModbusDevice) => void
onDelete: (device: ModbusDevice) => void
}
function DeviceTable({ devices, onEdit, onDelete }: DeviceTableProps) {
if (devices.length === 0) {
return (
<Text c="dimmed" ta="center" size="sm" data-testid="devices-empty">
No devices configured yet. Click "New Device" to add one.
</Text>
)
}
return (
<ScrollArea>
<Table striped highlightOnHover withTableBorder data-testid="devices-table">
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Host</Table.Th>
<Table.Th>Port</Table.Th>
<Table.Th>Unit ID</Table.Th>
<Table.Th>Profile</Table.Th>
<Table.Th>Poll (s)</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{devices.map((device) => (
<Table.Tr key={device.uuid} data-testid={`device-row-${device.uuid}`}>
<Table.Td>
<Text fw={500} size="sm">
{device.friendly_name}
</Text>
</Table.Td>
<Table.Td>{device.host}</Table.Td>
<Table.Td>{device.port}</Table.Td>
<Table.Td>{device.unit_id}</Table.Td>
<Table.Td>
<Badge variant="outline" size="sm">
{device.profile}
</Badge>
</Table.Td>
<Table.Td>{device.poll_interval_s}</Table.Td>
<Table.Td>
<Group gap="xs">
<Badge color={device.enabled ? 'green' : 'gray'} variant="light" size="sm">
{device.enabled ? 'enabled' : 'disabled'}
</Badge>
{device.last_poll_ok !== null && (
<Badge
color={device.last_poll_ok ? 'teal' : 'red'}
variant="dot"
size="sm"
>
{device.last_poll_ok ? 'online' : 'offline'}
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Group justify="flex-end" gap="xs">
<TestReadButton device={device} />
<Button
size="xs"
variant="outline"
onClick={() => onEdit(device)}
data-testid={`device-edit-${device.uuid}`}
>
Edit
</Button>
<Button
size="xs"
variant="outline"
color="red"
onClick={() => onDelete(device)}
data-testid={`device-delete-${device.uuid}`}
>
Delete
</Button>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
)
}
// ---------------------------------------------------------------------------
// EnergyPage — top-level
// ---------------------------------------------------------------------------
export function EnergyPage() {
const devicesQuery = useDevices()
const deleteMutation = useDeleteDevice()
const [editDevice, setEditDevice] = useState<ModbusDevice | null>(null)
const [showCreateForm, setShowCreateForm] = useState(false)
const [deleteDevice, setDeleteDevice] = useState<ModbusDevice | null>(null)
const [delete409, setDelete409] = useState(false)
function openCreate() {
setEditDevice(null)
setShowCreateForm(true)
}
function openEdit(device: ModbusDevice) {
setShowCreateForm(false)
setEditDevice(device)
}
function openDelete(device: ModbusDevice) {
setDeleteDevice(device)
setDelete409(false)
}
function closeDelete() {
setDeleteDevice(null)
setDelete409(false)
}
async function handleDeleteConfirm() {
if (!deleteDevice) return
setDelete409(false)
try {
await deleteMutation.mutateAsync(deleteDevice.uuid)
closeDelete()
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setDelete409(true)
}
// Leave modal open so user sees the hint.
}
}
// ---------------------------------------------------------------------------
// Loading / error states
// ---------------------------------------------------------------------------
if (devicesQuery.isLoading) {
return (
<Center pt="xl" data-testid="energy-loading">
<Loader />
</Center>
)
}
if (devicesQuery.isError || !devicesQuery.data) {
return (
<Container size="xl" pt="xl">
<Alert color="red" data-testid="energy-load-error">
Failed to load devices. Please refresh.
</Alert>
</Container>
)
}
const devices = devicesQuery.data.items
// ---------------------------------------------------------------------------
// Main render
// ---------------------------------------------------------------------------
return (
<Container size="xl" pt="xl" pb="xl" data-testid="energy-page">
<Stack gap="lg">
<Group justify="space-between" align="center">
<Title order={2}>Energy Devices</Title>
<Button onClick={openCreate} data-testid="device-new-button">
New Device
</Button>
</Group>
<DeviceTable
devices={devices}
onEdit={openEdit}
onDelete={openDelete}
/>
</Stack>
{/* Create form */}
{showCreateForm && (
<DeviceForm
onClose={() => setShowCreateForm(false)}
onSaved={() => setShowCreateForm(false)}
/>
)}
{/* Edit form */}
{editDevice && (
<DeviceForm
device={editDevice}
onClose={() => setEditDevice(null)}
onSaved={() => setEditDevice(null)}
/>
)}
{/* Delete confirmation */}
{deleteDevice && (
<ConfirmDeleteModal
device={deleteDevice}
onConfirm={handleDeleteConfirm}
onCancel={closeDelete}
loading={deleteMutation.isPending}
has409Error={delete409}
/>
)}
</Container>
)
}