M5: Energy view — per-metric decimal formatting and chart auto-refresh toggle
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user