diff --git a/src/components/common/BondingCurveChart.tsx b/src/components/common/BondingCurveChart.tsx index 419500a6..05a9a787 100644 --- a/src/components/common/BondingCurveChart.tsx +++ b/src/components/common/BondingCurveChart.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { ResponsiveContainer, LineChart, @@ -21,6 +21,7 @@ import { import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; import { formatCompactNumber } from '@/utils/numberFormat.utils'; import { useIsMobile } from '@/hooks/useIsMobile'; +import type { TwapWindow } from '@/hooks/useKeyTwap'; export interface BondingCurveDataPoint { supply: number; @@ -28,7 +29,7 @@ export interface BondingCurveDataPoint { isCurrent?: boolean; } -interface BondingCurveChartProps { +export interface BondingCurveChartProps { currentSupply?: number; currentPriceStroops?: number; buyQuantity?: number; @@ -37,21 +38,103 @@ interface BondingCurveChartProps { data?: BondingCurveDataPoint[]; height?: number | `${number}%`; width?: number | `${number}%`; + // TWAP price chart overlay props (#931) + twapPriceStroops?: number | null; + twapWindow?: TwapWindow; + onTwapWindowChange?: (window: TwapWindow) => void; + spotPriceStroops?: number | null; + showTwapControls?: boolean; + showTwapOverlay?: boolean; + onToggleOverlay?: (show: boolean) => void; + isTwapLoading?: boolean; } -const CustomTooltip = ({ active, payload }: { active?: boolean; payload?: Array<{ payload: { priceStroops: number; supply: number; label?: string } }> }) => { +export interface CustomTooltipProps { + active?: boolean; + payload?: Array<{ + payload: { + priceStroops: number; + supply: number; + label?: string; + twapPriceStroops?: number | null; + }; + }>; + twapPriceStroops?: number | null; + twapWindow?: TwapWindow; + spotPriceStroops?: number | null; + showTwapOverlay?: boolean; +} + +export const CustomTooltip: React.FC = ({ + active, + payload, + twapPriceStroops, + twapWindow = '24h', + spotPriceStroops, + showTwapOverlay = true, +}) => { if (!active || !payload || !payload.length) return null; const data = payload[0].payload; const priceDisplay = formatDisplayKeyPrice(data.priceStroops); const supplyDisplay = formatCompactNumber(data.supply); + // TWAP price resolution: prioritize prop, fallback to chart data point payload + const effectiveTwap = + twapPriceStroops !== undefined ? twapPriceStroops : (data.twapPriceStroops ?? null); + const hasTwap = showTwapOverlay && effectiveTwap != null && effectiveTwap > 0; + + // Reference spot price for deviation calculation: spotPriceStroops or current hovered point price + const referenceSpot = spotPriceStroops ?? data.priceStroops; + const twapDelta = hasTwap && referenceSpot ? effectiveTwap - referenceSpot : null; + const twapDeltaPercent = + twapDelta != null && referenceSpot > 0 + ? (twapDelta / referenceSpot) * 100 + : null; + return ( -
-

Supply: {supplyDisplay} keys

-

{priceDisplay}

+
+

Supply: {supplyDisplay} keys

+
+ Spot: + {priceDisplay} +
{data.label && ( -

{data.label}

+

{data.label}

+ )} + + {hasTwap && ( +
+
+ + TWAP ({twapWindow}): + + + {formatDisplayKeyPrice(effectiveTwap)} + +
+ {twapDeltaPercent != null && ( +
+ {twapDeltaPercent < 0 ? '▼' : '▲'}{' '} + {Math.abs(twapDeltaPercent).toFixed(2)}% vs spot +
+ )} +
)}
); @@ -66,6 +149,14 @@ const BondingCurveChart: React.FC = ({ data, height = 300, width = '100%', + twapPriceStroops, + twapWindow, + onTwapWindowChange, + spotPriceStroops, + showTwapControls, + showTwapOverlay, + onToggleOverlay, + isTwapLoading, }) => { // Mobile-tuned axis layout (#1055): at <768px the chart is ~315px wide // inside the page padding, so we use tighter margins, straight x-ticks @@ -77,12 +168,53 @@ const BondingCurveChart: React.FC = ({ : { top: 20, right: 30, left: 20, bottom: 60 }; const xAxisHeight = isMobile ? 24 : 60; + // TWAP overlay controls state + const [internalTwapWindow, setInternalTwapWindow] = useState('24h'); + const [internalOverlayVisible, setInternalOverlayVisible] = useState(true); + + const effectiveTwapWindow = twapWindow ?? internalTwapWindow; + const isOverlayVisible = + (showTwapOverlay ?? internalOverlayVisible) && twapPriceStroops != null; + + const handleTwapWindowChange = (win: TwapWindow) => { + if (onTwapWindowChange) { + onTwapWindowChange(win); + } else { + setInternalTwapWindow(win); + } + if (onToggleOverlay) { + onToggleOverlay(true); + } else { + setInternalOverlayVisible(true); + } + }; + + const handleToggleOverlay = () => { + const nextState = !isOverlayVisible; + if (onToggleOverlay) { + onToggleOverlay(nextState); + } else { + setInternalOverlayVisible(nextState); + } + }; + + const shouldShowTwapControls = + showTwapControls ?? (Boolean(onTwapWindowChange) || twapPriceStroops !== undefined); + // Use data array if provided, otherwise generate from supply/price - let chartData: Array<{ supply: number; priceStroops: number; priceXLM: number; isMilestone?: boolean; label?: string; isCurrent?: boolean }>; + let chartData: Array<{ + supply: number; + priceStroops: number; + priceXLM: number; + isMilestone?: boolean; + label?: string; + isCurrent?: boolean; + twapPriceStroops?: number; + }>; let priceImpactData: { currentPrice: number; newPrice: number; priceIncrease: number; priceIncreasePercent: number } | null = null; let currentMilestoneRange: { current: BondingCurveMilestone; next?: BondingCurveMilestone } | null = null; let effectiveCurrentSupply = currentSupply; - let effectiveCurrentPriceStroops = currentPriceStroops; + let effectiveCurrentPriceStroops = currentPriceStroops ?? spotPriceStroops; if (data && data.length > 0) { // Use provided data array (from upstream/dev implementation) @@ -91,6 +223,7 @@ const BondingCurveChart: React.FC = ({ priceStroops: point.priceXLM * 10_000_000, // Convert XLM to stroops priceXLM: point.priceXLM, isCurrent: point.isCurrent, + twapPriceStroops: isOverlayVisible ? (twapPriceStroops ?? undefined) : undefined, })); const currentPoint = data.find(d => d.isCurrent); if (currentPoint) { @@ -122,6 +255,7 @@ const BondingCurveChart: React.FC = ({ ...point, isMilestone: !!milestone, label: milestone?.label, + twapPriceStroops: isOverlayVisible ? (twapPriceStroops ?? undefined) : undefined, }; }); @@ -140,8 +274,99 @@ const BondingCurveChart: React.FC = ({ return (
+ {/* TWAP overlay controls (#931) */} + {shouldShowTwapControls && ( +
+
+ + TWAP Overlay: + + {twapPriceStroops != null && isOverlayVisible ? ( + + {formatDisplayKeyPrice(twapPriceStroops)} + + ) : isTwapLoading && twapPriceStroops === undefined ? ( + + Loading... + + ) : ( + + {twapPriceStroops == null ? 'Unavailable' : 'Hidden'} + + )} +
+ +
+
+ + +
+ + {twapPriceStroops != null && ( + + )} +
+
+ )} +
- + @@ -150,27 +375,36 @@ const BondingCurveChart: React.FC = ({ stroke="rgba(255, 255, 255, 0.1)" vertical={false} /> - formatCompactNumber(value)} - angle={isMobile ? 0 : -45} - textAnchor={isMobile ? 'middle' : 'end'} - height={xAxisHeight} - minTickGap={isMobile ? 32 : 16} - /> - formatDisplayKeyPrice(value)} - width={isMobile ? 62 : 80} - /> - } /> + formatCompactNumber(value)} + angle={isMobile ? 0 : -45} + textAnchor={isMobile ? 'middle' : 'end'} + height={xAxisHeight} + minTickGap={isMobile ? 32 : 16} + /> + formatDisplayKeyPrice(value)} + width={isMobile ? 62 : 80} + /> + + } + /> = ({ strokeWidth={2} dot={false} activeDot={{ r: 6, fill: '#f59e0b', stroke: '#1e293b', strokeWidth: 2 }} + name="Bonding Curve Price" /> + {/* TWAP overlay line (#931) */} + {isOverlayVisible && twapPriceStroops != null && ( + + )} {/* Current position marker */} {effectiveCurrentSupply !== undefined && ( = ({
+ {/* Chart Legend */} + {shouldShowTwapControls && ( +
+
+ + Bonding Curve (Spot) +
+ {isOverlayVisible && twapPriceStroops != null && ( +
+ + + TWAP ({effectiveTwapWindow}) + +
+ )} +
+ )} + {/* Legend and current position info - only for graduated curve */} {!data && effectiveCurrentSupply !== undefined && effectiveCurrentPriceStroops !== undefined && (
diff --git a/src/components/common/__tests__/BondingCurveChart.twap.test.tsx b/src/components/common/__tests__/BondingCurveChart.twap.test.tsx new file mode 100644 index 00000000..55c33933 --- /dev/null +++ b/src/components/common/__tests__/BondingCurveChart.twap.test.tsx @@ -0,0 +1,202 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import BondingCurveChart, { CustomTooltip } from '../BondingCurveChart'; + +describe('BondingCurveChart TWAP Overlay (#931)', () => { + const mockData = [ + { supply: 20, priceXLM: 0.1 }, + { supply: 40, priceXLM: 0.12 }, + { supply: 60, priceXLM: 0.15 }, + { supply: 80, priceXLM: 0.2 }, + ]; + + it('renders TWAP controls and legend when twapPriceStroops is provided', () => { + render( + + ); + + expect(screen.getByTestId('twap-controls')).toBeInTheDocument(); + expect(screen.getByTestId('twap-current-value')).toHaveTextContent(/0\.12 XLM/i); + expect(screen.getByTestId('twap-window-1h')).toBeInTheDocument(); + expect(screen.getByTestId('twap-window-24h')).toBeInTheDocument(); + expect(screen.getByTestId('twap-window-24h')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByTestId('twap-window-1h')).toHaveAttribute('aria-pressed', 'false'); + + // Legend indicator + expect(screen.getByTestId('chart-legend')).toBeInTheDocument(); + expect(screen.getByTestId('twap-legend-indicator')).toHaveTextContent(/TWAP \(24h\)/i); + }); + + it('switches between 1h and 24h windows when toggle buttons are clicked', () => { + const onTwapWindowChange = vi.fn(); + + render( + + ); + + const button1h = screen.getByTestId('twap-window-1h'); + fireEvent.click(button1h); + expect(onTwapWindowChange).toHaveBeenCalledWith('1h'); + + const button24h = screen.getByTestId('twap-window-24h'); + fireEvent.click(button24h); + expect(onTwapWindowChange).toHaveBeenCalledWith('24h'); + }); + + it('handles unavailable TWAP state gracefully with hidden overlay', () => { + render( + + ); + + expect(screen.getByTestId('twap-controls')).toBeInTheDocument(); + expect(screen.getByTestId('twap-status-unavailable')).toHaveTextContent(/Unavailable/i); + expect(screen.queryByTestId('twap-legend-indicator')).not.toBeInTheDocument(); + expect(screen.queryByTestId('twap-current-value')).not.toBeInTheDocument(); + }); + + it('allows toggling overlay visibility on and off', () => { + render( + + ); + + const toggleBtn = screen.getByTestId('twap-toggle-visibility'); + expect(toggleBtn).toHaveTextContent(/Hide/i); + expect(screen.getByTestId('twap-legend-indicator')).toBeInTheDocument(); + + // Click to hide + fireEvent.click(toggleBtn); + expect(toggleBtn).toHaveTextContent(/Show/i); + expect(screen.queryByTestId('twap-legend-indicator')).not.toBeInTheDocument(); + expect(screen.getByTestId('twap-status-unavailable')).toHaveTextContent(/Hidden/i); + + // Click to show again + fireEvent.click(toggleBtn); + expect(toggleBtn).toHaveTextContent(/Hide/i); + expect(screen.getByTestId('twap-legend-indicator')).toBeInTheDocument(); + }); + + describe('CustomTooltip', () => { + it('renders spot price, supply, and TWAP with deviation percentage when TWAP is lower than spot', () => { + const payload = [ + { + payload: { + priceStroops: 20_000_000, // 2 XLM + supply: 100, + twapPriceStroops: 18_000_000, // 1.8 XLM (-10%) + }, + }, + ]; + + render( + + ); + + expect(screen.getByTestId('bonding-curve-tooltip')).toBeInTheDocument(); + expect(screen.getByText(/Supply: 100 keys/i)).toBeInTheDocument(); + expect(screen.getByText(/2 XLM/i)).toBeInTheDocument(); + + // TWAP section + expect(screen.getByTestId('twap-tooltip-section')).toBeInTheDocument(); + expect(screen.getByTestId('twap-tooltip-label')).toHaveTextContent('TWAP (1h):'); + expect(screen.getByTestId('twap-tooltip-price')).toHaveTextContent(/1\.8 XLM/i); + expect(screen.getByTestId('twap-tooltip-deviation')).toHaveTextContent(/▼ 10\.00% vs spot/i); + }); + + it('renders positive deviation percentage when TWAP is higher than spot', () => { + const payload = [ + { + payload: { + priceStroops: 10_000_000, // 1 XLM + supply: 50, + }, + }, + ]; + + render( + + ); + + expect(screen.getByTestId('twap-tooltip-label')).toHaveTextContent('TWAP (24h):'); + expect(screen.getByTestId('twap-tooltip-price')).toHaveTextContent(/1\.15 XLM/i); + expect(screen.getByTestId('twap-tooltip-deviation')).toHaveTextContent(/▲ 15\.00% vs spot/i); + }); + + it('hides TWAP section in tooltip when TWAP is unavailable or overlay is disabled', () => { + const payload = [ + { + payload: { + priceStroops: 10_000_000, + supply: 50, + }, + }, + ]; + + const { rerender } = render( + + ); + + expect(screen.queryByTestId('twap-tooltip-section')).not.toBeInTheDocument(); + + rerender( + + ); + + expect(screen.queryByTestId('twap-tooltip-section')).not.toBeInTheDocument(); + }); + + it('returns null when active is false or payload is empty', () => { + const { container, rerender } = render(); + expect(container.firstChild).toBeNull(); + + rerender(); + expect(container.firstChild).toBeNull(); + }); + }); +}); diff --git a/src/hooks/__tests__/useKeyTwap.test.tsx b/src/hooks/__tests__/useKeyTwap.test.tsx new file mode 100644 index 00000000..6df3b036 --- /dev/null +++ b/src/hooks/__tests__/useKeyTwap.test.tsx @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode } from 'react'; +import { useKeyTwap } from '../useKeyTwap'; +import { courseService } from '@/services/course.service'; + +vi.mock('@/services/course.service', () => ({ + courseService: { + getKeyTwap: vi.fn(), + }, +})); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +describe('useKeyTwap (#931)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should query for 24h TWAP by default with 60s staleTime and 60s refetchInterval', async () => { + vi.mocked(courseService.getKeyTwap).mockResolvedValueOnce({ + priceStroops: 12_500_000, + window: '24h', + }); + + const { result } = renderHook(() => useKeyTwap('creator-1'), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(courseService.getKeyTwap).toHaveBeenCalledWith('creator-1', '24h'); + expect(result.current.data?.priceStroops).toBe(12_500_000); + }); + + it('should query for 1h TWAP when specified', async () => { + vi.mocked(courseService.getKeyTwap).mockResolvedValueOnce({ + priceStroops: 13_000_000, + window: '1h', + }); + + const { result } = renderHook(() => useKeyTwap('creator-1', '1h'), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(courseService.getKeyTwap).toHaveBeenCalledWith('creator-1', '1h'); + expect(result.current.data?.priceStroops).toBe(13_000_000); + }); + + it('should not query when keyId is empty', () => { + renderHook(() => useKeyTwap(''), { + wrapper: createWrapper(), + }); + + expect(courseService.getKeyTwap).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useKeyTwap.ts b/src/hooks/useKeyTwap.ts index 6dd8f504..5d41b6a8 100644 --- a/src/hooks/useKeyTwap.ts +++ b/src/hooks/useKeyTwap.ts @@ -2,12 +2,15 @@ import { useQuery } from '@tanstack/react-query'; import { queryKeys } from '@/lib/queryKeys'; import { courseService } from '@/services/course.service'; -export function useKeyTwap(keyId: string) { +export type TwapWindow = '1h' | '24h'; + +export function useKeyTwap(keyId: string, window: TwapWindow = '24h') { return useQuery({ - queryKey: queryKeys.creators.twap(keyId), - queryFn: () => courseService.getKeyTwap(keyId), + queryKey: queryKeys.creators.twap(keyId, window), + queryFn: () => courseService.getKeyTwap(keyId, window), enabled: !!keyId, staleTime: 60_000, + refetchInterval: 60_000, retry: false, }); } diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index d7b8d133..23ed31c9 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -31,8 +31,8 @@ export const queryKeys = { ['creators', creatorId, 'holders'] as const, activity: (creatorId: string) => ['creators', creatorId, 'activity'] as const, - twap: (creatorId: string) => - ['creators', creatorId, 'twap', '24h'] as const, + twap: (creatorId: string, window = '24h') => + ['creators', creatorId, 'twap', window] as const, stats: (creatorId: string) => ['creators', creatorId, 'stats'] as const, auctionBids: (creatorId: string) => ['creators', creatorId, 'auction-bids'] as const, diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index fc767eb1..2e477154 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -56,7 +56,7 @@ import showToast from '@/utils/toast.util'; import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; import { usePurchaseConfetti } from '@/hooks/usePurchaseConfetti'; import { useDocumentTitle } from '@/hooks/useDocumentTitle'; -import { useKeyTwap } from '@/hooks/useKeyTwap'; +import { useKeyTwap, type TwapWindow } from '@/hooks/useKeyTwap'; import { useKeyStats } from '@/hooks/useKeyStats'; import { useKeyUniqueTraders } from '@/hooks/useKeyUniqueTraders'; import { useKeyConfig } from '@/hooks/useKeyConfig'; @@ -156,7 +156,8 @@ function CreatorDetailPageContent() { const nextBuyAllowedAt = userPosition?.nextBuyAllowedAt ?? creator?.nextBuyAllowedAt ?? null; - const { data: twap, isLoading: isTwapLoading } = useKeyTwap(id || ''); + const [twapWindow, setTwapWindow] = useState('24h'); + const { data: twap, isLoading: isTwapLoading } = useKeyTwap(id || '', twapWindow); const { data: keyStats, @@ -714,7 +715,7 @@ function CreatorDetailPageContent() { className="rounded-2xl border border-white/10 bg-white/[0.03] px-5 py-4" data-testid="twap-price" > -
+
@@ -736,13 +737,13 @@ function CreatorDetailPageContent() { : '' } > - TWAP (24h) + TWAP ({twapWindow}) - +
diff --git a/src/pages/__tests__/CreatorDetailPage.twapOverlay.integration.test.tsx b/src/pages/__tests__/CreatorDetailPage.twapOverlay.integration.test.tsx new file mode 100644 index 00000000..422c34f5 --- /dev/null +++ b/src/pages/__tests__/CreatorDetailPage.twapOverlay.integration.test.tsx @@ -0,0 +1,175 @@ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import CreatorDetailPage from '../CreatorDetailPage'; +import { useCreatorDetail } from '@/hooks/useCreators'; +import { courseService } from '@/services/course.service'; +import { MemoryRouter, Route, Routes } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +vi.mock('@/hooks/useCreators', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + useCreatorDetail: vi.fn(), + useSetCoCreator: vi.fn(() => ({ mutate: vi.fn(), isPending: false })), + }; +}); + +vi.mock('@/hooks/useCreatorProfileStaleIndicator', () => ({ + useCreatorProfileStaleIndicator: () => ({ + shouldShowBadge: false, + handleRefetch: vi.fn(), + }), +})); + +vi.mock('@/hooks/useNavigationTiming', () => ({ + useNavigationTiming: vi.fn(), +})); + +vi.mock('@/services/course.service', () => ({ + courseService: { + getKeyTwap: vi.fn(), + getHoldersPage: vi.fn().mockResolvedValue({ holders: [], nextCursor: null }), + getKeyStats: vi.fn().mockResolvedValue({ + supply: 100, + holderCount: 10, + volume24h: 0, + totalVolume: 0, + twap1h: null, + twap24h: null, + }), + getKeyUniqueTraders: vi.fn().mockResolvedValue({ + uniqueTraders: 10, + uniqueTraders24hAgo: 8, + }), + getKeyConfig: vi.fn().mockResolvedValue({ + buyPriceStroops: 10_000_000, + sellPriceStroops: 9_500_000, + spreadStroops: 500_000, + spreadBps: 500, + }), + getKeyOraclePrice: vi.fn().mockResolvedValue({ + priceStroops: 10_000_000, + updatedAt: new Date().toISOString(), + source: 'OracleFeed', + }), + getKeyPerformanceBond: vi.fn().mockResolvedValue(null), + getContractPausedStatus: vi.fn().mockResolvedValue(false), + }, +})); + +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + +describe('CreatorDetailPage TWAP Overlay Integration (#931)', () => { + const mockCreator = { + id: 'creator-1', + title: 'Creator One', + description: 'Creator One Bio', + price: 1.0, + priceStroops: 10_000_000, + instructorId: 'inst-1', + socialHandle: 'creatorone', + isVerified: true, + thumbnail: 'https://example.com/avatar.jpg', + category: 'Tech', + level: 'BEGINNER' as const, + creatorFeeBps: 250, + protocolFeeBps: 250, + creatorShareSupply: 100, + volume24h: 10_000_000, + priceHistory: [10_000_000, 12_000_000, 15_000_000], + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useCreatorDetail).mockReturnValue({ + data: mockCreator, + isLoading: false, + isError: false, + error: null, + refetch: vi.fn(), + } as unknown as ReturnType); + }); + + it('renders TWAP overlay controls and toggles between 1h and 24h windows on the price chart', async () => { + vi.mocked(courseService.getKeyTwap).mockImplementation(async (_keyId, window) => { + if (window === '1h') { + return { priceStroops: 11_000_000, window: '1h' }; + } + return { priceStroops: 10_500_000, window: '24h' }; + }); + + render( + + + + } /> + + + + ); + + // Wait for chart container and initial 24h TWAP + await waitFor(() => { + expect(screen.getByTestId('creator-chart-container')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByTestId('twap-controls')).toBeInTheDocument(); + }); + + // Check 24h button is pressed by default + const button24h = screen.getByTestId('twap-window-24h'); + const button1h = screen.getByTestId('twap-window-1h'); + expect(button24h).toHaveAttribute('aria-pressed', 'true'); + expect(button1h).toHaveAttribute('aria-pressed', 'false'); + + // Click 1H toggle + fireEvent.click(button1h); + + // Verify 1h window request was made + await waitFor(() => { + expect(courseService.getKeyTwap).toHaveBeenCalledWith('creator-1', '1h'); + }); + + await waitFor(() => { + expect(button1h).toHaveAttribute('aria-pressed', 'true'); + expect(button24h).toHaveAttribute('aria-pressed', 'false'); + }); + }); + + it('handles unavailable TWAP state gracefully without chart disruption', async () => { + vi.mocked(courseService.getKeyTwap).mockResolvedValueOnce({ + priceStroops: null, + window: '24h', + }); + + render( + + + + } /> + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('creator-chart-container')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByTestId('twap-status-unavailable')).toHaveTextContent(/Unavailable/i); + }); + + // The chart still renders cleanly + expect(screen.getByTestId('creator-chart-container')).toBeInTheDocument(); + }); +});