M8-R03: hand off channel binding during meter swap

This commit is contained in:
2026-08-24 06:45:31 +02:00
parent 231c340ea6
commit 631b14e2ec
6 changed files with 669 additions and 24 deletions
+197
View File
@@ -61,6 +61,7 @@ const ACTIVE_METER = {
reason: 'initial',
note: null,
created_at: '2024-01-15T00:00:00Z',
bindings: [],
}
const CLOSED_METER = {
@@ -246,6 +247,202 @@ describe('MeterManager — declare new meter', () => {
})
})
it('offers a channel with closed history and one current old-meter binding for a safe swap', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{
...CLOSED_METER,
bindings: [{
uuid: 'binding-history', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
started_at: '2023-06-01T00:00:00Z', ended_at: '2024-01-15T00:00:00Z',
}],
}, {
...ACTIVE_METER,
bindings: [{
uuid: 'binding-current', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
started_at: '2024-01-15T00:00:00Z', ended_at: null,
}],
}], total: 2 } })
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'source-1', name: 'DSMR' }] } })
if (path === '/api/energy/sources/{source_uuid}/channels') {
return Promise.resolve({ data: { items: [
{ uuid: 'handoff-channel', label: 'Current total', unit: 'kWh', binding_count: 2, bound_meter_ids: [CLOSED_METER.id, ACTIVE_METER.id] },
{ uuid: 'occupied-channel', label: 'Other total', unit: 'kWh', binding_count: 1, bound_meter_ids: [999] },
] } })
}
return Promise.resolve({ data: { items: [] } })
})
mockPost.mockResolvedValue({ data: ACTIVE_METER })
renderWithProviders(<MeterManager />)
await user.click(await screen.findByTestId('meter-declare-button'))
await user.type(screen.getByTestId('meter-label'), 'Replacement meter')
await user.type(screen.getByTestId('meter-started-at'), '2026-01-01')
await user.click(screen.getByTestId('meter-reason'))
await user.click(await screen.findByText('Meter swap (same address)'))
await user.click(screen.getAllByLabelText('Bind source (optional)')[0])
await user.click(await screen.findByText('DSMR'))
await user.click((await screen.findAllByLabelText('Compatible source channel (optional)'))[0])
expect(await screen.findByText('Current total (kWh) — hand off from current meter')).toBeInTheDocument()
expect(screen.getByRole('option', { name: 'Other total (kWh)' })).toHaveAttribute('data-combobox-disabled')
await user.click(screen.getByText('Current total (kWh) — hand off from current meter'))
await user.click(screen.getByTestId('declare-meter-submit'))
await waitFor(() => expect(mockPost).toHaveBeenCalledWith(
'/api/energy/meters',
expect.objectContaining({ body: expect.objectContaining({ source_channel_uuid: 'handoff-channel' }) }),
))
})
it('keeps a channel disabled when its current open binding is ambiguous', async () => {
const user = userEvent.setup()
const competingMeter = {
...ACTIVE_METER,
id: 999,
label: 'Competing meter',
bindings: [{
uuid: 'binding-competing', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
started_at: '2025-01-01T00:00:00Z', ended_at: null,
}],
}
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{
...ACTIVE_METER,
bindings: [{
uuid: 'binding-current', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
started_at: '2024-01-15T00:00:00Z', ended_at: null,
}],
}, competingMeter], total: 2 } })
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'source-1', name: 'DSMR' }] } })
if (path === '/api/energy/sources/{source_uuid}/channels') {
return Promise.resolve({ data: { items: [
{ uuid: 'handoff-channel', label: 'Current total', unit: 'kWh', binding_count: 2, bound_meter_ids: [ACTIVE_METER.id, competingMeter.id] },
] } })
}
return Promise.resolve({ data: { items: [] } })
})
renderWithProviders(<MeterManager />)
await user.click(await screen.findByTestId('meter-declare-button'))
await user.type(screen.getByTestId('meter-label'), 'Replacement meter')
await user.type(screen.getByTestId('meter-started-at'), '2026-01-01')
await user.click(screen.getByTestId('meter-reason'))
await user.click(await screen.findByText('Meter swap (same address)'))
await user.click(screen.getAllByLabelText('Bind source (optional)')[0])
await user.click(await screen.findByText('DSMR'))
await user.click((await screen.findAllByLabelText('Compatible source channel (optional)'))[0])
expect(await screen.findByRole('option', { name: 'Current total (kWh)' })).toHaveAttribute(
'data-combobox-disabled',
)
})
it('clears a selected handoff channel when the swap date becomes unsafe', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{
...ACTIVE_METER,
bindings: [{
uuid: 'binding-1', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
started_at: '2024-01-15T00:00:00Z', ended_at: null,
}],
}], total: 1 } })
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'source-1', name: 'DSMR' }] } })
if (path === '/api/energy/sources/{source_uuid}/channels') {
return Promise.resolve({ data: { items: [
{ uuid: 'handoff-channel', label: 'Current total', unit: 'kWh', binding_count: 1, bound_meter_ids: [ACTIVE_METER.id] },
] } })
}
return Promise.resolve({ data: { items: [] } })
})
mockPost.mockResolvedValue({ data: ACTIVE_METER })
renderWithProviders(<MeterManager />)
await user.click(await screen.findByTestId('meter-declare-button'))
await user.type(screen.getByTestId('meter-label'), 'Replacement meter')
await user.type(screen.getByTestId('meter-started-at'), '2026-01-01')
await user.click(screen.getByTestId('meter-reason'))
await user.click(await screen.findByText('Meter swap (same address)'))
await user.click(screen.getAllByLabelText('Bind source (optional)')[0])
await user.click(await screen.findByText('DSMR'))
await user.click((await screen.findAllByLabelText('Compatible source channel (optional)'))[0])
await user.click(await screen.findByText('Current total (kWh) — hand off from current meter'))
const startedAt = screen.getByTestId('meter-started-at')
await user.clear(startedAt)
await user.type(startedAt, '2024-01-15')
expect(screen.getAllByLabelText('Compatible source channel (optional)')[0]).toHaveValue('')
await user.click(screen.getByTestId('declare-meter-submit'))
await waitFor(() => expect(mockPost).toHaveBeenCalledWith(
'/api/energy/meters',
expect.objectContaining({ body: expect.not.objectContaining({ source_channel_uuid: 'handoff-channel' }) }),
))
})
it('treats a naive UTC binding timestamp as Amsterdam local time when validating a handoff', async () => {
vi.stubEnv('TZ', 'Europe/Amsterdam')
try {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{
...ACTIVE_METER,
bindings: [{
uuid: 'binding-1', source_channel_uuid: 'handoff-channel', source_uuid: 'source-1',
// SQLite commonly round-trips this UTC instant without a timezone suffix.
started_at: '2024-01-14T23:00:00', ended_at: null,
}],
}], total: 1 } })
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'source-1', name: 'DSMR' }] } })
if (path === '/api/energy/sources/{source_uuid}/channels') {
return Promise.resolve({ data: { items: [
{ uuid: 'handoff-channel', label: 'Current total', unit: 'kWh', binding_count: 1, bound_meter_ids: [ACTIVE_METER.id] },
] } })
}
return Promise.resolve({ data: { items: [] } })
})
mockPost.mockRejectedValueOnce(new Error('keep modal open after unsafe submission'))
mockPost.mockResolvedValueOnce({ data: ACTIVE_METER })
renderWithProviders(<MeterManager />)
await user.click(await screen.findByTestId('meter-declare-button'))
await user.type(screen.getByTestId('meter-label'), 'Replacement meter')
await user.type(screen.getByTestId('meter-started-at'), '2024-01-16')
await user.click(screen.getByTestId('meter-reason'))
await user.click(await screen.findByText('Meter swap (same address)'))
await user.click(screen.getAllByLabelText('Bind source (optional)')[0])
await user.click(await screen.findByText('DSMR'))
await user.click((await screen.findAllByLabelText('Compatible source channel (optional)'))[0])
await user.click(await screen.findByText('Current total (kWh) — hand off from current meter'))
const startedAt = screen.getByTestId('meter-started-at')
await user.clear(startedAt)
await user.type(startedAt, '2024-01-15')
expect(screen.getAllByLabelText('Compatible source channel (optional)')[0]).toHaveValue('')
await user.click(screen.getByTestId('declare-meter-submit'))
await waitFor(() => expect(mockPost).toHaveBeenLastCalledWith(
'/api/energy/meters',
expect.objectContaining({ body: expect.not.objectContaining({ source_channel_uuid: 'handoff-channel' }) }),
))
await user.clear(startedAt)
await user.type(startedAt, '2024-01-16')
await user.click(screen.getAllByLabelText('Compatible source channel (optional)')[0])
await user.click(await screen.findByText('Current total (kWh) — hand off from current meter'))
await user.click(screen.getByTestId('declare-meter-submit'))
await waitFor(() => expect(mockPost).toHaveBeenLastCalledWith(
'/api/energy/meters',
expect.objectContaining({ body: expect.objectContaining({ source_channel_uuid: 'handoff-channel' }) }),
))
} finally {
vi.unstubAllEnvs()
}
})
it('displays error when POST fails with 422 (倒挂 / validation error)', async () => {
const user = userEvent.setup()
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })