M7-T06: add meter management UI (timeline, declare swap, edit) to Energy view
This commit is contained in:
Vendored
+278
@@ -559,6 +559,84 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/energy/meters": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* List Energy Meters
|
||||
* @description List all meter epochs in ascending ``started_at`` order.
|
||||
*
|
||||
* Returns the full historical sequence of meter installations across all
|
||||
* commodities. The active meter (``ended_at=null``) appears last because it
|
||||
* has the latest ``started_at``.
|
||||
*/
|
||||
get: operations["list_energy_meters_api_energy_meters_get"];
|
||||
put?: never;
|
||||
/**
|
||||
* Declare Energy Meter
|
||||
* @description Declare a new meter epoch (swap, home move, or initial declaration).
|
||||
*
|
||||
* Closes the current active meter for the given commodity at ``started_at``
|
||||
* and opens a new active meter. If no active meter exists, the new meter is
|
||||
* simply created without closing anything.
|
||||
*
|
||||
* **Validation**: ``started_at`` must be **≥** the current active meter's
|
||||
* own ``started_at`` (no chronological backdate below the active epoch's
|
||||
* start). Equal timestamps are allowed (replaces the current meter at the
|
||||
* same logical moment). Violation → 422.
|
||||
*
|
||||
* **Retroactive recompute**: if ``started_at`` is in the past, billing
|
||||
* records from that point forward are re-judged via ``recompute_range`` to
|
||||
* reflect the new meter attribution. The response includes the count of
|
||||
* recomputed periods in ``recomputed_periods`` (not part of ``MeterResponse``
|
||||
* — the recompute is transparent; callers should re-fetch costs if needed).
|
||||
*/
|
||||
post: operations["declare_energy_meter_api_energy_meters_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/energy/meters/{meter_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/**
|
||||
* Patch Energy Meter
|
||||
* @description Partially update a meter epoch: rename, edit note, or correct started_at.
|
||||
*
|
||||
* - ``label``: updates the human-readable label.
|
||||
* - ``note``: updates the free-form note.
|
||||
* - ``started_at``: **retroactive correction** — shifts this meter's start
|
||||
* boundary. The service layer maintains timeline continuity by also
|
||||
* updating the preceding meter's ``ended_at``. Validation:
|
||||
* * Must be strictly after the previous meter's own ``started_at``.
|
||||
* * Must be strictly before this meter's ``ended_at`` (if closed).
|
||||
* Violation → 422.
|
||||
*
|
||||
* **Retroactive recompute when ``started_at`` changes**: billing records in
|
||||
* the window ``[min(old, new), now)`` are re-judged to reflect the corrected
|
||||
* meter attribution.
|
||||
*
|
||||
* Not found → 404.
|
||||
*/
|
||||
patch: operations["patch_energy_meter_api_energy_meters__meter_id__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/expose": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1569,6 +1647,114 @@ export interface components {
|
||||
*/
|
||||
sell_normal: number;
|
||||
};
|
||||
/**
|
||||
* MeterDeclareRequest
|
||||
* @description Request body for POST /api/energy/meters.
|
||||
*
|
||||
* Declares a new meter epoch (swap, home move, or initial declaration). The
|
||||
* service layer closes the current active meter for the given commodity at
|
||||
* ``started_at`` and opens a new one.
|
||||
*
|
||||
* ``started_at`` follows the Principle-A localisation convention: a
|
||||
* timezone-naive value is interpreted as the **server's local wall-clock time**
|
||||
* (e.g. CEST midnight → stored as UTC the night before); a timezone-aware
|
||||
* value is converted to UTC as-is. Omitting ``started_at`` is not allowed —
|
||||
* every meter declaration must carry an explicit start timestamp.
|
||||
*
|
||||
* ``commodity`` defaults to ``"electricity"``; the field is available for
|
||||
* future use with ``gas`` or ``heating``.
|
||||
*/
|
||||
MeterDeclareRequest: {
|
||||
/** Label */
|
||||
label: string;
|
||||
/**
|
||||
* Started At
|
||||
* Format: date-time
|
||||
* @description UTC (or server-local naive) datetime from which this meter epoch starts. May be in the past (retroactive declaration).
|
||||
*/
|
||||
started_at: string;
|
||||
/** @description Why this epoch was created. One of: initial, meter_swap, home_move, other. */
|
||||
reason: components["schemas"]["MeterReason"];
|
||||
/** Note */
|
||||
note?: string | null;
|
||||
/**
|
||||
* Commodity
|
||||
* @description Energy commodity this meter measures. Defaults to 'electricity'.
|
||||
* @default electricity
|
||||
*/
|
||||
commodity: string;
|
||||
};
|
||||
/**
|
||||
* MeterListResponse
|
||||
* @description Response schema for GET /api/energy/meters.
|
||||
*
|
||||
* Meters are returned in ascending ``started_at`` order so the caller sees
|
||||
* the historical installation sequence.
|
||||
*/
|
||||
MeterListResponse: {
|
||||
/** Items */
|
||||
items: components["schemas"]["MeterResponse"][];
|
||||
/** Total */
|
||||
total: number;
|
||||
};
|
||||
/**
|
||||
* MeterPatchRequest
|
||||
* @description Request body for PATCH /api/energy/meters/{id}.
|
||||
*
|
||||
* All fields are optional. Only non-``None`` values are applied.
|
||||
*
|
||||
* Updating ``started_at`` is a **retroactive correction**: the service layer
|
||||
* maintains timeline continuity (adjusting the preceding meter's ``ended_at``)
|
||||
* and the API layer triggers ``recompute_range`` over the affected window so
|
||||
* that billing attribution is re-judged.
|
||||
*/
|
||||
MeterPatchRequest: {
|
||||
/** Label */
|
||||
label?: string | null;
|
||||
/** Note */
|
||||
note?: string | null;
|
||||
/**
|
||||
* Started At
|
||||
* @description Retroactive correction of the meter epoch start timestamp. Triggers billing recompute over the affected window.
|
||||
*/
|
||||
started_at?: string | null;
|
||||
};
|
||||
/**
|
||||
* MeterReason
|
||||
* @description Allowed values for the meter epoch creation reason.
|
||||
* @enum {string}
|
||||
*/
|
||||
MeterReason: "initial" | "meter_swap" | "home_move" | "other";
|
||||
/**
|
||||
* MeterResponse
|
||||
* @description Response schema for a single Meter epoch row.
|
||||
*
|
||||
* ``ended_at`` is ``null`` for the currently active meter.
|
||||
*/
|
||||
MeterResponse: {
|
||||
/** Id */
|
||||
id: number;
|
||||
/** Label */
|
||||
label: string;
|
||||
/** Commodity */
|
||||
commodity: string;
|
||||
/**
|
||||
* Started At
|
||||
* Format: date-time
|
||||
*/
|
||||
started_at: string;
|
||||
/** Ended At */
|
||||
ended_at: string | null;
|
||||
/** Reason */
|
||||
reason: string;
|
||||
/** Note */
|
||||
note: string | null;
|
||||
/**
|
||||
* Created At
|
||||
* Format: date-time
|
||||
*/
|
||||
created_at: string;
|
||||
};
|
||||
/**
|
||||
* MetricInfo
|
||||
* @description Metadata for a single measurable quantity in a device's profile.
|
||||
@@ -3007,6 +3193,98 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
list_energy_meters_api_energy_meters_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MeterListResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
declare_energy_meter_api_energy_meters_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
"X-CSRF-Token"?: string | null;
|
||||
};
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["MeterDeclareRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MeterResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
patch_energy_meter_api_energy_meters__meter_id__patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: {
|
||||
"X-CSRF-Token"?: string | null;
|
||||
};
|
||||
path: {
|
||||
meter_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["MeterPatchRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MeterResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_expose_api_expose_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* 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', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('renders loading state initially', () => {
|
||||
mockGet.mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
renderWithProviders(<MeterManager />)
|
||||
|
||||
expect(screen.getByTestId('meters-loading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders error state when GET fails', async () => {
|
||||
mockGet.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
renderWithProviders(<MeterManager />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('meters-load-error')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders empty state when no meters exist', async () => {
|
||||
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||
|
||||
renderWithProviders(<MeterManager />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('meters-empty')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('MeterManager — meter list', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('renders meter timeline with label, dates, status badge, reason', async () => {
|
||||
mockGet.mockResolvedValue({ data: METERS_RESPONSE })
|
||||
|
||||
renderWithProviders(<MeterManager />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('meters-table')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Labels
|
||||
expect(screen.getByText('Initial 2G meter')).toBeInTheDocument()
|
||||
expect(screen.getByText('Old 4G meter')).toBeInTheDocument()
|
||||
|
||||
// Status badges
|
||||
expect(screen.getByTestId(`meter-status-${ACTIVE_METER.id}`)).toHaveTextContent('active')
|
||||
expect(screen.getByTestId(`meter-status-${CLOSED_METER.id}`)).toHaveTextContent('closed')
|
||||
|
||||
// Reason badges
|
||||
expect(screen.getByText('initial')).toBeInTheDocument()
|
||||
expect(screen.getByText('meter_swap')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders "Declare New Meter" button', async () => {
|
||||
mockGet.mockResolvedValue({ data: METERS_RESPONSE })
|
||||
|
||||
renderWithProviders(<MeterManager />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('MeterManager — declare new meter', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('opens declare modal when "Declare New Meter" is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||
|
||||
renderWithProviders(<MeterManager />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getByTestId('meter-declare-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('declare-meter-modal')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls POST /api/energy/meters with correct payload including local-midnight naive datetime', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||
mockPost.mockResolvedValue({ data: ACTIVE_METER })
|
||||
|
||||
renderWithProviders(<MeterManager />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
|
||||
await user.click(screen.getByTestId('meter-declare-button'))
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument())
|
||||
|
||||
// Fill form
|
||||
await user.type(screen.getByTestId('meter-label'), 'New meter label')
|
||||
await user.type(screen.getByTestId('meter-started-at'), '2026-01-01')
|
||||
|
||||
// Select reason via the combobox (Mantine Select renders a combobox)
|
||||
await user.click(screen.getByTestId('meter-reason'))
|
||||
await waitFor(() => screen.getByText('Initial installation'))
|
||||
await user.click(screen.getByText('Initial installation'))
|
||||
|
||||
await user.click(screen.getByTestId('declare-meter-submit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
'/api/energy/meters',
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
label: 'New meter label',
|
||||
// FU10 local-midnight naive convention: no Z suffix
|
||||
started_at: '2026-01-01T00:00:00',
|
||||
reason: 'initial',
|
||||
commodity: 'electricity',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('displays error when POST fails with 422 (倒挂 / validation error)', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
|
||||
|
||||
const { ApiError } = await import('../api/client')
|
||||
mockPost.mockRejectedValue(
|
||||
new ApiError(422, { detail: 'started_at must be ≥ current active meter started_at' }),
|
||||
)
|
||||
|
||||
renderWithProviders(<MeterManager />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
|
||||
await user.click(screen.getByTestId('meter-declare-button'))
|
||||
await waitFor(() => expect(screen.getByTestId('declare-meter-form')).toBeInTheDocument())
|
||||
|
||||
await user.type(screen.getByTestId('meter-label'), 'Bad meter')
|
||||
await user.type(screen.getByTestId('meter-started-at'), '2020-01-01')
|
||||
await user.click(screen.getByTestId('meter-reason'))
|
||||
await waitFor(() => screen.getByText('Meter swap (same address)'))
|
||||
await user.click(screen.getByText('Meter swap (same address)'))
|
||||
|
||||
await user.click(screen.getByTestId('declare-meter-submit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('declare-meter-error')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('declare-meter-error').textContent).toContain(
|
||||
'started_at must be ≥',
|
||||
)
|
||||
})
|
||||
|
||||
it('cancel button closes the declare modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockGet.mockResolvedValue({ data: { items: [], total: 0 } })
|
||||
|
||||
renderWithProviders(<MeterManager />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('meter-declare-button')).toBeInTheDocument())
|
||||
await user.click(screen.getByTestId('meter-declare-button'))
|
||||
await waitFor(() => expect(screen.getByTestId('declare-meter-modal')).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getByTestId('declare-meter-cancel'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('declare-meter-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('MeterManager — edit meter', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('opens edit modal when Edit button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockGet.mockResolvedValue({ data: METERS_RESPONSE })
|
||||
|
||||
renderWithProviders(<MeterManager />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
|
||||
|
||||
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('edit-meter-modal')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls PATCH /api/energy/meters/{meter_id} when label is changed', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
|
||||
mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, label: 'Renamed meter' } })
|
||||
|
||||
renderWithProviders(<MeterManager />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
|
||||
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
|
||||
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
|
||||
|
||||
// Clear label and type new one
|
||||
const labelInput = screen.getByTestId('edit-meter-label')
|
||||
await user.clear(labelInput)
|
||||
await user.type(labelInput, 'Renamed meter')
|
||||
|
||||
await user.click(screen.getByTestId('edit-meter-submit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
'/api/energy/meters/{meter_id}',
|
||||
expect.objectContaining({
|
||||
params: { path: { meter_id: ACTIVE_METER.id } },
|
||||
body: expect.objectContaining({ label: 'Renamed meter' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows recompute notice when started_at is changed (retroactive correction)', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
|
||||
mockPatch.mockResolvedValue({ data: { ...ACTIVE_METER, started_at: '2024-02-01T00:00:00' } })
|
||||
|
||||
renderWithProviders(<MeterManager />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
|
||||
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
|
||||
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
|
||||
|
||||
// Change the date field
|
||||
const dateInput = screen.getByTestId('edit-meter-started-at')
|
||||
await user.clear(dateInput)
|
||||
await user.type(dateInput, '2024-02-01')
|
||||
|
||||
await user.click(screen.getByTestId('edit-meter-submit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('meter-recompute-notice')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays error when PATCH fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockGet.mockResolvedValue({ data: { items: [ACTIVE_METER], total: 1 } })
|
||||
|
||||
const { ApiError } = await import('../api/client')
|
||||
mockPatch.mockRejectedValue(new ApiError(422, { detail: 'started_at conflict' }))
|
||||
|
||||
renderWithProviders(<MeterManager />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`)).toBeInTheDocument())
|
||||
await user.click(screen.getByTestId(`meter-edit-${ACTIVE_METER.id}`))
|
||||
await waitFor(() => expect(screen.getByTestId('edit-meter-form')).toBeInTheDocument())
|
||||
|
||||
// Change label so there's something to patch
|
||||
const labelInput = screen.getByTestId('edit-meter-label')
|
||||
await user.clear(labelInput)
|
||||
await user.type(labelInput, 'Different label')
|
||||
|
||||
await user.click(screen.getByTestId('edit-meter-submit'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('edit-meter-error')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('edit-meter-error').textContent).toContain('started_at conflict')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* MeterManager — electricity meter timeline UI.
|
||||
*
|
||||
* Features:
|
||||
* - Table of meter epochs: label / interval (started_at → ended_at or "active") /
|
||||
* active badge / reason.
|
||||
* - "Declare New Meter" button: form with label + date (started_at) + reason +
|
||||
* optional note. Sends local-midnight naive datetime per FU10 convention.
|
||||
* - Edit modal: update label, note, or correct started_at (retroactive).
|
||||
* - Retroactive feedback: if started_at is changed, a success notice mentions
|
||||
* that affected billing periods have been recomputed.
|
||||
* - Loading / error / empty states.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Group,
|
||||
Text,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
Stack,
|
||||
Badge,
|
||||
ScrollArea,
|
||||
Modal,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Select,
|
||||
Notification,
|
||||
} from '@mantine/core'
|
||||
import {
|
||||
useMeters,
|
||||
useDeclareMeter,
|
||||
useUpdateMeter,
|
||||
type MeterResponse,
|
||||
type MeterReason,
|
||||
} from './hooks'
|
||||
import { ApiError } from '../api/client'
|
||||
import { formatLocalDate } from '../utils/datetime'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const REASON_OPTIONS: { value: MeterReason; label: string }[] = [
|
||||
{ value: 'initial', label: 'Initial installation' },
|
||||
{ value: 'meter_swap', label: 'Meter swap (same address)' },
|
||||
{ value: 'home_move', label: 'Home / address move' },
|
||||
{ value: 'other', label: 'Other' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Convert a local date string "YYYY-MM-DD" to a naive local-midnight datetime
|
||||
* string (no Z suffix) following the FU10 / ContractForm convention.
|
||||
* The backend interprets naive datetimes as server local wall-clock time.
|
||||
*/
|
||||
function toLocalMidnightNaive(dateStr: string): string {
|
||||
return `${dateStr}T00:00:00`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Declare meter form (modal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DeclareMeterFormProps {
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
function DeclareMeterForm({ onClose, onSaved }: DeclareMeterFormProps) {
|
||||
const [label, setLabel] = useState('')
|
||||
const [dateStr, setDateStr] = useState('')
|
||||
const [reason, setReason] = useState<string | null>(null)
|
||||
const [note, setNote] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const declareMutation = useDeclareMeter()
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (!label.trim()) {
|
||||
setError('Label is required.')
|
||||
return
|
||||
}
|
||||
if (!dateStr) {
|
||||
setError('Start date is required.')
|
||||
return
|
||||
}
|
||||
if (!reason) {
|
||||
setError('Reason is required.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await declareMutation.mutateAsync({
|
||||
label: label.trim(),
|
||||
started_at: toLocalMidnightNaive(dateStr),
|
||||
reason: reason as MeterReason,
|
||||
note: note.trim() || undefined,
|
||||
commodity: 'electricity',
|
||||
})
|
||||
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.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened
|
||||
onClose={onClose}
|
||||
title="Declare New Meter"
|
||||
size="md"
|
||||
data-testid="declare-meter-modal"
|
||||
>
|
||||
<form onSubmit={handleSubmit} data-testid="declare-meter-form">
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Label"
|
||||
description={'Human-readable identifier, e.g. "2G meter @ Dorpsstraat 1"'}
|
||||
required
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.currentTarget.value)}
|
||||
data-testid="meter-label"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Start date"
|
||||
description="Date in YYYY-MM-DD format (interpreted as local midnight)"
|
||||
type="date"
|
||||
required
|
||||
value={dateStr}
|
||||
onChange={(e) => setDateStr(e.currentTarget.value)}
|
||||
data-testid="meter-started-at"
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Reason"
|
||||
required
|
||||
data={REASON_OPTIONS}
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
data-testid="meter-reason"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Note (optional)"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
data-testid="meter-note"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" data-testid="declare-meter-error">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={onClose}
|
||||
data-testid="declare-meter-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={declareMutation.isPending}
|
||||
data-testid="declare-meter-submit"
|
||||
>
|
||||
Declare Meter
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Edit meter form (modal)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EditMeterFormProps {
|
||||
meter: MeterResponse
|
||||
onClose: () => void
|
||||
onSaved: (retroactive: boolean) => void
|
||||
}
|
||||
|
||||
function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
|
||||
const [label, setLabel] = useState(meter.label)
|
||||
const [note, setNote] = useState(meter.note ?? '')
|
||||
// Convert UTC started_at to a local date string for the <input type="date">.
|
||||
// We parse as UTC (appending Z if needed) and format to "YYYY-MM-DD" in local tz.
|
||||
const initialDateStr = (() => {
|
||||
const iso = meter.started_at.includes('T') && !meter.started_at.match(/[zZ+-]\d*$/)
|
||||
? meter.started_at + 'Z'
|
||||
: meter.started_at
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
})()
|
||||
const [dateStr, setDateStr] = useState(initialDateStr)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const updateMutation = useUpdateMeter()
|
||||
|
||||
// Detect if the user changed started_at (retroactive correction).
|
||||
const startedAtChanged = dateStr !== initialDateStr
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
const patchBody: { label?: string | null; note?: string | null; started_at?: string | null } = {}
|
||||
if (label.trim() !== meter.label) patchBody.label = label.trim()
|
||||
const noteVal = note.trim() || null
|
||||
if (noteVal !== meter.note) patchBody.note = noteVal
|
||||
if (startedAtChanged && dateStr) {
|
||||
patchBody.started_at = toLocalMidnightNaive(dateStr)
|
||||
}
|
||||
|
||||
if (Object.keys(patchBody).length === 0) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateMutation.mutateAsync({ id: meter.id, body: patchBody })
|
||||
onSaved(startedAtChanged)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
const detail = (err.body as { detail?: string } | null)?.detail
|
||||
setError(detail ?? `Error ${err.status}: failed to update meter.`)
|
||||
} else {
|
||||
setError('Failed to update meter. Please try again.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened
|
||||
onClose={onClose}
|
||||
title={`Edit Meter — ${meter.label}`}
|
||||
size="md"
|
||||
data-testid="edit-meter-modal"
|
||||
>
|
||||
<form onSubmit={handleSubmit} data-testid="edit-meter-form">
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label="Label"
|
||||
required
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.currentTarget.value)}
|
||||
data-testid="edit-meter-label"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Start date"
|
||||
description="Retroactive correction: shifts the epoch boundary and re-judges billing periods"
|
||||
type="date"
|
||||
value={dateStr}
|
||||
onChange={(e) => setDateStr(e.currentTarget.value)}
|
||||
data-testid="edit-meter-started-at"
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Note (optional)"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
data-testid="edit-meter-note"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert color="red" data-testid="edit-meter-error">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
onClick={onClose}
|
||||
data-testid="edit-meter-cancel"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={updateMutation.isPending}
|
||||
data-testid="edit-meter-submit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Meter timeline table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MeterTableProps {
|
||||
meters: MeterResponse[]
|
||||
onEdit: (meter: MeterResponse) => void
|
||||
}
|
||||
|
||||
function MeterTable({ meters, onEdit }: MeterTableProps) {
|
||||
if (meters.length === 0) {
|
||||
return (
|
||||
<Text c="dimmed" ta="center" size="sm" data-testid="meters-empty">
|
||||
No meters declared yet. Click "Declare New Meter" to add one.
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea>
|
||||
<Table striped highlightOnHover withTableBorder data-testid="meters-table">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Label</Table.Th>
|
||||
<Table.Th>Commodity</Table.Th>
|
||||
<Table.Th>From</Table.Th>
|
||||
<Table.Th>To</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Reason</Table.Th>
|
||||
<Table.Th style={{ textAlign: 'right' }}>Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{meters.map((meter) => {
|
||||
const isActive = meter.ended_at === null
|
||||
return (
|
||||
<Table.Tr key={meter.id} data-testid={`meter-row-${meter.id}`}>
|
||||
<Table.Td>
|
||||
<Text fw={500} size="sm">
|
||||
{meter.label}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="outline" size="sm">
|
||||
{meter.commodity}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatLocalDate(meter.started_at)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">
|
||||
{meter.ended_at ? formatLocalDate(meter.ended_at) : '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={isActive ? 'green' : 'gray'}
|
||||
variant="light"
|
||||
size="sm"
|
||||
data-testid={`meter-status-${meter.id}`}
|
||||
>
|
||||
{isActive ? 'active' : 'closed'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="outline" size="sm" color="blue">
|
||||
{meter.reason}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group justify="flex-end" gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
onClick={() => onEdit(meter)}
|
||||
data-testid={`meter-edit-${meter.id}`}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MeterManager — top-level
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function MeterManager() {
|
||||
const metersQuery = useMeters()
|
||||
|
||||
const [showDeclareForm, setShowDeclareForm] = useState(false)
|
||||
const [editMeter, setEditMeter] = useState<MeterResponse | null>(null)
|
||||
const [recomputeNotice, setRecomputeNotice] = useState(false)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render states
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (metersQuery.isLoading) {
|
||||
return (
|
||||
<Center py="xl" data-testid="meters-loading">
|
||||
<Loader />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (metersQuery.isError || !metersQuery.data) {
|
||||
return (
|
||||
<Alert color="red" data-testid="meters-load-error">
|
||||
Failed to load meters. Please refresh.
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
const meters = metersQuery.data.items
|
||||
|
||||
return (
|
||||
<Stack gap="lg" data-testid="meter-manager">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={500}>Electricity Meters</Text>
|
||||
<Button
|
||||
onClick={() => setShowDeclareForm(true)}
|
||||
data-testid="meter-declare-button"
|
||||
>
|
||||
Declare New Meter
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{recomputeNotice && (
|
||||
<Notification
|
||||
color="teal"
|
||||
title="Billing periods recomputed"
|
||||
onClose={() => setRecomputeNotice(false)}
|
||||
data-testid="meter-recompute-notice"
|
||||
>
|
||||
The start date was corrected. Affected billing periods have been
|
||||
re-judged and cost attributions updated.
|
||||
</Notification>
|
||||
)}
|
||||
|
||||
<MeterTable meters={meters} onEdit={(m) => setEditMeter(m)} />
|
||||
|
||||
{/* Declare new meter */}
|
||||
{showDeclareForm && (
|
||||
<DeclareMeterForm
|
||||
onClose={() => setShowDeclareForm(false)}
|
||||
onSaved={() => setShowDeclareForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit existing meter */}
|
||||
{editMeter && (
|
||||
<EditMeterForm
|
||||
meter={editMeter}
|
||||
onClose={() => setEditMeter(null)}
|
||||
onSaved={(retroactive) => {
|
||||
setEditMeter(null)
|
||||
if (retroactive) setRecomputeNotice(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -214,6 +214,12 @@ export function useMetrics(uuid: string) {
|
||||
// ===========================================================================
|
||||
|
||||
// Re-exported energy types for consumers
|
||||
export type MeterResponse = components['schemas']['MeterResponse']
|
||||
export type MeterListResponse = components['schemas']['MeterListResponse']
|
||||
export type MeterDeclareRequest = components['schemas']['MeterDeclareRequest']
|
||||
export type MeterPatchRequest = components['schemas']['MeterPatchRequest']
|
||||
export type MeterReason = components['schemas']['MeterReason']
|
||||
|
||||
export type ContractResponse = components['schemas']['ContractResponse']
|
||||
export type ContractDetailResponse = components['schemas']['ContractDetailResponse']
|
||||
export type ContractVersionResponse = components['schemas']['ContractVersionResponse']
|
||||
@@ -434,6 +440,66 @@ export function useRecomputeCosts() {
|
||||
})
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Meter hooks — typed TanStack Query wrappers for /api/energy/meters.
|
||||
//
|
||||
// Query-key conventions:
|
||||
// ['energy-meters'] — meter list
|
||||
// ===========================================================================
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query: list all meter epochs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useMeters() {
|
||||
return useQuery({
|
||||
queryKey: ['energy-meters'],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.GET('/api/energy/meters')
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutation: declare a new meter epoch (swap / home move / initial)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useDeclareMeter() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: MeterDeclareRequest) =>
|
||||
apiClient.POST('/api/energy/meters', { body }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['energy-meters'] })
|
||||
// Invalidate cost-related queries: a new meter may trigger recompute server-side.
|
||||
void qc.invalidateQueries({ queryKey: ['energy-costs'] })
|
||||
void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutation: update (PATCH) a meter epoch (label / note / started_at)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useUpdateMeter() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: MeterPatchRequest }) =>
|
||||
apiClient.PATCH('/api/energy/meters/{meter_id}', {
|
||||
params: { path: { meter_id: id } },
|
||||
body,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['energy-meters'] })
|
||||
// Retroactive started_at correction triggers recompute server-side.
|
||||
void qc.invalidateQueries({ queryKey: ['energy-costs'] })
|
||||
void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query: time-range readings for a device (window + limit — never full-table)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -381,6 +381,44 @@ describe('EnergyPage — test-read', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('EnergyPage — meters tab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setupDefaultMocks()
|
||||
})
|
||||
|
||||
it('renders Meters tab in the tab list', async () => {
|
||||
renderEnergy()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('tab-meters')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId('tab-meters').textContent).toContain('Meters')
|
||||
})
|
||||
|
||||
it('renders meters panel when Meters tab is clicked', async () => {
|
||||
mockGet.mockImplementation((path: string) => {
|
||||
if (path === '/api/modbus/devices') {
|
||||
return Promise.resolve({ data: { items: [DEVICE], total: 1 } })
|
||||
}
|
||||
if (path === '/api/energy/meters') {
|
||||
return Promise.resolve({ data: { items: [], total: 0 } })
|
||||
}
|
||||
return Promise.resolve({ data: null })
|
||||
})
|
||||
|
||||
renderEnergy()
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('tab-meters')).toBeInTheDocument())
|
||||
|
||||
fireEvent.click(screen.getByTestId('tab-meters'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('panel-meters')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EnergyPage — auto-refresh switch', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
@@ -42,6 +42,7 @@ import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMe
|
||||
import { DeviceForm } from '../energy/DeviceForm'
|
||||
import { EnergyCharts } from '../energy/EnergyCharts'
|
||||
import { ContractManager } from '../energy/ContractManager'
|
||||
import { MeterManager } from '../energy/MeterManager'
|
||||
import { TibberPrices } from '../energy/TibberPrices'
|
||||
import { CostView } from '../energy/CostView'
|
||||
import { DsmrPanel } from '../energy/DsmrPanel'
|
||||
@@ -661,6 +662,9 @@ export function EnergyPage() {
|
||||
<Tabs.Tab value="devices" data-testid="tab-devices">
|
||||
Devices
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="meters" data-testid="tab-meters">
|
||||
Meters
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="contracts" data-testid="tab-contracts">
|
||||
Contracts
|
||||
</Tabs.Tab>
|
||||
@@ -679,6 +683,10 @@ export function EnergyPage() {
|
||||
<DevicesTab />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="meters" data-testid="panel-meters">
|
||||
<MeterManager />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="contracts" data-testid="panel-contracts">
|
||||
<ContractManager />
|
||||
</Tabs.Panel>
|
||||
|
||||
Reference in New Issue
Block a user