Compare commits

..
3 Commits
Author SHA1 Message Date
tliu93 8180082f90 M8-R14: preserve zero DSMR sample interval
docker-image / build-and-push (push) Successful in 1m35s
frontend / frontend (push) Successful in 47s
pytest / test (push) Successful in 4m15s
2026-08-27 21:29:45 +02:00
tliu93 b472f91f19 M8-R13: simplify prices and meter controls
frontend / frontend (push) Successful in 53s
pytest / test (push) Successful in 4m34s
docker-image / build-and-push (push) Successful in 1m49s
2026-08-27 20:01:41 +02:00
tliu93 c9ce05d95a M8-R12: align and format thermal cost UI 2026-08-27 19:18:03 +02:00
9 changed files with 330 additions and 57 deletions
+3 -1
View File
@@ -98,8 +98,10 @@ def _validate_field_value(kind: str, field: SourceConfigField, value: Any) -> No
_check_type(field, value) _check_type(field, value)
if field.name == "path" and not value.startswith("/dev/"): if field.name == "path" and not value.startswith("/dev/"):
raise SourceProfileError("warmtelink_serial config path must start with '/dev/'.") raise SourceProfileError("warmtelink_serial config path must start with '/dev/'.")
if field.name in {"broker_port", "sample_interval_s", "baudrate"} and value <= 0: if field.name in {"broker_port", "baudrate"} and value <= 0:
raise SourceProfileError(f"Config field {field.name!r} must be greater than zero.") raise SourceProfileError(f"Config field {field.name!r} must be greater than zero.")
if field.name == "sample_interval_s" and value < 0:
raise SourceProfileError("Config field 'sample_interval_s' must not be negative.")
if field.name == "data_bits" and value != 7: if field.name == "data_bits" and value != 7:
raise SourceProfileError("warmtelink_serial data_bits must be 7.") raise SourceProfileError("warmtelink_serial data_bits must be 7.")
if field.name == "parity" and value != "N": if field.name == "parity" and value != "N":
+89 -2
View File
@@ -79,6 +79,23 @@ const THERMAL_SUMMARY = {
period_count: 4, degraded_count: 2, 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' }, fixed_breakdown: { heating_network: '0.1', metering: '0.1', delivery_set: '0', hot_water_network: '0.1', other: '0.2' },
} }
const ROUNDED_THERMAL_SUMMARY = {
...THERMAL_SUMMARY,
heating: '20.12344',
hot_water_heating: '8.20005',
hot_water: '1.99995',
hot_water_tax: '0.40000',
variable_subtotal: '7.0000',
fixed_subtotal: '0.00004',
all_in: '9.99995',
fixed_breakdown: {
heating_network: '100.000000000000000001',
metering: '0.0000',
delivery_set: '20.20000',
hot_water_network: '30.30004',
other: '40.40005',
},
}
const THERMAL_VALUES = { const THERMAL_VALUES = {
variable: { variable: {
heating: '20.123456789123456789', hot_water_heating: '8.200000000000000001', heating: '20.123456789123456789', hot_water_heating: '8.200000000000000001',
@@ -95,6 +112,12 @@ const THERMAL_ROW = {
cost_breakdown: { heating: '0.123456789' }, pricing_snapshot: THERMAL_VALUES, cost_breakdown: { heating: '0.123456789' }, pricing_snapshot: THERMAL_VALUES,
quality: 'unverifiable', degraded: false, degraded_reason: null, quality: 'unverifiable', degraded: false, degraded_reason: null,
} }
const ROUNDED_THERMAL_ROW = {
...THERMAL_ROW,
quantity: '1.23456',
cost: '0.12344',
cost_breakdown: { heating: '0.12345', hot_water: '0.10000' },
}
const THERMAL_ROW_OTHER_VERSION = { const THERMAL_ROW_OTHER_VERSION = {
...THERMAL_ROW, commodity: 'hot_water', period_start: '2026-06-22T10:15:00Z', period_end: '2026-06-22T10:30:00Z', ...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', contract_version_id: 100, quantity: '2.3', cost: '4.339506172839506170',
@@ -233,6 +256,32 @@ describe('CostView', () => {
}) })
}) })
it('uses the same bottom-aligned toolbar structure in both scopes', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/costs/summary') return Promise.resolve({ data: SUMMARY })
if (path === '/api/energy/meter-costs/summary') return Promise.resolve({ data: THERMAL_SUMMARY })
return Promise.resolve({ data: { items: [], total: 0 } })
})
renderWithProviders(<CostView />)
const electricityToolbar = screen.getByTestId('cost-toolbar')
expect(electricityToolbar).toContainElement(screen.getByTestId('costs-scope-selector'))
expect(electricityToolbar).toContainElement(screen.getByTestId('cost-range-control'))
expect(electricityToolbar).toContainElement(screen.getByTestId('cost-recompute-button'))
expect(screen.getByTestId('cost-recompute-button')).toHaveStyle({ marginLeft: 'auto' })
await user.click(screen.getByTestId('costs-scope-selector'))
await user.click(screen.getByText('Thermal'))
await waitFor(() => expect(screen.getByTestId('thermal-cost-toolbar')).toBeInTheDocument())
const thermalToolbar = screen.getByTestId('thermal-cost-toolbar')
expect(thermalToolbar).toContainElement(screen.getByTestId('costs-scope-selector'))
expect(thermalToolbar).toContainElement(screen.getByTestId('thermal-cost-range-control'))
expect(thermalToolbar).toContainElement(screen.getByTestId('thermal-recompute-button'))
expect(screen.getByTestId('thermal-recompute-button')).toHaveStyle({ marginLeft: 'auto' })
})
it('calls recompute mutation when confirmed', async () => { it('calls recompute mutation when confirmed', async () => {
const user = userEvent.setup() const user = userEvent.setup()
@@ -349,8 +398,8 @@ describe('CostView', () => {
it.each([ it.each([
['only heating', [ACTIVE_HEATING_METER], 'hot-water meter is not configured', 'Not configured'], ['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'], ['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'], ['both current meters', [ACTIVE_HEATING_METER, ACTIVE_HOT_WATER_METER], null, '1.1'],
['a replaced heating meter plus its current epoch', [ENDED_HEATING_METER, 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.1'],
])('uses active meter epochs for %s without treating zero amounts as missing', async (_name, meterItems, missingText, heatingValue) => { ])('uses active meter epochs for %s without treating zero amounts as missing', async (_name, meterItems, missingText, heatingValue) => {
mockGet.mockImplementation((path: string) => { mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: meterItems, total: meterItems.length } }) if (path === '/api/energy/meters') return Promise.resolve({ data: { items: meterItems, total: meterItems.length } })
@@ -373,6 +422,44 @@ describe('CostView', () => {
} }
}) })
it('rounds ordinary thermal Decimal strings without changing the audit snapshot', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((path: string) => {
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') {
return Promise.resolve({ data: { items: [ROUNDED_THERMAL_ROW], total: 1 } })
}
if (path === '/api/energy/meter-costs/summary') {
return Promise.resolve({ data: ROUNDED_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-cost-summary')).toBeInTheDocument())
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Heating20.1234')
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Hot-water heating8.2001')
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Hot water2')
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Hot-water tax0.4')
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Variable subtotal7')
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('Fixed subtotal0')
expect(screen.getByTestId('thermal-cost-summary')).toHaveTextContent('All-in total10')
expect(screen.getByTestId('thermal-fixed-breakdown')).toHaveTextContent('heating_network 100')
expect(screen.getByTestId('thermal-fixed-breakdown')).toHaveTextContent('other 40.4001')
expect(screen.getByTestId('thermal-cost-row-0')).toHaveTextContent('1.2346')
expect(screen.getByTestId('thermal-cost-row-0')).toHaveTextContent('0.1234 EUR')
expect(screen.getByTestId('thermal-cost-row-0')).toHaveTextContent('heating 0.1235, hot_water 0.1')
await user.click(screen.getByTestId('thermal-cost-expand-0'))
expect(screen.getByTestId('thermal-cost-audit-0')).toHaveTextContent('20.123456789123456789')
})
it('paginates the complete thermal ledger and resets offset when its range or scope changes', async () => { it('paginates the complete thermal ledger and resets offset when its range or scope changes', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const lastRow = { ...THERMAL_ROW, period_start: '2026-06-22T12:00:00Z', quantity: '501' } const lastRow = { ...THERMAL_ROW, period_start: '2026-06-22T12:00:00Z', quantity: '501' }
+59 -17
View File
@@ -110,6 +110,49 @@ function SummaryCard({ label, value, sub, testId }: SummaryCardProps) {
type RangePreset = 'today' | 'month' | 'custom' type RangePreset = 'today' | 'month' | 'custom'
/** Format an API Decimal string without converting it through binary floating point. */
function formatDecimal(value: string): string {
const negative = value.startsWith('-')
const unsigned = negative ? value.slice(1) : value
const [rawWhole = '0', rawFraction = ''] = unsigned.split('.')
let whole = rawWhole.replace(/^0+(?=\d)/, '') || '0'
let fraction = rawFraction.slice(0, 4)
if (rawFraction.length > 4 && rawFraction[4] >= '5') {
const digits = '0123456789'
const fractionDigits = fraction.split('')
let carry = true
for (let index = fractionDigits.length - 1; index >= 0 && carry; index -= 1) {
const digit = fractionDigits[index]
if (digit === '9') {
fractionDigits[index] = '0'
} else {
fractionDigits[index] = digits[digits.indexOf(digit) + 1]
carry = false
}
}
fraction = fractionDigits.join('')
if (carry) {
const wholeDigits = whole.split('')
for (let index = wholeDigits.length - 1; index >= 0 && carry; index -= 1) {
const digit = wholeDigits[index]
if (digit === '9') {
wholeDigits[index] = '0'
} else {
wholeDigits[index] = digits[digits.indexOf(digit) + 1]
carry = false
}
}
whole = `${carry ? '1' : ''}${wholeDigits.join('')}`
}
}
fraction = fraction.replace(/0+$/, '')
const formatted = fraction ? `${whole}.${fraction}` : whole
return negative && formatted !== '0' ? `-${formatted}` : formatted
}
export function CostView() { export function CostView() {
const [scope, setScope] = useState<'electricity' | 'thermal'>('electricity') const [scope, setScope] = useState<'electricity' | 'thermal'>('electricity')
if (scope === 'thermal') return <ThermalCostView onScopeChange={setScope} /> if (scope === 'thermal') return <ThermalCostView onScopeChange={setScope} />
@@ -155,7 +198,7 @@ function ElectricityCostView({ onScopeChange }: { onScopeChange: (scope: 'electr
return ( return (
<Stack gap="lg" data-testid="cost-view"> <Stack gap="lg" data-testid="cost-view">
{/* Date range selector */} {/* Date range selector */}
<Group align="flex-start" gap="md" wrap="wrap"> <Group align="flex-end" gap="md" wrap="wrap" data-testid="cost-toolbar">
<ScopeSelector scope="electricity" onScopeChange={onScopeChange} /> <ScopeSelector scope="electricity" onScopeChange={onScopeChange} />
<Stack gap="xs"> <Stack gap="xs">
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
@@ -192,18 +235,17 @@ function ElectricityCostView({ onScopeChange }: { onScopeChange: (scope: 'electr
</Group> </Group>
)} )}
<Group gap="sm" style={{ marginLeft: 'auto' }} align="flex-end"> <Button
<Button variant="outline"
variant="outline" color="orange"
color="orange" size="sm"
size="sm" onClick={() => setShowRecomputeConfirm(true)}
onClick={() => setShowRecomputeConfirm(true)} loading={recomputeMutation.isPending}
loading={recomputeMutation.isPending} style={{ marginLeft: 'auto' }}
data-testid="cost-recompute-button" data-testid="cost-recompute-button"
> >
Recompute Recompute
</Button> </Button>
</Group>
</Group> </Group>
{/* Summary cards */} {/* Summary cards */}
@@ -483,16 +525,16 @@ function ThermalCostView({ onScopeChange }: { onScopeChange: (scope: 'electricit
const shownStart = totalRows === 0 ? 0 : ledgerOffset + 1 const shownStart = totalRows === 0 ? 0 : ledgerOffset + 1
const shownEnd = Math.min(ledgerOffset + (rows.data?.items.length ?? 0), totalRows) const shownEnd = Math.min(ledgerOffset + (rows.data?.items.length ?? 0), totalRows)
return <Stack gap="lg" data-testid="thermal-cost-view"> return <Stack gap="lg" data-testid="thermal-cost-view">
<Group align="flex-start" gap="md" wrap="wrap"><ScopeSelector scope="thermal" onScopeChange={onScopeChange} /><Stack gap="xs"><Text size="sm" fw={500}>Date range</Text><SegmentedControl value={rangePreset} onChange={(value) => { resetLedgerPage(); setRangePreset(value as RangePreset) }} data={[{ label: 'Today', value: 'today' }, { label: 'This month', value: 'month' }, { label: 'Custom', value: 'custom' }]} data-testid="thermal-cost-range-control" /></Stack>{rangePreset === 'custom' && <Group gap="sm" align="flex-end"><TextInput label="From" type="date" value={customStartStr} onChange={(event) => { resetLedgerPage(); setCustomStartStr(event.currentTarget.value) }} data-testid="thermal-cost-custom-start" /><TextInput label="To" type="date" value={customEndStr} onChange={(event) => { resetLedgerPage(); setCustomEndStr(event.currentTarget.value) }} data-testid="thermal-cost-custom-end" /></Group>}<Button variant="outline" color="orange" onClick={() => { setRecomputeError(null); setRecomputeSuccess(null); setShowConfirm(true) }} disabled={!recomputeAvailable} data-testid="thermal-recompute-button">Recompute</Button></Group> <Group align="flex-end" gap="md" wrap="wrap" data-testid="thermal-cost-toolbar"><ScopeSelector scope="thermal" onScopeChange={onScopeChange} /><Stack gap="xs"><Text size="sm" fw={500}>Date range</Text><SegmentedControl value={rangePreset} onChange={(value) => { resetLedgerPage(); setRangePreset(value as RangePreset) }} data={[{ label: 'Today', value: 'today' }, { label: 'This month', value: 'month' }, { label: 'Custom', value: 'custom' }]} data-testid="thermal-cost-range-control" /></Stack>{rangePreset === 'custom' && <Group gap="sm" align="flex-end"><TextInput label="From" type="date" value={customStartStr} onChange={(event) => { resetLedgerPage(); setCustomStartStr(event.currentTarget.value) }} data-testid="thermal-cost-custom-start" /><TextInput label="To" type="date" value={customEndStr} onChange={(event) => { resetLedgerPage(); setCustomEndStr(event.currentTarget.value) }} data-testid="thermal-cost-custom-end" /></Group>}<Button variant="outline" color="orange" onClick={() => { setRecomputeError(null); setRecomputeSuccess(null); setShowConfirm(true) }} disabled={!recomputeAvailable} style={{ marginLeft: 'auto' }} data-testid="thermal-recompute-button">Recompute</Button></Group>
{(rows.isLoading || summary.isLoading) && <Center><Loader size="sm" /></Center>} {(rows.isLoading || summary.isLoading) && <Center><Loader size="sm" /></Center>}
{(rows.isError || summary.isError) && <Alert color="red">Failed to load thermal costs.</Alert>} {(rows.isError || summary.isError) && <Alert color="red">Failed to load thermal costs.</Alert>}
{recomputeError && <Alert color="red" data-testid="thermal-recompute-error">{recomputeError}</Alert>} {recomputeError && <Alert color="red" data-testid="thermal-recompute-error">{recomputeError}</Alert>}
{recomputeSuccess && <Alert color="green" data-testid="thermal-recompute-success">{recomputeSuccess}</Alert>} {recomputeSuccess && <Alert color="green" data-testid="thermal-recompute-success">{recomputeSuccess}</Alert>}
{summary.data && <Stack gap="xs" data-testid="thermal-cost-summary"><Title order={6}>{rangePreset === 'today' ? 'Today' : rangePreset === 'month' ? 'This month' : 'Custom range'} ({currency})</Title><Text size="sm" data-testid="thermal-cost-range">{start ?? 'Select a start date'} {end ?? 'Select an end date'}</Text><SimpleGrid cols={{ base: 2, sm: 3 }}> {summary.data && <Stack gap="xs" data-testid="thermal-cost-summary"><Title order={6}>{rangePreset === 'today' ? 'Today' : rangePreset === 'month' ? 'This month' : 'Custom range'} ({currency})</Title><Text size="sm" data-testid="thermal-cost-range">{start ?? 'Select a start date'} {end ?? 'Select an end date'}</Text><SimpleGrid cols={{ base: 2, sm: 3 }}>
<SummaryCard label="Heating" value={hasCurrentHeatingMeter === false ? 'Not configured' : summary.data.heating} /><SummaryCard label="Hot-water heating" value={hasCurrentHotWaterMeter === false ? 'Not configured' : summary.data.hot_water_heating} /><SummaryCard label="Hot water" value={hasCurrentHotWaterMeter === false ? 'Not configured' : summary.data.hot_water} /><SummaryCard label="Hot-water tax" value={hasCurrentHotWaterMeter === false ? 'Not configured' : summary.data.hot_water_tax} /><SummaryCard label="Variable subtotal" value={summary.data.variable_subtotal} /><SummaryCard label="Fixed subtotal" value={summary.data.fixed_subtotal} /><SummaryCard label="All-in total" value={summary.data.all_in} /> <SummaryCard label="Heating" value={hasCurrentHeatingMeter === false ? 'Not configured' : formatDecimal(summary.data.heating)} /><SummaryCard label="Hot-water heating" value={hasCurrentHotWaterMeter === false ? 'Not configured' : formatDecimal(summary.data.hot_water_heating)} /><SummaryCard label="Hot water" value={hasCurrentHotWaterMeter === false ? 'Not configured' : formatDecimal(summary.data.hot_water)} /><SummaryCard label="Hot-water tax" value={hasCurrentHotWaterMeter === false ? 'Not configured' : formatDecimal(summary.data.hot_water_tax)} /><SummaryCard label="Variable subtotal" value={formatDecimal(summary.data.variable_subtotal)} /><SummaryCard label="Fixed subtotal" value={formatDecimal(summary.data.fixed_subtotal)} /><SummaryCard label="All-in total" value={formatDecimal(summary.data.all_in)} />
</SimpleGrid>{missingCurrentMeters.length > 0 && <Alert color="yellow" data-testid="thermal-missing-current-meter">Current {missingCurrentMeters.join(' and ')} meter{missingCurrentMeters.length > 1 ? 's are' : ' is'} not configured. Historical ledger rows do not establish a current meter.</Alert>}<Text size="sm" data-testid="thermal-period-count">{summary.data.period_count} periods; {summary.data.degraded_count} degraded</Text>{summary.data.degraded_count > 0 && <Alert color="orange" data-testid="thermal-summary-degraded">Some totals include degraded periods. Expand a row to see its recorded reason.</Alert>}{fixed && <Text size="sm" data-testid="thermal-fixed-breakdown">Fixed once per settled local day (summary only): {Object.entries(fixed).map(([key, value]) => `${key} ${value}`).join(' · ')}</Text>}</Stack>} </SimpleGrid>{missingCurrentMeters.length > 0 && <Alert color="yellow" data-testid="thermal-missing-current-meter">Current {missingCurrentMeters.join(' and ')} meter{missingCurrentMeters.length > 1 ? 's are' : ' is'} not configured. Historical ledger rows do not establish a current meter.</Alert>}<Text size="sm" data-testid="thermal-period-count">{summary.data.period_count} periods; {summary.data.degraded_count} degraded</Text>{summary.data.degraded_count > 0 && <Alert color="orange" data-testid="thermal-summary-degraded">Some totals include degraded periods. Expand a row to see its recorded reason.</Alert>}{fixed && <Text size="sm" data-testid="thermal-fixed-breakdown">Fixed once per settled local day (summary only): {Object.entries(fixed).map(([key, value]) => `${key} ${formatDecimal(value)}`).join(' · ')}</Text>}</Stack>}
{rows.data?.items.length === 0 && <Alert color="gray" data-testid="thermal-costs-empty">No thermal cost data for this range. Check that heating or hot-water meters are bound and have settled readings.</Alert>} {rows.data?.items.length === 0 && <Alert color="gray" data-testid="thermal-costs-empty">No thermal cost data for this range. Check that heating or hot-water meters are bound and have settled readings.</Alert>}
{rows.data && <Stack gap="xs"><Text size="sm" c="dimmed" data-testid="thermal-ledger-count">Showing {shownStart}-{shownEnd} of {totalRows}</Text>{rows.data.items.length > 0 && <ScrollArea><Table striped withTableBorder data-testid="thermal-costs-table"><Table.Thead><Table.Tr><Table.Th>Time</Table.Th><Table.Th>Commodity</Table.Th><Table.Th>Quantity</Table.Th><Table.Th>Cost</Table.Th><Table.Th>Breakdown</Table.Th><Table.Th>Status</Table.Th><Table.Th></Table.Th></Table.Tr></Table.Thead><Table.Tbody>{rows.data.items.flatMap((item, index) => [<Table.Tr key={`${item.commodity}-${item.period_start}`} data-testid={`thermal-cost-row-${index}`}><Table.Td>{formatLocalTime(item.period_start)}</Table.Td><Table.Td>{item.commodity}</Table.Td><Table.Td>{item.quantity}</Table.Td><Table.Td>{item.cost} {item.currency}</Table.Td><Table.Td>{Object.entries(item.cost_breakdown).map(([key, value]) => `${key} ${value}`).join(', ')}</Table.Td><Table.Td>{item.degraded ? <Badge color="orange" data-testid={`thermal-degraded-${index}`}>{item.degraded_reason ?? 'degraded'}</Badge> : 'normal'}</Table.Td><Table.Td><Button size="xs" variant="subtle" onClick={() => setExpandedRows((current) => { const next = new Set(current); if (next.has(index)) next.delete(index); else next.add(index); return next })} data-testid={`thermal-cost-expand-${index}`}>{expandedRows.has(index) ? 'Hide audit' : 'Audit'}</Button></Table.Td></Table.Tr>, ...(expandedRows.has(index) ? [<Table.Tr key={`audit-${item.commodity}-${item.period_start}`} data-testid={`thermal-cost-audit-${index}`}><Table.Td colSpan={7}><Text size="xs">Contract version: {item.contract_version_id ?? 'none'}</Text><Text size="xs">Pricing snapshot: {JSON.stringify(item.pricing_snapshot)}</Text></Table.Td></Table.Tr>] : [])])}</Table.Tbody></Table></ScrollArea>}<Group justify="flex-end"><Button size="xs" variant="default" disabled={ledgerOffset === 0} onClick={() => { setExpandedRows(new Set()); setLedgerOffset((current) => Math.max(0, current - COSTS_MAX_LIMIT)) }} data-testid="thermal-ledger-prev">Previous</Button><Button size="xs" variant="default" disabled={ledgerOffset + (rows.data.items.length ?? 0) >= totalRows} onClick={() => { setExpandedRows(new Set()); setLedgerOffset((current) => current + COSTS_MAX_LIMIT) }} data-testid="thermal-ledger-next">Next</Button></Group></Stack>} {rows.data && <Stack gap="xs"><Text size="sm" c="dimmed" data-testid="thermal-ledger-count">Showing {shownStart}-{shownEnd} of {totalRows}</Text>{rows.data.items.length > 0 && <ScrollArea><Table striped withTableBorder data-testid="thermal-costs-table"><Table.Thead><Table.Tr><Table.Th>Time</Table.Th><Table.Th>Commodity</Table.Th><Table.Th>Quantity</Table.Th><Table.Th>Cost</Table.Th><Table.Th>Breakdown</Table.Th><Table.Th>Status</Table.Th><Table.Th></Table.Th></Table.Tr></Table.Thead><Table.Tbody>{rows.data.items.flatMap((item, index) => [<Table.Tr key={`${item.commodity}-${item.period_start}`} data-testid={`thermal-cost-row-${index}`}><Table.Td>{formatLocalTime(item.period_start)}</Table.Td><Table.Td>{item.commodity}</Table.Td><Table.Td>{formatDecimal(item.quantity)}</Table.Td><Table.Td>{formatDecimal(item.cost)} {item.currency}</Table.Td><Table.Td>{Object.entries(item.cost_breakdown).map(([key, value]) => `${key} ${formatDecimal(value)}`).join(', ')}</Table.Td><Table.Td>{item.degraded ? <Badge color="orange" data-testid={`thermal-degraded-${index}`}>{item.degraded_reason ?? 'degraded'}</Badge> : 'normal'}</Table.Td><Table.Td><Button size="xs" variant="subtle" onClick={() => setExpandedRows((current) => { const next = new Set(current); if (next.has(index)) next.delete(index); else next.add(index); return next })} data-testid={`thermal-cost-expand-${index}`}>{expandedRows.has(index) ? 'Hide audit' : 'Audit'}</Button></Table.Td></Table.Tr>, ...(expandedRows.has(index) ? [<Table.Tr key={`audit-${item.commodity}-${item.period_start}`} data-testid={`thermal-cost-audit-${index}`}><Table.Td colSpan={7}><Text size="xs">Contract version: {item.contract_version_id ?? 'none'}</Text><Text size="xs">Pricing snapshot: {JSON.stringify(item.pricing_snapshot)}</Text></Table.Td></Table.Tr>] : [])])}</Table.Tbody></Table></ScrollArea>}<Group justify="flex-end"><Button size="xs" variant="default" disabled={ledgerOffset === 0} onClick={() => { setExpandedRows(new Set()); setLedgerOffset((current) => Math.max(0, current - COSTS_MAX_LIMIT)) }} data-testid="thermal-ledger-prev">Previous</Button><Button size="xs" variant="default" disabled={ledgerOffset + (rows.data.items.length ?? 0) >= totalRows} onClick={() => { setExpandedRows(new Set()); setLedgerOffset((current) => current + COSTS_MAX_LIMIT) }} data-testid="thermal-ledger-next">Next</Button></Group></Stack>}
{showConfirm && <Modal opened onClose={() => setShowConfirm(false)} title="Recompute thermal costs?" data-testid="thermal-recompute-confirm-modal"><Stack><Text>This explicitly overwrites closed 15-minute thermal ledger rows for {recomputeStart ?? 'the selected start'} {closedEnd}. Continue?</Text>{!recomputeAvailable && <Alert color="yellow">Select a range containing at least one closed UTC quarter.</Alert>}<Group justify="flex-end"><Button variant="default" onClick={() => setShowConfirm(false)} data-testid="thermal-recompute-cancel">Cancel</Button><Button color="orange" loading={recompute.isPending} disabled={!recomputeAvailable} onClick={async () => { try { await recompute.mutateAsync(); setShowConfirm(false) } catch { setRecomputeError('Failed to recompute thermal costs. Please try again.'); setShowConfirm(false) } }} data-testid="thermal-recompute-confirm">Recompute</Button></Group></Stack></Modal>} {showConfirm && <Modal opened onClose={() => setShowConfirm(false)} title="Recompute thermal costs?" data-testid="thermal-recompute-confirm-modal"><Stack><Text>This explicitly overwrites closed 15-minute thermal ledger rows for {recomputeStart ?? 'the selected start'} {closedEnd}. Continue?</Text>{!recomputeAvailable && <Alert color="yellow">Select a range containing at least one closed UTC quarter.</Alert>}<Group justify="flex-end"><Button variant="default" onClick={() => setShowConfirm(false)} data-testid="thermal-recompute-cancel">Cancel</Button><Button color="orange" loading={recompute.isPending} disabled={!recomputeAvailable} onClick={async () => { try { await recompute.mutateAsync(); setShowConfirm(false) } catch { setRecomputeError('Failed to recompute thermal costs. Please try again.'); setShowConfirm(false) } }} data-testid="thermal-recompute-confirm">Recompute</Button></Group></Stack></Modal>}
</Stack> </Stack>
} }
+44 -3
View File
@@ -209,14 +209,55 @@ describe('MeterManager — meter list', () => {
{ uuid: 'binding-active', source_uuid: 'source-new', source_channel_uuid: 'channel-new', started_at: '2025-02-01T00:00:00Z', ended_at: null }, { uuid: 'binding-active', source_uuid: 'source-new', source_channel_uuid: 'channel-new', started_at: '2025-02-01T00:00:00Z', ended_at: null },
], ],
} }
mockGet.mockResolvedValue({ data: { items: [meterWithBindings], total: 1 } }) mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [meterWithBindings], total: 1 } })
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [
{ uuid: 'source-old', name: 'Old source' }, { uuid: 'source-new', name: 'New source' },
], total: 2 } })
if (path === '/api/energy/sources/{source_uuid}/channels') return Promise.resolve({ data: { items: [
{ uuid: 'channel-old', label: 'Old channel', unit: 'kWh' }, { uuid: 'channel-new', label: 'New channel', unit: 'kWh' },
], total: 2 } })
return Promise.resolve({ data: { items: [] } })
})
renderWithProviders(<MeterManager />) renderWithProviders(<MeterManager />)
const closed = await screen.findByTestId('binding-timeline-binding-closed') const closed = await screen.findByTestId('binding-timeline-binding-closed')
await waitFor(() => expect(closed).toHaveTextContent('Old source → Old channel'))
const active = screen.getByTestId('binding-timeline-binding-active') const active = screen.getByTestId('binding-timeline-binding-active')
expect(closed).toHaveTextContent('source-old → channel-old') expect(closed).toHaveTextContent('Old source → Old channel')
expect(closed).toHaveTextContent('[1/1/2025, 00:00:00, 2/1/2025, 00:00:00) (closed)') expect(closed).toHaveTextContent('[1/1/2025, 00:00:00, 2/1/2025, 00:00:00) (closed)')
expect(active).toHaveTextContent('source-new → channel-new') expect(active).toHaveTextContent('New source → New channel')
expect(active).toHaveTextContent('[2/1/2025, 00:00:00, open-ended) (active)') expect(active).toHaveTextContent('[2/1/2025, 00:00:00, open-ended) (active)')
expect(closed).not.toHaveTextContent('source-old')
expect(active).not.toHaveTextContent('channel-new')
})
it('uses UUID-free honest binding fallbacks while source details load or fail', async () => {
const meter = { ...ACTIVE_METER, bindings: [{ uuid: 'binding-visible-id-only', source_uuid: 'private-source-id', source_channel_uuid: 'private-channel-id', started_at: '2025-01-01T00:00:00Z', ended_at: null }] }
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [meter], total: 1 } })
if (path === '/api/energy/sources') return new Promise(() => {})
return Promise.resolve({ data: { items: [] } })
})
renderWithProviders(<MeterManager />)
const timeline = await screen.findByTestId('binding-timeline-binding-visible-id-only')
expect(timeline).toHaveTextContent('Loading source details… → Channel details unavailable')
expect(timeline).not.toHaveTextContent('private-source-id')
expect(timeline).not.toHaveTextContent('private-channel-id')
})
it('keeps meter row actions in the shared light, xs order', async () => {
const binding = { uuid: 'ordered-binding', source_uuid: 'source-1', source_channel_uuid: 'channel-1', started_at: '2024-01-15T00:00:00Z', ended_at: null }
mockGet.mockImplementation((path: string) => {
if (path === '/api/energy/meters') return Promise.resolve({ data: { items: [{ ...ACTIVE_METER, bindings: [binding] }], total: 1 } })
if (path === '/api/energy/sources') return Promise.resolve({ data: { items: [{ uuid: 'source-1', name: 'DSMR' }], total: 1 } })
if (path === '/api/energy/sources/{source_uuid}/channels') return Promise.resolve({ data: { items: [{ uuid: 'channel-1', label: 'Total import', unit: 'kWh' }], total: 1 } })
return Promise.resolve({ data: { items: [] } })
})
renderWithProviders(<MeterManager />)
const row = await screen.findByTestId(`meter-row-${ACTIVE_METER.id}`)
const buttons = Array.from(row.querySelectorAll('button'))
expect(buttons.map((button) => button.textContent)).toEqual(['Edit', 'Transfer source', 'Unbind', 'Close meter'])
expect(buttons).toHaveLength(4)
}) })
it('renders "Declare New Meter" button', async () => { it('renders "Declare New Meter" button', async () => {
+42 -18
View File
@@ -167,6 +167,34 @@ function recoveryTargetFor(meter: MeterResponse, meters: MeterResponse[]): Meter
return target return target
} }
function BindingTimeline({ binding, sources }: {
binding: NonNullable<MeterResponse['bindings']>[number]
sources: ReturnType<typeof useSources>
}) {
const source = sources.data?.items.find((item) => item.uuid === binding.source_uuid)
const channels = useSourceChannels(source?.uuid ?? null)
const channel = channels.data?.items.find((item) => item.uuid === binding.source_channel_uuid)
let endpoint = 'Source details unavailable'
if (sources.isLoading) endpoint = 'Loading source details…'
else if (!sources.isError && source) endpoint = source.name
else if (!sources.isError) endpoint = 'Source unavailable'
let channelName = 'Channel details unavailable'
if (source && channels.isLoading) channelName = 'Loading channel details…'
else if (source && !channels.isError && channel) channelName = channel.label
else if (source && !channels.isError) channelName = 'Channel unavailable'
return (
<Stack gap={0} mb="xs" data-testid={`binding-timeline-${binding.uuid}`}>
<Text size="xs">{endpoint} {channelName}</Text>
<Text size="xs" c="dimmed">
[{formatLocalDateTime(binding.started_at)}, {binding.ended_at ? formatLocalDateTime(binding.ended_at) : 'open-ended'})
{' '}({binding.ended_at ? 'closed' : 'active'})
</Text>
</Stack>
)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Declare meter form (modal) // Declare meter form (modal)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -515,11 +543,12 @@ function EditMeterForm({ meter, onClose, onSaved }: EditMeterFormProps) {
interface MeterTableProps { interface MeterTableProps {
meters: MeterResponse[] meters: MeterResponse[]
sources: ReturnType<typeof useSources>
onEdit: (meter: MeterResponse) => void onEdit: (meter: MeterResponse) => void
onClose: (meter: MeterResponse) => void onClose: (meter: MeterResponse) => void
} }
function MeterTable({ meters, onEdit, onClose }: MeterTableProps) { function MeterTable({ meters, sources, onEdit, onClose }: MeterTableProps) {
if (meters.length === 0) { if (meters.length === 0) {
return ( return (
<Text c="dimmed" ta="center" size="sm" data-testid="meters-empty"> <Text c="dimmed" ta="center" size="sm" data-testid="meters-empty">
@@ -585,30 +614,24 @@ function MeterTable({ meters, onEdit, onClose }: MeterTableProps) {
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
{meter.bindings?.length ? meter.bindings.map((binding) => ( {meter.bindings?.length ? meter.bindings.map((binding) => (
<Stack key={binding.uuid} gap={0} mb="xs" data-testid={`binding-timeline-${binding.uuid}`}> <BindingTimeline key={binding.uuid} binding={binding} sources={sources} />
<Text size="xs">{binding.source_uuid} {binding.source_channel_uuid}</Text>
<Text size="xs" c="dimmed">
[{formatLocalDateTime(binding.started_at)}, {binding.ended_at ? formatLocalDateTime(binding.ended_at) : 'open-ended'})
{' '}({binding.ended_at ? 'closed' : 'active'})
</Text>
</Stack>
)) : <Text size="xs" c="dimmed">Unbound</Text>} )) : <Text size="xs" c="dimmed">Unbound</Text>}
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group justify="flex-end" gap="xs"> <Group justify="flex-end" gap="xs">
{meter.bindings?.filter((binding) => binding.ended_at === null).map((binding) => (
<BindingActions key={binding.uuid} meter={meter} binding={binding} meters={meters} />
))}
{isActive && !meter.bindings?.some((binding) => binding.ended_at === null) && <DirectBindButton meter={meter} meters={meters} />}
{isActive && <Button size="xs" color="red" variant="light" onClick={() => onClose(meter)}>Close meter</Button>}
<Button <Button
size="xs" size="xs"
variant="outline" variant="light"
onClick={() => onEdit(meter)} onClick={() => onEdit(meter)}
data-testid={`meter-edit-${meter.id}`} data-testid={`meter-edit-${meter.id}`}
> >
Edit Edit
</Button> </Button>
{meter.bindings?.filter((binding) => binding.ended_at === null).map((binding) => (
<BindingActions key={binding.uuid} meter={meter} binding={binding} meters={meters} />
))}
{isActive && !meter.bindings?.some((binding) => binding.ended_at === null) && <DirectBindButton meter={meter} meters={meters} />}
{isActive && <Button size="xs" color="red" variant="light" onClick={() => onClose(meter)}>Close meter</Button>}
</Group> </Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
@@ -622,7 +645,7 @@ function MeterTable({ meters, onEdit, onClose }: MeterTableProps) {
function DirectBindButton({ meter, meters }: { meter: MeterResponse; meters: MeterResponse[] }) { function DirectBindButton({ meter, meters }: { meter: MeterResponse; meters: MeterResponse[] }) {
const [opened, setOpened] = useState(false) const [opened, setOpened] = useState(false)
return <>{<Button size="xs" variant="subtle" onClick={() => setOpened(true)}>Bind source</Button>}{opened && <DirectBindModal meter={meter} meters={meters} onClose={() => setOpened(false)} />}</> return <>{<Button size="xs" variant="light" onClick={() => setOpened(true)}>Bind source</Button>}{opened && <DirectBindModal meter={meter} meters={meters} onClose={() => setOpened(false)} />}</>
} }
function DirectBindModal({ meter, meters, onClose }: { meter: MeterResponse; meters: MeterResponse[]; onClose: () => void }) { function DirectBindModal({ meter, meters, onClose }: { meter: MeterResponse; meters: MeterResponse[]; onClose: () => void }) {
@@ -676,8 +699,8 @@ function BindingActions({ meter, binding, meters }: { meter: MeterResponse; bind
const recoverySourceIsClosable = meter.ended_at === null || const recoverySourceIsClosable = meter.ended_at === null ||
parseBackendTimestamp(binding.started_at).getTime() < parseBackendTimestamp(meter.ended_at).getTime() parseBackendTimestamp(binding.started_at).getTime() < parseBackendTimestamp(meter.ended_at).getTime()
return <> return <>
<Button size="xs" variant="subtle" onClick={() => setUnbindOpened(true)}>Unbind</Button> {meter.ended_at === null ? <Button size="xs" variant="light" onClick={() => setTransferOpened(true)}>Transfer source</Button> : recoveryTarget && recoverySourceIsClosable && <Button size="xs" variant="light" onClick={() => setTransferOpened(true)}>Recover binding</Button>}
{meter.ended_at === null ? <Button size="xs" variant="subtle" onClick={() => setTransferOpened(true)}>Transfer source</Button> : recoveryTarget && recoverySourceIsClosable && <Button size="xs" variant="light" onClick={() => setTransferOpened(true)}>Recover binding</Button>} <Button size="xs" variant="light" onClick={() => setUnbindOpened(true)}>Unbind</Button>
{meter.ended_at !== null && recoveryTarget && !recoverySourceIsClosable && <Text size="xs" c="red">Cannot recover: the source binding starts at or after this Meter ended.</Text>} {meter.ended_at !== null && recoveryTarget && !recoverySourceIsClosable && <Text size="xs" c="red">Cannot recover: the source binding starts at or after this Meter ended.</Text>}
{unbindOpened && <UnbindModal meter={meter} binding={binding} onClose={() => setUnbindOpened(false)} />} {unbindOpened && <UnbindModal meter={meter} binding={binding} onClose={() => setUnbindOpened(false)} />}
{transferOpened && <TransferModal target={recoveryTarget ?? meter} sourceBinding={binding} meters={meters} recovery={!!recoveryTarget} onClose={() => setTransferOpened(false)} />} {transferOpened && <TransferModal target={recoveryTarget ?? meter} sourceBinding={binding} meters={meters} recovery={!!recoveryTarget} onClose={() => setTransferOpened(false)} />}
@@ -793,6 +816,7 @@ function CloseMeterModal({ meter, onClose }: { meter: MeterResponse; onClose: ()
export function MeterManager() { export function MeterManager() {
const metersQuery = useMeters() const metersQuery = useMeters()
const sources = useSources()
const [showDeclareForm, setShowDeclareForm] = useState(false) const [showDeclareForm, setShowDeclareForm] = useState(false)
const [editMeter, setEditMeter] = useState<MeterResponse | null>(null) const [editMeter, setEditMeter] = useState<MeterResponse | null>(null)
@@ -845,7 +869,7 @@ export function MeterManager() {
</Notification> </Notification>
)} )}
<MeterTable meters={meters} onEdit={(m) => setEditMeter(m)} onClose={(m) => setCloseMeter(m)} /> <MeterTable meters={meters} sources={sources} onEdit={(m) => setEditMeter(m)} onClose={(m) => setCloseMeter(m)} />
{/* Declare new meter */} {/* Declare new meter */}
{showDeclareForm && ( {showDeclareForm && (
+23 -6
View File
@@ -245,7 +245,7 @@ describe('TibberPrices', () => {
expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900') expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900')
}) })
it('keeps thermal prices scoped and renders the complete D11 Decimal snapshot with units', async () => { it('keeps thermal prices scoped and renders the complete D11 Decimal snapshot as a table', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const thermal = { const thermal = {
kind: 'district_heating', currency: 'EUR', points: [], tariff: null, kind: 'district_heating', currency: 'EUR', points: [], tariff: null,
@@ -273,15 +273,19 @@ describe('TibberPrices', () => {
await waitFor(() => expect(screen.getByTestId('thermal-price-snapshot')).toBeInTheDocument()) await waitFor(() => expect(screen.getByTestId('thermal-price-snapshot')).toBeInTheDocument())
const snapshot = screen.getByTestId('thermal-price-snapshot') const snapshot = screen.getByTestId('thermal-price-snapshot')
const table = screen.getByTestId('thermal-price-table')
expect(snapshot).toHaveTextContent('Version 42') expect(snapshot).toHaveTextContent('Version 42')
expect(snapshot).toHaveTextContent('effective 2026-01-01T00:00:00Z to 2026-12-31T00:00:00Z') expect(snapshot).toHaveTextContent('effective 2026-01-01T00:00:00Z to 2026-12-31T00:00:00Z')
expect(snapshot).toHaveTextContent('not a 15-minute market spot price') expect(snapshot).toHaveTextContent('not a 15-minute market spot price')
expect(snapshot).toHaveTextContent('20.123456789123456789 EUR/GJ') expect(screen.getAllByRole('columnheader').map((header) => header.textContent)).toEqual([
expect(snapshot).toHaveTextContent('8.200000000000000001 EUR/m³') 'Category', 'Charge', 'Rate', 'Unit',
expect(snapshot).toHaveTextContent('1.234567890123456789 EUR/m³') ])
expect(snapshot).toHaveTextContent('0.456789012345678901 EUR/m³') expect(table.querySelectorAll('tbody tr')).toHaveLength(9)
expect(screen.getByTestId('thermal-price-row-variable-heating')).toHaveTextContent('VariableHeating20.123456789123456789EUR/GJ')
expect(screen.getByTestId('thermal-price-row-variable-hot_water_heating')).toHaveTextContent('Hot Water Heating8.200000000000000001EUR/m³')
expect(screen.getByTestId('thermal-price-row-standing-heating_network')).toHaveTextContent('StandingHeating Network100.000000000000000001EUR/year')
for (const value of Object.values(thermal.values.standing)) { for (const value of Object.values(thermal.values.standing)) {
expect(snapshot).toHaveTextContent(`${value} EUR/year`) expect(table).toHaveTextContent(value)
} }
expect(mockGet).toHaveBeenCalledWith('/api/energy/prices', expect.objectContaining({ expect(mockGet).toHaveBeenCalledWith('/api/energy/prices', expect.objectContaining({
params: { query: expect.objectContaining({ scope: 'thermal' }) }, params: { query: expect.objectContaining({ scope: 'thermal' }) },
@@ -292,6 +296,19 @@ describe('TibberPrices', () => {
expect(screen.queryByTestId('thermal-price-snapshot')).not.toBeInTheDocument() expect(screen.queryByTestId('thermal-price-snapshot')).not.toBeInTheDocument()
}) })
it('keeps unknown thermal snapshot fields with safe human-readable labels', async () => {
const user = userEvent.setup()
mockGet.mockImplementation((_path: string, options?: { params?: { query?: { scope?: string } } }) => Promise.resolve({
data: options?.params?.query?.scope === 'thermal'
? { kind: 'district_heating', currency: 'EUR', values: { future_fee: { experimental_charge: '1.000000000000000001' } } }
: { kind: 'manual', currency: 'EUR', points: [], tariff: { buy_dal: 0.1, buy_normal: 0.2, sell_dal: 0.03, sell_normal: 0.04 } },
}))
renderWithProviders(<TibberPrices />)
await user.click(await screen.findByText('Thermal'))
const row = await screen.findByTestId('thermal-price-row-future_fee-experimental_charge')
expect(row).toHaveTextContent('Future FeeExperimental Charge1.000000000000000001EUR')
})
it('marks the currently active price slot with a dot and a caption', async () => { it('marks the currently active price slot with a dot and a caption', async () => {
installChartSize() installChartSize()
+36 -7
View File
@@ -23,6 +23,7 @@ import {
Group, Group,
Paper, Paper,
SegmentedControl, SegmentedControl,
ScrollArea,
} from '@mantine/core' } from '@mantine/core'
import { import {
LineChart, LineChart,
@@ -60,6 +61,19 @@ function thermalUnit(section: string, key: string, currency: string): string {
return currency return currency
} }
function humanizeThermalField(value: string): string {
return value
.replace(/_/g, ' ')
.replace(/\b\w/g, (letter) => letter.toUpperCase())
}
function thermalSections(values: Record<string, Record<string, string>>): string[] {
const preferred = ['variable', 'standing']
return [...preferred.filter((section) => section in values), ...Object.keys(values)
.filter((section) => !preferred.includes(section))
.sort()]
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Time range helpers // Time range helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -399,13 +413,28 @@ export function TibberPrices() {
<Text fw={500}>Thermal contract snapshot</Text> <Text fw={500}>Thermal contract snapshot</Text>
<Text size="sm">Version {data.contract_version_id ?? '—'} · effective {data.effective_from ?? '—'} to {data.effective_to ?? 'open'}</Text> <Text size="sm">Version {data.contract_version_id ?? '—'} · effective {data.effective_from ?? '—'} to {data.effective_to ?? 'open'}</Text>
<Text size="sm" c="dimmed">This is a contract snapshot, not a 15-minute market spot price.</Text> <Text size="sm" c="dimmed">This is a contract snapshot, not a 15-minute market spot price.</Text>
{Object.entries(data.values).map(([section, values]) => ( <ScrollArea type="auto">
<Text size="sm" key={section} data-testid={`thermal-price-section-${section}`}> <Table withTableBorder withColumnBorders data-testid="thermal-price-table">
{section}: {Object.entries(values).map(([key, value]) => <Table.Thead>
`${key} ${value} ${thermalUnit(section, key, currency)}`, <Table.Tr>
).join(', ')} <Table.Th>Category</Table.Th>
</Text> <Table.Th>Charge</Table.Th>
))} <Table.Th>Rate</Table.Th>
<Table.Th>Unit</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{thermalSections(data.values!).flatMap((section) => Object.entries(data.values![section]).map(([key, rate]) => (
<Table.Tr key={`${section}-${key}`} data-testid={`thermal-price-row-${section}-${key}`}>
<Table.Td>{humanizeThermalField(section)}</Table.Td>
<Table.Td>{humanizeThermalField(key)}</Table.Td>
<Table.Td>{rate}</Table.Td>
<Table.Td>{thermalUnit(section, key, currency)}</Table.Td>
</Table.Tr>
)))}
</Table.Tbody>
</Table>
</ScrollArea>
</Stack> </Stack>
</Paper> </Paper>
)} )}
+8 -3
View File
@@ -12,6 +12,8 @@ from alembic import command
from alembic.config import Config from alembic.config import Config
from sqlalchemy import create_engine, event, inspect, text from sqlalchemy import create_engine, event, inspect, text
from app.integrations.meter_sources import sanitize_source_config, validate_source_config
def _config(database_url: str) -> Config: def _config(database_url: str) -> Config:
config = Config("alembic_app.ini") config = Config("alembic_app.ini")
@@ -108,7 +110,7 @@ def test_populated_revision_14_adopts_dsmr_history_at_revision_16(tmp_path: Path
{"key": "DSMR_INGEST_ENABLED", "value": "true", "at": start}, {"key": "DSMR_INGEST_ENABLED", "value": "true", "at": start},
{"key": "DSMR_MQTT_TOPIC", "value": "historic/dsmr", "at": start}, {"key": "DSMR_MQTT_TOPIC", "value": "historic/dsmr", "at": start},
{"key": "DSMR_TARIFF_TOPIC", "value": "historic/tariff", "at": start}, {"key": "DSMR_TARIFF_TOPIC", "value": "historic/tariff", "at": start},
{"key": "DSMR_SAMPLE_INTERVAL_S", "value": "15", "at": start}, {"key": "DSMR_SAMPLE_INTERVAL_S", "value": "0", "at": start},
{"key": "MQTT_BROKER_HOST", "value": "mqtt.example.invalid", "at": start}, {"key": "MQTT_BROKER_HOST", "value": "mqtt.example.invalid", "at": start},
{"key": "MQTT_BROKER_PORT", "value": "1884", "at": start}, {"key": "MQTT_BROKER_PORT", "value": "1884", "at": start},
{"key": "MQTT_USERNAME", "value": "historic-user", "at": start}, {"key": "MQTT_USERNAME", "value": "historic-user", "at": start},
@@ -159,11 +161,14 @@ def test_populated_revision_14_adopts_dsmr_history_at_revision_16(tmp_path: Path
text("SELECT id, enabled, config FROM meter_source WHERE kind = 'dsmr_mqtt'") text("SELECT id, enabled, config FROM meter_source WHERE kind = 'dsmr_mqtt'")
).one() ).one()
assert source.enabled == 1 assert source.enabled == 1
assert json.loads(source.config) == { source_config = json.loads(source.config)
assert source_config == {
"broker_host": "mqtt.example.invalid", "broker_port": 1884, "broker_host": "mqtt.example.invalid", "broker_port": 1884,
"username": "historic-user", "password": "historic-password", "tls_enabled": True, "username": "historic-user", "password": "historic-password", "tls_enabled": True,
"topic": "historic/dsmr", "tariff_topic": "historic/tariff", "sample_interval_s": 15, "topic": "historic/dsmr", "tariff_topic": "historic/tariff", "sample_interval_s": 0,
} }
assert validate_source_config("dsmr_mqtt", source_config) == source_config
assert sanitize_source_config("dsmr_mqtt", source_config)["sample_interval_s"] == 0
assert connection.execute(text("SELECT value FROM app_config WHERE key = 'DSMR_MQTT_TOPIC'")).scalar_one() == "historic/dsmr" assert connection.execute(text("SELECT value FROM app_config WHERE key = 'DSMR_MQTT_TOPIC'")).scalar_one() == "historic/dsmr"
assert dict(connection.execute(text("SELECT key, value FROM app_config")).all()) == config_before assert dict(connection.execute(text("SELECT key, value FROM app_config")).all()) == config_before
assert connection.execute(text("SELECT group_concat(telegram_id) FROM dsmr_reading")).scalar_one() == "77,78,77" assert connection.execute(text("SELECT group_concat(telegram_id) FROM dsmr_reading")).scalar_one() == "77,78,77"
+26
View File
@@ -68,6 +68,32 @@ def test_secret_sanitize_and_mask_merge_keep_old_value():
assert merged["topic"] == "new/topic" assert merged["topic"] == "new/topic"
def test_dsmr_zero_interval_validates_sanitizes_and_merges_unchanged():
config = validate_source_config("dsmr_mqtt", {"sample_interval_s": 0})
assert config["sample_interval_s"] == 0
assert sanitize_source_config("dsmr_mqtt", config)["sample_interval_s"] == 0
assert merge_source_config("dsmr_mqtt", config, {"topic": "new/topic"})["sample_interval_s"] == 0
@pytest.mark.parametrize(
("kind", "config"),
[
("dsmr_mqtt", {"sample_interval_s": -1}),
("dsmr_mqtt", {"sample_interval_s": False}),
("dsmr_mqtt", {"broker_port": 0}),
("dsmr_mqtt", {"broker_port": -1}),
("dsmr_mqtt", {"broker_port": False}),
("warmtelink_serial", {"path": "/dev/warmtelink", "baudrate": 0}),
("warmtelink_serial", {"path": "/dev/warmtelink", "baudrate": -1}),
("warmtelink_serial", {"path": "/dev/warmtelink", "baudrate": False}),
],
)
def test_numeric_source_profile_constraints_still_reject_invalid_values(kind, config):
with pytest.raises(SourceProfileError):
validate_source_config(kind, config)
@pytest.fixture() @pytest.fixture()
def session(tmp_path): def session(tmp_path):
engine = create_engine(f"sqlite:///{tmp_path / 'source_services.db'}") engine = create_engine(f"sqlite:///{tmp_path / 'source_services.db'}")