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
@@ -0,0 +1,212 @@
/**
* Tests for Tibber test button in ConfigPage.
*
* Coverage:
* 1. Tibber test button appears when Tibber section is in config.
* 2. Success tri-state shows green alert.
* 3. Config-error tri-state shows orange alert.
* 4. Failed tri-state shows red alert.
*/
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()
const mockPut = vi.fn()
vi.mock('../api/client', () => ({
default: {
GET: (...args: unknown[]) => mockGet(...args),
POST: (...args: unknown[]) => mockPost(...args),
PUT: (...args: unknown[]) => mockPut(...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 CONFIG_WITH_TIBBER = {
sections: [
{
name: 'Tibber',
fields: [
{
env_name: 'TIBBER_TOKEN',
label: 'Tibber API Token',
secret: true,
input_type: 'text',
configured: true,
value: null,
},
],
},
],
}
// ---------------------------------------------------------------------------
// Import component
// ---------------------------------------------------------------------------
import { ConfigPage } from './ConfigPage'
// ---------------------------------------------------------------------------
// Helper to suppress TOTP and Expose Settings sub-queries
// ---------------------------------------------------------------------------
function mockConfigDependencies() {
mockGet.mockImplementation((path: string) => {
if (path === '/api/config') {
return Promise.resolve({ data: CONFIG_WITH_TIBBER })
}
if (path === '/api/expose') {
return Promise.resolve({
data: {
catalog: [],
mqtt_status: { connected: false, broker: null },
},
})
}
if (path === '/api/auth/totp/status') {
return Promise.resolve({ data: { enabled: false } })
}
return Promise.resolve({ data: null })
})
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ConfigPage — Tibber test button', () => {
beforeEach(() => vi.clearAllMocks())
it('renders Tibber test button when Tibber section is present', async () => {
mockConfigDependencies()
renderWithProviders(<ConfigPage />)
await waitFor(() => {
expect(screen.getByTestId('tibber-test-button')).toBeInTheDocument()
})
})
it('shows success alert on successful Tibber test', async () => {
const user = userEvent.setup()
mockConfigDependencies()
mockPost.mockImplementation((path: string) => {
if (path === '/api/energy/tibber/test') {
return Promise.resolve({
data: {
result: 'success',
message: 'Connected to Tibber API',
price: {
starts_at: '2026-06-22T10:00:00Z',
total: 0.1337,
energy: 0.08,
tax: 0.0537,
currency: 'EUR',
level: 'NORMAL',
},
},
})
}
return Promise.resolve({ data: null })
})
renderWithProviders(<ConfigPage />)
await waitFor(() => {
expect(screen.getByTestId('tibber-test-button')).toBeInTheDocument()
})
await user.click(screen.getByTestId('tibber-test-button'))
await waitFor(() => {
expect(screen.getByTestId('tibber-result-success')).toBeInTheDocument()
})
expect(screen.getByTestId('tibber-result-success')).toHaveTextContent(
'Tibber connection successful',
)
})
it('shows config-error alert when Tibber is misconfigured', async () => {
const user = userEvent.setup()
mockConfigDependencies()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ApiErrorClass = (await import('../api/client')).ApiError as any
mockPost.mockImplementation((path: string) => {
if (path === '/api/energy/tibber/test') {
throw new ApiErrorClass(400, {
result: 'config-error',
message: 'Tibber token not configured',
})
}
return Promise.resolve({ data: null })
})
renderWithProviders(<ConfigPage />)
await waitFor(() => {
expect(screen.getByTestId('tibber-test-button')).toBeInTheDocument()
})
await user.click(screen.getByTestId('tibber-test-button'))
await waitFor(() => {
expect(screen.getByTestId('tibber-result-config-error')).toBeInTheDocument()
})
})
it('shows failed alert when Tibber test fails', async () => {
const user = userEvent.setup()
mockConfigDependencies()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ApiErrorClass = (await import('../api/client')).ApiError as any
mockPost.mockImplementation((path: string) => {
if (path === '/api/energy/tibber/test') {
throw new ApiErrorClass(500, {
result: 'failed',
message: 'Connection refused',
})
}
return Promise.resolve({ data: null })
})
renderWithProviders(<ConfigPage />)
await waitFor(() => {
expect(screen.getByTestId('tibber-test-button')).toBeInTheDocument()
})
await user.click(screen.getByTestId('tibber-test-button'))
await waitFor(() => {
expect(screen.getByTestId('tibber-result-failed')).toBeInTheDocument()
})
})
})
+96
View File
@@ -49,6 +49,7 @@ import { TotpSettings } from './TotpSettings'
type ConfigField = components['schemas']['ConfigField']
type ConfigSection = components['schemas']['ConfigSection']
type TibberTestPriceSchema = components['schemas']['TibberTestPriceSchema']
/** SMTP test result tri-state. */
type SmtpResult =
@@ -64,6 +65,13 @@ type MqttResult =
| { kind: 'failed'; message: string }
| null
/** Tibber test result tri-state. */
type TibberResult =
| { kind: 'success'; message: string; price?: TibberTestPriceSchema | null }
| { kind: 'config-error'; message: string }
| { kind: 'failed'; message: string }
| null
// ---------------------------------------------------------------------------
// Hook: load config
// ---------------------------------------------------------------------------
@@ -339,6 +347,85 @@ function MqttTestButton({ mqttResult, setMqttResult }: MqttTestButtonProps) {
)
}
// ---------------------------------------------------------------------------
// TibberTestButton — sends POST /api/energy/tibber/test and displays tri-state result
// ---------------------------------------------------------------------------
interface TibberTestButtonProps {
tibberResult: TibberResult
setTibberResult: (r: TibberResult) => void
}
function TibberTestButton({ tibberResult, setTibberResult }: TibberTestButtonProps) {
const [testing, setTesting] = useState(false)
async function handleTest() {
setTibberResult(null)
setTesting(true)
try {
const res = await apiClient.POST('/api/energy/tibber/test')
if (res.data) {
setTibberResult({
kind: 'success',
message: res.data.message,
price: res.data.price,
})
}
} catch (err) {
if (err instanceof ApiError) {
const body = err.body as { result?: string; message?: string } | null
const result = body?.result
const message = body?.message ?? 'Unknown error'
if (result === 'config-error') {
setTibberResult({ kind: 'config-error', message })
} else {
setTibberResult({ kind: 'failed', message })
}
} else {
setTibberResult({ kind: 'failed', message: 'Unexpected error during Tibber test.' })
}
} finally {
setTesting(false)
}
}
return (
<Stack gap="xs">
<Button
type="button"
variant="outline"
onClick={handleTest}
loading={testing}
data-testid="tibber-test-button"
>
Test Tibber Connection
</Button>
{tibberResult?.kind === 'success' && (
<Alert color="green" data-testid="tibber-result-success">
Tibber connection successful. {tibberResult.message}
{tibberResult.price && (
<Text size="xs" mt={4}>
Current price: {tibberResult.price.total} {tibberResult.price.currency}/kWh
{tibberResult.price.level ? ` (${tibberResult.price.level})` : ''}
</Text>
)}
</Alert>
)}
{tibberResult?.kind === 'config-error' && (
<Alert color="orange" data-testid="tibber-result-config-error">
Tibber configuration error check your Tibber API token. {tibberResult.message}
</Alert>
)}
{tibberResult?.kind === 'failed' && (
<Alert color="red" data-testid="tibber-result-failed">
Tibber test failed. {tibberResult.message}
</Alert>
)}
</Stack>
)
}
// ---------------------------------------------------------------------------
// ConfigPage — main component
// ---------------------------------------------------------------------------
@@ -374,6 +461,9 @@ export function ConfigPage() {
// MQTT test tri-state
const [mqttResult, setMqttResult] = useState<MqttResult>(null)
// Tibber test tri-state
const [tibberResult, setTibberResult] = useState<TibberResult>(null)
function handleChange(envName: string, value: string) {
setLocalValues((prev) => ({ ...prev, [envName]: value }))
setSaveStatus(null)
@@ -432,6 +522,9 @@ export function ConfigPage() {
// Detect if there is an MQTT section (to show the MQTT test button).
const hasMqttSection = data.sections.some((s) => s.name.toLowerCase() === 'mqtt')
// Detect if there is a Tibber section (to show the Tibber test button).
const hasTibberSection = data.sections.some((s) => s.name.toLowerCase() === 'tibber')
// Default: open the first section so users immediately see content.
const defaultAccordionValue = data.sections[0]?.name ?? null
@@ -512,6 +605,9 @@ export function ConfigPage() {
{hasMqttSection && (
<MqttTestButton mqttResult={mqttResult} setMqttResult={setMqttResult} />
)}
{hasTibberSection && (
<TibberTestButton tibberResult={tibberResult} setTibberResult={setTibberResult} />
)}
</Group>
</Group>
</Stack>
+68 -31
View File
@@ -11,6 +11,8 @@
* - Latest readings card per device (T07): fetches /latest and /metrics; tolerates
* missing payload keys.
* - Trend charts per device (T07): EnergyCharts component (Recharts isolated inside).
*
* M6-T10: added Tabs layout with Devices / Contracts / Prices / Costs tabs.
*/
import { useState } from 'react'
@@ -34,10 +36,14 @@ import {
SimpleGrid,
Divider,
Switch,
Tabs,
} from '@mantine/core'
import { useDevices, useDeleteDevice, useTestReadDevice, useLatestReading, useMetrics } from '../energy/hooks'
import { DeviceForm } from '../energy/DeviceForm'
import { EnergyCharts } from '../energy/EnergyCharts'
import { ContractManager } from '../energy/ContractManager'
import { TibberPrices } from '../energy/TibberPrices'
import { CostView } from '../energy/CostView'
import type { ModbusDevice, ModbusTestReadResponse, MetricInfo } from '../energy/hooks'
import { ApiError } from '../api/client'
import { formatMetricValue } from '../energy/format'
@@ -488,10 +494,10 @@ function DeviceReadingsSection({ devices }: DeviceReadingsSectionProps) {
}
// ---------------------------------------------------------------------------
// EnergyPage — top-level
// DevicesTab — self-contained devices management tab (extracted from original EnergyPage)
// ---------------------------------------------------------------------------
export function EnergyPage() {
function DevicesTab() {
const devicesQuery = useDevices()
const deleteMutation = useDeleteDevice()
@@ -534,10 +540,6 @@ export function EnergyPage() {
}
}
// ---------------------------------------------------------------------------
// Loading / error states
// ---------------------------------------------------------------------------
if (devicesQuery.isLoading) {
return (
<Center pt="xl" data-testid="energy-loading">
@@ -548,39 +550,31 @@ export function EnergyPage() {
if (devicesQuery.isError || !devicesQuery.data) {
return (
<Container size="xl" pt="xl">
<Alert color="red" data-testid="energy-load-error">
Failed to load devices. Please refresh.
</Alert>
</Container>
<Alert color="red" data-testid="energy-load-error">
Failed to load devices. Please refresh.
</Alert>
)
}
const devices = devicesQuery.data.items
// ---------------------------------------------------------------------------
// Main render
// ---------------------------------------------------------------------------
return (
<Container size="xl" pt="xl" pb="xl" data-testid="energy-page">
<Stack gap="lg">
<Group justify="space-between" align="center">
<Title order={2}>Energy Devices</Title>
<Button onClick={openCreate} data-testid="device-new-button">
New Device
</Button>
</Group>
<Stack gap="lg">
<Group justify="space-between" align="center">
<Title order={2}>Energy Devices</Title>
<Button onClick={openCreate} data-testid="device-new-button">
New Device
</Button>
</Group>
<DeviceTable
devices={devices}
onEdit={openEdit}
onDelete={openDelete}
/>
<DeviceTable
devices={devices}
onEdit={openEdit}
onDelete={openDelete}
/>
{/* Latest readings cards + trend charts (T07) */}
<DeviceReadingsSection devices={devices} />
</Stack>
{/* Latest readings cards + trend charts (T07) */}
<DeviceReadingsSection devices={devices} />
{/* Create form */}
{showCreateForm && (
@@ -609,6 +603,49 @@ export function EnergyPage() {
has409Error={delete409}
/>
)}
</Stack>
)
}
// ---------------------------------------------------------------------------
// EnergyPage — top-level with Tabs
// ---------------------------------------------------------------------------
export function EnergyPage() {
return (
<Container size="xl" pt="xl" pb="xl" data-testid="energy-page">
<Tabs defaultValue="devices">
<Tabs.List mb="lg">
<Tabs.Tab value="devices" data-testid="tab-devices">
Devices
</Tabs.Tab>
<Tabs.Tab value="contracts" data-testid="tab-contracts">
Contracts
</Tabs.Tab>
<Tabs.Tab value="prices" data-testid="tab-prices">
Prices
</Tabs.Tab>
<Tabs.Tab value="costs" data-testid="tab-costs">
Costs
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="devices" data-testid="panel-devices">
<DevicesTab />
</Tabs.Panel>
<Tabs.Panel value="contracts" data-testid="panel-contracts">
<ContractManager />
</Tabs.Panel>
<Tabs.Panel value="prices" data-testid="panel-prices">
<TibberPrices />
</Tabs.Panel>
<Tabs.Panel value="costs" data-testid="panel-costs">
<CostView />
</Tabs.Panel>
</Tabs>
</Container>
)
}