388 lines
18 KiB
TypeScript
388 lines
18 KiB
TypeScript
/**
|
|
* 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',
|
|
// Money totals and kWh totals are deliberately distinct so the assertions
|
|
// below prove the cards read the *_kwh fields, not the monetary ones.
|
|
metered_import: 10.5,
|
|
metered_export: 2.3,
|
|
metered_net: 8.2,
|
|
metered_import_kwh: 33.3,
|
|
metered_export_kwh: 44.4,
|
|
fixed_costs: 5.0,
|
|
credits: 50.0,
|
|
total_payable: 12.5,
|
|
}
|
|
|
|
const THERMAL_SUMMARY = {
|
|
currency: 'EUR', heating: '1.10', hot_water_heating: '2.20', hot_water: '3.30',
|
|
hot_water_tax: '0.40', variable_subtotal: '7.00', fixed_subtotal: '0.50', all_in: '7.50',
|
|
period_count: 4, degraded_count: 2,
|
|
fixed_breakdown: { heating_network: '0.1', metering: '0.1', delivery_set: '0', hot_water_network: '0.1', other: '0.2' },
|
|
}
|
|
const THERMAL_VALUES = {
|
|
variable: {
|
|
heating: '20.123456789123456789', hot_water_heating: '8.200000000000000001',
|
|
hot_water: '1.234567890123456789', hot_water_tax: '0.456789012345678901',
|
|
},
|
|
standing: {
|
|
heating_network: '100.000000000000000001', metering: '0', delivery_set: '20.2',
|
|
hot_water_network: '30.3', other: '40.4',
|
|
},
|
|
}
|
|
const THERMAL_ROW = {
|
|
commodity: 'heating', period_start: '2026-06-22T10:00:00Z', period_end: '2026-06-22T10:15:00Z',
|
|
meter_id: 1, source_binding_id: 2, contract_version_id: 99, quantity: '1.2', cost: '0.123456789', currency: 'EUR',
|
|
cost_breakdown: { heating: '0.123456789' }, pricing_snapshot: THERMAL_VALUES,
|
|
quality: 'unverifiable', degraded: false, degraded_reason: null,
|
|
}
|
|
const THERMAL_ROW_OTHER_VERSION = {
|
|
...THERMAL_ROW, commodity: 'hot_water', period_start: '2026-06-22T10:15:00Z', period_end: '2026-06-22T10:30:00Z',
|
|
contract_version_id: 100, quantity: '2.3', cost: '4.339506172839506170',
|
|
cost_breakdown: { hot_water_heating: '1.2', hot_water: '2.8', hot_water_tax: '0.339506172839506170' },
|
|
pricing_snapshot: { ...THERMAL_VALUES, variable: { ...THERMAL_VALUES.variable, hot_water: '1.234567890123456789' } },
|
|
}
|
|
const THERMAL_DEGRADED_MISSING_CONTRACT = {
|
|
commodity: 'heating', period_start: '2026-06-22T10:30:00Z', period_end: '2026-06-22T10:45:00Z',
|
|
meter_id: 1, source_binding_id: 2, contract_version_id: null, quantity: '0', cost: '0', currency: 'EUR',
|
|
cost_breakdown: {}, pricing_snapshot: {}, quality: 'invalid', degraded: true, degraded_reason: 'missing_contract',
|
|
}
|
|
const THERMAL_DEGRADED_CROSS_EPOCH = {
|
|
commodity: 'hot_water', period_start: '2026-06-22T10:45:00Z', period_end: '2026-06-22T11:00:00Z',
|
|
meter_id: 2, source_binding_id: null, contract_version_id: null, quantity: '0', cost: '0', currency: 'EUR',
|
|
cost_breakdown: {}, pricing_snapshot: {}, quality: 'invalid', degraded: true, degraded_reason: 'cross_meter_epoch',
|
|
}
|
|
const ACTIVE_HEATING_METER = { id: 10, commodity: 'heating', ended_at: null }
|
|
const ACTIVE_HOT_WATER_METER = { id: 11, commodity: 'hot_water', ended_at: null }
|
|
const ENDED_HEATING_METER = { id: 9, commodity: 'heating', ended_at: '2026-06-01T00:00:00Z' }
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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()
|
|
})
|
|
|
|
// Main figure is energy (kWh), taken from the *_kwh fields.
|
|
expect(screen.getByTestId('summary-import')).toHaveTextContent('33.300')
|
|
expect(screen.getByTestId('summary-export')).toHaveTextContent('44.400')
|
|
expect(screen.getByTestId('summary-total')).toHaveTextContent('12.50')
|
|
|
|
// Sub-line carries the monetary equivalent, so money is still visible.
|
|
expect(screen.getByTestId('summary-import-sub')).toHaveTextContent('10.50 EUR')
|
|
expect(screen.getByTestId('summary-export-sub')).toHaveTextContent('2.30 EUR')
|
|
})
|
|
|
|
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),
|
|
)
|
|
})
|
|
})
|
|
|
|
it('audits thermal rows and recomputes only a closed UTC quarter through the typed client', async () => {
|
|
const user = userEvent.setup()
|
|
mockGet.mockImplementation((path: string) => {
|
|
if (path === '/api/energy/meter-costs') return Promise.resolve({ data: { items: [THERMAL_ROW, THERMAL_ROW_OTHER_VERSION, THERMAL_DEGRADED_MISSING_CONTRACT, THERMAL_DEGRADED_CROSS_EPOCH], total: 4 } })
|
|
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY })
|
|
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: { processed: 1, normal: 0, degraded: 1 } })
|
|
renderWithProviders(<CostView />)
|
|
await user.click(screen.getByTestId('costs-scope-selector'))
|
|
await user.click(screen.getByText('Thermal'))
|
|
await waitFor(() => expect(screen.getByTestId('thermal-cost-summary')).toBeInTheDocument())
|
|
expect(screen.getByTestId('thermal-period-count')).toHaveTextContent('4 periods; 2 degraded')
|
|
expect(screen.getByTestId('thermal-summary-degraded')).toHaveTextContent('Expand a row to see its recorded reason')
|
|
expect(screen.queryByTestId('thermal-degraded-0')).not.toBeInTheDocument()
|
|
expect(screen.queryByTestId('thermal-degraded-1')).not.toBeInTheDocument()
|
|
expect(screen.getByTestId('thermal-degraded-2')).toHaveTextContent('missing_contract')
|
|
expect(screen.getByTestId('thermal-degraded-3')).toHaveTextContent('cross_meter_epoch')
|
|
await user.click(screen.getByTestId('thermal-cost-expand-0'))
|
|
expect(screen.getByTestId('thermal-cost-audit-0')).toHaveTextContent('Contract version: 99')
|
|
expect(screen.getByTestId('thermal-cost-audit-0')).toHaveTextContent('20.123456789123456789')
|
|
expect(screen.getByTestId('thermal-cost-audit-0')).toHaveTextContent('heating_network')
|
|
await user.click(screen.getByTestId('thermal-cost-expand-1'))
|
|
expect(screen.getByTestId('thermal-cost-audit-1')).toHaveTextContent('Contract version: 100')
|
|
expect(screen.getByTestId('thermal-cost-audit-1')).toHaveTextContent('1.234567890123456789')
|
|
expect(screen.getByTestId('thermal-cost-audit-1')).toHaveTextContent('hot_water_tax')
|
|
await user.click(screen.getByTestId('thermal-cost-expand-2'))
|
|
expect(screen.getByTestId('thermal-cost-audit-2')).toHaveTextContent('Contract version: none')
|
|
expect(screen.getByTestId('thermal-cost-audit-2')).toHaveTextContent('Pricing snapshot: {}')
|
|
expect(screen.getByTestId('thermal-fixed-breakdown')).toHaveTextContent('summary only')
|
|
expect(screen.getAllByText(/Fixed subtotal|All-in total/)).toHaveLength(2)
|
|
expect(screen.getByTestId('thermal-costs-table')).not.toHaveTextContent('Fixed')
|
|
await user.click(screen.getByTestId('thermal-recompute-button'))
|
|
expect(screen.getByTestId('thermal-recompute-confirm-modal')).toHaveTextContent('closed 15-minute')
|
|
await user.click(screen.getByTestId('thermal-recompute-confirm'))
|
|
await waitFor(() => expect(mockPost).toHaveBeenCalledWith('/api/energy/meter-costs/recompute', expect.any(Object)))
|
|
const options = mockPost.mock.calls.find(([path]) => path === '/api/energy/meter-costs/recompute')?.[1] as { params: { query: { end: string } } }
|
|
expect(new Date(options.params.query.end).getTime()).toBeLessThanOrEqual(Date.now())
|
|
expect(new Date(options.params.query.end).getUTCMinutes() % 15).toBe(0)
|
|
})
|
|
|
|
it('keeps the thermal confirmation cancellable and surfaces a 422 recompute failure', async () => {
|
|
const user = userEvent.setup()
|
|
mockGet.mockImplementation((path: string) => {
|
|
if (path === '/api/energy/meter-costs') return Promise.resolve({ data: { items: [THERMAL_ROW], total: 1 } })
|
|
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY })
|
|
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
|
|
return Promise.resolve({ data: { items: [], total: 0 } })
|
|
})
|
|
mockPost.mockRejectedValue(new Error('422'))
|
|
renderWithProviders(<CostView />)
|
|
await user.click(screen.getByTestId('costs-scope-selector')); await user.click(screen.getByText('Thermal'))
|
|
await waitFor(() => expect(screen.getByTestId('thermal-recompute-button')).toBeInTheDocument())
|
|
await user.click(screen.getByTestId('thermal-recompute-button')); await user.click(screen.getByTestId('thermal-recompute-cancel'))
|
|
expect(screen.queryByTestId('thermal-recompute-confirm-modal')).not.toBeInTheDocument()
|
|
await user.click(screen.getByTestId('thermal-recompute-button')); await user.click(screen.getByTestId('thermal-recompute-confirm'))
|
|
await waitFor(() => expect(screen.getByTestId('thermal-recompute-error')).toBeInTheDocument())
|
|
})
|
|
|
|
it.each([
|
|
['only heating', [ACTIVE_HEATING_METER], 'hot-water meter is not configured', 'Not configured'],
|
|
['only hot water', [ACTIVE_HOT_WATER_METER], 'heating meter is not configured', 'Not configured'],
|
|
['both current meters', [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.10'],
|
|
['a replaced heating meter plus its current epoch', [ENDED_HEATING_METER, ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.10'],
|
|
])('uses active meter epochs for %s without treating zero amounts as missing', async (_name, meterItems, missingText, heatingValue) => {
|
|
mockGet.mockImplementation((path: string) => {
|
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: meterItems, total: meterItems.length } })
|
|
if (path === '/api/energy/meter-costs') return Promise.resolve({ data: { items: [THERMAL_ROW], total: 1 } })
|
|
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: { ...THERMAL_SUMMARY, heating: heatingValue === 'Not configured' ? '0' : THERMAL_SUMMARY.heating } })
|
|
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
|
|
return Promise.resolve({ data: { items: [], total: 0 } })
|
|
})
|
|
const user = userEvent.setup()
|
|
renderWithProviders(<CostView />)
|
|
await user.click(screen.getByTestId('costs-scope-selector'))
|
|
await user.click(screen.getByText('Thermal'))
|
|
await waitFor(() => expect(screen.getByTestId('thermal-cost-summary')).toBeInTheDocument())
|
|
if (missingText) {
|
|
expect(screen.getByTestId('thermal-missing-current-meter')).toHaveTextContent(missingText)
|
|
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Not configured')
|
|
} else {
|
|
expect(screen.queryByTestId('thermal-missing-current-meter')).not.toBeInTheDocument()
|
|
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent(heatingValue)
|
|
}
|
|
})
|
|
|
|
it('paginates the complete thermal ledger and resets offset when its range or scope changes', async () => {
|
|
const user = userEvent.setup()
|
|
const lastRow = { ...THERMAL_ROW, period_start: '2026-06-22T12:00:00Z', quantity: '501' }
|
|
mockGet.mockImplementation((path: string, options?: { params?: { query?: { offset?: number } } }) => {
|
|
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], total: 2 } })
|
|
if (path === '/api/energy/meter-costs') {
|
|
const offset = options?.params?.query?.offset ?? 0
|
|
return Promise.resolve({ data: offset === 0 ? { items: Array.from({ length: 500 }, () => THERMAL_ROW), total: 501 } : { items: [lastRow], total: 501 } })
|
|
}
|
|
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY })
|
|
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
|
|
return Promise.resolve({ data: { items: [], total: 0 } })
|
|
})
|
|
renderWithProviders(<CostView />)
|
|
await user.click(screen.getByTestId('costs-scope-selector'))
|
|
await user.click(screen.getByText('Thermal'))
|
|
await waitFor(() => expect(screen.getByTestId('thermal-ledger-count')).toHaveTextContent('Showing 1-500 of 501'))
|
|
expect(screen.getByTestId('thermal-ledger-prev')).toBeDisabled()
|
|
expect(screen.getByTestId('thermal-ledger-next')).toBeEnabled()
|
|
await user.click(screen.getByTestId('thermal-ledger-next'))
|
|
await waitFor(() => expect(screen.getByTestId('thermal-ledger-count')).toHaveTextContent('Showing 501-501 of 501'))
|
|
expect(screen.getByTestId('thermal-ledger-prev')).toBeEnabled()
|
|
expect(screen.getByTestId('thermal-ledger-next')).toBeDisabled()
|
|
expect(mockGet).toHaveBeenCalledWith('/api/energy/meter-costs', expect.objectContaining({ params: { query: expect.objectContaining({ scope: 'thermal', offset: 500, limit: 500 }) } }))
|
|
await user.click(screen.getByTestId('thermal-cost-range-control'))
|
|
await user.click(screen.getByText('This month'))
|
|
await waitFor(() => expect(screen.getByTestId('thermal-ledger-count')).toHaveTextContent('Showing 1-500 of 501'))
|
|
await user.click(screen.getByTestId('costs-scope-selector'))
|
|
await user.click(screen.getByText('Electricity'))
|
|
await user.click(screen.getByTestId('costs-scope-selector'))
|
|
await user.click(screen.getByText('Thermal'))
|
|
await waitFor(() => expect(screen.getByTestId('thermal-ledger-count')).toHaveTextContent('Showing 1-500 of 501'))
|
|
})
|
|
})
|