2026-06-25 16:45:11 +02:00
|
|
|
/**
|
|
|
|
|
* 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',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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', () => {
|
2026-08-23 14:13:56 +02:00
|
|
|
// M8 keeps the existing Modbus-facing meter regressions alongside commodity additions.
|
2026-06-25 16:45:11 +02:00
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-23 14:13:56 +02:00
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-25 16:45:11 +02:00
|
|
|
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()
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-23 14:13:56 +02:00
|
|
|
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)')
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-25 16:45:11 +02:00
|
|
|
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('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()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-25 18:17:24 +02:00
|
|
|
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')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-25 16:45:11 +02:00
|
|
|
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')
|
|
|
|
|
})
|
|
|
|
|
})
|