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()
);
});
});