/** * 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. * 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. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { screen, waitFor, fireEvent } from '@testing-library/react' 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 // --------------------------------------------------------------------------- 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 ?? '' } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe('TibberPrices', () => { beforeEach(() => vi.clearAllMocks()) afterEach(() => { vi.restoreAllMocks() restoreChartSize() }) it('renders loading state initially', () => { mockGet.mockImplementation(() => new Promise(() => {})) renderWithProviders() 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() await waitFor(() => { expect(screen.getByTestId('prices-no-contract')).toBeInTheDocument() }) }) it('renders error state when fetch fails', async () => { mockGet.mockRejectedValue(new Error('Network error')) renderWithProviders() 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() 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() 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') }) 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() 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() 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() }) })