diff --git a/frontend/src/energy/MeterManager.test.tsx b/frontend/src/energy/MeterManager.test.tsx index 4c17e3a..a8704c5 100644 --- a/frontend/src/energy/MeterManager.test.tsx +++ b/frontend/src/energy/MeterManager.test.tsx @@ -123,16 +123,57 @@ describe('MeterManager — binding switch safety', () => { mockGet.mockResolvedValue({ data: { items: [CLOSED_METER], total: 1 } }) renderWithProviders() await waitFor(() => expect(screen.getByTestId('meters-table')).toBeInTheDocument()) - expect(screen.queryByRole('button', { name: 'Switch source' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Transfer source' })).not.toBeInTheDocument() }) - it('disables switch submit until the binding timeline has loaded', async () => { + it('closes an active meter with one atomic close request and local datetime payload', async () => { const user = userEvent.setup() - mockGet.mockImplementation((path: string) => path === '/api/energy/meters' ? Promise.resolve({ data: { items: [ACTIVE_METER], total: 1 } }) : new Promise(() => {})) + mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } }) + mockPost.mockResolvedValue({ data: { ...ACTIVE_METER, ended_at: '2026-08-24T10:00:00Z' } }) renderWithProviders() - await user.click(await screen.findByRole('button', { name: 'Switch source' })) - expect(await screen.findByText('Loading binding timeline…')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Switch binding' })).toBeDisabled() + 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$/) }) }), + )) }) + + 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() + 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() + await waitFor(() => expect(screen.getByTestId('meters-table')).toBeInTheDocument()) + expect(screen.queryByRole('button', { name: 'Recover binding' })).not.toBeInTheDocument() + }) + }) describe('MeterManager — meter list', () => { @@ -285,7 +326,9 @@ describe('MeterManager — declare new meter', () => { 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') + // 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') await user.click(screen.getByText('Current total (kWh) — hand off from current meter')) await user.click(screen.getByTestId('declare-meter-submit')) @@ -333,12 +376,12 @@ describe('MeterManager — declare new meter', () => { 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( + expect(await screen.findByRole('option', { name: /Current total \(kWh\).*ambiguous/ })).toHaveAttribute( 'data-combobox-disabled', ) }) - it('clears a selected handoff channel when the swap date becomes unsafe', async () => { + it('disables an equal handoff boundary with a reason and fails closed on submit', async () => { const user = userEvent.setup() mockGet.mockImplementation((path: string) => { if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{ @@ -361,28 +404,22 @@ describe('MeterManager — declare new meter', () => { renderWithProviders() 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.type(screen.getByTestId('meter-started-at'), '2024-01-15') 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('') - + 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}') 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' }) }), - )) + expect(screen.getByTestId('declare-meter-error')).toHaveTextContent('Handoff boundary must be strictly after') + expect(mockPost).not.toHaveBeenCalled() }) - it('treats a naive UTC binding timestamp as Amsterdam local time when validating a handoff', async () => { + it('submits an explicitly selected handoff strictly after its binding start', async () => { vi.stubEnv('TZ', 'Europe/Amsterdam') try { const user = userEvent.setup() @@ -391,8 +428,7 @@ describe('MeterManager — declare new meter', () => { ...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, + 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' }] } }) @@ -403,8 +439,7 @@ describe('MeterManager — declare new meter', () => { } return Promise.resolve({ data: { items: [] } }) }) - mockPost.mockRejectedValueOnce(new Error('keep modal open after unsafe submission')) - mockPost.mockResolvedValueOnce({ data: ACTIVE_METER }) + mockPost.mockResolvedValue({ data: ACTIVE_METER }) renderWithProviders() await user.click(await screen.findByTestId('meter-declare-button')) @@ -418,31 +453,75 @@ describe('MeterManager — declare new meter', () => { 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]) + const channelInput = screen.getAllByLabelText('Compatible source channel (optional)')[0] + await user.click(channelInput) await user.click(await screen.findByText('Current total (kWh) — hand off from current meter')) + await waitFor(() => expect(channelInput).toHaveValue('Current total (kWh) — hand off from current meter')) await user.click(screen.getByTestId('declare-meter-submit')) - await waitFor(() => expect(mockPost).toHaveBeenLastCalledWith( + await waitFor(() => expect(mockPost).toHaveBeenCalledWith( '/api/energy/meters', - expect.objectContaining({ body: expect.objectContaining({ source_channel_uuid: 'handoff-channel' }) }), + expect.objectContaining({ body: expect.objectContaining({ source_channel_uuid: 'handoff-channel', started_at: '2024-01-16T00:00:00' }) }), )) + expect(mockPost).toHaveBeenCalledTimes(1) } finally { vi.unstubAllEnvs() } }) + 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() + 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) + }) + it('displays error when POST fails with 422 (倒挂 / validation error)', async () => { const user = userEvent.setup() mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } }) @@ -678,3 +757,719 @@ describe('MeterManager — edit meter', () => { expect(screen.getByTestId('edit-meter-error').textContent).toContain('started_at conflict') }) }) + +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() + 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) => { + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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) + }) +}) diff --git a/frontend/src/energy/MeterManager.tsx b/frontend/src/energy/MeterManager.tsx index 8fbbd83..7f93519 100644 --- a/frontend/src/energy/MeterManager.tsx +++ b/frontend/src/energy/MeterManager.tsx @@ -36,9 +36,10 @@ import { useUpdateMeter, useSources, useSourceChannels, - useMeterBindings, useCreateBinding, useCloseBinding, + useCloseMeter, + useTransferBinding, type MeterResponse, type MeterReason, } from './hooks' @@ -78,6 +79,94 @@ function toLocalDateInputString(d: Date): string { return `${y}-${m}-${day}` } +function toLocalDateTimeInputString(d = new Date()): string { + const pad = (value: number) => String(value).padStart(2, '0') + return `${toLocalDateInputString(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +function expectedUnit(commodity: string): string { + return ({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record)[commodity] ?? '' +} + +function apiErrorMessage(err: unknown, fallback: string): string { + if (err instanceof ApiError) { + const body = err.body + if (typeof body === 'string') return body + if (body && typeof body === 'object' && 'detail' in body) { + const detail = (body as { detail?: unknown }).detail + if (typeof detail === 'string') return detail + if (Array.isArray(detail)) { + const messages = detail.map((item) => item && typeof item === 'object' && typeof item.msg === 'string' + ? item.msg : String(item)).filter(Boolean) + if (messages.length) return messages.join('; ') + } + if (detail != null) return typeof detail === 'object' ? JSON.stringify(detail) : String(detail) + } + return `${fallback} (error ${err.status}).` + } + return fallback +} + +function isValidLocalDateTime(value: string): boolean { + return value.trim() !== '' && !Number.isNaN(new Date(value).getTime()) +} + +type Eligibility = { eligible: boolean; reason?: string } + +function localInstant(value: string): number | null { + const instant = new Date(value).getTime() + return Number.isNaN(instant) ? null : instant +} + +function intervalsOverlap(start: number, end: number | null, otherStart: number, otherEnd: number | null): boolean { + return (end === null || otherStart < end) && (otherEnd === null || start < otherEnd) +} + +function channelIntervalEligibility( + meters: MeterResponse[], channelUuid: string, startedAt: string, excludeBindingUuids: string[] = [], +): Eligibility { + const start = localInstant(startedAt) + if (start === null) return { eligible: false, reason: 'choose a valid start time first' } + const conflicts = meters.flatMap((meter) => (meter.bindings ?? []).filter((binding) => + binding.source_channel_uuid === channelUuid && !excludeBindingUuids.includes(binding.uuid) && + intervalsOverlap(start, null, parseBackendTimestamp(binding.started_at).getTime(), binding.ended_at ? parseBackendTimestamp(binding.ended_at).getTime() : null), + )) + if (!conflicts.length) return { eligible: true } + if (conflicts.length > 1) return { eligible: false, reason: 'ambiguous: channel has overlapping binding history; resolve it first' } + return conflicts[0].ended_at === null + ? { eligible: false, reason: 'occupied by an open binding; close or transfer it first' } + : { eligible: false, reason: 'overlaps closed binding history; choose a time at or after it ends' } +} + +function meterContainsInstant(meter: MeterResponse, instant: number): boolean { + const start = parseBackendTimestamp(meter.started_at).getTime() + const end = meter.ended_at ? parseBackendTimestamp(meter.ended_at).getTime() : null + return instant >= start && (end === null || instant < end) +} + +function recoveryTargetFor(meter: MeterResponse, meters: MeterResponse[]): MeterResponse | null { + if (meter.ended_at === null) return null + const active = meters.filter((candidate) => candidate.commodity === meter.commodity && candidate.ended_at === null) + if (active.length !== 1) return null + const target = active[0] + const targetStart = parseBackendTimestamp(target.started_at).getTime() + const predecessors = meters.filter((candidate) => candidate.commodity === meter.commodity && candidate.ended_at !== null && + parseBackendTimestamp(candidate.ended_at).getTime() <= targetStart) + const latestEnd = Math.max(...predecessors.map((candidate) => parseBackendTimestamp(candidate.ended_at!).getTime())) + const immediate = predecessors.filter((candidate) => parseBackendTimestamp(candidate.ended_at!).getTime() === latestEnd) + if (immediate.length !== 1 || immediate[0].id !== meter.id) return null + const sourceStart = parseBackendTimestamp(meter.started_at).getTime() + const sourceEnd = parseBackendTimestamp(meter.ended_at).getTime() + for (const candidate of meters) { + if (candidate.id === meter.id || candidate.id === target.id || candidate.commodity !== meter.commodity) continue + const candidateStart = parseBackendTimestamp(candidate.started_at).getTime() + const candidateEnd = candidate.ended_at ? parseBackendTimestamp(candidate.ended_at).getTime() : null + if (intervalsOverlap(sourceStart, sourceEnd, candidateStart, candidateEnd) || + intervalsOverlap(targetStart, null, candidateStart, candidateEnd)) return null + } + return target +} + // --------------------------------------------------------------------------- // Declare meter form (modal) // --------------------------------------------------------------------------- @@ -101,11 +190,16 @@ function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) { const channels = useSourceChannels(sourceUuid) const declareMutation = useDeclareMeter() - const expectedUnit = ({ electricity: 'kWh', heating: 'GJ', hot_water: 'm³' } as Record)[commodity ?? 'electricity'] + const unit = expectedUnit(commodity ?? 'electricity') const oldMeter = meters.find((meter) => meter.commodity === commodity && meter.ended_at === null) - const isChannelEligible = (uuid: string, startedAt: string) => { + const channelEligibility = (uuid: string, startedAt: string): Eligibility => { const channel = channels.data?.items.find((item) => item.uuid === uuid) - if (!channel || channel.unit !== expectedUnit) return false + if (!channel) return { eligible: false, reason: 'channel is unavailable; reload the source' } + if (channel.unit !== unit) return { eligible: false, reason: `unit mismatch: ${channel.unit}; this meter needs ${unit}` } + if (!startedAt) return { eligible: false, reason: 'choose a start date first' } + const start = localInstant(toLocalMidnightNaive(startedAt)) + if (start === null) return { eligible: false, reason: 'choose a valid start date first' } + if (startedAt > toLocalDateInputString(new Date())) return { eligible: false, reason: 'future start dates cannot bind a channel' } // Channel aggregates include closed binding history. For a meter swap, only // currently open bindings determine whether this channel can be handed off. @@ -114,30 +208,45 @@ function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) { .filter((binding) => binding.source_channel_uuid === channel.uuid && binding.ended_at === null) .map((binding) => ({ meter, binding })), ) - const isUnbound = channel.binding_count === 0 && - channel.bound_meter_ids.length === 0 && openBindings.length === 0 const oldBinding = openBindings[0] - const canHandoff = reason === 'meter_swap' && oldMeter !== undefined && + const isSingleOldBinding = reason === 'meter_swap' && oldMeter !== undefined && openBindings.length === 1 && oldBinding !== undefined && - oldBinding.meter.id === oldMeter.id && oldBinding.meter.commodity === commodity && - startedAt > toLocalDateInputString(parseBackendTimestamp(oldBinding.binding.started_at)) - return isUnbound || canHandoff + oldBinding.meter.id === oldMeter.id && oldBinding.meter.commodity === commodity + // A handoff must leave a non-empty interval on the old Meter. The backend + // rejects equality too, so surface it here instead of offering a request + // that is guaranteed to fail. + if (isSingleOldBinding && start <= parseBackendTimestamp(oldBinding.binding.started_at).getTime()) { + return { eligible: false, reason: 'handoff boundary must be strictly after the current binding start' } + } + const canHandoff = isSingleOldBinding + const interval = channelIntervalEligibility( + meters, channel.uuid, toLocalMidnightNaive(startedAt), canHandoff ? [oldBinding.binding.uuid] : [], + ) + if (!interval.eligible) return interval + if (openBindings.length && !canHandoff) { + return openBindings.length > 1 + ? { eligible: false, reason: 'ambiguous: multiple open bindings; resolve them first' } + : { eligible: false, reason: 'occupied by an open binding; choose a different channel or close/transfer it first' } + } + return { eligible: true } } - const channelOptions = channels.data?.items - .filter((channel) => channel.unit === expectedUnit) - .map((channel) => { - const canHandoff = !( - channel.binding_count === 0 && channel.bound_meter_ids.length === 0 - ) && isChannelEligible(channel.uuid, dateStr) + const channelOptions = channels.data?.items.map((channel) => { + const result = channelEligibility(channel.uuid, dateStr) + const canHandoff = !isUnboundChannel(channel.uuid) && result.eligible return { value: channel.uuid, - label: `${channel.label} (${channel.unit})${canHandoff ? ' — hand off from current meter' : ''}`, - disabled: !isChannelEligible(channel.uuid, dateStr), + label: `${channel.label} (${channel.unit})${canHandoff ? ' — hand off from current meter' : result.reason ? ` — ${result.reason}` : ''}`, + disabled: !result.eligible, } }) ?? [] - const selectedChannelUuid = channelUuid && isChannelEligible(channelUuid, dateStr) + const selectedChannelUuid = channelUuid && channelEligibility(channelUuid, dateStr).eligible ? channelUuid : null + function isUnboundChannel(uuid: string): boolean { + return !meters.some((meter) => meter.bindings?.some( + (binding) => binding.source_channel_uuid === uuid && binding.ended_at === null, + )) + } async function handleSubmit(e: React.FormEvent) { e.preventDefault() @@ -155,6 +264,29 @@ function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) { setError('Reason is required.') return } + if (sourceUuid && !selectedChannelUuid) { + const unavailable = channelOptions.length === 1 && channelOptions[0].disabled + ? channelEligibility(channelOptions[0].value, dateStr).reason + : undefined + setError(unavailable ? `${unavailable[0].toUpperCase()}${unavailable.slice(1)}` : 'Select an eligible source channel or clear the optional source.') + return + } + // Omitting a channel for meter_swap asks the backend to auto-handoff the + // sole open binding. Keep that implicit path subject to the same strict + // boundary rule as an explicitly selected channel. + const start = localInstant(toLocalMidnightNaive(dateStr)) + const oldMeter = meters.find((meter) => meter.commodity === commodity && meter.ended_at === null) + // The implicit backend handoff only considers bindings on the current + // commodity's old meter. Bindings are unit-compatible with their meter + // by the binding contract, so an unrelated heating/hot-water binding must + // neither make this ambiguous nor bypass this strict boundary check. + const autoHandoffCandidates = (oldMeter?.bindings ?? []).filter((binding) => binding.ended_at === null) + const oldBinding = autoHandoffCandidates[0] + if (reason === 'meter_swap' && start !== null && oldMeter !== undefined && autoHandoffCandidates.length === 1 && + oldBinding !== undefined && start <= parseBackendTimestamp(oldBinding.started_at).getTime()) { + setError('Handoff boundary must be strictly after the current binding start.') + return + } try { await declareMutation.mutateAsync({ @@ -167,14 +299,7 @@ function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) { }) onSaved() onClose() - } catch (err) { - if (err instanceof ApiError) { - const detail = (err.body as { detail?: string } | null)?.detail - setError(detail ?? `Error ${err.status}: failed to declare meter.`) - } else { - setError('Failed to declare meter. Please try again.') - } - } + } catch (err) { setError(apiErrorMessage(err, 'Failed to declare meter. Please try again.')) } } return ( @@ -205,7 +330,7 @@ function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) { onChange={(e) => { const nextDateStr = e.currentTarget.value setDateStr(nextDateStr) - setChannelUuid((uuid) => uuid && !isChannelEligible(uuid, nextDateStr) ? null : uuid) + setChannelUuid((uuid) => uuid && !channelEligibility(uuid, nextDateStr).eligible ? null : uuid) }} data-testid="meter-started-at" /> @@ -225,7 +350,10 @@ function DeclareMeterForm({ meters, onClose, onSaved }: DeclareMeterFormProps) { { value: 'hot_water', label: 'Hot water' }, ]} /> } + {reason === 'meter_swap' && If the previous meter has exactly one compatible open binding, declaring this meter automatically hands that channel over atomically. Ambiguous bindings remain unavailable.} + {sources.isLoading && Loading sources…}{sources.isError && Could not load sources. Retry after the connection recovers.} + {sourceUuid && channels.isLoading && Loading source channels…}{sourceUuid && channels.isError && Could not load source channels. Choose another source or retry.} + {sourceUuid &&