2026-06-23 23:32:39 +02:00
|
|
|
/**
|
|
|
|
|
* Tests for TibberPrices component.
|
|
|
|
|
*
|
|
|
|
|
* Coverage:
|
|
|
|
|
* 1. Loading state.
|
|
|
|
|
* 2. Empty state (no active contract / no kind).
|
|
|
|
|
* 3. Renders tibber chart when tibber kind data is available.
|
|
|
|
|
* 4. Shows tariff table for manual kind.
|
2026-07-27 18:29:08 +02:00
|
|
|
* 5. Marks the currently active price slot (dot + caption).
|
|
|
|
|
* 6. Hovering past midnight resolves tomorrow's slot, not today's (regression).
|
|
|
|
|
* 7. buildChartRows: unique X keys across midnight.
|
|
|
|
|
* 8. findActiveSlotIndex: which slot is currently active.
|
2026-06-23 23:32:39 +02:00
|
|
|
*/
|
|
|
|
|
|
2026-07-27 18:29:08 +02:00
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
|
|
|
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
2026-06-23 23:32:39 +02:00
|
|
|
import { renderWithProviders } from '../test-utils'
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Mock apiClient
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const mockGet = vi.fn()
|
|
|
|
|
|
|
|
|
|
vi.mock('../api/client', () => ({
|
|
|
|
|
default: {
|
|
|
|
|
GET: (...args: unknown[]) => mockGet(...args),
|
|
|
|
|
POST: vi.fn(),
|
|
|
|
|
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(),
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Import component
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
2026-07-27 18:29:08 +02:00
|
|
|
import { TibberPrices, buildChartRows, findActiveSlotIndex } from './TibberPrices'
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Chart size harness
|
|
|
|
|
//
|
|
|
|
|
// jsdom reports every element as 0x0, so Recharts renders an empty plot and no
|
|
|
|
|
// pointer interaction is possible. These helpers hand the chart a fixed size:
|
|
|
|
|
// - the ResponsiveContainer gets 800x300 from its bounding rect + a ResizeObserver
|
|
|
|
|
// that reports the same size,
|
|
|
|
|
// - the chart wrapper reports 800x260 (the height the component asks for), which
|
|
|
|
|
// is what Recharts uses to translate clientX/clientY into chart coordinates,
|
|
|
|
|
// - everything else stays 0x0 so the legend does not eat the whole plot area.
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
const CHART_W = 800
|
|
|
|
|
const CONTAINER_H = 300
|
|
|
|
|
const CHART_H = 260
|
|
|
|
|
|
|
|
|
|
function fakeRect(width: number, height: number): DOMRect {
|
|
|
|
|
return {
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
left: 0,
|
|
|
|
|
top: 0,
|
|
|
|
|
right: width,
|
|
|
|
|
bottom: height,
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
toJSON: () => {},
|
|
|
|
|
} as DOMRect
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const originalResizeObserver = globalThis.ResizeObserver
|
|
|
|
|
const offsetWidthDescriptor = Object.getOwnPropertyDescriptor(
|
|
|
|
|
HTMLElement.prototype,
|
|
|
|
|
'offsetWidth',
|
|
|
|
|
)
|
|
|
|
|
const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(
|
|
|
|
|
HTMLElement.prototype,
|
|
|
|
|
'offsetHeight',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
function installChartSize() {
|
|
|
|
|
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
|
|
|
|
if (this.classList.contains('recharts-responsive-container')) {
|
|
|
|
|
return fakeRect(CHART_W, CONTAINER_H)
|
|
|
|
|
}
|
|
|
|
|
if (this.classList.contains('recharts-wrapper')) return fakeRect(CHART_W, CHART_H)
|
|
|
|
|
return fakeRect(0, 0)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Recharts divides rect size by offset size to undo CSS transform scaling;
|
|
|
|
|
// matching them keeps the scale factor at 1.
|
|
|
|
|
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
|
|
|
|
|
configurable: true,
|
|
|
|
|
value: CHART_W,
|
|
|
|
|
})
|
|
|
|
|
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
|
|
|
|
|
configurable: true,
|
|
|
|
|
value: CHART_H,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
globalThis.ResizeObserver = class implements ResizeObserver {
|
|
|
|
|
private readonly cb: ResizeObserverCallback
|
|
|
|
|
constructor(cb: ResizeObserverCallback) {
|
|
|
|
|
this.cb = cb
|
|
|
|
|
}
|
|
|
|
|
observe() {
|
|
|
|
|
this.cb(
|
|
|
|
|
[{ contentRect: { width: CHART_W, height: CONTAINER_H } } as ResizeObserverEntry],
|
|
|
|
|
this,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
unobserve() {}
|
|
|
|
|
disconnect() {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function restoreChartSize() {
|
|
|
|
|
globalThis.ResizeObserver = originalResizeObserver
|
|
|
|
|
if (offsetWidthDescriptor) {
|
|
|
|
|
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidthDescriptor)
|
|
|
|
|
}
|
|
|
|
|
if (offsetHeightDescriptor) {
|
|
|
|
|
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeightDescriptor)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Hourly price points, one per hour starting at `startUtc`, with unique prices. */
|
|
|
|
|
function hourlyPoints(startUtc: number, count: number) {
|
|
|
|
|
return Array.from({ length: count }, (_, i) => ({
|
|
|
|
|
starts_at: new Date(startUtc + i * 3600_000).toISOString(),
|
|
|
|
|
buy: 0.1 + i / 1000,
|
|
|
|
|
sell: 0.05 + i / 1000,
|
|
|
|
|
level: 'NORMAL',
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function tooltipText(): string {
|
|
|
|
|
return document.querySelector('.recharts-tooltip-wrapper')?.textContent ?? ''
|
|
|
|
|
}
|
2026-06-23 23:32:39 +02:00
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Tests
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe('TibberPrices', () => {
|
|
|
|
|
beforeEach(() => vi.clearAllMocks())
|
2026-07-27 18:29:08 +02:00
|
|
|
afterEach(() => {
|
|
|
|
|
vi.restoreAllMocks()
|
|
|
|
|
restoreChartSize()
|
|
|
|
|
})
|
2026-06-23 23:32:39 +02:00
|
|
|
|
|
|
|
|
it('renders loading state initially', () => {
|
|
|
|
|
mockGet.mockImplementation(() => new Promise(() => {}))
|
|
|
|
|
|
|
|
|
|
renderWithProviders(<TibberPrices />)
|
|
|
|
|
|
|
|
|
|
expect(screen.getByTestId('prices-loading')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('renders "no active contract" when kind is missing/null', async () => {
|
|
|
|
|
mockGet.mockResolvedValue({
|
|
|
|
|
data: {
|
|
|
|
|
kind: null,
|
|
|
|
|
currency: 'EUR',
|
|
|
|
|
points: [],
|
|
|
|
|
tariff: null,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
renderWithProviders(<TibberPrices />)
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('prices-no-contract')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('renders error state when fetch fails', async () => {
|
|
|
|
|
mockGet.mockRejectedValue(new Error('Network error'))
|
|
|
|
|
|
|
|
|
|
renderWithProviders(<TibberPrices />)
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('prices-error')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('renders tibber chart when tibber data is available', async () => {
|
|
|
|
|
mockGet.mockResolvedValue({
|
|
|
|
|
data: {
|
|
|
|
|
kind: 'tibber',
|
|
|
|
|
currency: 'EUR',
|
|
|
|
|
points: [
|
|
|
|
|
{ starts_at: '2026-06-22T10:00:00Z', buy: 0.133, sell: 0.09, level: 'NORMAL' },
|
|
|
|
|
{ starts_at: '2026-06-22T10:15:00Z', buy: 0.140, sell: 0.092, level: 'NORMAL' },
|
|
|
|
|
],
|
|
|
|
|
tariff: null,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
renderWithProviders(<TibberPrices />)
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('tibber-chart')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Check badge shows kind
|
|
|
|
|
expect(screen.getByText('tibber')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows tariff table for manual kind', async () => {
|
|
|
|
|
mockGet.mockResolvedValue({
|
|
|
|
|
data: {
|
|
|
|
|
kind: 'manual',
|
|
|
|
|
currency: 'EUR',
|
|
|
|
|
points: [],
|
|
|
|
|
tariff: {
|
|
|
|
|
buy_dal: 0.127,
|
|
|
|
|
buy_normal: 0.133,
|
|
|
|
|
sell_dal: 0.09,
|
|
|
|
|
sell_normal: 0.09,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
renderWithProviders(<TibberPrices />)
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('manual-tariff-table')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(screen.getByTestId('tariff-buy-normal')).toHaveTextContent('0.1330')
|
|
|
|
|
expect(screen.getByTestId('tariff-buy-dal')).toHaveTextContent('0.1270')
|
|
|
|
|
expect(screen.getByTestId('tariff-sell-normal')).toHaveTextContent('0.0900')
|
|
|
|
|
expect(screen.getByTestId('tariff-sell-dal')).toHaveTextContent('0.0900')
|
|
|
|
|
})
|
2026-07-27 18:29:08 +02:00
|
|
|
|
|
|
|
|
it('marks the currently active price slot with a dot and a caption', async () => {
|
|
|
|
|
installChartSize()
|
|
|
|
|
|
|
|
|
|
const SLOT_MS = 15 * 60 * 1000
|
|
|
|
|
// Start of the quarter-hour slot that contains "now".
|
|
|
|
|
const currentSlot = Math.floor(Date.now() / SLOT_MS) * SLOT_MS
|
|
|
|
|
|
|
|
|
|
mockGet.mockResolvedValue({
|
|
|
|
|
data: {
|
|
|
|
|
kind: 'tibber',
|
|
|
|
|
currency: 'EUR',
|
|
|
|
|
points: [
|
|
|
|
|
{ starts_at: new Date(currentSlot - SLOT_MS).toISOString(), buy: 0.11, sell: 0.05 },
|
|
|
|
|
{ starts_at: new Date(currentSlot).toISOString(), buy: 0.2431, sell: 0.1102 },
|
|
|
|
|
{ starts_at: new Date(currentSlot + SLOT_MS).toISOString(), buy: 0.31, sell: 0.15 },
|
|
|
|
|
],
|
|
|
|
|
tariff: null,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
renderWithProviders(<TibberPrices />)
|
|
|
|
|
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(screen.getByTestId('tibber-current-price')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const marker = screen.getByTestId('tibber-current-price')
|
|
|
|
|
expect(marker).toHaveTextContent('0.2431')
|
|
|
|
|
expect(marker).toHaveTextContent('0.1102')
|
|
|
|
|
|
|
|
|
|
// One dot on the buy line, one on the sell line — visible without hovering.
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
expect(document.querySelectorAll('.recharts-reference-dot')).toHaveLength(2)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('resolves the hovered slot past midnight to tomorrow, not today', async () => {
|
|
|
|
|
installChartSize()
|
|
|
|
|
|
|
|
|
|
// 26 hourly points starting at 2020-01-01T00:00Z, so "00:00" and "01:00"
|
|
|
|
|
// each appear twice. Fixed past dates keep the "now" marker out of range.
|
|
|
|
|
const points = hourlyPoints(Date.UTC(2020, 0, 1), 26)
|
|
|
|
|
|
|
|
|
|
mockGet.mockResolvedValue({
|
|
|
|
|
data: { kind: 'tibber', currency: 'EUR', points, tariff: null },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
renderWithProviders(<TibberPrices />)
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(screen.getByTestId('tibber-chart')).toBeInTheDocument())
|
|
|
|
|
expect(screen.queryByTestId('tibber-current-price')).not.toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
const wrapper = document.querySelector('.recharts-wrapper')
|
|
|
|
|
expect(wrapper).not.toBeNull()
|
|
|
|
|
|
|
|
|
|
// Right edge of the plot area = the last slot (day 2, 01:00, buy 0.1250).
|
|
|
|
|
fireEvent.mouseMove(wrapper!, { clientX: 770, clientY: CHART_H / 2 })
|
|
|
|
|
|
|
|
|
|
await waitFor(() => expect(tooltipText()).toContain('0.1250'))
|
|
|
|
|
|
|
|
|
|
// Label carries the date, so day 2 is distinguishable from day 1.
|
|
|
|
|
expect(tooltipText()).toContain('1/2/2020')
|
|
|
|
|
expect(tooltipText()).toContain('0.0750')
|
|
|
|
|
|
|
|
|
|
// The active dots must sit on the hovered point (right half of the plot).
|
|
|
|
|
// The bug put them on day 1's identically-labelled slot near the left edge.
|
|
|
|
|
const dots = Array.from(document.querySelectorAll('.recharts-active-dot circle'))
|
|
|
|
|
expect(dots).toHaveLength(2)
|
|
|
|
|
for (const dot of dots) {
|
|
|
|
|
expect(Number(dot.getAttribute('cx'))).toBeGreaterThan(CHART_W / 2)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// buildChartRows
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe('buildChartRows', () => {
|
|
|
|
|
it('keeps X-axis keys unique across midnight', () => {
|
|
|
|
|
// Same local time-of-day on two consecutive days: as "HH:mm" labels these
|
|
|
|
|
// collided, which made Recharts resolve the hovered point to the first match
|
|
|
|
|
// (today) instead of the hovered one (tomorrow).
|
|
|
|
|
const rows = buildChartRows([
|
|
|
|
|
{ starts_at: '2026-07-26T22:00:00Z', buy: 0.1, sell: 0.05 },
|
|
|
|
|
{ starts_at: '2026-07-27T22:00:00Z', buy: 0.2, sell: 0.06 },
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
expect(rows).toHaveLength(2)
|
|
|
|
|
expect(new Set(rows.map((r) => r.ts)).size).toBe(2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('sorts rows by slot start and parses naive timestamps as UTC', () => {
|
|
|
|
|
const rows = buildChartRows([
|
|
|
|
|
{ starts_at: '2026-07-27T02:00:00', buy: 0.3, sell: 0.07 },
|
|
|
|
|
{ starts_at: '2026-07-27T01:00:00Z', buy: 0.2, sell: 0.06 },
|
|
|
|
|
{ starts_at: '2026-07-27T00:00:00Z', buy: 0.1, sell: 0.05 },
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
expect(rows.map((r) => r.buy)).toEqual([0.1, 0.2, 0.3])
|
|
|
|
|
expect(rows.map((r) => r.ts)).toEqual([
|
|
|
|
|
'2026-07-27T00:00:00.000Z',
|
|
|
|
|
'2026-07-27T01:00:00.000Z',
|
|
|
|
|
'2026-07-27T02:00:00.000Z',
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// findActiveSlotIndex
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
describe('findActiveSlotIndex', () => {
|
|
|
|
|
const rows = buildChartRows([
|
|
|
|
|
{ starts_at: '2026-07-27T00:00:00Z', buy: 0.1, sell: 0.05 },
|
|
|
|
|
{ starts_at: '2026-07-27T00:15:00Z', buy: 0.2, sell: 0.06 },
|
|
|
|
|
{ starts_at: '2026-07-27T00:30:00Z', buy: 0.3, sell: 0.07 },
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
const at = (iso: string) => new Date(iso).getTime()
|
|
|
|
|
|
|
|
|
|
it('returns the slot containing now', () => {
|
|
|
|
|
expect(findActiveSlotIndex(rows, at('2026-07-27T00:20:00Z'))).toBe(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('returns the slot at its exact start boundary', () => {
|
|
|
|
|
expect(findActiveSlotIndex(rows, at('2026-07-27T00:15:00Z'))).toBe(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('returns null before the first slot', () => {
|
|
|
|
|
expect(findActiveSlotIndex(rows, at('2026-07-26T23:59:00Z'))).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('stays on the last slot until its inferred end, then returns null', () => {
|
|
|
|
|
expect(findActiveSlotIndex(rows, at('2026-07-27T00:44:00Z'))).toBe(2)
|
|
|
|
|
expect(findActiveSlotIndex(rows, at('2026-07-27T00:45:00Z'))).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('returns null for empty data', () => {
|
|
|
|
|
expect(findActiveSlotIndex([], Date.now())).toBeNull()
|
|
|
|
|
})
|
2026-06-23 23:32:39 +02:00
|
|
|
})
|