M5: add read-only per-meter readings tabs to Records page; English-only UI strings

This commit is contained in:
2026-06-22 22:16:55 +02:00
parent 6090b98ab0
commit c89d083942
3 changed files with 440 additions and 1 deletions
+301
View File
@@ -20,6 +20,7 @@ import { screen, waitFor, fireEvent } from '@testing-library/react'
import { renderWithProviders } from '../test-utils'
import { RecordsPage } from './RecordsPage'
import type { LocationRecord } from '../records'
import type { ModbusDevice, MetricInfo } from '../energy/hooks'
// ---------------------------------------------------------------------------
// Fixtures
@@ -439,3 +440,303 @@ describe('RecordsPage — multiple poo rows', () => {
expect(screen.getByText('2026-06-12T11:00:00Z')).toBeInTheDocument()
})
})
// ---------------------------------------------------------------------------
// Meter readings tabs (M5)
// ---------------------------------------------------------------------------
const METER_DEVICE_1: ModbusDevice = {
uuid: 'dev-uuid-1',
friendly_name: 'Main Meter',
transport: 'tcp',
host: '192.168.1.10',
port: 502,
unit_id: 1,
profile: 'sdm630',
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-22T10:00:00Z',
}
const METER_DEVICE_2: ModbusDevice = {
uuid: 'dev-uuid-2',
friendly_name: 'Sub Meter',
transport: 'tcp',
host: '192.168.1.11',
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-22T10:00:00Z',
}
const METER_METRICS: MetricInfo[] = [
{ key: 'voltage', label: 'Voltage', unit: 'V', device_class: 'voltage' },
{ key: 'energy_kwh', label: 'Energy', unit: 'kWh', device_class: 'energy' },
]
/** A single reading with an open-ended payload shape for test flexibility. */
type TestReading = { recorded_at: string; payload: Record<string, number> }
const METER_READINGS: TestReading[] = [
{
recorded_at: '2026-06-22T09:00:00Z',
payload: { voltage: 230.5, energy_kwh: 12.345 },
},
{
recorded_at: '2026-06-22T09:05:00Z',
payload: { voltage: 231.1, energy_kwh: 12.350 },
},
]
/** Extend the poo/location mock to also serve Modbus endpoints. */
function setupGetMockWithMeters({
devices = [METER_DEVICE_1],
metricsMap = { 'dev-uuid-1': METER_METRICS },
readingsMap = { 'dev-uuid-1': METER_READINGS },
}: {
devices?: ModbusDevice[]
metricsMap?: Record<string, MetricInfo[]>
readingsMap?: Record<string, TestReading[]>
} = {}) {
mockGet.mockImplementation((path: string, opts?: { params?: { path?: { uuid?: string }; query?: { offset?: number } } }) => {
const offset = opts?.params?.query?.offset ?? 0
if (path === '/api/poo') {
return Promise.resolve({
data: { items: [POO_RECORD], limit: 100, offset },
response: { status: 200, ok: true },
})
}
if (path === '/api/locations') {
return Promise.resolve({
data: { items: [LOCATION_RECORD], limit: 100, offset },
response: { status: 200, ok: true },
})
}
if (path === '/api/modbus/devices') {
return Promise.resolve({
data: { items: devices, total: devices.length },
response: { status: 200, ok: true },
})
}
const uuid = opts?.params?.path?.uuid
if (path === '/api/modbus/devices/{uuid}/metrics' && uuid) {
return Promise.resolve({
data: { profile: 'sdm630', metrics: metricsMap[uuid] ?? [] },
response: { status: 200, ok: true },
})
}
if (path === '/api/modbus/devices/{uuid}/readings' && uuid) {
return Promise.resolve({
data: { items: readingsMap[uuid] ?? [] },
response: { status: 200, ok: true },
})
}
return Promise.resolve({ data: null })
})
}
describe('RecordsPage — meter tabs', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// -------------------------------------------------------------------------
// 0 devices: no meter tabs
// -------------------------------------------------------------------------
it('shows no meter tabs when there are 0 devices', async () => {
setupGetMockWithMeters({ devices: [] })
renderRecords()
// Poo and Locations tabs must still be present
await waitFor(() => {
expect(screen.getByTestId('tab-poo')).toBeInTheDocument()
expect(screen.getByTestId('tab-locations')).toBeInTheDocument()
})
// No meter tab rendered
expect(screen.queryByTestId('tab-meter-dev-uuid-1')).not.toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 1 device: 1 meter tab with friendly_name label
// -------------------------------------------------------------------------
it('renders 1 meter tab with friendly_name for 1 device', async () => {
setupGetMockWithMeters({ devices: [METER_DEVICE_1] })
renderRecords()
await waitFor(() => {
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
})
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toHaveTextContent(METER_DEVICE_1.friendly_name)
})
// -------------------------------------------------------------------------
// 2 devices: 2 meter tabs with correct friendly_names
// -------------------------------------------------------------------------
it('renders 2 meter tabs for 2 devices, each with correct friendly_name', async () => {
setupGetMockWithMeters({
devices: [METER_DEVICE_1, METER_DEVICE_2],
metricsMap: { 'dev-uuid-1': METER_METRICS, 'dev-uuid-2': METER_METRICS },
readingsMap: { 'dev-uuid-1': METER_READINGS, 'dev-uuid-2': [] },
})
renderRecords()
await waitFor(() => {
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_2.uuid}`)).toBeInTheDocument()
})
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toHaveTextContent('Main Meter')
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_2.uuid}`)).toHaveTextContent('Sub Meter')
})
// -------------------------------------------------------------------------
// Meter tab: switching to it shows the readings table with metric columns
// -------------------------------------------------------------------------
it('switching to meter tab shows table with metric column headers and reading rows', async () => {
setupGetMockWithMeters({ devices: [METER_DEVICE_1] })
renderRecords()
// Wait for tab to appear then click it
await waitFor(() => {
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`))
// Table should be rendered
await waitFor(() => {
expect(screen.getByTestId(`meter-table-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
})
// Metric column headers
expect(screen.getByText(/Voltage/i)).toBeInTheDocument()
expect(screen.getByText(/Energy/i)).toBeInTheDocument()
// At least one reading row
const rows = screen.getAllByTestId(`meter-row-${METER_DEVICE_1.uuid}`)
expect(rows).toHaveLength(2)
})
// -------------------------------------------------------------------------
// Meter tab: values formatted correctly (voltage 2 dp, energy 3 dp)
// -------------------------------------------------------------------------
it('formats voltage with 2 dp and energy with 3 dp', async () => {
setupGetMockWithMeters({ devices: [METER_DEVICE_1] })
renderRecords()
await waitFor(() => {
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`))
await waitFor(() => {
expect(screen.getByTestId(`meter-table-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
})
// voltage: 230.5 → "230.50" (2 dp, device_class != energy)
expect(screen.getByText('230.50')).toBeInTheDocument()
// energy_kwh: 12.345 → "12.345" (3 dp, device_class == energy)
expect(screen.getByText('12.345')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// Meter tab: missing key in payload → placeholder "—"
// -------------------------------------------------------------------------
it('shows placeholder "—" for a missing key in payload', async () => {
const readingsWithMissingKey = [
{
recorded_at: '2026-06-22T09:00:00Z',
payload: { voltage: 230.5 }, // energy_kwh missing
},
]
setupGetMockWithMeters({
devices: [METER_DEVICE_1],
readingsMap: { 'dev-uuid-1': readingsWithMissingKey },
})
renderRecords()
await waitFor(() => {
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`))
await waitFor(() => {
expect(screen.getByTestId(`meter-table-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
})
// The "—" placeholder must appear (for the missing energy_kwh key)
expect(screen.getByText('—')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// Meter tab: empty readings → empty state message
// -------------------------------------------------------------------------
it('shows empty state when device has no readings', async () => {
setupGetMockWithMeters({
devices: [METER_DEVICE_1],
readingsMap: { 'dev-uuid-1': [] },
})
renderRecords()
await waitFor(() => {
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`))
await waitFor(() => {
expect(screen.getByTestId(`meter-empty-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
})
})
// -------------------------------------------------------------------------
// Meter tab: 1000 readings → truncation notice shown
// -------------------------------------------------------------------------
it('shows truncation notice when readings.length equals METER_READINGS_LIMIT (1000)', async () => {
const truncatedReadings = Array.from({ length: 1000 }, (_, i) => ({
recorded_at: `2026-06-22T${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}:00Z`,
payload: { voltage: 230 + i * 0.01, energy_kwh: 10 + i * 0.001 },
}))
setupGetMockWithMeters({
devices: [METER_DEVICE_1],
readingsMap: { 'dev-uuid-1': truncatedReadings },
})
renderRecords()
await waitFor(() => {
expect(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId(`tab-meter-${METER_DEVICE_1.uuid}`))
await waitFor(() => {
expect(screen.getByTestId(`meter-truncated-${METER_DEVICE_1.uuid}`)).toBeInTheDocument()
})
})
})