import React, { useRef } from 'react'; import ReactDOM from 'react-dom'; import { act } from 'react-dom/test-utils'; import '@testing-library/jest-dom'; import { useLiveMinuteUpdater } from '../useLiveMinuteUpdater'; class MockMutationObserver { observe = jest.fn(); disconnect = jest.fn(); takeRecords = jest.fn().mockReturnValue([]); constructor(public callback: MutationCallback) {} } (global as any).MutationObserver = MockMutationObserver; const mockFetch = jest.fn(); (global as any).fetch = mockFetch; describe('useLiveMinuteUpdater', () => { const Component: React.FC<{ children?: React.ReactNode; }> = ({ children }) => { const ref = useRef(null); useLiveMinuteUpdater({ ref }); return
{children}
; }; let container: HTMLDivElement; beforeEach(() => { jest.useFakeTimers(); mockFetch.mockReset(); container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { ReactDOM.unmountComponentAtNode(container); container.remove(); jest.useRealTimers(); }); const createFixtureHtml = ( matchId: string, period: string, timeText: string ) => `
${timeText}
`; const mockMomentumResponse = (matchTime: number) => ({ ok: true, json: () => Promise.resolve({ liveData: { matchDetails: { matchStatus: 'Playing', matchTime } } }) }); it('does not fetch for non-live fixtures (PreMatch)', () => { act(() => { ReactDOM.render(
, container ); }); act(() => { jest.advanceTimersByTime(5000); }); expect(mockFetch).not.toHaveBeenCalled(); }); it('does not fetch for FullTime fixtures', () => { act(() => { ReactDOM.render(
, container ); }); act(() => { jest.advanceTimersByTime(5000); }); expect(mockFetch).not.toHaveBeenCalled(); }); it('does not fetch for HalfTime fixtures', () => { act(() => { ReactDOM.render(
, container ); }); act(() => { jest.advanceTimersByTime(5000); }); expect(mockFetch).not.toHaveBeenCalled(); }); it('fetches momentum data for FirstHalf fixtures and updates the DOM', async () => { mockFetch.mockResolvedValue(mockMomentumResponse(23)); act(() => { ReactDOM.render(
, container ); }); // Advance past init interval to detect live fixtures act(() => { jest.advanceTimersByTime(500); }); // Wait for fetch to resolve await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(mockFetch).toHaveBeenCalledWith( '/api/match-stats/momentum/2608241/fixture.json', expect.objectContaining({ signal: expect.any(AbortSignal) }) ); const abbrEl = container.querySelector('.Opta-Time abbr'); expect(abbrEl).toHaveTextContent("23'"); expect(abbrEl).toHaveAttribute('title', '23 minutes'); }); it('fetches momentum data for SecondHalf fixtures', async () => { mockFetch.mockResolvedValue(mockMomentumResponse(67)); act(() => { ReactDOM.render(
, container ); }); act(() => { jest.advanceTimersByTime(500); }); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(mockFetch).toHaveBeenCalledWith( '/api/match-stats/momentum/2608242/fixture.json', expect.objectContaining({ signal: expect.any(AbortSignal) }) ); const abbrEl = container.querySelector('.Opta-Time abbr'); expect(abbrEl).toHaveTextContent("67'"); }); it('handles multiple live fixtures simultaneously', async () => { mockFetch.mockImplementation((url: string) => { if (url.includes('111')) { return Promise.resolve(mockMomentumResponse(12)); } if (url.includes('222')) { return Promise.resolve(mockMomentumResponse(55)); } return Promise.resolve({ ok: false }); }); const html = createFixtureHtml('111', 'FirstHalf', '1H') + createFixtureHtml('222', 'SecondHalf', '2H') + createFixtureHtml('333', 'FullTime', 'FT'); act(() => { ReactDOM.render(
, container ); }); act(() => { jest.advanceTimersByTime(500); }); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); // Should fetch for live matches only expect(mockFetch).toHaveBeenCalledTimes(2); const abbrs = container.querySelectorAll('.Opta-Time abbr'); expect(abbrs[0]).toHaveTextContent("12'"); expect(abbrs[1]).toHaveTextContent("55'"); // FullTime fixture should remain unchanged expect(abbrs[2]).toHaveTextContent('FT'); }); it('does not update DOM when fetch fails', async () => { mockFetch.mockResolvedValue({ ok: false }); act(() => { ReactDOM.render(
, container ); }); act(() => { jest.advanceTimersByTime(500); }); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); const abbrEl = container.querySelector('.Opta-Time abbr'); expect(abbrEl).toHaveTextContent('1H'); }); it('does not update DOM when matchTime is missing from feed', async () => { mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ liveData: { matchDetails: {} } }) }); act(() => { ReactDOM.render(
, container ); }); act(() => { jest.advanceTimersByTime(500); }); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); const abbrEl = container.querySelector('.Opta-Time abbr'); expect(abbrEl).toHaveTextContent('1H'); }); it('polls every 30 seconds after initial fetch', async () => { mockFetch.mockResolvedValue(mockMomentumResponse(10)); act(() => { ReactDOM.render(
, container ); }); // Initial detection act(() => { jest.advanceTimersByTime(500); }); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(mockFetch).toHaveBeenCalledTimes(1); // Advance 30s for next poll mockFetch.mockResolvedValue(mockMomentumResponse(11)); act(() => { jest.advanceTimersByTime(30000); }); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); expect(mockFetch).toHaveBeenCalledTimes(2); const abbrEl = container.querySelector('.Opta-Time abbr'); expect(abbrEl).toHaveTextContent("11'"); }); it('cleans up on unmount (aborts fetch, clears intervals)', async () => { mockFetch.mockResolvedValue(mockMomentumResponse(10)); act(() => { ReactDOM.render(
, container ); }); act(() => { jest.advanceTimersByTime(500); }); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); // Unmount act(() => { ReactDOM.unmountComponentAtNode(container); }); mockFetch.mockClear(); // Advance time — should not trigger any more fetches act(() => { jest.advanceTimersByTime(60000); }); expect(mockFetch).not.toHaveBeenCalled(); }); it('uses matchLengthMin as fallback when matchTime is undefined', async () => { mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ liveData: { matchDetails: { matchStatus: 'Playing', matchLengthMin: 34 } } }) }); act(() => { ReactDOM.render(
, container ); }); act(() => { jest.advanceTimersByTime(500); }); await act(async () => { await Promise.resolve(); await Promise.resolve(); }); const abbrEl = container.querySelector('.Opta-Time abbr'); expect(abbrEl).toHaveTextContent("34'"); }); });