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',
|
2026-08-24 02:33:34 +02:00
|
|
|
bindings: [],
|
2026-06-25 16:45:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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())
|
2026-08-24 13:38:02 +02:00
|
|
|
expect(screen.queryByRole('button', { name: 'Transfer source' })).not.toBeInTheDocument()
|
2026-08-23 14:13:56 +02:00
|
|
|
})
|
2026-08-24 13:38:02 +02:00
|
|
|
it('closes an active meter with one atomic close request and local datetime payload', async () => {
|
2026-08-23 14:13:56 +02:00
|
|
|
const user = userEvent.setup()
|
2026-08-24 13:38:02 +02:00
|
|
|
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
|
|
|
|
|
mockPost.mockResolvedValue({ data: { ...ACTIVE_METER, ended_at: '2026-08-24T10:00:00Z' } })
|
2026-08-23 14:13:56 +02:00
|
|
|
renderWithProviders(<MeterManager />)
|
2026-08-24 13:38:02 +02:00
|
|
|
await user.click(await screen.findByRole('button', { name: 'Close meter' }))
|
|
|
|
|
expect(await screen.findByText(/Every open binding/)).toBeInTheDocument()
|
|
|
|
|
await user.click(screen.getAllByRole('button', { name: 'Close meter' })[1])
|
|
|
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith(
|
|
|
|
|
'/api/energy/meters/{meter_id}/close',
|
|
|
|
|
expect.objectContaining({ params: { path: { meter_id: ACTIVE_METER.id } }, body: expect.objectContaining({ ended_at: expect.not.stringMatching(/Z$/) }) }),
|
|
|
|
|
))
|
2026-08-23 14:13:56 +02:00
|
|
|
})
|
2026-08-24 13:38:02 +02:00
|
|
|
|
|
|
|
|
it('offers atomic stranded-binding recovery to the adjacent active meter and warns about a gap', async () => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const stranded = {
|
|
|
|
|
...CLOSED_METER,
|
|
|
|
|
ended_at: '2024-01-10T00:00:00Z',
|
|
|
|
|
bindings: [{ uuid: 'stranded-binding', source_uuid: 'dsmr', source_channel_uuid: 'dsmr-total', started_at: '2023-06-01T00:00:00Z', ended_at: null }],
|
|
|
|
|
}
|
|
|
|
|
mockGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [stranded, ACTIVE_METER], total: 2 } })
|
|
|
|
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'dsmr', name: 'DSMR' }], total: 1 } })
|
|
|
|
|
return Promise.resolve({ data: { items: [] } })
|
|
|
|
|
})
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Recover binding' }))
|
|
|
|
|
expect(await screen.findByText(/one atomic Transfer request/)).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText(/defaults to the new meter start/)).toBeInTheDocument()
|
|
|
|
|
const effectiveAt = screen.getByTestId('transfer-effective-at')
|
|
|
|
|
await user.clear(effectiveAt)
|
|
|
|
|
await user.type(effectiveAt, '2024-01-16T12:00')
|
|
|
|
|
expect(screen.getByText(/leaves an unbound gap/)).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('does not offer recovery when another epoch overlaps the source or target timeline', async () => {
|
|
|
|
|
const stranded = {
|
|
|
|
|
...CLOSED_METER,
|
|
|
|
|
ended_at: '2024-01-10T00:00:00Z',
|
|
|
|
|
bindings: [{ uuid: 'stranded-binding', source_uuid: 'dsmr', source_channel_uuid: 'dsmr-total', started_at: '2023-06-01T00:00:00Z', ended_at: null }],
|
|
|
|
|
}
|
|
|
|
|
const ambiguous = { ...CLOSED_METER, id: 3, started_at: '2024-01-12T00:00:00Z', ended_at: '2024-01-20T00:00:00Z' }
|
|
|
|
|
mockGet.mockResolvedValue({ data: { items: [stranded, ambiguous, ACTIVE_METER], total: 3 } })
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await waitFor(() => expect(screen.getByTestId('meters-table')).toBeInTheDocument())
|
|
|
|
|
expect(screen.queryByRole('button', { name: 'Recover binding' })).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-23 14:13:56 +02:00
|
|
|
})
|
|
|
|
|
|
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 },
|
|
|
|
|
],
|
|
|
|
|
}
|
2026-08-27 20:01:41 +02:00
|
|
|
mockGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [meterWithBindings], total: 1 } })
|
|
|
|
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [
|
|
|
|
|
{ uuid: 'source-old', name: 'Old source' }, { uuid: 'source-new', name: 'New source' },
|
|
|
|
|
], total: 2 } })
|
|
|
|
|
if (path === '/api/energy/sources/{source_uuid}/channels') return Promise.resolve({ data: { items: [
|
|
|
|
|
{ uuid: 'channel-old', label: 'Old channel', unit: 'kWh' }, { uuid: 'channel-new', label: 'New channel', unit: 'kWh' },
|
|
|
|
|
], total: 2 } })
|
|
|
|
|
return Promise.resolve({ data: { items: [] } })
|
|
|
|
|
})
|
2026-08-23 14:13:56 +02:00
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
const closed = await screen.findByTestId('binding-timeline-binding-closed')
|
2026-08-27 20:01:41 +02:00
|
|
|
await waitFor(() => expect(closed).toHaveTextContent('Old source → Old channel'))
|
2026-08-23 14:13:56 +02:00
|
|
|
const active = screen.getByTestId('binding-timeline-binding-active')
|
2026-08-27 20:01:41 +02:00
|
|
|
expect(closed).toHaveTextContent('Old source → Old channel')
|
2026-08-23 14:13:56 +02:00
|
|
|
expect(closed).toHaveTextContent('[1/1/2025, 00:00:00, 2/1/2025, 00:00:00) (closed)')
|
2026-08-27 20:01:41 +02:00
|
|
|
expect(active).toHaveTextContent('New source → New channel')
|
2026-08-23 14:13:56 +02:00
|
|
|
expect(active).toHaveTextContent('[2/1/2025, 00:00:00, open-ended) (active)')
|
2026-08-27 20:01:41 +02:00
|
|
|
expect(closed).not.toHaveTextContent('source-old')
|
|
|
|
|
expect(active).not.toHaveTextContent('channel-new')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('uses UUID-free honest binding fallbacks while source details load or fail', async () => {
|
|
|
|
|
const meter = { ...ACTIVE_METER, bindings: [{ uuid: 'binding-visible-id-only', source_uuid: 'private-source-id', source_channel_uuid: 'private-channel-id', started_at: '2025-01-01T00:00:00Z', ended_at: null }] }
|
|
|
|
|
mockGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [meter], total: 1 } })
|
|
|
|
|
if (path === '/api/energy/sources') return new Promise(() => {})
|
|
|
|
|
return Promise.resolve({ data: { items: [] } })
|
|
|
|
|
})
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
const timeline = await screen.findByTestId('binding-timeline-binding-visible-id-only')
|
|
|
|
|
expect(timeline).toHaveTextContent('Loading source details… → Channel details unavailable')
|
|
|
|
|
expect(timeline).not.toHaveTextContent('private-source-id')
|
|
|
|
|
expect(timeline).not.toHaveTextContent('private-channel-id')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps meter row actions in the shared light, xs order', async () => {
|
|
|
|
|
const binding = { uuid: 'ordered-binding', source_uuid: 'source-1', source_channel_uuid: 'channel-1', started_at: '2024-01-15T00:00:00Z', ended_at: null }
|
|
|
|
|
mockGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{ ...ACTIVE_METER, bindings: [binding] }], total: 1 } })
|
|
|
|
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'source-1', name: 'DSMR' }], total: 1 } })
|
|
|
|
|
if (path === '/api/energy/sources/{source_uuid}/channels') return Promise.resolve({ data: { items: [{ uuid: 'channel-1', label: 'Total import', unit: 'kWh' }], total: 1 } })
|
|
|
|
|
return Promise.resolve({ data: { items: [] } })
|
|
|
|
|
})
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
const row = await screen.findByTestId(`meter-row-${ACTIVE_METER.id}`)
|
|
|
|
|
const buttons = Array.from(row.querySelectorAll('button'))
|
|
|
|
|
expect(buttons.map((button) => button.textContent)).toEqual(['Edit', 'Transfer source', 'Unbind', 'Close meter'])
|
|
|
|
|
expect(buttons).toHaveLength(4)
|
2026-08-23 14:13:56 +02:00
|
|
|
})
|
|
|
|
|
|
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',
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-24 02:33:34 +02:00
|
|
|
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()
|
2026-08-24 13:38:02 +02:00
|
|
|
// Its historical count alone does not make it unavailable: eligibility is
|
|
|
|
|
// derived from actual open intervals, not lifetime binding_count.
|
|
|
|
|
expect(screen.getByRole('option', { name: 'Other total (kWh)' })).not.toHaveAttribute('data-combobox-disabled')
|
2026-08-24 02:33:34 +02:00
|
|
|
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])
|
|
|
|
|
|
2026-08-24 13:38:02 +02:00
|
|
|
expect(await screen.findByRole('option', { name: /Current total \(kWh\).*ambiguous/ })).toHaveAttribute(
|
2026-08-24 02:33:34 +02:00
|
|
|
'data-combobox-disabled',
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-24 13:38:02 +02:00
|
|
|
it('disables an equal handoff boundary with a reason and fails closed on submit', async () => {
|
2026-08-24 02:33:34 +02:00
|
|
|
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')
|
2026-08-24 13:38:02 +02:00
|
|
|
await user.type(screen.getByTestId('meter-started-at'), '2024-01-15')
|
2026-08-24 02:33:34 +02:00
|
|
|
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'))
|
2026-08-24 13:38:02 +02:00
|
|
|
const channelInput = screen.getAllByLabelText('Compatible source channel (optional)')[0]
|
|
|
|
|
await user.click(channelInput)
|
|
|
|
|
const disabled = await screen.findByText(/handoff boundary must be strictly after the current binding start/)
|
|
|
|
|
expect(disabled.closest('[role="option"]')).toHaveAttribute('data-combobox-disabled', 'true')
|
|
|
|
|
await user.keyboard('{Escape}')
|
2026-08-24 02:33:34 +02:00
|
|
|
await user.click(screen.getByTestId('declare-meter-submit'))
|
2026-08-24 13:38:02 +02:00
|
|
|
expect(screen.getByTestId('declare-meter-error')).toHaveTextContent('Handoff boundary must be strictly after')
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled()
|
2026-08-24 02:33:34 +02:00
|
|
|
})
|
|
|
|
|
|
2026-08-24 13:38:02 +02:00
|
|
|
it('submits an explicitly selected handoff strictly after its binding start', async () => {
|
2026-08-24 02:33:34 +02:00
|
|
|
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',
|
2026-08-24 13:38:02 +02:00
|
|
|
started_at: '2024-01-15T00:00:00Z', ended_at: null,
|
2026-08-24 02:33:34 +02:00
|
|
|
}],
|
|
|
|
|
}], 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: [] } })
|
|
|
|
|
})
|
2026-08-24 13:38:02 +02:00
|
|
|
mockPost.mockResolvedValue({ data: ACTIVE_METER })
|
2026-08-24 02:33:34 +02:00
|
|
|
|
|
|
|
|
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-16')
|
2026-08-24 13:38:02 +02:00
|
|
|
const channelInput = screen.getAllByLabelText('Compatible source channel (optional)')[0]
|
|
|
|
|
await user.click(channelInput)
|
2026-08-24 02:33:34 +02:00
|
|
|
await user.click(await screen.findByText('Current total (kWh) — hand off from current meter'))
|
2026-08-24 13:38:02 +02:00
|
|
|
await waitFor(() => expect(channelInput).toHaveValue('Current total (kWh) — hand off from current meter'))
|
2026-08-24 02:33:34 +02:00
|
|
|
await user.click(screen.getByTestId('declare-meter-submit'))
|
|
|
|
|
|
2026-08-24 13:38:02 +02:00
|
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith(
|
2026-08-24 02:33:34 +02:00
|
|
|
'/api/energy/meters',
|
2026-08-24 13:38:02 +02:00
|
|
|
expect.objectContaining({ body: expect.objectContaining({ source_channel_uuid: 'handoff-channel', started_at: '2024-01-16T00:00:00' }) }),
|
2026-08-24 02:33:34 +02:00
|
|
|
))
|
2026-08-24 13:38:02 +02:00
|
|
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
2026-08-24 02:33:34 +02:00
|
|
|
} finally {
|
|
|
|
|
vi.unstubAllEnvs()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
2026-08-24 13:38:02 +02:00
|
|
|
it.each([
|
|
|
|
|
['equal boundary', '2024-01-15', false],
|
|
|
|
|
['strictly later boundary', '2024-01-16', true],
|
|
|
|
|
])('handles an implicit auto-handoff at the %s with another commodity open', async (_name, date, submits) => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const electricity = {
|
|
|
|
|
...ACTIVE_METER,
|
|
|
|
|
bindings: [{
|
|
|
|
|
uuid: 'electricity-open', source_uuid: 'dsmr-source', source_channel_uuid: 'electricity-kwh',
|
|
|
|
|
started_at: '2024-01-15T00:00:00Z', ended_at: null,
|
|
|
|
|
}],
|
|
|
|
|
}
|
|
|
|
|
const heating = {
|
|
|
|
|
...ACTIVE_METER,
|
|
|
|
|
id: 2,
|
|
|
|
|
label: 'Heating meter',
|
|
|
|
|
commodity: 'heating',
|
|
|
|
|
started_at: '2024-01-01T00:00:00Z',
|
|
|
|
|
bindings: [{
|
|
|
|
|
uuid: 'heating-open', source_uuid: 'warmtelink-source', source_channel_uuid: 'heating-gj',
|
|
|
|
|
started_at: '2024-01-01T00:00:00Z', ended_at: null,
|
|
|
|
|
}],
|
|
|
|
|
}
|
|
|
|
|
mockGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [electricity, heating], total: 2 } })
|
|
|
|
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [], total: 0 } })
|
|
|
|
|
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'), date)
|
|
|
|
|
await user.click(screen.getByTestId('meter-reason'))
|
|
|
|
|
await user.click(await screen.findByText('Meter swap (same address)'))
|
|
|
|
|
await user.click(screen.getByTestId('declare-meter-submit'))
|
|
|
|
|
|
|
|
|
|
if (!submits) {
|
|
|
|
|
expect(screen.getByTestId('declare-meter-error')).toHaveTextContent('Handoff boundary must be strictly after')
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters', {
|
|
|
|
|
body: {
|
|
|
|
|
label: 'Replacement meter', started_at: '2024-01-16T00:00:00', reason: 'meter_swap',
|
|
|
|
|
commodity: 'electricity',
|
|
|
|
|
},
|
|
|
|
|
}))
|
|
|
|
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
|
|
|
|
})
|
|
|
|
|
|
2026-06-25 16:45:11 +02:00
|
|
|
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')
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-08-24 13:38:02 +02:00
|
|
|
|
|
|
|
|
describe('MeterManager — declare channel interval boundaries', () => {
|
|
|
|
|
beforeEach(() => vi.clearAllMocks())
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
['closed-history overlap', '2025-06-01', false, 'overlaps closed binding history; choose a time at or after it ends'],
|
|
|
|
|
['closed-history equal end', '2025-12-31', true, null],
|
|
|
|
|
])('treats Declare Meter %s as %s with the corresponding request state', async (_name, date, eligible, reason) => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const source = { uuid: 'source-1', name: 'DSMR' }
|
|
|
|
|
const channel = { uuid: 'channel-1', label: 'Total import', unit: 'kWh', binding_count: 1, bound_meter_ids: [8] }
|
|
|
|
|
const history = { ...CLOSED_METER, id: 8, started_at: '2024-01-01T00:00:00Z', ended_at: '2026-01-01T00:00:00Z', bindings: [{ uuid: 'history', source_uuid: source.uuid, source_channel_uuid: channel.uuid, started_at: '2025-01-01T00:00:00Z', ended_at: '2025-12-31T00:00:00Z' }] }
|
|
|
|
|
mockGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [history], total: 1 } })
|
|
|
|
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [source], total: 1 } })
|
|
|
|
|
if (path === '/api/energy/sources/{source_uuid}/channels') return Promise.resolve({ data: { items: [channel], total: 1 } })
|
|
|
|
|
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'), 'Boundary meter')
|
|
|
|
|
await user.type(screen.getByTestId('meter-started-at'), date)
|
|
|
|
|
await user.click(screen.getByTestId('meter-reason')); await user.click(await screen.findByText('Initial installation'))
|
|
|
|
|
const sourceInput = screen.getAllByLabelText('Bind source (optional)').find((element) => element.tagName === 'INPUT')!
|
|
|
|
|
await user.click(sourceInput); await user.click(await screen.findByText('DSMR'))
|
|
|
|
|
const channelInput = screen.getAllByLabelText('Compatible source channel (optional)').find((element) => element.tagName === 'INPUT')!
|
|
|
|
|
await user.click(channelInput)
|
|
|
|
|
if (!eligible) {
|
|
|
|
|
const disabled = await screen.findByText(new RegExp(reason!))
|
|
|
|
|
expect(disabled.closest('[role="option"]')).toHaveAttribute('data-combobox-disabled', 'true')
|
|
|
|
|
await user.keyboard('{Escape}')
|
|
|
|
|
await user.click(screen.getByTestId('declare-meter-submit'))
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
await user.click(await screen.findByText('Total import (kWh)'))
|
|
|
|
|
await user.click(screen.getByTestId('declare-meter-submit'))
|
|
|
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters', expect.objectContaining({
|
|
|
|
|
body: expect.objectContaining({ source_channel_uuid: 'channel-1', started_at: '2025-12-31T00:00:00' }),
|
|
|
|
|
})))
|
|
|
|
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// These are deliberately component-level tests: the important contract is that
|
|
|
|
|
// each lifecycle dialog sends one endpoint request, rather than a UI-only
|
|
|
|
|
// assertion which could still regress into a two-step switch.
|
|
|
|
|
describe('MeterManager — lifecycle modal submissions', () => {
|
|
|
|
|
const source = { uuid: 'source-1', name: 'DSMR' }
|
|
|
|
|
const channel = { uuid: 'channel-1', label: 'Total import', unit: 'kWh', binding_count: 0, bound_meter_ids: [] }
|
|
|
|
|
const mockLifecycleReads = (items: unknown[], channels = [channel], sources = [source]) => {
|
|
|
|
|
mockGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items, total: items.length } })
|
|
|
|
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: sources, total: sources.length } })
|
|
|
|
|
if (path === '/api/energy/sources/{source_uuid}/channels') return Promise.resolve({ data: { items: channels, total: channels.length } })
|
|
|
|
|
return Promise.resolve({ data: { items: [] } })
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
const chooseChannel = async (user: ReturnType<typeof userEvent.setup>) => {
|
|
|
|
|
const labelledInput = (label: string) => screen.getAllByLabelText(label).find((element) => element.tagName === 'INPUT')!
|
|
|
|
|
await user.click(labelledInput('Source'))
|
|
|
|
|
await user.click(await screen.findByText('DSMR'))
|
|
|
|
|
await user.click(labelledInput('Source channel'))
|
|
|
|
|
await user.click(await screen.findByText('Total import (kWh)'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
beforeEach(() => vi.clearAllMocks())
|
|
|
|
|
|
|
|
|
|
it('unbinds an active binding with one PATCH, keeps the dialog after FastAPI detail-array error, and retries', async () => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const binding = { uuid: 'open-binding', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2024-01-15T00:00:00Z', ended_at: null }
|
|
|
|
|
mockLifecycleReads([{ ...ACTIVE_METER, bindings: [binding] }])
|
|
|
|
|
const { ApiError } = await import('../api/client')
|
|
|
|
|
mockPatch.mockRejectedValueOnce(new ApiError(422, { detail: [{ msg: 'unbind must follow binding start' }] }))
|
|
|
|
|
mockPatch.mockResolvedValueOnce({ data: { ...binding, ended_at: '2026-08-24T10:00:00' } })
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Unbind' }))
|
|
|
|
|
const input = screen.getByTestId('unbind-modal-open-binding').querySelector('input[type="datetime-local"]')!
|
|
|
|
|
await user.clear(input); await user.type(input, '2026-08-24T10:00')
|
|
|
|
|
const unbindButtons = screen.getAllByRole('button', { name: 'Unbind' })
|
|
|
|
|
await user.click(unbindButtons[unbindButtons.length - 1])
|
|
|
|
|
await waitFor(() => expect(mockPatch).toHaveBeenCalledWith('/api/energy/bindings/{binding_uuid}', expect.objectContaining({
|
|
|
|
|
params: { path: { binding_uuid: 'open-binding' } }, body: { ended_at: '2026-08-24T10:00' },
|
|
|
|
|
})))
|
|
|
|
|
expect(await screen.findByText('unbind must follow binding start')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByTestId('unbind-modal-open-binding')).toBeInTheDocument()
|
|
|
|
|
const retryUnbindButtons = screen.getAllByRole('button', { name: 'Unbind' })
|
|
|
|
|
await user.click(retryUnbindButtons[retryUnbindButtons.length - 1])
|
|
|
|
|
await waitFor(() => expect(mockPatch).toHaveBeenCalledTimes(2))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('binds an unbound active meter directly with local datetime and one create-binding request', async () => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
mockLifecycleReads([ACTIVE_METER])
|
|
|
|
|
mockPost.mockResolvedValue({ data: {} })
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Bind source' }))
|
|
|
|
|
await chooseChannel(user)
|
|
|
|
|
const start = screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
|
|
|
|
await user.clear(start); await user.type(start, '2026-08-24T10:00')
|
|
|
|
|
const bindButtons = screen.getAllByRole('button', { name: 'Bind source' })
|
|
|
|
|
await user.click(bindButtons[bindButtons.length - 1])
|
|
|
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/bindings', expect.objectContaining({
|
|
|
|
|
params: { path: { meter_id: ACTIVE_METER.id } }, body: { source_channel_uuid: 'channel-1', started_at: '2026-08-24T10:00' },
|
|
|
|
|
})))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('transfers on the same meter with one atomic request and excludes its source binding from channel eligibility', async () => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const binding = { uuid: 'same-source', source_uuid: 'source-1', source_channel_uuid: 'channel-1', started_at: '2024-01-15T00:00:00Z', ended_at: null }
|
|
|
|
|
mockLifecycleReads([{ ...ACTIVE_METER, bindings: [binding] }], [{ ...channel, binding_count: 1 }])
|
|
|
|
|
mockPost.mockResolvedValue({ data: {} })
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Transfer source' }))
|
|
|
|
|
await chooseChannel(user)
|
|
|
|
|
const effective = screen.getByTestId('transfer-effective-at')
|
|
|
|
|
await user.clear(effective); await user.type(effective, '2026-08-24T10:00')
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
|
|
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/bindings/transfer', expect.objectContaining({
|
|
|
|
|
params: { path: { meter_id: ACTIVE_METER.id } }, body: { from_binding_uuid: 'same-source', to_source_channel_uuid: 'channel-1', effective_at: '2026-08-24T10:00' },
|
|
|
|
|
})))
|
|
|
|
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('recovers a stranded binding to the unique later active meter in one transfer at its default start', async () => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const stranded = { ...CLOSED_METER, ended_at: '2024-01-10T00:00:00Z', bindings: [{ uuid: 'stranded', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2023-06-01T00:00:00Z', ended_at: null }] }
|
|
|
|
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2024-01-15T00:00:00Z', bindings: [] }
|
|
|
|
|
mockLifecycleReads([stranded, target])
|
|
|
|
|
mockPost.mockResolvedValue({ data: {} })
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Recover binding' }))
|
|
|
|
|
expect(screen.getByTestId('transfer-effective-at')).toHaveValue('2024-01-15T00:00')
|
|
|
|
|
await chooseChannel(user)
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
|
|
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/bindings/transfer', expect.objectContaining({
|
|
|
|
|
params: { path: { meter_id: 7 } }, body: { from_binding_uuid: 'stranded', to_source_channel_uuid: 'channel-1', effective_at: '2024-01-15T00:00' },
|
|
|
|
|
})))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows source loading and channel fetch failure without enabling a submit request', async () => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
mockGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [ACTIVE_METER], total: 1 } })
|
|
|
|
|
if (path === '/api/energy/sources') return new Promise(() => {})
|
|
|
|
|
return Promise.reject(new Error('channel fetch failed'))
|
|
|
|
|
})
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Bind source' }))
|
|
|
|
|
expect(await screen.findByText('Loading sources…')).toBeInTheDocument()
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows a source error and an enabled channel-query loading or error state independently', async () => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
mockGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [ACTIVE_METER], total: 1 } })
|
|
|
|
|
if (path === '/api/energy/sources') return Promise.reject(new Error('source fetch failed'))
|
|
|
|
|
return Promise.resolve({ data: { items: [] } })
|
|
|
|
|
})
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Bind source' }))
|
|
|
|
|
expect(await screen.findByText('Could not load sources. Retry after the connection recovers.')).toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
mockGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [ACTIVE_METER], total: 1 } })
|
|
|
|
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [source], total: 1 } })
|
|
|
|
|
if (path === '/api/energy/sources/{source_uuid}/channels') return Promise.reject(new Error('channel fetch failed'))
|
|
|
|
|
return Promise.resolve({ data: { items: [] } })
|
|
|
|
|
})
|
|
|
|
|
await user.click(screen.getAllByRole('button', { name: 'Cancel' })[0])
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Bind source' }))
|
|
|
|
|
await user.click((screen.getAllByLabelText('Source').find((element) => element.tagName === 'INPUT'))!)
|
|
|
|
|
await user.click(await screen.findByText('DSMR'))
|
|
|
|
|
expect(await screen.findByText('Could not load source channels. Choose another source or retry.')).toBeInTheDocument()
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('clears a direct-bind selection that becomes a closed-history overlap and does not submit it', async () => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const target = { ...ACTIVE_METER, bindings: [{ uuid: 'history', source_uuid: 'source-1', source_channel_uuid: 'channel-1', started_at: '2025-01-01T00:00:00Z', ended_at: '2025-12-31T00:00:00Z' }] }
|
|
|
|
|
mockLifecycleReads([target])
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Bind source' }))
|
|
|
|
|
await chooseChannel(user)
|
|
|
|
|
const start = screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
|
|
|
|
await user.clear(start); await user.type(start, '2025-06-01T10:00')
|
|
|
|
|
expect(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')).toHaveValue('')
|
|
|
|
|
const bindButtons = screen.getAllByRole('button', { name: 'Bind source' })
|
|
|
|
|
await user.click(bindButtons[bindButtons.length - 1])
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps a direct-bind selection at the equal closed-history boundary and submits it', async () => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const target = { ...ACTIVE_METER, bindings: [{ uuid: 'history', source_uuid: 'source-1', source_channel_uuid: 'channel-1', started_at: '2025-01-01T00:00:00Z', ended_at: '2025-12-31T00:00:00Z' }] }
|
|
|
|
|
mockLifecycleReads([target]); mockPost.mockResolvedValue({ data: {} })
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Bind source' })); await chooseChannel(user)
|
|
|
|
|
const start = screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
|
|
|
|
await user.clear(start); await user.type(start, '2025-12-31T00:00')
|
|
|
|
|
const bindButtons = screen.getAllByRole('button', { name: 'Bind source' })
|
|
|
|
|
await user.click(bindButtons[bindButtons.length - 1])
|
|
|
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/bindings', expect.objectContaining({ body: expect.objectContaining({ source_channel_uuid: 'channel-1', started_at: '2025-12-31T00:00' }) })))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('clears a same-meter transfer selection that becomes earlier than its source and does not submit it', async () => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const binding = { uuid: 'same-source', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2025-06-01T00:00:00Z', ended_at: null }
|
|
|
|
|
mockLifecycleReads([{ ...ACTIVE_METER, bindings: [binding] }]); renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Transfer source' })); await chooseChannel(user)
|
|
|
|
|
const effective = screen.getByTestId('transfer-effective-at')
|
|
|
|
|
await user.clear(effective); await user.type(effective, '2025-05-31T23:59')
|
|
|
|
|
expect(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')).toHaveValue('')
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('rejects a same-meter transfer exactly at its source start with a specific reason, but submits one minute later', async () => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const binding = { uuid: 'same-source', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2025-06-01T00:00:00Z', ended_at: null }
|
|
|
|
|
mockLifecycleReads([{ ...ACTIVE_METER, bindings: [binding] }])
|
|
|
|
|
mockPost.mockResolvedValue({ data: {} })
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Transfer source' }))
|
|
|
|
|
await chooseChannel(user)
|
|
|
|
|
const effective = screen.getByTestId('transfer-effective-at')
|
|
|
|
|
await user.clear(effective); await user.type(effective, '2025-06-01T00:00')
|
|
|
|
|
expect(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')).toHaveValue('')
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
|
|
|
expect(screen.getByText('Select a unit-compatible source channel.')).toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
await user.click(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')!)
|
|
|
|
|
expect(await screen.findByText(/strictly after the source binding start/)).toBeInTheDocument()
|
|
|
|
|
await user.clear(effective); await user.type(effective, '2025-06-01T00:01')
|
|
|
|
|
await user.click(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')!)
|
|
|
|
|
await user.click(await screen.findByText('Total import (kWh)'))
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
|
|
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/bindings/transfer', expect.objectContaining({
|
|
|
|
|
params: { path: { meter_id: ACTIVE_METER.id } },
|
|
|
|
|
body: { from_binding_uuid: 'same-source', to_source_channel_uuid: 'channel-1', effective_at: '2025-06-01T00:01' },
|
|
|
|
|
})))
|
|
|
|
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('does not apply the same-meter source-start rule to legal cross-meter recovery', async () => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
// The transfer service closes this old binding at the closed Meter end.
|
|
|
|
|
// Keep its start strictly earlier: [2025-05-31, 2025-06-01) is a real,
|
|
|
|
|
// non-zero source interval while the two Meter epochs meet at the selected
|
|
|
|
|
// effective boundary.
|
|
|
|
|
const stranded = { ...CLOSED_METER, ended_at: '2025-06-01T00:00:00Z', bindings: [{ uuid: 'stranded', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2025-05-31T00:00:00Z', ended_at: null }] }
|
|
|
|
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2025-06-01T00:00:00Z', bindings: [] }
|
|
|
|
|
mockLifecycleReads([stranded, target]); mockPost.mockResolvedValue({ data: {} })
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Recover binding' }))
|
|
|
|
|
expect(screen.getByTestId('transfer-effective-at')).toHaveValue('2025-06-01T00:00')
|
|
|
|
|
await chooseChannel(user)
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
|
|
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/bindings/transfer', expect.objectContaining({
|
|
|
|
|
params: { path: { meter_id: 7 } },
|
|
|
|
|
body: expect.objectContaining({ from_binding_uuid: 'stranded', effective_at: '2025-06-01T00:00' }),
|
|
|
|
|
})))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('fails closed for an anomalous cross recovery source interval that the server would reject', async () => {
|
|
|
|
|
const stranded = { ...CLOSED_METER, ended_at: '2025-06-01T00:00:00Z', bindings: [{ uuid: 'bad-stranded', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2025-06-01T00:00:00Z', ended_at: null }] }
|
|
|
|
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2025-06-01T00:00:00Z', bindings: [] }
|
|
|
|
|
mockLifecycleReads([stranded, target])
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await waitFor(() => expect(screen.getByTestId('meters-table')).toBeInTheDocument())
|
|
|
|
|
expect(screen.queryByRole('button', { name: 'Recover binding' })).not.toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText(/Cannot recover: the source binding starts at or after this Meter ended/)).toBeInTheDocument()
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('uses a closed meter end as the unbind default and sends it in the PATCH payload', async () => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const binding = { uuid: 'closed-binding', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2024-01-01T00:00:00Z', ended_at: null }
|
|
|
|
|
const closed = { ...CLOSED_METER, ended_at: '2024-02-02T03:04:00Z', bindings: [binding] }
|
|
|
|
|
mockLifecycleReads([closed]); mockPatch.mockResolvedValue({ data: {} })
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Unbind' }))
|
|
|
|
|
expect(screen.getByTestId('unbind-modal-closed-binding').querySelector('input[type="datetime-local"]')).toHaveValue('2024-02-02T03:04')
|
|
|
|
|
const unbindButtons = screen.getAllByRole('button', { name: 'Unbind' })
|
|
|
|
|
await user.click(unbindButtons[unbindButtons.length - 1])
|
|
|
|
|
await waitFor(() => expect(mockPatch).toHaveBeenCalledWith('/api/energy/bindings/{binding_uuid}', expect.objectContaining({
|
|
|
|
|
params: { path: { binding_uuid: 'closed-binding' } }, body: { ended_at: '2024-02-02T03:04' },
|
|
|
|
|
})))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows channel loading only after source selection and never posts while loading', async () => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
mockGet.mockImplementation((path: string) => {
|
|
|
|
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [ACTIVE_METER], total: 1 } })
|
|
|
|
|
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [source], total: 1 } })
|
|
|
|
|
if (path === '/api/energy/sources/{source_uuid}/channels') return new Promise(() => {})
|
|
|
|
|
return Promise.resolve({ data: { items: [] } })
|
|
|
|
|
})
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Bind source' }))
|
|
|
|
|
expect(screen.queryByText('Loading source channels…')).not.toBeInTheDocument()
|
|
|
|
|
await user.click(screen.getAllByLabelText('Source').find((element) => element.tagName === 'INPUT')!)
|
|
|
|
|
await user.click(await screen.findByText('DSMR'))
|
|
|
|
|
expect(await screen.findByText('Loading source channels…')).toBeInTheDocument()
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('clears a recovery transfer selection that moves before its target epoch and does not submit it', async () => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const stranded = { ...CLOSED_METER, ended_at: '2025-01-01T00:00:00Z', bindings: [{ uuid: 'stranded', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2024-01-01T00:00:00Z', ended_at: null }] }
|
|
|
|
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] }
|
|
|
|
|
mockLifecycleReads([stranded, target]); renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Recover binding' })); await chooseChannel(user)
|
|
|
|
|
const effective = screen.getByTestId('transfer-effective-at')
|
|
|
|
|
await user.clear(effective); await user.type(effective, '2024-12-31T23:59')
|
|
|
|
|
expect(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')).toHaveValue('')
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps Close Meter open after its own error and retries the close endpoint', async () => {
|
|
|
|
|
const user = userEvent.setup(); const { ApiError } = await import('../api/client')
|
|
|
|
|
mockLifecycleReads([ACTIVE_METER])
|
|
|
|
|
mockPost.mockRejectedValueOnce(new ApiError(422, { detail: 'close must follow meter start' }))
|
|
|
|
|
mockPost.mockResolvedValueOnce({ data: {} })
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Close meter' }))
|
|
|
|
|
const modal = screen.getByTestId(`close-meter-modal-${ACTIVE_METER.id}`)
|
|
|
|
|
const input = modal.querySelector('input[type="datetime-local"]')!
|
|
|
|
|
await user.clear(input); await user.type(input, '2026-08-24T10:00')
|
|
|
|
|
await user.click(screen.getAllByRole('button', { name: 'Close meter' })[1])
|
|
|
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/close', expect.objectContaining({
|
|
|
|
|
params: { path: { meter_id: ACTIVE_METER.id } }, body: { ended_at: '2026-08-24T10:00' },
|
|
|
|
|
})))
|
|
|
|
|
expect(await screen.findByText('close must follow meter start')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByTestId(`close-meter-modal-${ACTIVE_METER.id}`)).toBeInTheDocument()
|
|
|
|
|
await user.click(screen.getAllByRole('button', { name: 'Close meter' })[1])
|
|
|
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(2))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps Direct Bind open after its own error and retries the create endpoint', async () => {
|
|
|
|
|
const user = userEvent.setup(); const { ApiError } = await import('../api/client')
|
|
|
|
|
mockLifecycleReads([ACTIVE_METER])
|
|
|
|
|
mockPost.mockRejectedValueOnce(new ApiError(422, { detail: 'binding conflict' }))
|
|
|
|
|
mockPost.mockResolvedValueOnce({ data: {} })
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Bind source' })); await chooseChannel(user)
|
|
|
|
|
const bindButtons = screen.getAllByRole('button', { name: 'Bind source' })
|
|
|
|
|
await user.click(bindButtons[bindButtons.length - 1])
|
|
|
|
|
expect(await screen.findByText('binding conflict')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`)).toBeInTheDocument()
|
|
|
|
|
const retryButtons = screen.getAllByRole('button', { name: 'Bind source' })
|
|
|
|
|
await user.click(retryButtons[retryButtons.length - 1])
|
|
|
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(2))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
['same', false],
|
|
|
|
|
['cross recovery', true],
|
|
|
|
|
])('keeps %s Transfer open after failure, states no partial save, and retries', async (_name, recovery) => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const binding = { uuid: 'transfer-source', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: recovery ? '2024-01-01T00:00:00Z' : '2024-02-01T00:00:00Z', ended_at: null }
|
|
|
|
|
const sourceMeter = recovery
|
|
|
|
|
? { ...CLOSED_METER, ended_at: '2025-01-01T00:00:00Z', bindings: [binding] }
|
|
|
|
|
: { ...ACTIVE_METER, bindings: [binding] }
|
|
|
|
|
const target = recovery ? { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] } : undefined
|
|
|
|
|
mockLifecycleReads(target ? [sourceMeter, target] : [sourceMeter])
|
|
|
|
|
mockPost.mockRejectedValueOnce(new Error('network failure'))
|
|
|
|
|
mockPost.mockResolvedValueOnce({ data: {} })
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click((await screen.findAllByRole('button', { name: recovery ? 'Recover binding' : 'Transfer source' }))[0])
|
|
|
|
|
await chooseChannel(user); await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
|
|
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(1))
|
|
|
|
|
expect(await screen.findByText('Transfer failed. No partial source switch was saved.')).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByText(/No partial source switch was saved/)).toBeInTheDocument()
|
|
|
|
|
expect(screen.getByTestId('transfer-modal-transfer-source')).toBeInTheDocument()
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
|
|
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledTimes(2))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it.each(['close', 'unbind', 'direct bind', 'same-meter transfer', 'cross-meter recovery'])(
|
|
|
|
|
'submits %s with Enter once while pending, disables its button, and ignores a second click and Enter',
|
|
|
|
|
async (entry) => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
let release!: () => void
|
|
|
|
|
const pending = new Promise((resolve) => { release = () => resolve({ data: {} }) })
|
|
|
|
|
const binding = { uuid: 'pending-source', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2024-02-01T00:00:00Z', ended_at: null }
|
|
|
|
|
const stranded = { ...CLOSED_METER, ended_at: '2025-01-01T00:00:00Z', bindings: [binding] }
|
|
|
|
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] }
|
|
|
|
|
mockLifecycleReads(entry === 'close' || entry === 'direct bind' ? [ACTIVE_METER] : entry === 'cross-meter recovery' ? [stranded, target] : [{ ...ACTIVE_METER, bindings: [binding] }])
|
|
|
|
|
if (entry === 'unbind') mockPatch.mockReturnValue(pending)
|
|
|
|
|
else mockPost.mockReturnValue(pending)
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
|
|
|
|
|
let input: HTMLInputElement
|
|
|
|
|
let submit: HTMLElement
|
|
|
|
|
if (entry === 'close') {
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Close meter' }))
|
|
|
|
|
input = screen.getByTestId(`close-meter-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
|
|
|
|
submit = screen.getAllByRole('button', { name: 'Close meter' })[1]
|
|
|
|
|
} else if (entry === 'unbind') {
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Unbind' }))
|
|
|
|
|
input = screen.getByTestId('unbind-modal-pending-source').querySelector('input[type="datetime-local"]')!
|
|
|
|
|
submit = screen.getAllByRole('button', { name: 'Unbind' })[1]
|
|
|
|
|
} else {
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: entry === 'direct bind' ? 'Bind source' : entry === 'same-meter transfer' ? 'Transfer source' : 'Recover binding' }))
|
|
|
|
|
await chooseChannel(user)
|
|
|
|
|
input = entry === 'direct bind'
|
|
|
|
|
? screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
|
|
|
|
: screen.getByTestId('transfer-effective-at')
|
|
|
|
|
const submitButtons = screen.getAllByRole('button', { name: entry === 'direct bind' ? 'Bind source' : 'Transfer binding' })
|
|
|
|
|
submit = submitButtons[submitButtons.length - 1]
|
|
|
|
|
}
|
|
|
|
|
await user.type(input, '{Enter}')
|
|
|
|
|
await waitFor(() => expect(entry === 'unbind' ? mockPatch : mockPost).toHaveBeenCalledTimes(1))
|
|
|
|
|
expect(submit).toBeDisabled()
|
|
|
|
|
if (entry === 'unbind') expect(mockPatch).toHaveBeenCalledWith('/api/energy/bindings/{binding_uuid}', expect.objectContaining({ params: { path: { binding_uuid: 'pending-source' } } }))
|
|
|
|
|
else if (entry === 'close') expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/close', expect.objectContaining({ params: { path: { meter_id: ACTIVE_METER.id } } }))
|
|
|
|
|
else expect(mockPost).toHaveBeenCalledWith(entry === 'direct bind' ? '/api/energy/meters/{meter_id}/bindings' : '/api/energy/meters/{meter_id}/bindings/transfer', expect.objectContaining({ params: { path: { meter_id: entry === 'cross-meter recovery' ? 7 : ACTIVE_METER.id } } }))
|
|
|
|
|
await user.click(submit)
|
|
|
|
|
await user.type(input, '{Enter}')
|
|
|
|
|
expect(entry === 'unbind' ? mockPatch : mockPost).toHaveBeenCalledTimes(1)
|
|
|
|
|
release()
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
it('rejects an empty Direct Bind datetime without a request', async () => {
|
|
|
|
|
const user = userEvent.setup(); mockLifecycleReads([ACTIVE_METER])
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Bind source' })); await chooseChannel(user)
|
|
|
|
|
const start = screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
|
|
|
|
await user.clear(start)
|
|
|
|
|
const bindButtons = screen.getAllByRole('button', { name: 'Bind source' })
|
|
|
|
|
await user.click(bindButtons[bindButtons.length - 1])
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
|
|
|
expect(screen.getByText(/valid binding start time|Select a genuinely unbound/)).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it.each(['close', 'unbind', 'direct', 'same', 'cross'])(
|
|
|
|
|
'keeps the %s required-form empty case as a separate no-request regression',
|
|
|
|
|
async (entry) => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const binding = { uuid: 'empty-regression-source', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2024-02-01T00:00:00Z', ended_at: null }
|
|
|
|
|
const stranded = { ...CLOSED_METER, ended_at: '2025-01-01T00:00:00Z', bindings: [binding] }
|
|
|
|
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] }
|
|
|
|
|
mockLifecycleReads(entry === 'close' || entry === 'direct' ? [ACTIVE_METER] : entry === 'cross' ? [stranded, target] : [{ ...ACTIVE_METER, bindings: [binding] }])
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
if (entry === 'close') {
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Close meter' }))
|
|
|
|
|
const input = screen.getByTestId(`close-meter-modal-${ACTIVE_METER.id}`).querySelector('input')!
|
|
|
|
|
await user.clear(input); await user.click(screen.getAllByRole('button', { name: 'Close meter' })[1])
|
|
|
|
|
} else if (entry === 'unbind') {
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Unbind' }))
|
|
|
|
|
const input = screen.getByTestId('unbind-modal-empty-regression-source').querySelector('input')!
|
|
|
|
|
await user.clear(input); await user.click(screen.getAllByRole('button', { name: 'Unbind' })[1])
|
|
|
|
|
} else {
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: entry === 'direct' ? 'Bind source' : entry === 'same' ? 'Transfer source' : 'Recover binding' }))
|
|
|
|
|
await chooseChannel(user)
|
|
|
|
|
const input = entry === 'direct'
|
|
|
|
|
? screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
|
|
|
|
: screen.getByTestId('transfer-effective-at')
|
|
|
|
|
await user.clear(input)
|
|
|
|
|
const submits = screen.getAllByRole('button', { name: entry === 'direct' ? 'Bind source' : 'Transfer binding' })
|
|
|
|
|
await user.click(submits[submits.length - 1])
|
|
|
|
|
}
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled(); expect(mockPatch).not.toHaveBeenCalled()
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
['unit mismatch', { ...channel, unit: 'GJ' }, 'unit mismatch: GJ'],
|
|
|
|
|
['retained anomaly: open occupancy', channel, 'occupied by an open binding'],
|
|
|
|
|
['retained anomaly: ambiguity', channel, 'ambiguous: channel has overlapping binding history'],
|
|
|
|
|
])('shows Direct Bind %s reason and sends no request', async (_name, selected, reason) => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const selectedSource = selected.unit === 'GJ'
|
|
|
|
|
? { uuid: 'warmtelink-source', name: 'WarmteLink', kind: 'warmtelink_serial' }
|
|
|
|
|
: source
|
|
|
|
|
const conflicts = selected.unit === 'kWh' ? [{ ...ACTIVE_METER, id: 9, bindings: [
|
|
|
|
|
{ uuid: 'conflict-a', source_channel_uuid: 'channel-1', started_at: '2024-02-01T00:00:00Z', ended_at: null },
|
|
|
|
|
...(_name.includes('ambiguity') ? [{ uuid: 'conflict-b', source_channel_uuid: 'channel-1', started_at: '2024-03-01T00:00:00Z', ended_at: null }] : []),
|
|
|
|
|
] }] : []
|
|
|
|
|
mockLifecycleReads([ACTIVE_METER, ...conflicts], [selected], [selectedSource])
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Bind source' }))
|
|
|
|
|
const sourceInput = screen.getAllByLabelText('Source').find((element) => element.tagName === 'INPUT')!
|
|
|
|
|
await user.click(sourceInput); await user.click(await screen.findByText(selectedSource.name))
|
|
|
|
|
const channelInput = screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')!
|
|
|
|
|
await user.click(channelInput)
|
|
|
|
|
const disabled = await screen.findByText(new RegExp(reason))
|
|
|
|
|
expect(disabled.closest('[role="option"]')).toHaveAttribute('data-combobox-disabled', 'true')
|
|
|
|
|
await user.keyboard('{Escape}')
|
|
|
|
|
const submits = screen.getAllByRole('button', { name: 'Bind source' })
|
|
|
|
|
await user.click(submits[submits.length - 1])
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('offers recovery only to the unique latest predecessor in a multi-generation timeline', async () => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const older = { ...CLOSED_METER, id: 2, started_at: '2023-01-01T00:00:00Z', ended_at: '2024-01-01T00:00:00Z', bindings: [{ uuid: 'older', source_channel_uuid: 'older-channel', started_at: '2023-01-01T00:00:00Z', ended_at: null }] }
|
|
|
|
|
const binding = { uuid: 'predecessor', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2024-01-01T00:00:00Z', ended_at: null }
|
|
|
|
|
const predecessor = { ...CLOSED_METER, id: 3, started_at: '2024-01-01T00:00:00Z', ended_at: '2025-01-01T00:00:00Z', bindings: [binding] }
|
|
|
|
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] }
|
|
|
|
|
mockLifecycleReads([older, predecessor, target]); mockPost.mockResolvedValue({ data: {} })
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
expect(await screen.findByRole('button', { name: 'Recover binding' })).toBeInTheDocument()
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Recover binding' })); await chooseChannel(user)
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
|
|
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/bindings/transfer', expect.objectContaining({
|
|
|
|
|
params: { path: { meter_id: 7 } },
|
|
|
|
|
body: { from_binding_uuid: 'predecessor', to_source_channel_uuid: 'channel-1', effective_at: '2025-01-01T00:00' },
|
|
|
|
|
})))
|
|
|
|
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
['two equally recent predecessors', [
|
|
|
|
|
{ ...CLOSED_METER, id: 3, ended_at: '2025-01-01T00:00:00Z', bindings: [{ uuid: 'a', source_channel_uuid: 'old-channel', started_at: '2024-01-01T00:00:00Z', ended_at: null }] },
|
|
|
|
|
{ ...CLOSED_METER, id: 4, ended_at: '2025-01-01T00:00:00Z', bindings: [{ uuid: 'b', source_channel_uuid: 'old-channel', started_at: '2024-01-01T00:00:00Z', ended_at: null }] },
|
|
|
|
|
]],
|
|
|
|
|
['a non-predecessor stranded meter', [
|
|
|
|
|
{ ...CLOSED_METER, id: 3, ended_at: '2024-12-31T00:00:00Z', bindings: [{ uuid: 'old', source_channel_uuid: 'old-channel', started_at: '2024-01-01T00:00:00Z', ended_at: null }] },
|
|
|
|
|
{ ...CLOSED_METER, id: 4, ended_at: '2025-01-01T00:00:00Z', bindings: [] },
|
|
|
|
|
]],
|
|
|
|
|
])('fails closed for recovery with %s', async (_name, closed) => {
|
|
|
|
|
mockLifecycleReads([...closed, { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] }])
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await waitFor(() => expect(screen.getByTestId('meters-table')).toBeInTheDocument())
|
|
|
|
|
expect(screen.queryByRole('button', { name: 'Recover binding' })).not.toBeInTheDocument()
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
['Close Meter equal to meter start', 'close', '2024-01-15T00:00', 'Close time must be strictly after the meter start.'],
|
|
|
|
|
['Unbind equal to binding start', 'unbind', '2024-02-01T00:00', 'Unbind time must be strictly after the binding start.'],
|
|
|
|
|
['Direct Bind in the future', 'direct', '2099-01-01T00:00', 'future binding start times are not allowed'],
|
|
|
|
|
['same-Meter Transfer equal to source start', 'same', '2024-02-01T00:00', 'same-meter transfer must be strictly after the source binding start'],
|
|
|
|
|
['cross-Meter Recovery before target epoch', 'cross', '2024-12-31T23:59', 'effective time must be within the target meter epoch'],
|
|
|
|
|
])('rejects %s expressible datetime boundary with a reason and no request', async (_name, entry, replacement, reason) => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const binding = { uuid: 'empty-source', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2024-02-01T00:00:00Z', ended_at: null }
|
|
|
|
|
const stranded = { ...CLOSED_METER, ended_at: '2025-01-01T00:00:00Z', bindings: [binding] }
|
|
|
|
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] }
|
|
|
|
|
mockLifecycleReads(entry === 'close' || entry === 'direct' ? [ACTIVE_METER] : entry === 'unbind' || entry === 'same'
|
|
|
|
|
? [{ ...ACTIVE_METER, bindings: [binding] }] : [stranded, target])
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
if (entry === 'close') {
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Close meter' }))
|
|
|
|
|
const input = screen.getByTestId(`close-meter-modal-${ACTIVE_METER.id}`).querySelector('input')!
|
|
|
|
|
await user.clear(input); await user.type(input, replacement); await user.click(screen.getAllByRole('button', { name: 'Close meter' })[1])
|
|
|
|
|
} else if (entry === 'unbind') {
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Unbind' }))
|
|
|
|
|
const input = screen.getByTestId('unbind-modal-empty-source').querySelector('input')!
|
|
|
|
|
await user.clear(input); await user.type(input, replacement); await user.click(screen.getAllByRole('button', { name: 'Unbind' })[1])
|
|
|
|
|
} else {
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: entry === 'direct' ? 'Bind source' : entry === 'same' ? 'Transfer source' : 'Recover binding' }))
|
|
|
|
|
await chooseChannel(user)
|
|
|
|
|
const input = entry === 'direct'
|
|
|
|
|
? screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
|
|
|
|
: screen.getByTestId('transfer-effective-at')
|
|
|
|
|
await user.clear(input); await user.type(input, replacement)
|
|
|
|
|
if (entry === 'direct' || entry === 'same' || entry === 'cross') {
|
|
|
|
|
expect(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')).toHaveValue('')
|
|
|
|
|
await user.click(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')!)
|
|
|
|
|
const disabled = await screen.findByText(new RegExp(reason))
|
|
|
|
|
expect(disabled.closest('[role="option"]')).toHaveAttribute(
|
|
|
|
|
'data-combobox-disabled',
|
|
|
|
|
'true',
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
const submitButtons = screen.getAllByRole('button', { name: entry === 'direct' ? 'Bind source' : 'Transfer binding' })
|
|
|
|
|
await user.click(submitButtons[submitButtons.length - 1])
|
|
|
|
|
}
|
|
|
|
|
if (entry === 'close' || entry === 'unbind') expect(await screen.findByText(reason)).toBeInTheDocument()
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled(); expect(mockPatch).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
['before closed history', '2024-12-31T23:59', 'overlaps closed binding history; choose a time at or after it ends'],
|
|
|
|
|
['inside closed history', '2025-06-01T00:00', 'overlaps closed binding history; choose a time at or after it ends'],
|
|
|
|
|
['at target epoch start', '2023-12-31T23:59', 'binding start must be within this meter epoch'],
|
|
|
|
|
['future', '2099-01-01T00:00', 'future binding start times are not allowed'],
|
|
|
|
|
])('shows Direct Bind %s reason, clears selection, and sends no request', async (_name, value, reason) => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const target = { ...ACTIVE_METER, bindings: [{ uuid: 'closed-history', source_channel_uuid: 'channel-1', started_at: '2025-01-01T00:00:00Z', ended_at: '2025-12-31T00:00:00Z' }] }
|
|
|
|
|
mockLifecycleReads([target]); renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Bind source' })); await chooseChannel(user)
|
|
|
|
|
const input = screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
|
|
|
|
|
await user.clear(input); await user.type(input, value)
|
|
|
|
|
expect(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')).toHaveValue('')
|
|
|
|
|
await user.click(screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')!)
|
|
|
|
|
const disabled = await screen.findByText(new RegExp(reason))
|
|
|
|
|
expect(disabled.closest('[role="option"]')).toHaveAttribute('data-combobox-disabled', 'true')
|
|
|
|
|
await user.keyboard('{Escape}')
|
|
|
|
|
const submits = screen.getAllByRole('button', { name: 'Bind source' })
|
|
|
|
|
await user.click(submits[submits.length - 1])
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
['same target channel early', false, '2023-12-31T23:59', 'effective time must be within the target meter epoch'],
|
|
|
|
|
['same source equality', false, '2025-06-01T00:00', 'same-meter transfer must be strictly after the source binding start'],
|
|
|
|
|
['same future', false, '2099-01-01T00:00', 'future effective times are not allowed'],
|
|
|
|
|
['cross target epoch early', true, '2024-12-31T23:59', 'effective time must be within the target meter epoch'],
|
|
|
|
|
['cross target channel overlap', true, '2025-01-02T00:00', 'overlaps closed binding history; choose a time at or after it ends'],
|
|
|
|
|
['cross target channel unit mismatch', true, '2025-01-02T00:00', 'unit mismatch: GJ; target needs kWh'],
|
|
|
|
|
['cross retained anomaly: target channel open occupancy', true, '2025-01-02T00:00', 'occupied by an open binding; close or transfer it first'],
|
|
|
|
|
['cross retained anomaly: target channel ambiguity', true, '2025-01-02T00:00', 'ambiguous: channel has overlapping binding history; resolve it first'],
|
|
|
|
|
['cross future', true, '2099-01-01T00:00', 'future effective times are not allowed'],
|
|
|
|
|
])('shows %s reason, clears selection, and sends no transfer request', async (_name, recovery, value, reason) => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const sourceBinding = { uuid: 'matrix-source', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: recovery ? '2024-01-01T00:00:00Z' : '2025-06-01T00:00:00Z', ended_at: null }
|
|
|
|
|
const sourceMeter = recovery ? { ...CLOSED_METER, ended_at: '2025-01-01T00:00:00Z', bindings: [sourceBinding] } : { ...ACTIVE_METER, bindings: [sourceBinding] }
|
|
|
|
|
const target = recovery ? { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] } : sourceMeter
|
|
|
|
|
const targetStart = recovery ? '2025-01-01T00:00:00Z' : '2024-01-15T00:00:00Z'
|
|
|
|
|
const conflictBindings = _name.includes('overlap') ? [{ uuid: 'history', source_channel_uuid: 'channel-1', started_at: recovery ? '2025-01-01T00:00:00Z' : '2025-01-01T00:00:00Z', ended_at: '2025-12-31T00:00:00Z' }] :
|
|
|
|
|
_name.includes('open occupancy') ? [{ uuid: 'bad-stranded-open', source_channel_uuid: 'channel-1', started_at: targetStart, ended_at: null }] :
|
|
|
|
|
_name.includes('ambiguity') ? [{ uuid: 'bad-stranded-a', source_channel_uuid: 'channel-1', started_at: targetStart, ended_at: null }, { uuid: 'bad-stranded-b', source_channel_uuid: 'channel-1', started_at: targetStart, ended_at: null }] : []
|
|
|
|
|
const targetWithConflict = { ...target, bindings: [...(target.bindings ?? []), ...conflictBindings] }
|
|
|
|
|
const selected = _name.includes('unit mismatch') ? { ...channel, unit: 'GJ' } : channel
|
|
|
|
|
const selectedSource = selected.unit === 'GJ'
|
|
|
|
|
? { uuid: 'warmtelink-source', name: 'WarmteLink', kind: 'warmtelink_serial' }
|
|
|
|
|
: source
|
|
|
|
|
mockLifecycleReads(recovery ? [sourceMeter, targetWithConflict] : [targetWithConflict], [selected], [selectedSource])
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click((await screen.findAllByRole('button', { name: recovery ? 'Recover binding' : 'Transfer source' }))[0])
|
|
|
|
|
const sourceInput = screen.getAllByLabelText('Source').find((element) => element.tagName === 'INPUT')!
|
|
|
|
|
await user.click(sourceInput); await user.click(await screen.findByText(selectedSource.name))
|
|
|
|
|
const channelInput = screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')!
|
|
|
|
|
// Rows that begin eligible exercise a real selection before the invalid
|
|
|
|
|
// time edit; initially invalid rows instead prove their disabled option.
|
|
|
|
|
if (!_name.includes('unit mismatch') && !_name.includes('open occupancy') && !_name.includes('ambiguity')) {
|
|
|
|
|
await user.click(channelInput); await user.click(await screen.findByText(/Total import \(kWh\)/))
|
|
|
|
|
}
|
|
|
|
|
const effective = screen.getByTestId('transfer-effective-at'); await user.clear(effective); await user.type(effective, value)
|
|
|
|
|
await user.click(channelInput)
|
|
|
|
|
const disabled = await screen.findByText(new RegExp(reason))
|
|
|
|
|
expect(disabled.closest('[role="option"]')).toHaveAttribute('data-combobox-disabled', 'true')
|
|
|
|
|
expect(channelInput).toHaveValue('')
|
|
|
|
|
await user.keyboard('{Escape}')
|
|
|
|
|
const submits = screen.getAllByRole('button', { name: 'Transfer binding' })
|
|
|
|
|
await user.click(submits[submits.length - 1])
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('treats cross-Meter target-channel history before its start as overlap and its equal end as a valid recovery transfer', async () => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const sourceBinding = { uuid: 'target-history-source', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2024-01-01T00:00:00Z', ended_at: null }
|
|
|
|
|
const sourceMeter = { ...CLOSED_METER, id: 3, ended_at: '2025-01-01T00:00:00Z', bindings: [sourceBinding] }
|
|
|
|
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] }
|
|
|
|
|
// A same-Meter target-history positive is backend-unreachable: its open
|
|
|
|
|
// source occupies the target Meter until now. Cross-Meter history is
|
|
|
|
|
// legal and exercises the actual target-channel interval boundary.
|
|
|
|
|
const targetWithHistory = {
|
|
|
|
|
...target,
|
|
|
|
|
bindings: [...(target.bindings ?? []), {
|
|
|
|
|
uuid: 'target-history', source_channel_uuid: 'channel-1',
|
|
|
|
|
started_at: '2025-01-02T00:00:00Z', ended_at: '2025-01-03T00:00:00Z',
|
|
|
|
|
}],
|
|
|
|
|
}
|
|
|
|
|
const early = '2025-01-01T00:00'
|
|
|
|
|
const equalEnd = '2025-01-03T00:00'
|
|
|
|
|
mockLifecycleReads([sourceMeter, targetWithHistory])
|
|
|
|
|
mockPost.mockResolvedValue({ data: {} })
|
|
|
|
|
renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Recover binding' }))
|
|
|
|
|
const effective = screen.getByTestId('transfer-effective-at')
|
|
|
|
|
// Select while the target channel is valid, then prove a time edit
|
|
|
|
|
// clears that real selection and disables the now-conflicting option.
|
|
|
|
|
await user.clear(effective); await user.type(effective, equalEnd)
|
|
|
|
|
const sourceInput = screen.getAllByLabelText('Source').find((element) => element.tagName === 'INPUT')!
|
|
|
|
|
await user.click(sourceInput); await user.click(await screen.findByText('DSMR'))
|
|
|
|
|
const channelInput = screen.getAllByLabelText('Source channel').find((element) => element.tagName === 'INPUT')!
|
|
|
|
|
await user.click(channelInput); await user.click(await screen.findByText(/Total import \(kWh\)/))
|
|
|
|
|
await user.clear(effective); await user.type(effective, early)
|
|
|
|
|
expect(channelInput).toHaveValue('')
|
|
|
|
|
await user.click(channelInput)
|
|
|
|
|
const reason = await screen.findByText(/overlaps closed binding history; choose a time at or after it ends/)
|
|
|
|
|
expect(reason.closest('[role="option"]')).toHaveAttribute('data-combobox-disabled', 'true')
|
|
|
|
|
await user.keyboard('{Escape}')
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
|
|
|
|
expect(mockPost).not.toHaveBeenCalled()
|
|
|
|
|
|
|
|
|
|
await user.clear(effective); await user.type(effective, equalEnd)
|
|
|
|
|
await user.click(channelInput); await user.click(await screen.findByText(/Total import \(kWh\)/))
|
|
|
|
|
expect(channelInput).toHaveValue('Total import (kWh)')
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
|
|
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/bindings/transfer', expect.objectContaining({
|
|
|
|
|
params: { path: { meter_id: 7 } },
|
|
|
|
|
body: { from_binding_uuid: 'target-history-source', to_source_channel_uuid: 'channel-1', effective_at: equalEnd },
|
|
|
|
|
})))
|
|
|
|
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
['recovery gap', '2025-01-02T00:00', true],
|
|
|
|
|
['recovery target equal boundary', '2025-01-01T00:00', false],
|
|
|
|
|
])('submits %s with the selected target time and exact transfer endpoint', async (_name, effectiveAt, warns) => {
|
|
|
|
|
const user = userEvent.setup()
|
|
|
|
|
const sourceBinding = { uuid: 'recovery-source', source_uuid: 'source-1', source_channel_uuid: 'old-channel', started_at: '2024-01-01T00:00:00Z', ended_at: null }
|
|
|
|
|
const sourceMeter = { ...CLOSED_METER, ended_at: '2025-01-01T00:00:00Z', bindings: [sourceBinding] }
|
|
|
|
|
const target = { ...ACTIVE_METER, id: 7, started_at: '2025-01-01T00:00:00Z', bindings: [] }
|
|
|
|
|
mockLifecycleReads([sourceMeter, target]); mockPost.mockResolvedValue({ data: {} }); renderWithProviders(<MeterManager />)
|
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Recover binding' })); await chooseChannel(user)
|
|
|
|
|
const input = screen.getByTestId('transfer-effective-at'); await user.clear(input); await user.type(input, effectiveAt)
|
|
|
|
|
if (warns) expect(screen.getByText(/leaves an unbound gap/)).toBeInTheDocument()
|
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Transfer binding' }))
|
|
|
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meters/{meter_id}/bindings/transfer', expect.objectContaining({
|
|
|
|
|
params: { path: { meter_id: 7 } }, body: { from_binding_uuid: 'recovery-source', to_source_channel_uuid: 'channel-1', effective_at: effectiveAt },
|
|
|
|
|
})))
|
|
|
|
|
expect(mockPost).toHaveBeenCalledTimes(1)
|
|
|
|
|
})
|
|
|
|
|
})
|