M6-T10: frontend contract management + price/cost views + Tibber test

- EnergyPage: Mantine Tabs (Devices kept intact + Contracts/Prices/Costs).
- ContractManager/ContractForm: list/activate/add-version + version history;
  form fields rendered dynamically from /api/energy/profiles structure.
- TibberPrices + CostView: Recharts price curve, cost trend/detail/summary,
  recompute; window-bounded, currency/units from API, empty/error/loading states.
- ConfigPage: tri-state Tibber test button (token never shown).
- hooks.ts: typed energy hooks; schema.d.ts regenerated via codegen.
This commit is contained in:
2026-06-23 23:32:39 +02:00
parent 57f2459f60
commit 96e88861d4
15 changed files with 4235 additions and 32 deletions
+214
View File
@@ -0,0 +1,214 @@
/**
* Tests for CostView component.
*
* Coverage:
* 1. Loading state.
* 2. Empty state.
* 3. Renders costs table with data.
* 4. Shows summary values.
* 5. Recompute button triggers confirmation modal and then recompute call.
*/
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'
// ---------------------------------------------------------------------------
// Mock apiClient
// ---------------------------------------------------------------------------
const mockGet = vi.fn()
const mockPost = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
POST: (...args: unknown[]) => mockPost(...args),
PATCH: vi.fn(),
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 COST_PERIOD = {
period_start: '2026-06-22T10:00:00Z',
d1_kwh: 0.1,
d2_kwh: 0.2,
r1_kwh: 0.0,
r2_kwh: 0.05,
import_cost: 0.044,
export_revenue: 0.005,
net_cost: 0.039,
currency: 'EUR',
degraded: false,
contract_version_id: 1,
}
const SUMMARY = {
currency: 'EUR',
metered_import: 10.5,
metered_export: 2.3,
metered_net: 8.2,
fixed_costs: 5.0,
credits: 50.0,
total_payable: 12.5,
}
// ---------------------------------------------------------------------------
// Import component
// ---------------------------------------------------------------------------
import { CostView } from './CostView'
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('CostView', () => {
beforeEach(() => vi.clearAllMocks())
it('renders loading state initially', () => {
mockGet.mockImplementation(() => new Promise(() => {}))
renderWithProviders(<CostView />)
expect(screen.getByTestId('costs-loading')).toBeInTheDocument()
})
it('renders empty state when no cost periods available', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/costs') {
return Promise.resolve({ data: { items: [], total: 0 } })
}
if (path === '/api/energy/costs/summary') {
return Promise.resolve({ data: SUMMARY })
}
return Promise.resolve({ data: null })
})
renderWithProviders(<CostView />)
await waitFor(() => {
expect(screen.getByTestId('costs-empty')).toBeInTheDocument()
})
})
it('renders costs table when data is available', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/costs') {
return Promise.resolve({ data: { items: [COST_PERIOD], total: 1 } })
}
if (path === '/api/energy/costs/summary') {
return Promise.resolve({ data: SUMMARY })
}
return Promise.resolve({ data: null })
})
renderWithProviders(<CostView />)
await waitFor(() => {
expect(screen.getByTestId('costs-table')).toBeInTheDocument()
})
expect(screen.getByTestId('cost-row-0')).toBeInTheDocument()
})
it('shows summary values correctly', async () => {
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/costs') {
return Promise.resolve({ data: { items: [], total: 0 } })
}
if (path === '/api/energy/costs/summary') {
return Promise.resolve({ data: SUMMARY })
}
return Promise.resolve({ data: null })
})
renderWithProviders(<CostView />)
await waitFor(() => {
expect(screen.getByTestId('summary-import')).toBeInTheDocument()
})
expect(screen.getByTestId('summary-import')).toHaveTextContent('10.500')
expect(screen.getByTestId('summary-export')).toHaveTextContent('2.300')
expect(screen.getByTestId('summary-total')).toHaveTextContent('12.50')
})
it('shows recompute confirmation modal on button click', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/costs') {
return Promise.resolve({ data: { items: [], total: 0 } })
}
if (path === '/api/energy/costs/summary') {
return Promise.resolve({ data: SUMMARY })
}
return Promise.resolve({ data: null })
})
renderWithProviders(<CostView />)
await waitFor(() => {
expect(screen.getByTestId('cost-recompute-button')).toBeInTheDocument()
})
await user.click(screen.getByTestId('cost-recompute-button'))
await waitFor(() => {
expect(screen.getByTestId('recompute-confirm-modal')).toBeInTheDocument()
})
})
it('calls recompute mutation when confirmed', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/costs') {
return Promise.resolve({ data: { items: [], total: 0 } })
}
if (path === '/api/energy/costs/summary') {
return Promise.resolve({ data: SUMMARY })
}
return Promise.resolve({ data: null })
})
mockPost.mockResolvedValue({ data: { recomputed: 0 } })
renderWithProviders(<CostView />)
await waitFor(() => {
expect(screen.getByTestId('cost-recompute-button')).toBeInTheDocument()
})
await user.click(screen.getByTestId('cost-recompute-button'))
await waitFor(() => {
expect(screen.getByTestId('recompute-confirm')).toBeInTheDocument()
})
await user.click(screen.getByTestId('recompute-confirm'))
await waitFor(() => {
expect(mockPost).toHaveBeenCalledWith(
'/api/energy/costs/recompute',
expect.any(Object),
)
})
})
})