Files
home-automation/frontend/src/energy/MeterManager.test.tsx
T

681 lines
29 KiB
TypeScript
Raw Normal View History

/**
* Tests for MeterManager component.
*
* Coverage:
* 1. Loading state rendering.
* 2. Error state rendering.
* 3. Empty state when no meters exist.
* 4. Meter timeline list rendering (label, dates, active badge, reason).
* 5. "Declare New Meter" button opens form modal.
* 6. Declare meter — form submit calls POST /api/energy/meters.
* 7. Declare meter — 422 (倒挂) error is displayed.
* 8. Edit button opens edit form modal.
* 9. Edit meter — saves label/note via PATCH /api/energy/meters/{meter_id}.
* 10. Edit meter — retroactive started_at triggers recompute notice.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '../test-utils'
import { MeterManager } from './MeterManager'
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
const mockPatch = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
POST: (...args: unknown[]) => mockPost(...args),
PATCH: (...args: unknown[]) => mockPatch(...args),
DELETE: vi.fn(),
},
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(),
}))
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const ACTIVE_METER = {
id: 1,
label: 'Initial 2G meter',
commodity: 'electricity',
started_at: '2024-01-15T00:00:00Z',
ended_at: null,
reason: 'initial',
note: null,
created_at: '2024-01-15T00:00:00Z',
bindings: [],
}
const CLOSED_METER = {
id: 2,
label: 'Old 4G meter',
commodity: 'electricity',
started_at: '2023-06-01T00:00:00Z',
ended_at: '2024-01-15T00:00:00Z',
reason: 'meter_swap',
note: 'Replaced by grid company',
created_at: '2023-06-01T00:00:00Z',
}
const METERS_RESPONSE = {
items: [CLOSED_METER, ACTIVE_METER],
total: 2,
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('MeterManager — loading / error / empty states', () => {
// M8 keeps the existing Modbus-facing meter regressions alongside commodity additions.
beforeEach(() => vi.clearAllMocks())
it('renders loading state initially', () => {
mockGet.mockImplementation(() => new Promise(() => {}))
renderWithProviders(<MeterManager />)
expect(screen.getByTestId('meters-loading')).toBeInTheDocument()
})
it('renders error state when GET fails', async () => {
mockGet.mockRejectedValue(new Error('Network error'))
renderWithProviders(<MeterManager />)
await waitFor(() => {
expect(screen.getByTestId('meters-load-error')).toBeInTheDocument()
})
})
it('renders empty state when no meters exist', async () => {
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
renderWithProviders(<MeterManager />)
await waitFor(() => {
expect(screen.getByTestId('meters-empty')).toBeInTheDocument()
})
})
})
describe('MeterManager — binding switch safety', () => {
beforeEach(() => vi.clearAllMocks())
it('does not offer switching for a closed meter epoch', async () => {
mockGet.mockResolvedValue({ data: { items: [CLOSED_METER], total: 1 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId('meters-table')).toBeInTheDocument())
expect(screen.queryByRole('button', { name: 'Switch source' })).not.toBeInTheDocument()
})
it('disables switch submit until the binding timeline has loaded', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => path === '/api/energy/meters' ? Promise.resolve({ data: { items: [ACTIVE_METER], total: 1 } }) : new Promise(() => {}))
renderWithProviders(<MeterManager />)
await user.click(await screen.findByRole('button', { name: 'Switch source' }))
expect(await screen.findByText('Loading binding timeline…')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Switch binding' })).toBeDisabled()
})
})
describe('MeterManager — meter list', () => {
beforeEach(() => vi.clearAllMocks())
it('renders meter timeline with label, dates, status badge, reason', async () => {
mockGet.mockResolvedValue({ data: METERS_RESPONSE })
renderWithProviders(<MeterManager />)
await waitFor(() => {
expect(screen.getByTestId('meters-table')).toBeInTheDocument()
})
// Labels
expect(screen.getByText('Initial 2G meter')).toBeInTheDocument()
expect(screen.getByText('Old 4G meter')).toBeInTheDocument()
// Status badges
expect(screen.getByTestId(`meter-status-${ACTIVE_METER.id}`)).toHaveTextContent('active')
expect(screen.getByTestId(`meter-status-${CLOSED_METER.id}`)).toHaveTextContent('closed')
// Reason badges
expect(screen.getByText('initial')).toBeInTheDocument()
expect(screen.getByText('meter_swap')).toBeInTheDocument()
})
it('renders every binding timeline segment with source, channel, and half-open boundaries', async () => {
const meterWithBindings = {
...ACTIVE_METER,
bindings: [
{ uuid: 'binding-closed', source_uuid: 'source-old', source_channel_uuid: 'channel-old', started_at: '2025-01-01T00:00:00Z', ended_at: '2025-02-01T00:00:00Z' },
{ uuid: 'binding-active', source_uuid: 'source-new', source_channel_uuid: 'channel-new', started_at: '2025-02-01T00:00:00Z', ended_at: null },
],
}
mockGet.mockResolvedValue({ data: { items: [meterWithBindings], total: 1 } })
renderWithProviders(<MeterManager />)
const closed = await screen.findByTestId('binding-timeline-binding-closed')
const active = screen.getByTestId('binding-timeline-binding-active')
expect(closed).toHaveTextContent('source-old → channel-old')
expect(closed).toHaveTextContent('[1/1/2025, 00:00:00, 2/1/2025, 00:00:00) (closed)')
expect(active).toHaveTextContent('source-new → channel-new')
expect(active).toHaveTextContent('[2/1/2025, 00:00:00, open-ended) (active)')
})
it('renders "Declare New Meter" button', async () => {
mockGet.mockResolvedValue({ data: METERS_RESPONSE })
renderWithProviders(<MeterManager />)
await waitFor(() => {
expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument()
})
})
})
describe('MeterManager — declare new meter', () => {
beforeEach(() => vi.clearAllMocks())
it('opens declare modal when "Declare New Meter" is clicked', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
await user.click(screen.getByTestId('meter-declare-button'))
await waitFor(() => {
expect(screen.getByTestId('declare-meter-modal')).toBeInTheDocument()
})
expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument()
})
it('calls POST /api/energy/meters with correct payload including local-midnight naive datetime', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
mockPost.mockResolvedValue({ data: ACTIVE_METER })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
await user.click(screen.getByTestId('meter-declare-button'))
await waitFor(() => expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument())
// Fill form
await user.type(screen.getByTestId('meter-label'), 'New meter label')
await user.type(screen.getByTestId('meter-started-at'), '2026-01-01')
// Select reason via the combobox (Mantine Select renders a combobox)
await user.click(screen.getByTestId('meter-reason'))
await waitFor(() => screen.getByText('Initial installation'))
await user.click(screen.getByText('Initial installation'))
await user.click(screen.getByTestId('declare-meter-submit'))
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
'/api/energy/meters',
expect.objectContaining({
body: expect.objectContaining({
label: 'New meter label',
// FU10 local-midnight naive convention: no Z suffix
started_at: '2026-01-01T00:00:00',
reason: 'initial',
commodity: 'electricity',
}),
}),
)
})
})
it('offers a channel with closed history and one current old-meter binding for a safe swap', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{
...CLOSED_METER,
bindings: [{
uuid: 'binding-history', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
started_at: '2023-06-01T00:00:00Z', ended_at: '2024-01-15T00:00:00Z',
}],
}, {
...ACTIVE_METER,
bindings: [{
uuid: 'binding-current', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
started_at: '2024-01-15T00:00:00Z', ended_at: null,
}],
}], total: 2 } })
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'source-1', name: 'DSMR' }] } })
if (path === '/api/energy/sources/{source_uuid}/channels') {
return Promise.resolve({ data: { items: [
{ uuid: 'handoff-channel', label: 'Current total', unit: 'kWh', binding_count: 2, bound_meter_ids: [CLOSED_METER.id, ACTIVE_METER.id] },
{ uuid: 'occupied-channel', label: 'Other total', unit: 'kWh', binding_count: 1, bound_meter_ids: [999] },
] } })
}
return Promise.resolve({ data: { items: [] } })
})
mockPost.mockResolvedValue({ data: ACTIVE_METER })
renderWithProviders(<MeterManager />)
await user.click(await screen.findByTestId('meter-declare-button'))
await user.type(screen.getByTestId('meter-label'), 'Replacement meter')
await user.type(screen.getByTestId('meter-started-at'), '2026-01-01')
await user.click(screen.getByTestId('meter-reason'))
await user.click(await screen.findByText('Meter swap (same address)'))
await user.click(screen.getAllByLabelText('Bind source (optional)')[0])
await user.click(await screen.findByText('DSMR'))
await user.click((await screen.findAllByLabelText('Compatible source channel (optional)'))[0])
expect(await screen.findByText('Current total (kWh) — hand off from current meter')).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'Other total (kWh)' })).toHaveAttribute('data-combobox-disabled')
await user.click(screen.getByText('Current total (kWh) — hand off from current meter'))
await user.click(screen.getByTestId('declare-meter-submit'))
await waitFor(() => expect(mockPost).toHaveBeenCalledWith(
'/api/energy/meters',
expect.objectContaining({ body: expect.objectContaining({ source_channel_uuid: 'handoff-channel' }) }),
))
})
it('keeps a channel disabled when its current open binding is ambiguous', async () => {
const user = userEvent.setup()
const competingMeter = {
...ACTIVE_METER,
id: 999,
label: 'Competing meter',
bindings: [{
uuid: 'binding-competing', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
started_at: '2025-01-01T00:00:00Z', ended_at: null,
}],
}
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{
...ACTIVE_METER,
bindings: [{
uuid: 'binding-current', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
started_at: '2024-01-15T00:00:00Z', ended_at: null,
}],
}, competingMeter], total: 2 } })
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'source-1', name: 'DSMR' }] } })
if (path === '/api/energy/sources/{source_uuid}/channels') {
return Promise.resolve({ data: { items: [
{ uuid: 'handoff-channel', label: 'Current total', unit: 'kWh', binding_count: 2, bound_meter_ids: [ACTIVE_METER.id, competingMeter.id] },
] } })
}
return Promise.resolve({ data: { items: [] } })
})
renderWithProviders(<MeterManager />)
await user.click(await screen.findByTestId('meter-declare-button'))
await user.type(screen.getByTestId('meter-label'), 'Replacement meter')
await user.type(screen.getByTestId('meter-started-at'), '2026-01-01')
await user.click(screen.getByTestId('meter-reason'))
await user.click(await screen.findByText('Meter swap (same address)'))
await user.click(screen.getAllByLabelText('Bind source (optional)')[0])
await user.click(await screen.findByText('DSMR'))
await user.click((await screen.findAllByLabelText('Compatible source channel (optional)'))[0])
expect(await screen.findByRole('option', { name: 'Current total (kWh)' })).toHaveAttribute(
'data-combobox-disabled',
)
})
it('clears a selected handoff channel when the swap date becomes unsafe', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{
...ACTIVE_METER,
bindings: [{
uuid: 'binding-1', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
started_at: '2024-01-15T00:00:00Z', ended_at: null,
}],
}], total: 1 } })
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'source-1', name: 'DSMR' }] } })
if (path === '/api/energy/sources/{source_uuid}/channels') {
return Promise.resolve({ data: { items: [
{ uuid: 'handoff-channel', label: 'Current total', unit: 'kWh', binding_count: 1, bound_meter_ids: [ACTIVE_METER.id] },
] } })
}
return Promise.resolve({ data: { items: [] } })
})
mockPost.mockResolvedValue({ data: ACTIVE_METER })
renderWithProviders(<MeterManager />)
await user.click(await screen.findByTestId('meter-declare-button'))
await user.type(screen.getByTestId('meter-label'), 'Replacement meter')
await user.type(screen.getByTestId('meter-started-at'), '2026-01-01')
await user.click(screen.getByTestId('meter-reason'))
await user.click(await screen.findByText('Meter swap (same address)'))
await user.click(screen.getAllByLabelText('Bind source (optional)')[0])
await user.click(await screen.findByText('DSMR'))
await user.click((await screen.findAllByLabelText('Compatible source channel (optional)'))[0])
await user.click(await screen.findByText('Current total (kWh) — hand off from current meter'))
const startedAt = screen.getByTestId('meter-started-at')
await user.clear(startedAt)
await user.type(startedAt, '2024-01-15')
expect(screen.getAllByLabelText('Compatible source channel (optional)')[0]).toHaveValue('')
await user.click(screen.getByTestId('declare-meter-submit'))
await waitFor(() => expect(mockPost).toHaveBeenCalledWith(
'/api/energy/meters',
expect.objectContaining({ body: expect.not.objectContaining({ source_channel_uuid: 'handoff-channel' }) }),
))
})
it('treats a naive UTC binding timestamp as Amsterdam local time when validating a handoff', async () => {
vi.stubEnv('TZ', 'Europe/Amsterdam')
try {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{
...ACTIVE_METER,
bindings: [{
uuid: 'binding-1', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
// SQLite commonly round-trips this UTC instant without a timezone suffix.
started_at: '2024-01-14T23:00:00', ended_at: null,
}],
}], total: 1 } })
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'source-1', name: 'DSMR' }] } })
if (path === '/api/energy/sources/{source_uuid}/channels') {
return Promise.resolve({ data: { items: [
{ uuid: 'handoff-channel', label: 'Current total', unit: 'kWh', binding_count: 1, bound_meter_ids: [ACTIVE_METER.id] },
] } })
}
return Promise.resolve({ data: { items: [] } })
})
mockPost.mockRejectedValueOnce(new Error('keep modal open after unsafe submission'))
mockPost.mockResolvedValueOnce({ data: ACTIVE_METER })
renderWithProviders(<MeterManager />)
await user.click(await screen.findByTestId('meter-declare-button'))
await user.type(screen.getByTestId('meter-label'), 'Replacement meter')
await user.type(screen.getByTestId('meter-started-at'), '2024-01-16')
await user.click(screen.getByTestId('meter-reason'))
await user.click(await screen.findByText('Meter swap (same address)'))
await user.click(screen.getAllByLabelText('Bind source (optional)')[0])
await user.click(await screen.findByText('DSMR'))
await user.click((await screen.findAllByLabelText('Compatible source channel (optional)'))[0])
await user.click(await screen.findByText('Current total (kWh) — hand off from current meter'))
const startedAt = screen.getByTestId('meter-started-at')
await user.clear(startedAt)
await user.type(startedAt, '2024-01-15')
expect(screen.getAllByLabelText('Compatible source channel (optional)')[0]).toHaveValue('')
await user.click(screen.getByTestId('declare-meter-submit'))
await waitFor(() => expect(mockPost).toHaveBeenLastCalledWith(
'/api/energy/meters',
expect.objectContaining({ body: expect.not.objectContaining({ source_channel_uuid: 'handoff-channel' }) }),
))
await user.clear(startedAt)
await user.type(startedAt, '2024-01-16')
await user.click(screen.getAllByLabelText('Compatible source channel (optional)')[0])
await user.click(await screen.findByText('Current total (kWh) — hand off from current meter'))
await user.click(screen.getByTestId('declare-meter-submit'))
await waitFor(() => expect(mockPost).toHaveBeenLastCalledWith(
'/api/energy/meters',
expect.objectContaining({ body: expect.objectContaining({ source_channel_uuid: 'handoff-channel' }) }),
))
} finally {
vi.unstubAllEnvs()
}
})
it('displays error when POST fails with 422 (倒挂 / validation error)', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
const { ApiError } = await import('../api/client')
mockPost.mockRejectedValue(
new ApiError(422, { detail: 'started_at must be ≥ current active meter started_at' }),
)
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
await user.click(screen.getByTestId('meter-declare-button'))
await waitFor(() => expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument())
await user.type(screen.getByTestId('meter-label'), 'Bad meter')
await user.type(screen.getByTestId('meter-started-at'), '2020-01-01')
await user.click(screen.getByTestId('meter-reason'))
await waitFor(() => screen.getByText('Meter swap (same address)'))
await user.click(screen.getByText('Meter swap (same address)'))
await user.click(screen.getByTestId('declare-meter-submit'))
await waitFor(() => {
expect(screen.getByTestId('declare-meter-error')).toBeInTheDocument()
})
expect(screen.getByTestId('declare-meter-error').textContent).toContain(
'started_at must be ≥',
)
})
it('cancel button closes the declare modal', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
await user.click(screen.getByTestId('meter-declare-button'))
await waitFor(() => expect(screen.getByTestId('declare-meter-modal')).toBeInTheDocument())
await user.click(screen.getByTestId('declare-meter-cancel'))
await waitFor(() => {
expect(screen.queryByTestId('declare-meter-modal')).not.toBeInTheDocument()
})
})
})
describe('MeterManager — edit meter date initialisation', () => {
beforeEach(() => vi.clearAllMocks())
it('initialises date input from started_at (Z-suffix, UTC midnight → local date)', async () => {
// ACTIVE_METER.started_at = '2024-01-15T00:00:00Z' (UTC midnight).
// The test suite is pinned to TZ=UTC (via vite.config.ts test.env), so
// the local date is deterministically '2024-01-15' on any CI runner.
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
expect(dateInput.value).toBe('2024-01-15')
})
it('initialises date input from started_at (naive, no tz marker)', async () => {
// A naive timestamp without timezone marker — parseBackendTimestamp appends 'Z'
// so it is treated as UTC. With TZ=UTC (pinned in vite.config.ts), the local date
// equals the UTC date exactly.
const naiveMeter = { ...ACTIVE_METER, started_at: '2024-03-20T00:00:00' }
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [naiveMeter], total: 1 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${naiveMeter.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${naiveMeter.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
expect(dateInput.value).toBe('2024-03-20')
})
it('initialises date input from started_at with explicit UTC offset (+02:00) — regression for old buggy regex', async () => {
// The old hand-written regex /[zZ+-]\d*$/ would fail to match '+02:00' (the ':00'
// suffix broke the pattern) and would incorrectly append 'Z', producing an Invalid Date.
// The new code uses parseBackendTimestamp which uses the correct TZ_MARKER_RE regex
// and handles explicit offsets properly.
const offsetMeter = { ...ACTIVE_METER, started_at: '2024-01-15T02:00:00+02:00' }
// UTC equivalent: 2024-01-15T00:00:00Z → with TZ=UTC (pinned) local date = '2024-01-15'
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [offsetMeter], total: 1 } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${offsetMeter.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${offsetMeter.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement
// Must not be empty (which would indicate Invalid Date from the old buggy path)
expect(dateInput.value).not.toBe('')
expect(dateInput.value).toBe('2024-01-15')
})
it('does not include started_at in PATCH body when date is unchanged (round-trip idempotence)', async () => {
// Open the edit form and immediately submit without changing any fields except label.
// The date should be considered unchanged → no started_at in the PATCH body.
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, label: 'New label' } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
// Change only label; leave date untouched
const labelInput = screen.getByTestId('edit-meter-label')
await user.clear(labelInput)
await user.type(labelInput, 'New label')
await user.click(screen.getByTestId('edit-meter-submit'))
await waitFor(() => {
expect(mockPatch).toHaveBeenCalled()
})
const patchBody = mockPatch.mock.calls[0][1].body
expect(patchBody).not.toHaveProperty('started_at')
})
})
describe('MeterManager — edit meter', () => {
beforeEach(() => vi.clearAllMocks())
it('opens edit modal when Edit button is clicked', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: METERS_RESPONSE })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => {
expect(screen.getByTestId('edit-meter-modal')).toBeInTheDocument()
})
expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()
})
it('calls PATCH /api/energy/meters/{meter_id} when label is changed', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, label: 'Renamed meter' } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
// Clear label and type new one
const labelInput = screen.getByTestId('edit-meter-label')
await user.clear(labelInput)
await user.type(labelInput, 'Renamed meter')
await user.click(screen.getByTestId('edit-meter-submit'))
await waitFor(() => {
expect(mockPatch).toHaveBeenCalledWith(
'/api/energy/meters/{meter_id}',
expect.objectContaining({
params: { path: { meter_id: ACTIVE_METER.id } },
body: expect.objectContaining({ label: 'Renamed meter' }),
}),
)
})
})
it('shows recompute notice when started_at is changed (retroactive correction)', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, started_at: '2024-02-01T00:00:00' } })
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
// Change the date field
const dateInput = screen.getByTestId('edit-meter-started-at')
await user.clear(dateInput)
await user.type(dateInput, '2024-02-01')
await user.click(screen.getByTestId('edit-meter-submit'))
await waitFor(() => {
expect(screen.getByTestId('meter-recompute-notice')).toBeInTheDocument()
})
})
it('displays error when PATCH fails', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
const { ApiError } = await import('../api/client')
mockPatch.mockRejectedValue(new ApiError(422, { detail: 'started_at conflict' }))
renderWithProviders(<MeterManager />)
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
// Change label so there's something to patch
const labelInput = screen.getByTestId('edit-meter-label')
await user.clear(labelInput)
await user.type(labelInput, 'Different label')
await user.click(screen.getByTestId('edit-meter-submit'))
await waitFor(() => {
expect(screen.getByTestId('edit-meter-error')).toBeInTheDocument()
})
expect(screen.getByTestId('edit-meter-error').textContent).toContain('started_at conflict')
})
})