import { afterEach, describe, expect, test } from "bun:test"; import { act, useState } from "react"; import { testRender } from "../renderers/opentui/test-utils"; import { createTestDataProvider } from "../test-support/data-provider"; import type { DataProvider } from "../types/data-provider"; import type { PricePoint, TickerFinancials } from "../types/financials"; import { MarketDataCoordinator, setSharedMarketDataCoordinator, } from "../market-data/coordinator"; import { createIdleEntry } from "../market-data/result-types"; import { IDLE_COORDINATOR_QUOTES } from "./fixtures/quote-store"; import type { ChartResolveSources } from "./resolve"; import { CHART_SPEC_VERSION, type ChartSpec } from "./types"; import { useChartResolution, type UseChartResolutionResult, } from "./use-chart-resolution"; type AutoViewport = NonNullable[2]["autoViewport"]>; let testSetup: Awaited> | undefined; let setAutoViewport: ((viewport: AutoViewport | null) => void) | null = null; let setRequestViewport: ((viewport: AutoViewport | null) => void) | null = null; let setChartSpec: ((spec: ChartSpec) => void) | null = null; let latestResult: UseChartResolutionResult | null = null; const EMPTY_FINANCIALS: TickerFinancials = { annualStatements: [], quarterlyStatements: [], priceHistory: [], }; const INITIAL_HISTORY: PricePoint[] = [ { date: new Date("2025-01-02T16:00:00.000Z"), open: 100, high: 103, low: 99, close: 102, volume: 1_000, }, { date: new Date("2025-01-03T16:00:00.000Z"), open: 102, high: 105, low: 101, close: 104, volume: 1_200, }, ]; const SPEC: ChartSpec = { version: CHART_SPEC_VERSION, viewport: { range: "1Y", resolution: "auto" }, panels: [{ id: "main" }], series: [{ id: "price", source: { kind: "security", instrument: { symbol: "RESOLUTION-HOOK-TEST", exchange: "NASDAQ" }, fieldId: "market.ohlcv", }, style: "candles", transform: "raw", axis: "left", panelId: "main", interpolation: "none", }], studies: [], }; function deferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise; reject = rejectPromise; }); return { promise, resolve, reject }; } function sourcesFor(dataProvider: DataProvider): ChartResolveSources { return { dataProvider, now: new Date("2025-01-10T00:00:00.000Z"), loadFredSeries: async () => { throw new Error("FRED is unused in this test."); }, }; } function ResolutionHarness({ sources, spec = SPEC, }: { sources: ChartResolveSources; spec?: ChartSpec; }) { const [autoViewport, setAutoViewportState] = useState(null); const [requestViewport, setRequestViewportState] = useState(null); setAutoViewport = setAutoViewportState; setRequestViewport = setRequestViewportState; latestResult = useChartResolution(spec, sources, { autoViewport, requestViewport, targetPointCount: 100, liveRefreshIntervalMs: 0, }); const pointCount = (latestResult.bufferedSeries ?? latestResult.series) .reduce((total, series) => total + series.points.length, 0); return {`${latestResult.loading ? "loading" : "settled"}:${pointCount}`}; } function MutableSpecHarness({ sources, }: { sources: ChartResolveSources; }) { const [spec, setSpecState] = useState(SPEC); setChartSpec = setSpecState; latestResult = useChartResolution(spec, sources, { liveRefreshIntervalMs: 0, }); const pointCount = (latestResult.bufferedSeries ?? latestResult.series) .reduce((total, series) => total + series.points.length, 0); return {`${latestResult.loading ? "loading" : "settled"}:${pointCount}`}; } function PollingResolutionHarness({ sources }: { sources: ChartResolveSources }) { latestResult = useChartResolution(SPEC, sources, { liveStreaming: false, quotePollingIntervalMs: 30, }); return {latestResult.loading ? "loading" : "settled"}; } async function flushEffects(iterations = 1): Promise { for (let iteration = 0; iteration < iterations; iteration += 1) { await act(async () => { await Promise.resolve(); await testSetup!.renderOnce(); }); } } async function waitFor(predicate: () => boolean): Promise { for (let attempt = 0; attempt < 100; attempt += 1) { await flushEffects(); if (predicate()) return; await new Promise((resolve) => setTimeout(resolve, 1)); } throw new Error("Timed out waiting for chart resolution."); } afterEach(async () => { if (testSetup) { await act(async () => { testSetup!.renderer.destroy(); }); testSetup = undefined; } setAutoViewport = null; setRequestViewport = null; setChartSpec = null; latestResult = null; setSharedMarketDataCoordinator(null); }); describe("useChartResolution", () => { test("with streaming off, polls quotes into the chart without reloading its history", async () => { let historyCalls = 0; let quoteCalls = 0; let subscribeCalls = 0; const provider = createTestDataProvider({ getTickerFinancials: async () => EMPTY_FINANCIALS, getPriceHistoryForResolution: async () => { historyCalls += 1; return INITIAL_HISTORY; }, getQuote: async () => { quoteCalls += 1; return { symbol: "RESOLUTION-HOOK-TEST", price: 104 + quoteCalls, currency: "USD", change: 0, changePercent: 0, lastUpdated: Date.parse("2025-01-03T20:00:00.000Z") + quoteCalls, }; }, subscribeQuotes: () => { subscribeCalls += 1; return () => {}; }, }); setSharedMarketDataCoordinator(new MarketDataCoordinator(provider)); testSetup = await testRender(, { width: 24, height: 1, }); await waitFor(() => quoteCalls >= 3 && latestResult?.loading === false); expect(subscribeCalls).toBe(0); expect(historyCalls).toBe(1); expect(latestResult?.series[0]?.points).toHaveLength(2); }); test("streams shared quotes into the forming bar and opens the next bar at its boundary", async () => { const minute = Math.floor(Date.now() / 60_000) * 60_000; const history: PricePoint[] = [ { date: new Date(minute - 180_000), open: 100, high: 101, low: 99, close: 100, volume: 10 }, { date: new Date(minute - 120_000), open: 100, high: 100.5, low: 99.5, close: 100, volume: 5 }, ]; let emit: Parameters>[1] | null = null; let target: Parameters>[0][number] | null = null; const provider = createTestDataProvider({ getTickerFinancials: async () => EMPTY_FINANCIALS, getPriceHistoryForResolution: async () => history, subscribeQuotes: (targets, onQuote) => { target = targets[0] ?? null; emit = onQuote; return () => {}; }, }); setSharedMarketDataCoordinator(new MarketDataCoordinator(provider)); const spec: ChartSpec = { ...SPEC, viewport: { range: "1D", resolution: "1m" }, series: [{ ...SPEC.series[0]!, source: { kind: "security", instrument: { symbol: "LIVE-BARS", exchange: "CCC" }, fieldId: "market.ohlcv" } }] }; const sources: ChartResolveSources = { dataProvider: provider, loadFredSeries: async () => { throw new Error("unused"); } }; testSetup = await testRender(, { width: 24, height: 1 }); const bars = () => (latestResult?.bufferedSeries ?? []).find((entry) => entry.id === "price")?.points ?? []; const settle = async (predicate: () => boolean) => { for (let attempt = 0; attempt < 200 && !predicate(); attempt += 1) { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 10)); await testSetup!.renderOnce(); }); } expect(predicate()).toBe(true); }; await settle(() => bars().length === 2 && emit !== null); const quote = (offset: number, price: number) => ({ symbol: "LIVE-BARS", price, currency: "USD", change: 0, changePercent: 0, lastUpdated: minute + offset, listingExchangeName: "CCC", }); emit!(target!, quote(-100_000, 103)); await settle(() => bars().at(-1)?.close === 103); emit!(target!, quote(-90_000, 98)); await settle(() => bars().at(-1)?.close === 98); expect(bars().at(-1)).toMatchObject({ open: 100, high: 103, low: 98, close: 98 }); emit!(target!, quote(-30_000, 99)); await settle(() => bars().length === 3); expect(bars().at(-2)).toMatchObject({ high: 103, low: 98, close: 98 }); expect(bars().at(-1)).toMatchObject({ date: new Date(minute - 60_000), open: 99, high: 99, low: 99, close: 99 }); }); test("keeps renderable data settled through empty adaptive and live refreshes", async () => { const adaptiveHistory = deferred(); let detailedCalls = 0; let quoteHandler: Parameters>[1] | null = null; let quoteTarget: Parameters>[0][number] | null = null; const provider = createTestDataProvider({ getTickerFinancials: async () => EMPTY_FINANCIALS, getChartResolutionSupport: () => [ { resolution: "15m", maxRange: "1M" }, { resolution: "1d", maxRange: "ALL" }, ], getPriceHistoryForResolution: async (_symbol, _exchange, _range, resolution) => ( resolution === "1d" ? INITIAL_HISTORY : [] ), getDetailedPriceHistory: async () => { detailedCalls += 1; return adaptiveHistory.promise; }, getPriceHistory: async () => [], subscribeQuotes: (targets, onQuote) => { quoteTarget = targets[0] ?? null; quoteHandler = onQuote; return () => {}; }, }); setSharedMarketDataCoordinator(new MarketDataCoordinator(provider)); const sources = sourcesFor(provider); testSetup = await testRender(, { width: 24, height: 1, }); await waitFor(() => latestResult?.loading === false && latestResult.series[0]?.points.length === 2); const lastGoodSeries = latestResult!.series; const lastGoodBufferedSeries = latestResult!.bufferedSeries; await act(async () => { setAutoViewport?.({ start: new Date("2025-01-02T00:00:00.000Z"), end: new Date("2025-01-04T00:00:00.000Z"), }); await testSetup!.renderOnce(); }); await waitFor(() => detailedCalls === 1); expect(latestResult?.loading).toBe(false); expect(latestResult?.series).toBe(lastGoodSeries); expect(latestResult?.bufferedSeries).toBe(lastGoodBufferedSeries); adaptiveHistory.resolve([]); await flushEffects(3); expect(latestResult?.loading).toBe(false); expect(latestResult?.series).toBe(lastGoodSeries); expect(latestResult?.bufferedSeries).toBe(lastGoodBufferedSeries); expect(latestResult?.errors).toEqual([]); await act(async () => { quoteHandler!(quoteTarget!, { symbol: "RESOLUTION-HOOK-TEST", price: 105, currency: "USD", change: 1, changePercent: 0.96, lastUpdated: Date.parse("2025-01-03T20:00:00.000Z"), }); await new Promise((resolve) => setTimeout(resolve, 10)); await testSetup!.renderOnce(); }); await flushEffects(3); expect(latestResult?.loading).toBe(false); expect(latestResult?.series).toBe(lastGoodSeries); expect(latestResult?.bufferedSeries).toBe(lastGoodBufferedSeries); expect(latestResult?.errors).toEqual([]); }); test("keeps renderable data through an empty panned-history live refresh", async () => { let detailedCalls = 0; let fixedHistoryCalls = 0; let quoteHandler: Parameters>[1] | null = null; let quoteTarget: Parameters>[0][number] | null = null; const provider = createTestDataProvider({ getTickerFinancials: async () => EMPTY_FINANCIALS, getPriceHistoryForResolution: async () => { fixedHistoryCalls += 1; return fixedHistoryCalls === 1 ? INITIAL_HISTORY : []; }, getDetailedPriceHistory: async () => { detailedCalls += 1; return []; }, getPriceHistory: async () => [], subscribeQuotes: (targets, onQuote) => { quoteTarget = targets[0] ?? null; quoteHandler = onQuote; return () => {}; }, }); setSharedMarketDataCoordinator(new MarketDataCoordinator(provider)); const sources = sourcesFor(provider); testSetup = await testRender(, { width: 24, height: 1, }); await waitFor(() => latestResult?.loading === false && latestResult.series[0]?.points.length === 2); const lastGoodSeries = latestResult!.series; const lastGoodBufferedSeries = latestResult!.bufferedSeries; await act(async () => { setRequestViewport?.({ start: new Date("2024-12-20T00:00:00.000Z"), end: new Date("2024-12-27T00:00:00.000Z"), }); await testSetup!.renderOnce(); }); await waitFor(() => detailedCalls === 1); await flushEffects(3); expect(latestResult?.series).toBe(lastGoodSeries); expect(latestResult?.bufferedSeries).toBe(lastGoodBufferedSeries); await act(async () => { quoteHandler!(quoteTarget!, { symbol: "RESOLUTION-HOOK-TEST", price: 105, currency: "USD", change: 1, changePercent: 0.96, lastUpdated: Date.parse("2025-01-03T20:00:00.000Z"), }); await new Promise((resolve) => setTimeout(resolve, 10)); await testSetup!.renderOnce(); }); await flushEffects(3); expect(latestResult?.loading).toBe(false); expect(latestResult?.series).toBe(lastGoodSeries); expect(latestResult?.bufferedSeries).toBe(lastGoodBufferedSeries); expect(latestResult?.errors).toEqual([]); }); test("keeps renderable data when an adaptive viewport request rejects", async () => { const provider = createTestDataProvider({ getTickerFinancials: async () => EMPTY_FINANCIALS, getPriceHistoryForResolution: async () => INITIAL_HISTORY, }); const sources = sourcesFor(provider); testSetup = await testRender(, { width: 24, height: 1, }); await waitFor(() => latestResult?.loading === false && latestResult.series[0]?.points.length === 2); const lastGoodSeries = latestResult!.series; const lastGoodBufferedSeries = latestResult!.bufferedSeries; provider.getChartResolutionSupport = () => { throw new Error("adaptive history failed"); }; await act(async () => { setAutoViewport?.({ start: new Date("2025-01-02T00:00:00.000Z"), end: new Date("2025-01-04T00:00:00.000Z"), }); await testSetup!.renderOnce(); }); await flushEffects(3); expect(latestResult?.loading).toBe(false); expect(latestResult?.series).toBe(lastGoodSeries); expect(latestResult?.bufferedSeries).toBe(lastGoodBufferedSeries); expect(latestResult?.errors).toEqual([]); }); test("keeps initial and explicit reloads blocking", async () => { const initialHistory = deferred(); const reloadHistory = deferred(); let historyCalls = 0; const provider = createTestDataProvider({ getTickerFinancials: async () => EMPTY_FINANCIALS, getPriceHistoryForResolution: async () => { historyCalls += 1; return historyCalls === 1 ? initialHistory.promise : reloadHistory.promise; }, getPriceHistory: async () => [], }); const sources = sourcesFor(provider); // This case needs a cold identity, independent of the earlier cache tests. const coldSpec: ChartSpec = { ...SPEC, series: SPEC.series.map(entry => ({ ...entry, source: { kind: "security", instrument: { symbol: "RESOLUTION-COLD-RELOAD", exchange: "NASDAQ" }, fieldId: "market.ohlcv" }, })) }; testSetup = await testRender(, { width: 24, height: 1, }); await waitFor(() => historyCalls === 1); expect(latestResult?.loading).toBe(true); expect(latestResult?.series).toEqual([]); await act(async () => { initialHistory.resolve(INITIAL_HISTORY); await testSetup!.renderOnce(); }); await waitFor(() => latestResult?.loading === false && latestResult.series[0]?.points.length === 2); await act(async () => { latestResult!.reload(); await testSetup!.renderOnce(); }); await waitFor(() => historyCalls === 2); expect(latestResult?.loading).toBe(true); await act(async () => { reloadHistory.resolve([]); await testSetup!.renderOnce(); }); await waitFor(() => ( latestResult?.loading === false && latestResult.series.every((series) => series.points.length === 0) )); expect(latestResult?.series.every((series) => series.points.length === 0)).toBe(true); expect(latestResult?.errors).toEqual([]); }); test("paints coordinator-cached candles on the first frame", async () => { const history = deferred(); const provider = createTestDataProvider({ getTickerFinancials: async () => EMPTY_FINANCIALS, getPriceHistoryForResolution: async () => history.promise, getPriceHistory: async () => [], }); setSharedMarketDataCoordinator({ ...IDLE_COORDINATOR_QUOTES, subscribe: () => () => {}, getVersion: () => 1, getKeysVersion: () => 1, getChartEntry: () => ({ ...createIdleEntry(), phase: "ready", data: INITIAL_HISTORY, lastGoodData: INITIAL_HISTORY, source: "test", fetchedAt: Date.now(), }), } as never); testSetup = await testRender(, { width: 24, height: 1, }); expect(latestResult?.series[0]?.points.length).toBe(2); expect(latestResult?.loading).toBe(true); await act(async () => { history.resolve(INITIAL_HISTORY); await testSetup!.renderOnce(); }); await waitFor(() => latestResult?.loading === false); }); test("a settled history failure is not replaced by an indefinitely loading cached seed", async () => { const history = deferred(); const provider = createTestDataProvider({ getTickerFinancials: async () => EMPTY_FINANCIALS, getPriceHistoryForResolution: async () => history.promise, getPriceHistory: async () => { throw new Error("Selected history unavailable"); }, }); setSharedMarketDataCoordinator({ ...IDLE_COORDINATOR_QUOTES, subscribe: () => () => {}, getVersion: () => 1, getChartEntry: () => ({ ...createIdleEntry(), phase: "ready", data: INITIAL_HISTORY, lastGoodData: INITIAL_HISTORY, source: "test", fetchedAt: Date.now() }), } as never); testSetup = await testRender(, { width: 24, height: 1 }); expect(latestResult?.loading).toBe(true); expect(latestResult?.series[0]?.points.length).toBe(2); history.reject(new Error("Selected history unavailable")); await waitFor(() => latestResult?.loading === false); expect(latestResult?.errors.join(" ")).toContain("Selected history unavailable"); await flushEffects(3); expect(latestResult?.loading).toBe(false); }); test("identical hydrated specs preserve one pending resolution during quote-driven renders", async () => { const history = deferred(); let historyCalls = 0; let resolvedInputs = 0; const provider = createTestDataProvider({ getTickerFinancials: async () => EMPTY_FINANCIALS, getPriceHistoryForResolution: async () => { historyCalls += 1; return history.promise; }, getPriceHistory: async () => [], }); const sources = { ...sourcesFor(provider), onSecurityData: () => { resolvedInputs += 1; } }; testSetup = await testRender(, { width: 24, height: 1 }); await waitFor(() => historyCalls === 1); for (let index = 0; index < 3; index += 1) { await act(async () => { setChartSpec?.(JSON.parse(JSON.stringify(SPEC))); }); await flushEffects(2); } history.resolve(INITIAL_HISTORY); await waitFor(() => latestResult?.loading === false); expect(historyCalls).toBe(1); expect(resolvedInputs).toBe(1); expect(latestResult?.series[0]?.points).toHaveLength(2); }); test("changing a security cannot retain its predecessor's chart after a failed request", async () => { const other = deferred(); const provider = createTestDataProvider({ getTickerFinancials: async () => EMPTY_FINANCIALS, getPriceHistoryForResolution: async (symbol) => symbol === "OTHER" ? other.promise : INITIAL_HISTORY, getPriceHistory: async () => { throw new Error("OTHER unavailable"); }, }); testSetup = await testRender(, { width: 24, height: 1 }); await waitFor(() => latestResult?.loading === false && latestResult.series[0]?.points.length === 2); await act(async () => { setChartSpec?.({ ...SPEC, series: [{ ...SPEC.series[0]!, source: { kind: "security", instrument: { symbol: "OTHER", exchange: "NASDAQ" }, fieldId: "market.ohlcv", } }] }); await testSetup!.renderOnce(); }); expect(latestResult?.series.flatMap((entry) => entry.points)).toEqual([]); expect(latestResult?.loading).toBe(true); other.reject(new Error("OTHER unavailable")); await waitFor(() => latestResult?.loading === false); expect(latestResult?.series.flatMap((entry) => entry.points)).toEqual([]); expect(latestResult?.errors.join(" ")).toContain("OTHER unavailable"); }); test("reuses cached history when the spec object identity changes", async () => { const initialHistory = deferred(); let historyCalls = 0; const provider = createTestDataProvider({ getTickerFinancials: async () => EMPTY_FINANCIALS, getPriceHistoryForResolution: async () => { historyCalls += 1; return historyCalls === 1 ? initialHistory.promise : INITIAL_HISTORY; }, getPriceHistory: async () => [], }); testSetup = await testRender(, { width: 24, height: 1, }); await waitFor(() => historyCalls === 1); await act(async () => { initialHistory.resolve(INITIAL_HISTORY); await testSetup!.renderOnce(); }); await waitFor(() => latestResult?.loading === false && latestResult.series[0]?.points.length === 2); await act(async () => { setChartSpec?.({ ...SPEC, series: [{ ...SPEC.series[0]!, style: "line", }], }); await testSetup!.renderOnce(); }); await flushEffects(3); expect(historyCalls).toBe(1); expect(latestResult?.loading).toBe(false); expect(latestResult?.series[0]?.points.length).toBe(2); expect(latestResult?.series[0]?.style).toBe("line"); }); });