M8-R03: hand off channel binding during meter swap
This commit is contained in:
@@ -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 } })
|
||||
|
||||
Reference in New Issue
Block a user