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:
@@ -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),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user