M8-T19: add scope-aware energy contract and cost UI
This commit is contained in:
@@ -73,6 +73,48 @@ const SUMMARY = {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -220,4 +262,126 @@ describe('CostView', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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'))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user