diff --git a/frontend/src/energy/EnergyCharts.tsx b/frontend/src/energy/EnergyCharts.tsx index 3a78721..f476f6d 100644 --- a/frontend/src/energy/EnergyCharts.tsx +++ b/frontend/src/energy/EnergyCharts.tsx @@ -41,7 +41,8 @@ import { } from '@mantine/core' import { useReadings, useMetrics } from './hooks' -import type { MetricInfo } from './hooks' +import type { MetricInfo, AutoRefreshOptions } from './hooks' +import { formatMetricValue } from './format' // --------------------------------------------------------------------------- // Constants @@ -59,6 +60,8 @@ interface EnergyChartsProps { uuid: string /** Friendly name for the chart title. */ deviceName: string + /** Auto-refresh options forwarded to useReadings. */ + autoRefresh?: AutoRefreshOptions } // --------------------------------------------------------------------------- @@ -138,7 +141,7 @@ function safePayloadValue( // EnergyCharts component // --------------------------------------------------------------------------- -export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) { +export function EnergyCharts({ uuid, deviceName, autoRefresh }: EnergyChartsProps) { const [activePreset, setActivePreset] = useState(DEFAULT_PRESET) const selectedPreset = TIME_PRESETS.find((p) => p.value === activePreset) ?? TIME_PRESETS[0] @@ -150,7 +153,7 @@ export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) { [activePreset], ) - const readingsQuery = useReadings(uuid, { start, end, limit: CHART_READINGS_LIMIT }) + const readingsQuery = useReadings(uuid, { start, end, limit: CHART_READINGS_LIMIT }, autoRefresh) const metricsQuery = useMetrics(uuid) // ------------------------------------------------------------------------- @@ -295,10 +298,10 @@ export function EnergyCharts({ uuid, deviceName }: EnergyChartsProps) { /> { - const n = value as number | null | undefined - if (n == null) return ['–', metric.label] as [string, string] + const formatted = formatMetricValue(value, metric) + if (formatted === '—') return ['—', metric.label] as [string, string] return [ - `${n}${metric.unit ? ' ' + metric.unit : ''}`, + `${formatted}${metric.unit ? ' ' + metric.unit : ''}`, metric.label, ] as [string, string] }} diff --git a/frontend/src/energy/format.test.ts b/frontend/src/energy/format.test.ts new file mode 100644 index 0000000..d39092c --- /dev/null +++ b/frontend/src/energy/format.test.ts @@ -0,0 +1,120 @@ +/** + * Tests for energy/format.ts — formatMetricValue helper. + * + * Coverage: + * 1. energy device_class → 3 decimal places. + * 2. non-energy device_class → 2 decimal places. + * 3. null value → "—" placeholder. + * 4. undefined value → "—" placeholder. + * 5. NaN value → "—" placeholder. + * 6. Missing / null metric metadata → default 2 decimal places. + * 7. Integer value still rendered with correct decimal places. + * 8. String numeric value is coerced and formatted. + */ + +import { describe, it, expect } from 'vitest' +import { formatMetricValue, VALUE_PLACEHOLDER } from './format' +import type { MetricInfo } from './hooks' + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +function makeMetric(overrides: Partial = {}): MetricInfo { + return { + key: 'voltage', + label: 'Voltage', + unit: 'V', + device_class: 'voltage', + ...overrides, + } +} + +const energyMetric = makeMetric({ key: 'import_energy', label: 'Import Energy', unit: 'kWh', device_class: 'energy' }) +const voltageMetric = makeMetric() +const powerMetric = makeMetric({ key: 'active_power', label: 'Active Power', unit: 'W', device_class: 'power' }) +const powerFactorMetric = makeMetric({ key: 'power_factor', label: 'Power Factor', unit: '', device_class: 'power_factor' }) + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('formatMetricValue — energy device_class → 3 decimal places', () => { + it('formats kWh energy value to 3 decimal places', () => { + expect(formatMetricValue(123.4567, energyMetric)).toBe('123.457') + }) + + it('formats a whole-number energy value to 3 decimal places', () => { + expect(formatMetricValue(123, energyMetric)).toBe('123.000') + }) + + it('formats zero energy value to 3 decimal places', () => { + expect(formatMetricValue(0, energyMetric)).toBe('0.000') + }) +}) + +describe('formatMetricValue — non-energy device_class → 2 decimal places', () => { + it('formats voltage to 2 decimal places', () => { + expect(formatMetricValue(230.2567, voltageMetric)).toBe('230.26') + }) + + it('formats power to 2 decimal places', () => { + expect(formatMetricValue(295.0, powerMetric)).toBe('295.00') + }) + + it('formats power factor to 2 decimal places', () => { + expect(formatMetricValue(0.98, powerFactorMetric)).toBe('0.98') + }) + + it('formats current to 2 decimal places', () => { + const currentMetric = makeMetric({ key: 'current', label: 'Current', unit: 'A', device_class: 'current' }) + expect(formatMetricValue(1.3456, currentMetric)).toBe('1.35') + }) +}) + +describe('formatMetricValue — null/undefined/NaN → placeholder', () => { + it('returns placeholder for null', () => { + expect(formatMetricValue(null, voltageMetric)).toBe(VALUE_PLACEHOLDER) + }) + + it('returns placeholder for undefined', () => { + expect(formatMetricValue(undefined, voltageMetric)).toBe(VALUE_PLACEHOLDER) + }) + + it('returns placeholder for NaN', () => { + expect(formatMetricValue(NaN, voltageMetric)).toBe(VALUE_PLACEHOLDER) + }) + + it('returns placeholder for non-numeric string', () => { + expect(formatMetricValue('not-a-number', voltageMetric)).toBe(VALUE_PLACEHOLDER) + }) + + it('returns placeholder for Infinity', () => { + expect(formatMetricValue(Infinity, voltageMetric)).toBe(VALUE_PLACEHOLDER) + }) +}) + +describe('formatMetricValue — missing metric metadata → default 2 decimal places', () => { + it('falls back to 2 decimal places when metric is undefined', () => { + expect(formatMetricValue(230.2567)).toBe('230.26') + }) + + it('falls back to 2 decimal places when metric is null', () => { + expect(formatMetricValue(230.2567, null)).toBe('230.26') + }) + + it('returns placeholder for null value even without metric', () => { + expect(formatMetricValue(null)).toBe(VALUE_PLACEHOLDER) + }) +}) + +describe('formatMetricValue — numeric string coercion', () => { + it('coerces a numeric string to number and formats it', () => { + // API could theoretically return strings; we handle them gracefully. + expect(formatMetricValue('230.2567', voltageMetric)).toBe('230.26') + }) + + it('coerces a numeric string for energy metric → 3 decimals', () => { + expect(formatMetricValue('123.4567', energyMetric)).toBe('123.457') + }) +}) diff --git a/frontend/src/energy/format.ts b/frontend/src/energy/format.ts new file mode 100644 index 0000000..9211ddc --- /dev/null +++ b/frontend/src/energy/format.ts @@ -0,0 +1,40 @@ +/** + * format.ts — shared metric value formatting helpers for the Energy view. + * + * Rules (from M5 polish requirements): + * - device_class === "energy" → 3 decimal places (kWh values need precision) + * - everything else → 2 decimal places + * - null / undefined / NaN → placeholder "—" + * - missing metric metadata → default 2 decimal places + */ + +import type { MetricInfo } from './hooks' + +/** Placeholder shown when a value is unavailable. */ +export const VALUE_PLACEHOLDER = '—' + +/** + * Format a numeric metric value for display. + * + * @param value The raw value from the API payload (any type; non-numeric → "—"). + * @param metric Optional metric metadata from /metrics. When absent, falls back + * to 2 decimal places. + * @returns A formatted string ready for display (e.g. "230.25" or "—"). + */ +export function formatMetricValue( + value: unknown, + metric?: MetricInfo | null, +): string { + // Guard: null / undefined + if (value === null || value === undefined) return VALUE_PLACEHOLDER + + const n = Number(value) + + // Guard: NaN or non-finite + if (!Number.isFinite(n)) return VALUE_PLACEHOLDER + + // Determine decimal places from device_class. + const decimals = metric?.device_class === 'energy' ? 3 : 2 + + return n.toFixed(decimals) +} diff --git a/frontend/src/energy/hooks.test.tsx b/frontend/src/energy/hooks.test.tsx index 51e5679..e91a5ab 100644 --- a/frontend/src/energy/hooks.test.tsx +++ b/frontend/src/energy/hooks.test.tsx @@ -274,6 +274,80 @@ describe('useMetrics', () => { }) }) +describe('useLatestReading — auto-refresh option', () => { + beforeEach(() => vi.clearAllMocks()) + + it('passes refetchInterval to TanStack Query when refetchIntervalMs is provided', async () => { + mockGet.mockResolvedValue({ + data: { found: true, recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }, + }) + + const { Wrapper } = makeWrapper() + const { useLatestReading } = await import('./hooks') + const { result } = renderHook( + () => useLatestReading('test-uuid-1', { refetchIntervalMs: 5_000 }), + { wrapper: Wrapper }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + // Query succeeds — refetchIntervalMs is wired through without error. + expect(result.current.data?.found).toBe(true) + }) + + it('works without options (no auto-refresh)', async () => { + mockGet.mockResolvedValue({ + data: { found: false, recorded_at: null, payload: null }, + }) + + const { Wrapper } = makeWrapper() + const { useLatestReading } = await import('./hooks') + const { result } = renderHook( + () => useLatestReading('test-uuid-2'), + { wrapper: Wrapper }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(result.current.data?.found).toBe(false) + }) +}) + +describe('useReadings — auto-refresh option', () => { + beforeEach(() => vi.clearAllMocks()) + + it('passes refetchInterval to TanStack Query when refetchIntervalMs is provided', async () => { + mockGet.mockResolvedValue({ + data: { items: [{ recorded_at: '2026-06-22T10:00:00Z', payload: { voltage: 230.2 } }] }, + }) + + const { Wrapper } = makeWrapper() + const { useReadings } = await import('./hooks') + const { result } = renderHook( + () => useReadings('test-uuid-1', {}, { refetchIntervalMs: 5_000 }), + { wrapper: Wrapper }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + // Query succeeds — refetchIntervalMs is wired through without error. + expect(result.current.data?.items).toHaveLength(1) + }) + + it('enforces minimum refetchInterval of 2000ms', async () => { + mockGet.mockResolvedValue({ data: { items: [] } }) + + const { Wrapper } = makeWrapper() + const { useReadings } = await import('./hooks') + // Pass a very small value — should be clamped to 2000ms internally. + const { result } = renderHook( + () => useReadings('test-uuid-1', {}, { refetchIntervalMs: 100 }), + { wrapper: Wrapper }, + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + // No crash; the hook accepted the option and clamped it internally. + expect(result.current.isSuccess).toBe(true) + }) +}) + describe('useReadings', () => { beforeEach(() => vi.clearAllMocks()) diff --git a/frontend/src/energy/hooks.ts b/frontend/src/energy/hooks.ts index 54fac76..f95bd21 100644 --- a/frontend/src/energy/hooks.ts +++ b/frontend/src/energy/hooks.ts @@ -132,11 +132,28 @@ export function useTestReadDevice() { }) } +// --------------------------------------------------------------------------- +// Query options shared by auto-refresh–capable queries +// --------------------------------------------------------------------------- + +export interface AutoRefreshOptions { + /** + * When provided, TanStack Query will automatically re-fetch this query at + * this interval (milliseconds). Pass `undefined` to disable auto-refresh. + * Minimum enforced value: 2 000 ms. + */ + refetchIntervalMs?: number +} + // --------------------------------------------------------------------------- // Query: latest reading for a device // --------------------------------------------------------------------------- -export function useLatestReading(uuid: string) { +export function useLatestReading(uuid: string, options?: AutoRefreshOptions) { + const refetchInterval = options?.refetchIntervalMs != null + ? Math.max(2_000, options.refetchIntervalMs) + : undefined + return useQuery({ queryKey: ['modbus-latest', uuid], queryFn: async () => { @@ -145,8 +162,7 @@ export function useLatestReading(uuid: string) { }) return res.data }, - // Refresh every 10 s to show up-to-date readings. - refetchInterval: 10_000, + refetchInterval, }) } @@ -175,10 +191,18 @@ export function useMetrics(uuid: string) { /** Hard upper-bound on readings fetched; prevents accidental full-table pulls. */ const READINGS_MAX_LIMIT = 1000 -export function useReadings(uuid: string, params: ReadingsQueryParams) { +export function useReadings( + uuid: string, + params: ReadingsQueryParams, + options?: AutoRefreshOptions, +) { const { start, end, limit } = params const effectiveLimit = Math.min(limit ?? READINGS_MAX_LIMIT, READINGS_MAX_LIMIT) + const refetchInterval = options?.refetchIntervalMs != null + ? Math.max(2_000, options.refetchIntervalMs) + : undefined + return useQuery({ queryKey: ['modbus-readings', uuid, { start, end, limit: effectiveLimit }], queryFn: async () => { @@ -196,5 +220,6 @@ export function useReadings(uuid: string, params: ReadingsQueryParams) { }, // Enabled only when we have valid uuid; start/end may be null (full window). enabled: !!uuid, + refetchInterval, }) } diff --git a/frontend/src/pages/EnergyPage.test.tsx b/frontend/src/pages/EnergyPage.test.tsx index 7c51f7a..8e32409 100644 --- a/frontend/src/pages/EnergyPage.test.tsx +++ b/frontend/src/pages/EnergyPage.test.tsx @@ -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 + // 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() + }) + }) +}) diff --git a/frontend/src/pages/EnergyPage.tsx b/frontend/src/pages/EnergyPage.tsx index dae0612..b6a28b7 100644 --- a/frontend/src/pages/EnergyPage.tsx +++ b/frontend/src/pages/EnergyPage.tsx @@ -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 ( Success - - {JSON.stringify(result.payload, null, 2)} - + {result.payload && typeof result.payload === 'object' ? ( + + {Object.entries(result.payload as Record).map(([key, val]) => { + const metricMeta = metrics?.find((m) => m.key === key) + return ( + + + {metricMeta?.label ?? key} + + + {formatMetricValue(val, metricMeta)} + {metricMeta?.unit ? ( + + {metricMeta.unit} + + ) : null} + + + ) + })} + + ) : ( + + {JSON.stringify(result.payload, null, 2)} + + )} ) : ( @@ -147,6 +174,7 @@ interface TestReadButtonProps { function TestReadButton({ device }: TestReadButtonProps) { const testMutation = useTestReadDevice() + const metricsQuery = useMetrics(device.uuid) const [result, setResult] = useState(null) const [testError, setTestError] = useState(null) @@ -209,6 +237,7 @@ function TestReadButton({ device }: TestReadButtonProps) { 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) { {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 ( - + + + setAutoRefreshEnabled(e.currentTarget.checked)} + size="sm" + data-testid="auto-refresh-switch" + /> + {devices.map((device) => ( {device.friendly_name} - - + + ))}