/** * Tests for MeterManager component. * * Coverage: * 1. Loading state rendering. * 2. Error state rendering. * 3. Empty state when no meters exist. * 4. Meter timeline list rendering (label, dates, active badge, reason). * 5. "Declare New Meter" button opens form modal. * 6. Declare meter — form submit calls POST /api/energy/meters. * 7. Declare meter — 422 (倒挂) error is displayed. * 8. Edit button opens edit form modal. * 9. Edit meter — saves label/note via PATCH /api/energy/meters/{meter_id}. * 10. Edit meter — retroactive started_at triggers recompute notice. */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { renderWithProviders } from '../test-utils' import { MeterManager } from './MeterManager' // --------------------------------------------------------------------------- // Mock apiClient // --------------------------------------------------------------------------- const mockGet = vi.fn() const mockPost = vi.fn() const mockPatch = vi.fn() vi.mock('../api/client', () => ({ default: { GET: (...args: unknown[]) => mockGet(...args), POST: (...args: unknown[]) => mockPost(...args), PATCH: (...args: unknown[]) => mockPatch(...args), DELETE: vi.fn(), }, ApiError: class ApiError extends Error { status: number body: unknown constructor(status: number, body: unknown) { super(`API error ${status}`) this.name = 'ApiError' this.status = status this.body = body } }, registerLoginRedirect: vi.fn(), })) // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- const ACTIVE_METER = { id: 1, label: 'Initial 2G meter', commodity: 'electricity', started_at: '2024-01-15T00:00:00Z', ended_at: null, reason: 'initial', note: null, created_at: '2024-01-15T00:00:00Z', } const CLOSED_METER = { id: 2, label: 'Old 4G meter', commodity: 'electricity', started_at: '2023-06-01T00:00:00Z', ended_at: '2024-01-15T00:00:00Z', reason: 'meter_swap', note: 'Replaced by grid company', created_at: '2023-06-01T00:00:00Z', } const METERS_RESPONSE = { items: [CLOSED_METER, ACTIVE_METER], total: 2, } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('MeterManager — loading / error / empty states', () => { // M8 keeps the existing Modbus-facing meter regressions alongside commodity additions. beforeEach(() => vi.clearAllMocks()) it('renders loading state initially', () => { mockGet.mockImplementation(() => new Promise(() => {})) renderWithProviders() expect(screen.getByTestId('meters-loading')).toBeInTheDocument() }) it('renders error state when GET fails', async () => { mockGet.mockRejectedValue(new Error('Network error')) renderWithProviders() await waitFor(() => { expect(screen.getByTestId('meters-load-error')).toBeInTheDocument() }) }) it('renders empty state when no meters exist', async () => { mockGet.mockResolvedValue({ data: { items: [], total: 0 } }) renderWithProviders() await waitFor(() => { expect(screen.getByTestId('meters-empty')).toBeInTheDocument() }) }) }) describe('MeterManager — binding switch safety', () => { beforeEach(() => vi.clearAllMocks()) it('does not offer switching for a closed meter epoch', async () => { mockGet.mockResolvedValue({ data: { items: [CLOSED_METER], total: 1 } }) renderWithProviders() await waitFor(() => expect(screen.getByTestId('meters-table')).toBeInTheDocument()) expect(screen.queryByRole('button', { name: 'Switch source' })).not.toBeInTheDocument() }) it('disables switch submit until the binding timeline has loaded', async () => { const user = userEvent.setup() mockGet.mockImplementation((path: string) => path === '/api/energy/meters' ? Promise.resolve({ data: { items: [ACTIVE_METER], total: 1 } }) : new Promise(() => {})) renderWithProviders() 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() }) }) describe('MeterManager — meter list', () => { beforeEach(() => vi.clearAllMocks()) it('renders meter timeline with label, dates, status badge, reason', async () => { mockGet.mockResolvedValue({ data: METERS_RESPONSE }) renderWithProviders() await waitFor(() => { expect(screen.getByTestId('meters-table')).toBeInTheDocument() }) // Labels expect(screen.getByText('Initial 2G meter')).toBeInTheDocument() expect(screen.getByText('Old 4G meter')).toBeInTheDocument() // Status badges expect(screen.getByTestId(`meter-status-${ACTIVE_METER.id}`)).toHaveTextContent('active') expect(screen.getByTestId(`meter-status-${CLOSED_METER.id}`)).toHaveTextContent('closed') // Reason badges expect(screen.getByText('initial')).toBeInTheDocument() expect(screen.getByText('meter_swap')).toBeInTheDocument() }) it('renders every binding timeline segment with source, channel, and half-open boundaries', async () => { const meterWithBindings = { ...ACTIVE_METER, bindings: [ { uuid: 'binding-closed', source_uuid: 'source-old', source_channel_uuid: 'channel-old', started_at: '2025-01-01T00:00:00Z', ended_at: '2025-02-01T00:00:00Z' }, { uuid: 'binding-active', source_uuid: 'source-new', source_channel_uuid: 'channel-new', started_at: '2025-02-01T00:00:00Z', ended_at: null }, ], } mockGet.mockResolvedValue({ data: { items: [meterWithBindings], total: 1 } }) renderWithProviders() const closed = await screen.findByTestId('binding-timeline-binding-closed') const active = screen.getByTestId('binding-timeline-binding-active') expect(closed).toHaveTextContent('source-old → channel-old') expect(closed).toHaveTextContent('[1/1/2025, 00:00:00, 2/1/2025, 00:00:00) (closed)') expect(active).toHaveTextContent('source-new → channel-new') expect(active).toHaveTextContent('[2/1/2025, 00:00:00, open-ended) (active)') }) it('renders "Declare New Meter" button', async () => { mockGet.mockResolvedValue({ data: METERS_RESPONSE }) renderWithProviders() await waitFor(() => { expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument() }) }) }) describe('MeterManager — declare new meter', () => { beforeEach(() => vi.clearAllMocks()) it('opens declare modal when "Declare New Meter" is clicked', async () => { const user = userEvent.setup() mockGet.mockResolvedValue({ data: { items: [], total: 0 } }) renderWithProviders() await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument()) await user.click(screen.getByTestId('meter-declare-button')) await waitFor(() => { expect(screen.getByTestId('declare-meter-modal')).toBeInTheDocument() }) expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument() }) it('calls POST /api/energy/meters with correct payload including local-midnight naive datetime', async () => { const user = userEvent.setup() mockGet.mockResolvedValue({ data: { items: [], total: 0 } }) mockPost.mockResolvedValue({ data: ACTIVE_METER }) renderWithProviders() await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument()) await user.click(screen.getByTestId('meter-declare-button')) await waitFor(() => expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument()) // Fill form await user.type(screen.getByTestId('meter-label'), 'New meter label') await user.type(screen.getByTestId('meter-started-at'), '2026-01-01') // Select reason via the combobox (Mantine Select renders a combobox) await user.click(screen.getByTestId('meter-reason')) await waitFor(() => screen.getByText('Initial installation')) await user.click(screen.getByText('Initial installation')) await user.click(screen.getByTestId('declare-meter-submit')) await waitFor(() => { expect(mockPost).toHaveBeenCalledWith( '/api/energy/meters', expect.objectContaining({ body: expect.objectContaining({ label: 'New meter label', // FU10 local-midnight naive convention: no Z suffix started_at: '2026-01-01T00:00:00', reason: 'initial', commodity: 'electricity', }), }), ) }) }) it('displays error when POST fails with 422 (倒挂 / validation error)', async () => { const user = userEvent.setup() mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } }) const { ApiError } = await import('../api/client') mockPost.mockRejectedValue( new ApiError(422, { detail: 'started_at must be ≥ current active meter started_at' }), ) renderWithProviders() await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument()) await user.click(screen.getByTestId('meter-declare-button')) await waitFor(() => expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument()) await user.type(screen.getByTestId('meter-label'), 'Bad meter') await user.type(screen.getByTestId('meter-started-at'), '2020-01-01') await user.click(screen.getByTestId('meter-reason')) await waitFor(() => screen.getByText('Meter swap (same address)')) await user.click(screen.getByText('Meter swap (same address)')) await user.click(screen.getByTestId('declare-meter-submit')) await waitFor(() => { expect(screen.getByTestId('declare-meter-error')).toBeInTheDocument() }) expect(screen.getByTestId('declare-meter-error').textContent).toContain( 'started_at must be ≥', ) }) it('cancel button closes the declare modal', async () => { const user = userEvent.setup() mockGet.mockResolvedValue({ data: { items: [], total: 0 } }) renderWithProviders() await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument()) await user.click(screen.getByTestId('meter-declare-button')) await waitFor(() => expect(screen.getByTestId('declare-meter-modal')).toBeInTheDocument()) await user.click(screen.getByTestId('declare-meter-cancel')) await waitFor(() => { expect(screen.queryByTestId('declare-meter-modal')).not.toBeInTheDocument() }) }) }) describe('MeterManager — edit meter date initialisation', () => { beforeEach(() => vi.clearAllMocks()) it('initialises date input from started_at (Z-suffix, UTC midnight → local date)', async () => { // ACTIVE_METER.started_at = '2024-01-15T00:00:00Z' (UTC midnight). // The test suite is pinned to TZ=UTC (via vite.config.ts test.env), so // the local date is deterministically '2024-01-15' on any CI runner. const user = userEvent.setup() mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } }) renderWithProviders() await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument()) await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)) await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()) const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement expect(dateInput.value).toBe('2024-01-15') }) it('initialises date input from started_at (naive, no tz marker)', async () => { // A naive timestamp without timezone marker — parseBackendTimestamp appends 'Z' // so it is treated as UTC. With TZ=UTC (pinned in vite.config.ts), the local date // equals the UTC date exactly. const naiveMeter = { ...ACTIVE_METER, started_at: '2024-03-20T00:00:00' } const user = userEvent.setup() mockGet.mockResolvedValue({ data: { items: [naiveMeter], total: 1 } }) renderWithProviders() await waitFor(() => expect(screen.getByTestId(`meter-edit-${naiveMeter.id}`)).toBeInTheDocument()) await user.click(screen.getByTestId(`meter-edit-${naiveMeter.id}`)) await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()) const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement expect(dateInput.value).toBe('2024-03-20') }) it('initialises date input from started_at with explicit UTC offset (+02:00) — regression for old buggy regex', async () => { // The old hand-written regex /[zZ+-]\d*$/ would fail to match '+02:00' (the ':00' // suffix broke the pattern) and would incorrectly append 'Z', producing an Invalid Date. // The new code uses parseBackendTimestamp which uses the correct TZ_MARKER_RE regex // and handles explicit offsets properly. const offsetMeter = { ...ACTIVE_METER, started_at: '2024-01-15T02:00:00+02:00' } // UTC equivalent: 2024-01-15T00:00:00Z → with TZ=UTC (pinned) local date = '2024-01-15' const user = userEvent.setup() mockGet.mockResolvedValue({ data: { items: [offsetMeter], total: 1 } }) renderWithProviders() await waitFor(() => expect(screen.getByTestId(`meter-edit-${offsetMeter.id}`)).toBeInTheDocument()) await user.click(screen.getByTestId(`meter-edit-${offsetMeter.id}`)) await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()) const dateInput = screen.getByTestId('edit-meter-started-at') as HTMLInputElement // Must not be empty (which would indicate Invalid Date from the old buggy path) expect(dateInput.value).not.toBe('') expect(dateInput.value).toBe('2024-01-15') }) it('does not include started_at in PATCH body when date is unchanged (round-trip idempotence)', async () => { // Open the edit form and immediately submit without changing any fields except label. // The date should be considered unchanged → no started_at in the PATCH body. const user = userEvent.setup() mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } }) mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, label: 'New label' } }) renderWithProviders() await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument()) await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)) await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()) // Change only label; leave date untouched const labelInput = screen.getByTestId('edit-meter-label') await user.clear(labelInput) await user.type(labelInput, 'New label') await user.click(screen.getByTestId('edit-meter-submit')) await waitFor(() => { expect(mockPatch).toHaveBeenCalled() }) const patchBody = mockPatch.mock.calls[0][1].body expect(patchBody).not.toHaveProperty('started_at') }) }) describe('MeterManager — edit meter', () => { beforeEach(() => vi.clearAllMocks()) it('opens edit modal when Edit button is clicked', async () => { const user = userEvent.setup() mockGet.mockResolvedValue({ data: METERS_RESPONSE }) renderWithProviders() await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument()) await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)) await waitFor(() => { expect(screen.getByTestId('edit-meter-modal')).toBeInTheDocument() }) expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument() }) it('calls PATCH /api/energy/meters/{meter_id} when label is changed', async () => { const user = userEvent.setup() mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } }) mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, label: 'Renamed meter' } }) renderWithProviders() await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument()) await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)) await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()) // Clear label and type new one const labelInput = screen.getByTestId('edit-meter-label') await user.clear(labelInput) await user.type(labelInput, 'Renamed meter') await user.click(screen.getByTestId('edit-meter-submit')) await waitFor(() => { expect(mockPatch).toHaveBeenCalledWith( '/api/energy/meters/{meter_id}', expect.objectContaining({ params: { path: { meter_id: ACTIVE_METER.id } }, body: expect.objectContaining({ label: 'Renamed meter' }), }), ) }) }) it('shows recompute notice when started_at is changed (retroactive correction)', async () => { const user = userEvent.setup() mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } }) mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, started_at: '2024-02-01T00:00:00' } }) renderWithProviders() await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument()) await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)) await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()) // Change the date field const dateInput = screen.getByTestId('edit-meter-started-at') await user.clear(dateInput) await user.type(dateInput, '2024-02-01') await user.click(screen.getByTestId('edit-meter-submit')) await waitFor(() => { expect(screen.getByTestId('meter-recompute-notice')).toBeInTheDocument() }) }) it('displays error when PATCH fails', async () => { const user = userEvent.setup() mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } }) const { ApiError } = await import('../api/client') mockPatch.mockRejectedValue(new ApiError(422, { detail: 'started_at conflict' })) renderWithProviders() await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument()) await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)) await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()) // Change label so there's something to patch const labelInput = screen.getByTestId('edit-meter-label') await user.clear(labelInput) await user.type(labelInput, 'Different label') await user.click(screen.getByTestId('edit-meter-submit')) await waitFor(() => { expect(screen.getByTestId('edit-meter-error')).toBeInTheDocument() }) expect(screen.getByTestId('edit-meter-error').textContent).toContain('started_at conflict') }) })