M6-T10: frontend contract management + price/cost views + Tibber test
- EnergyPage: Mantine Tabs (Devices kept intact + Contracts/Prices/Costs). - ContractManager/ContractForm: list/activate/add-version + version history; form fields rendered dynamically from /api/energy/profiles structure. - TibberPrices + CostView: Recharts price curve, cost trend/detail/summary, recompute; window-bounded, currency/units from API, empty/error/loading states. - ConfigPage: tri-state Tibber test button (token never shown). - hooks.ts: typed energy hooks; schema.d.ts regenerated via codegen.
This commit is contained in:
@@ -0,0 +1,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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user