M5: Energy view — per-metric decimal formatting and chart auto-refresh toggle

This commit is contained in:
2026-06-22 19:18:29 +02:00
parent 4767a7af60
commit 935e68846c
7 changed files with 386 additions and 26 deletions
+48 -1
View File
@@ -1,5 +1,5 @@
/**
* Tests for EnergyPage (M5-T06).
* Tests for EnergyPage (M5-T06 + M5-polish).
*
* Coverage:
* 1. Renders device list from GET /api/modbus/devices.
@@ -10,6 +10,7 @@
* 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.
* 9. Auto-refresh switch is rendered (default on) and can be toggled.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
@@ -321,6 +322,7 @@ describe('EnergyPage — test-read', () => {
})
expect(screen.getByTestId('test-read-ok')).toBeInTheDocument()
// Values rendered via formatMetricValue — voltage 230.2 → "230.20"
expect(screen.getByTestId('test-read-payload').textContent).toContain('230.2')
})
@@ -345,3 +347,48 @@ describe('EnergyPage — test-read', () => {
expect(screen.getByTestId('test-read-error').textContent).toContain('Connection timed out')
})
})
describe('EnergyPage — auto-refresh switch', () => {
beforeEach(() => {
vi.clearAllMocks()
setupDefaultMocks()
})
it('renders auto-refresh switch when devices exist', async () => {
renderEnergy()
await waitFor(() => {
expect(screen.getByTestId('auto-refresh-switch')).toBeInTheDocument()
})
})
it('auto-refresh switch is checked by default (default on)', async () => {
renderEnergy()
await waitFor(() => {
expect(screen.getByTestId('auto-refresh-switch')).toBeInTheDocument()
})
// Mantine Switch uses role="switch" and places data-testid directly on the
// <input> element; query it by the testid or by role.
const switchInput = screen.getByRole('switch', { name: /自动刷新/i })
expect(switchInput).toBeChecked()
})
it('toggling auto-refresh switch changes its checked state', async () => {
renderEnergy()
await waitFor(() => {
expect(screen.getByTestId('auto-refresh-switch')).toBeInTheDocument()
})
const switchInput = screen.getByRole('switch', { name: /自动刷新/i })
expect(switchInput).toBeChecked()
fireEvent.click(switchInput)
await waitFor(() => {
expect(switchInput).not.toBeChecked()
})
})
})
+66 -15
View File
@@ -33,12 +33,14 @@ import {
Paper,
SimpleGrid,
Divider,
Switch,
} from '@mantine/core'
import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks'
import { DeviceForm } from '../energy/DeviceForm'
import { EnergyCharts } from '../energy/EnergyCharts'
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
import { ApiError } from '../api/client'
import { formatMetricValue } from '../energy/format'
// ---------------------------------------------------------------------------
// Delete confirmation modal (with 409 "disable instead" hint)
@@ -99,10 +101,11 @@ function ConfirmDeleteModal({ device, onConfirm, onCancel, loading, has409Error
interface TestResultModalProps {
result: ModbusTestReadResponse
deviceName: string
metrics?: MetricInfo[]
onClose: () => void
}
function TestResultModal({ result, deviceName, onClose }: TestResultModalProps) {
function TestResultModal({ result, deviceName, metrics, onClose }: TestResultModalProps) {
return (
<Modal
opened
@@ -114,9 +117,33 @@ function TestResultModal({ result, deviceName, onClose }: TestResultModalProps)
{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>
{result.payload && typeof result.payload === 'object' ? (
<SimpleGrid cols={2} spacing="xs" data-testid="test-read-payload">
{Object.entries(result.payload as Record<string, unknown>).map(([key, val]) => {
const metricMeta = metrics?.find((m) => m.key === key)
return (
<Group key={key} justify="space-between" align="baseline" gap={4}
data-testid={`test-read-value-${key}`}>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{metricMeta?.label ?? key}
</Text>
<Text size="sm" fw={500}>
{formatMetricValue(val, metricMeta)}
{metricMeta?.unit ? (
<Text component="span" size="xs" c="dimmed" ml={2}>
{metricMeta.unit}
</Text>
) : null}
</Text>
</Group>
)
})}
</SimpleGrid>
) : (
<Code block data-testid="test-read-payload">
{JSON.stringify(result.payload, null, 2)}
</Code>
)}
</Stack>
) : (
<Stack gap="sm">
@@ -147,6 +174,7 @@ interface TestReadButtonProps {
function TestReadButton({ device }: TestReadButtonProps) {
const testMutation = useTestReadDevice()
const metricsQuery = useMetrics(device.uuid)
const [result, setResult] = useState<ModbusTestReadResponse | null>(null)
const [testError, setTestError] = useState<string | null>(null)
@@ -209,6 +237,7 @@ function TestReadButton({ device }: TestReadButtonProps) {
<TestResultModal
result={result}
deviceName={device.friendly_name}
metrics={metricsQuery.data?.metrics}
onClose={() => setResult(null)}
/>
)}
@@ -223,10 +252,11 @@ function TestReadButton({ device }: TestReadButtonProps) {
interface LatestReadingsCardProps {
device: ModbusDevice
autoRefresh?: { refetchIntervalMs: number }
}
function LatestReadingsCard({ device }: LatestReadingsCardProps) {
const latestQuery = useLatestReading(device.uuid)
function LatestReadingsCard({ device, autoRefresh }: LatestReadingsCardProps) {
const latestQuery = useLatestReading(device.uuid, autoRefresh)
const metricsQuery = useMetrics(device.uuid)
const metrics: MetricInfo[] = metricsQuery.data?.metrics ?? []
@@ -282,12 +312,8 @@ function LatestReadingsCard({ device }: LatestReadingsCardProps) {
<SimpleGrid cols={2} spacing="xs">
{metrics.map((m) => {
const raw = payload[m.key]
const hasValue = raw !== null && raw !== undefined
const displayValue = hasValue
? typeof raw === 'number'
? raw.toFixed(raw % 1 === 0 ? 0 : 2)
: String(raw)
: '—'
const displayValue = formatMetricValue(raw, m)
const hasValue = displayValue !== '—'
return (
<Group key={m.key} justify="space-between" align="baseline" gap={4}
@@ -411,25 +437,50 @@ function DeviceTable({ devices, onEdit, onDelete }: DeviceTableProps) {
// ---------------------------------------------------------------------------
// Device readings section — latest card + trend chart per device (T07)
// Auto-refresh switch controls TanStack Query refetchInterval for all
// readings and latest queries in this section.
// ---------------------------------------------------------------------------
/** Default auto-refresh interval in milliseconds (matches backend ~5s poll). */
const AUTO_REFRESH_INTERVAL_MS = 5_000
interface DeviceReadingsSectionProps {
devices: ModbusDevice[]
}
function DeviceReadingsSection({ devices }: DeviceReadingsSectionProps) {
// Default on — user wants charts to auto-update without manual refresh.
const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true)
if (devices.length === 0) return null
const autoRefresh = autoRefreshEnabled
? { refetchIntervalMs: AUTO_REFRESH_INTERVAL_MS }
: undefined
return (
<Stack gap="xl" data-testid="device-readings-section">
<Divider label="Readings & Trends" labelPosition="left" />
<Group justify="space-between" align="center">
<Divider label="Readings & Trends" labelPosition="left" style={{ flex: 1 }} />
<Switch
label="自动刷新"
checked={autoRefreshEnabled}
onChange={(e) => setAutoRefreshEnabled(e.currentTarget.checked)}
size="sm"
data-testid="auto-refresh-switch"
/>
</Group>
{devices.map((device) => (
<Stack key={device.uuid} gap="sm" data-testid={`device-readings-${device.uuid}`}>
<Text fw={500} size="sm">
{device.friendly_name}
</Text>
<LatestReadingsCard device={device} />
<EnergyCharts uuid={device.uuid} deviceName={device.friendly_name} />
<LatestReadingsCard device={device} autoRefresh={autoRefresh} />
<EnergyCharts
uuid={device.uuid}
deviceName={device.friendly_name}
autoRefresh={autoRefresh}
/>
</Stack>
))}
</Stack>