import React from 'react'; import '@testing-library/jest-dom'; import { render, screen, waitFor, fireEvent, act } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import FeedItems from './FeedItems'; describe('FeedItems Component', () => { beforeEach(() => { vi.resetAllMocks(); global.fetch = vi.fn(); window.HTMLElement.prototype.scrollIntoView = vi.fn(); // Mock IntersectionObserver class MockIntersectionObserver { observe = vi.fn(); unobserve = vi.fn(); disconnect = vi.fn(); } // eslint-disable-next-line @typescript-eslint/no-explicit-any window.IntersectionObserver = MockIntersectionObserver as any; }); it('renders loading state', () => { vi.mocked(global.fetch).mockImplementation(() => new Promise(() => { })); render( } /> ); expect(screen.getByText(/loading items/i)).toBeInTheDocument(); }); it('renders items for a feed', async () => { const mockItems = [ { _id: 101, title: 'Item One', url: 'http://example.com/1', publish_date: '2023-01-01', read: false, }, { _id: 102, title: 'Item Two', url: 'http://example.com/2', publish_date: '2023-01-02', read: true, }, ]; vi.mocked(global.fetch).mockResolvedValueOnce({ ok: true, json: async () => mockItems, } as Response); render( } /> ); await waitFor(() => { expect(screen.getByText('Item One')).toBeInTheDocument(); }); const params = new URLSearchParams(); params.append('feed_id', '1'); params.append('read_filter', 'unread'); expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`, expect.anything()); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); it('marks items as read when scrolled past', async () => { const mockItems = [{ _id: 101, title: 'Item 1', url: 'u1', read: false, starred: false }]; vi.mocked(global.fetch).mockResolvedValue({ ok: true, json: async () => mockItems, } as Response); // Mock getBoundingClientRect const getBoundingClientRectMock = vi.spyOn(Element.prototype, 'getBoundingClientRect'); getBoundingClientRectMock.mockImplementation(function (this: Element) { if (this.classList && this.classList.contains('dashboard-main')) { return { top: 0, bottom: 500, height: 500, left: 0, right: 1000, width: 1000, x: 0, y: 0, toJSON: () => { } } as DOMRect; } if (this.id && this.id.startsWith('item-')) { // Item top is -50 (above container top 0) return { top: -150, bottom: -50, height: 100, left: 0, right: 1000, width: 1000, x: 0, y: 0, toJSON: () => { } } as DOMRect; } return { top: 0, bottom: 0, height: 0, left: 0, right: 0, width: 0, x: 0, y: 0, toJSON: () => { } } as DOMRect; }); render(
); // Initial load fetch await waitFor(() => { expect(screen.getByText('Item 1')).toBeVisible(); }); // Trigger scroll const container = document.querySelector('.dashboard-main'); expect(container).not.toBeNull(); act(() => { // Dispatch scroll event fireEvent.scroll(container!); }); // Wait for throttle (500ms) + buffer await new Promise(r => setTimeout(r, 600)); await waitFor(() => { expect(global.fetch).toHaveBeenCalledWith( '/api/item/101', expect.objectContaining({ method: 'PUT', body: JSON.stringify({ read: true, starred: false }), }) ); }); }); it('loads more items when sentinel becomes visible', async () => { const initialItems = [{ _id: 101, title: 'Item 1', url: 'u1', read: true, starred: false }]; const moreItems = [{ _id: 100, title: 'Item 0', url: 'u0', read: true, starred: false }]; vi.mocked(global.fetch) .mockResolvedValueOnce({ ok: true, json: async () => initialItems } as Response) .mockResolvedValueOnce({ ok: true, json: async () => moreItems } as Response); const observerCallbacks: IntersectionObserverCallback[] = []; class MockIntersectionObserver { constructor(callback: IntersectionObserverCallback) { observerCallbacks.push(callback); } observe = vi.fn(); unobserve = vi.fn(); disconnect = vi.fn(); } // eslint-disable-next-line @typescript-eslint/no-explicit-any window.IntersectionObserver = MockIntersectionObserver as any; render( ); await waitFor(() => { expect(screen.getByText('Item 1')).toBeInTheDocument(); }); const entry = { isIntersecting: true, target: { id: 'load-more-sentinel' } as unknown as Element, boundingClientRect: {} as DOMRectReadOnly, intersectionRatio: 1, time: 0, rootBounds: null, intersectionRect: {} as DOMRectReadOnly, } as IntersectionObserverEntry; act(() => { // Trigger all observers observerCallbacks.forEach(cb => cb([entry], {} as IntersectionObserver)); }); await waitFor(() => { expect(screen.getByText('Item 0')).toBeInTheDocument(); const params = new URLSearchParams(); params.append('max_id', '101'); params.append('read_filter', 'unread'); // Verify the second fetch call content expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining('max_id=101'), expect.anything() ); }); }); it('loads more items when pressing j on last item', async () => { const initialItems = [ { _id: 103, title: 'Item 3', url: 'u3', read: true, starred: false }, { _id: 102, title: 'Item 2', url: 'u2', read: true, starred: false }, { _id: 101, title: 'Item 1', url: 'u1', read: true, starred: false }, ]; const moreItems = [ { _id: 100, title: 'Item 0', url: 'u0', read: true, starred: false }, ]; vi.mocked(global.fetch) .mockResolvedValueOnce({ ok: true, json: async () => initialItems } as Response) .mockResolvedValueOnce({ ok: true, json: async () => moreItems } as Response); render( ); await waitFor(() => { expect(screen.getByText('Item 1')).toBeInTheDocument(); }); fireEvent.keyDown(window, { key: 'j' }); // index 0 await waitFor(() => expect(document.getElementById('item-0')).toHaveAttribute('data-selected', 'true')); fireEvent.keyDown(window, { key: 'j' }); // index 1 await waitFor(() => expect(document.getElementById('item-1')).toHaveAttribute('data-selected', 'true')); fireEvent.keyDown(window, { key: 'j' }); // index 2 (last item) await waitFor(() => expect(document.getElementById('item-2')).toHaveAttribute('data-selected', 'true')); await waitFor(() => { expect(screen.getByText('Item 0')).toBeInTheDocument(); }); // Check fetch call expect(global.fetch).toHaveBeenCalledWith( expect.stringContaining('max_id=101'), expect.anything() ); }); });