2026-06-13 10:32:02 +02:00
|
|
|
/**
|
|
|
|
|
* Tests for RecordsPage (M2-T10).
|
|
|
|
|
*
|
|
|
|
|
* Coverage:
|
|
|
|
|
* 1. Poo list renders from mocked apiClient GET /api/poo.
|
|
|
|
|
* 2. Poo pagination: page 2 requests offset=100.
|
|
|
|
|
* 3. Edit poo: clicking Edit opens the modal; form submit calls PATCH with raw (un-encoded)
|
|
|
|
|
* PK in the path params (openapi-fetch encodes once; we must not double-encode).
|
|
|
|
|
* 4. Delete poo: clicking Delete opens confirmation; confirming calls DELETE and refreshes.
|
|
|
|
|
* 5. Location list renders from mocked apiClient GET /api/locations.
|
|
|
|
|
* 6. Location pagination: page 2 requests offset=100.
|
|
|
|
|
* 7. Edit location: clicking Edit opens modal; form submit calls PATCH with raw PK params.
|
|
|
|
|
* 8. Delete location: clicking Delete opens confirmation; confirming calls DELETE.
|
|
|
|
|
* 9. Real-encoding regression: stub global fetch; verify actual URL uses single encoding
|
|
|
|
|
* (%3A present, %253A absent) for PKs containing colons.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
|
|
|
|
import { renderWithProviders } from '../test-utils'
|
|
|
|
|
import { RecordsPage } from './RecordsPage'
|
|
|
|
|
import type { LocationRecord } from '../records'
|
2026-06-22 22:16:55 +02:00
|
|
|
import type { ModbusDevice, MetricInfo } from '../energy/hooks'
|
2026-06-13 10:32:02 +02:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Fixtures
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const POO_RECORD = {
|
|
|
|
|
timestamp: '2026-06-12T10:00:00Z',
|
|
|
|
|
status: 'done',
|
|
|
|
|
latitude: 51.5,
|
|
|
|
|
longitude: -0.1,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const POO_RECORD_2 = {
|
|
|
|
|
timestamp: '2026-06-12T11:00:00Z',
|
|
|
|
|
status: 'pending',
|
|
|
|
|
latitude: 51.6,
|
|
|
|
|
longitude: -0.2,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const LOCATION_RECORD: LocationRecord = {
|
|
|
|
|
person: 'alice',
|
|
|
|
|
datetime: '2026-06-12T09:00:00Z',
|
|
|
|
|
latitude: 52.0,
|
|
|
|
|
longitude: 1.0,
|
|
|
|
|
altitude: 10,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build a page of 100 items (all identical except for timestamp offset).
|
|
|
|
|
function makePooPage(offset: number) {
|
|
|
|
|
return Array.from({ length: 100 }, (_, i) => ({
|
|
|
|
|
timestamp: `2026-06-12T${String(offset + i).padStart(2, '0')}:00:00Z`,
|
|
|
|
|
status: 'done',
|
|
|
|
|
latitude: 51.5,
|
|
|
|
|
longitude: -0.1,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeLocationPage(offset: number): LocationRecord[] {
|
|
|
|
|
return Array.from({ length: 100 }, (_, i) => ({
|
|
|
|
|
person: 'alice',
|
|
|
|
|
datetime: `2026-06-12T${String(offset + i).padStart(2, '0')}:00:00Z`,
|
|
|
|
|
latitude: 52.0,
|
|
|
|
|
longitude: 1.0,
|
|
|
|
|
altitude: null,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Mock apiClient
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const mockGet = vi.fn()
|
|
|
|
|
const mockPatch = vi.fn()
|
|
|
|
|
const mockDelete = vi.fn()
|
|
|
|
|
|
|
|
|
|
vi.mock('../api/client', () => ({
|
|
|
|
|
default: {
|
|
|
|
|
GET: (...args: unknown[]) => mockGet(...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 renderRecords() {
|
|
|
|
|
return renderWithProviders(<RecordsPage />, { initialPath: '/records' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Make GET mock respond based on path. */
|
|
|
|
|
function setupGetMock({
|
|
|
|
|
pooItems = [POO_RECORD],
|
|
|
|
|
locationItems = [LOCATION_RECORD],
|
|
|
|
|
pooOffset = 0,
|
|
|
|
|
locationOffset = 0,
|
|
|
|
|
}: {
|
|
|
|
|
pooItems?: typeof POO_RECORD[]
|
|
|
|
|
locationItems?: typeof LOCATION_RECORD[]
|
|
|
|
|
pooOffset?: number
|
|
|
|
|
locationOffset?: number
|
|
|
|
|
} = {}) {
|
|
|
|
|
mockGet.mockImplementation((path: string, opts?: { params?: { query?: { offset?: number } } }) => {
|
|
|
|
|
const offset = opts?.params?.query?.offset ?? 0
|
|
|
|
|
if (path === '/api/poo') {
|
|
|
|
|
return Promise.resolve({
|
|
|
|
|
data: { items: pooItems, limit: 100, offset: pooOffset || offset },
|
|
|
|
|
response: { status: 200, ok: true },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
if (path === '/api/locations') {
|
|
|
|
|
return Promise.resolve({
|
|
|
|
|
data: { items: locationItems, limit: 100, offset: locationOffset || offset },
|
|
|
|
|
response: { status: 200, ok: true },
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return Promise.resolve({ data: null })
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Tests
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe('RecordsPage — Poo tab', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks()
|
|
|
|
|
setupGetMock()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// 1. Poo list renders
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
it('renders poo records from GET /api/poo', async () => {
|
|
|
|
|
renderRecords()
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('poo-table')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText('2026-06-12T10:00:00Z')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('done')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// 2. Poo pagination: page 2 sends offset=100
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
it('requests offset=100 when page 2 is selected', async () => {
|
|
|
|
|
// Return full page to trigger pagination display.
|
|
|
|
|
const page1 = makePooPage(0)
|
|
|
|
|
setupGetMock({ pooItems: page1 })
|
|
|
|
|
|
|
|
|
|
renderRecords()
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('poo-pagination')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Click page 2.
|
|
|
|
|
const page2Button = screen.getByRole('button', { name: '2' })
|
|
|
|
|
fireEvent.click(page2Button)
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
const allCalls = mockGet.mock.calls.filter((c) => c[0] === '/api/poo')
|
|
|
|
|
const page2Call = allCalls.find(
|
|
|
|
|
(c) => (c[1]?.params?.query?.offset ?? 0) === 100,
|
|
|
|
|
)
|
|
|
|
|
expect(page2Call).toBeDefined()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// 3. Edit poo: opens modal, submit calls PATCH with encoded PK
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
it('opens EditPooModal when Edit is clicked; submit calls PATCH with raw PK in path params and correct body', async () => {
|
|
|
|
|
mockPatch.mockResolvedValue({ data: POO_RECORD, response: { status: 200, ok: true } })
|
|
|
|
|
renderRecords()
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId(`poo-edit-${POO_RECORD.timestamp}`)).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByTestId(`poo-edit-${POO_RECORD.timestamp}`))
|
|
|
|
|
|
|
|
|
|
// Modal appears
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('edit-poo-modal')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Change status
|
|
|
|
|
const statusInput = screen.getByTestId('poo-status-input') as HTMLInputElement
|
|
|
|
|
fireEvent.change(statusInput, { target: { value: 'reviewed' } })
|
|
|
|
|
|
|
|
|
|
// Submit
|
|
|
|
|
fireEvent.submit(screen.getByTestId('edit-poo-form'))
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockPatch).toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const patchCall = mockPatch.mock.calls[0]
|
|
|
|
|
expect(patchCall[0]).toBe('/api/poo/{timestamp}')
|
|
|
|
|
// PK must be the raw value — openapi-fetch encodes it once; hooks must not pre-encode.
|
|
|
|
|
expect(patchCall[1].params.path.timestamp).toBe(POO_RECORD.timestamp)
|
|
|
|
|
// Body must include only non-PK fields
|
|
|
|
|
expect(patchCall[1].body).toHaveProperty('status')
|
|
|
|
|
expect(patchCall[1].body).not.toHaveProperty('timestamp')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// 4. Delete poo: confirmation then DELETE called; list refreshes
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
it('shows confirmation modal on Delete click; DELETE is called after confirmation', async () => {
|
|
|
|
|
mockDelete.mockResolvedValue({ data: null, response: { status: 204, ok: true } })
|
|
|
|
|
renderRecords()
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId(`poo-delete-${POO_RECORD.timestamp}`)).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Click Delete — confirmation modal appears
|
|
|
|
|
fireEvent.click(screen.getByTestId(`poo-delete-${POO_RECORD.timestamp}`))
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('confirm-delete-modal')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// The modal should show a helpful message
|
|
|
|
|
expect(screen.getByTestId('confirm-delete-message')).toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
// Cancel first — modal should close
|
|
|
|
|
fireEvent.click(screen.getByTestId('confirm-delete-cancel'))
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.queryByTestId('confirm-delete-modal')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Reopen and confirm
|
|
|
|
|
fireEvent.click(screen.getByTestId(`poo-delete-${POO_RECORD.timestamp}`))
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('confirm-delete-confirm')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByTestId('confirm-delete-confirm'))
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockDelete).toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const deleteCall = mockDelete.mock.calls[0]
|
|
|
|
|
expect(deleteCall[0]).toBe('/api/poo/{timestamp}')
|
|
|
|
|
// PK must be the raw value — hooks must not pre-encode; openapi-fetch encodes once.
|
|
|
|
|
expect(deleteCall[1].params.path.timestamp).toBe(POO_RECORD.timestamp)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('RecordsPage — Locations tab', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks()
|
|
|
|
|
setupGetMock()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// 5. Location list renders
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
it('renders location records after switching to Locations tab', async () => {
|
|
|
|
|
renderRecords()
|
|
|
|
|
|
|
|
|
|
// Switch to Locations tab
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('tab-locations')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByTestId('tab-locations'))
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('location-table')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText('alice')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('2026-06-12T09:00:00Z')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// 6. Location pagination: page 2 sends offset=100
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
it('requests offset=100 for locations when page 2 is selected', async () => {
|
|
|
|
|
const page1 = makeLocationPage(0)
|
|
|
|
|
setupGetMock({ locationItems: page1 })
|
|
|
|
|
|
|
|
|
|
renderRecords()
|
|
|
|
|
|
|
|
|
|
// Switch to Locations tab
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('tab-locations')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
fireEvent.click(screen.getByTestId('tab-locations'))
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('location-pagination')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const page2Button = screen.getByRole('button', { name: '2' })
|
|
|
|
|
fireEvent.click(page2Button)
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
const allCalls = mockGet.mock.calls.filter((c) => c[0] === '/api/locations')
|
|
|
|
|
const page2Call = allCalls.find(
|
|
|
|
|
(c) => (c[1]?.params?.query?.offset ?? 0) === 100,
|
|
|
|
|
)
|
|
|
|
|
expect(page2Call).toBeDefined()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// 7. Edit location: opens modal, submit calls PATCH with encoded PK
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
it('opens EditLocationModal; submit calls PATCH with raw person+datetime in path params', async () => {
|
|
|
|
|
mockPatch.mockResolvedValue({ data: LOCATION_RECORD, response: { status: 200, ok: true } })
|
|
|
|
|
|
|
|
|
|
renderRecords()
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByTestId('tab-locations'))
|
|
|
|
|
|
|
|
|
|
const rowKey = `${LOCATION_RECORD.person}__${LOCATION_RECORD.datetime}`
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId(`location-edit-${rowKey}`)).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByTestId(`location-edit-${rowKey}`))
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('edit-location-modal')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// PK shown read-only in the modal (may appear more than once in the page: table + modal)
|
|
|
|
|
const modalEl = screen.getByTestId('edit-location-modal')
|
|
|
|
|
expect(modalEl).toBeInTheDocument()
|
|
|
|
|
// 'alice' and datetime appear in modal read-only text
|
|
|
|
|
expect(modalEl.textContent).toContain('alice')
|
|
|
|
|
expect(modalEl.textContent).toContain('2026-06-12T09:00:00Z')
|
|
|
|
|
|
|
|
|
|
// Submit
|
|
|
|
|
fireEvent.submit(screen.getByTestId('edit-location-form'))
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockPatch).toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const patchCall = mockPatch.mock.calls[0]
|
|
|
|
|
expect(patchCall[0]).toBe('/api/locations/{person}/{datetime}')
|
|
|
|
|
// PKs must be raw — hooks must not pre-encode; openapi-fetch encodes once.
|
|
|
|
|
expect(patchCall[1].params.path.person).toBe(LOCATION_RECORD.person)
|
|
|
|
|
expect(patchCall[1].params.path.datetime).toBe(LOCATION_RECORD.datetime)
|
|
|
|
|
// Body must NOT contain PK fields
|
|
|
|
|
expect(patchCall[1].body).not.toHaveProperty('person')
|
|
|
|
|
expect(patchCall[1].body).not.toHaveProperty('datetime')
|
|
|
|
|
expect(patchCall[1].body).toHaveProperty('latitude')
|
|
|
|
|
expect(patchCall[1].body).toHaveProperty('longitude')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
// 8. Delete location: confirmation then DELETE called
|
|
|
|
|
// -------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
it('shows confirmation modal on Delete; DELETE is called with raw PK params', async () => {
|
|
|
|
|
mockDelete.mockResolvedValue({ data: null, response: { status: 204, ok: true } })
|
|
|
|
|
|
|
|
|
|
renderRecords()
|
|
|
|
|
fireEvent.click(screen.getByTestId('tab-locations'))
|
|
|
|
|
|
|
|
|
|
const rowKey = `${LOCATION_RECORD.person}__${LOCATION_RECORD.datetime}`
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId(`location-delete-${rowKey}`)).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByTestId(`location-delete-${rowKey}`))
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('confirm-delete-modal')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByTestId('confirm-delete-confirm'))
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(mockDelete).toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const deleteCall = mockDelete.mock.calls[0]
|
|
|
|
|
expect(deleteCall[0]).toBe('/api/locations/{person}/{datetime}')
|
|
|
|
|
// PKs must be raw — hooks must not pre-encode; openapi-fetch encodes once.
|
|
|
|
|
expect(deleteCall[1].params.path.person).toBe(LOCATION_RECORD.person)
|
|
|
|
|
expect(deleteCall[1].params.path.datetime).toBe(LOCATION_RECORD.datetime)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Additional: multiple poo records with correct timestamps
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe('RecordsPage — multiple poo rows', () => {
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.clearAllMocks()
|
|
|
|
|
setupGetMock({ pooItems: [POO_RECORD, POO_RECORD_2] })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('renders both rows', async () => {
|
|
|
|
|
renderRecords()
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('poo-table')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(screen.getByText('2026-06-12T10:00:00Z')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText('2026-06-12T11:00:00Z')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-06-22 22:16:55 +02:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// 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()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|