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 } 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();
}
window.IntersectionObserver = MockIntersectionObserver as any;
});
it('renders loading state', () => {
(global.fetch as any).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,
},
];
(global.fetch as any).mockResolvedValueOnce({
ok: true,
json: async () => mockItems,
});
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());
});
it('handles keyboard shortcuts', async () => {
const mockItems = [
{ _id: 101, title: 'Item 1', url: 'u1', read: false, starred: false },
{ _id: 102, title: 'Item 2', url: 'u2', read: true, starred: false },
];
(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => mockItems,
});
render(
);
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeVisible();
});
// Press 'j' to select first item (index 0 -> 1 because it starts at -1... wait logic says min(prev+1))
// init -1. j -> 0.
fireEvent.keyDown(window, { key: 'j' });
// Item 1 (index 0) should be selected.
// It's unread, so it should be marked read.
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
'/api/item/101',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ read: true, starred: false }),
credentials: 'include',
})
);
});
// Press 'j' again -> index 1 (Item 2)
fireEvent.keyDown(window, { key: 'j' });
// Item 2 is already read, so no markRead call expected for it (mocks clear? no).
// let's check selection class if possible, but testing library doesn't easily check class on div wrapper unless we query it.
// Press 's' to star Item 2
fireEvent.keyDown(window, { key: 's' });
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
'/api/item/102',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ read: true, starred: true }),
credentials: 'include', // toggled to true
})
);
});
});
it('marks items as read when scrolled past', async () => {
const mockItems = [{ _id: 101, title: 'Item 1', url: 'u1', read: false, starred: false }];
(global.fetch as any).mockResolvedValue({
ok: true,
json: async () => mockItems,
});
// Capture both callbacks
const observerCallbacks: IntersectionObserverCallback[] = [];
// Override the mock to capture both callbacks
class MockIntersectionObserver {
constructor(callback: IntersectionObserverCallback) {
observerCallbacks.push(callback);
}
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
window.IntersectionObserver = MockIntersectionObserver as any;
render(
);
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeVisible();
});
// Wait for observers to be created
await waitFor(() => {
expect(observerCallbacks.length).toBeGreaterThan(0);
});
// Simulate item leaving viewport at the top
// Element index is 0
const entry = {
isIntersecting: false,
boundingClientRect: { top: -50 } as DOMRectReadOnly,
target: { getAttribute: () => '0' } as unknown as Element,
intersectionRatio: 0,
time: 0,
rootBounds: null,
intersectionRect: {} as DOMRectReadOnly,
} as IntersectionObserverEntry;
// Call the last itemObserver (second-to-last in the array, since sentinelObserver is last)
act(() => {
const lastItemObserver = observerCallbacks[observerCallbacks.length - 2];
lastItemObserver([entry], {} as IntersectionObserver);
});
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 }];
(global.fetch as any)
.mockResolvedValueOnce({ ok: true, json: async () => initialItems })
.mockResolvedValueOnce({ ok: true, json: async () => moreItems });
const observerCallbacks: IntersectionObserverCallback[] = [];
class MockIntersectionObserver {
constructor(callback: IntersectionObserverCallback) {
observerCallbacks.push(callback);
}
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
window.IntersectionObserver = MockIntersectionObserver as any;
render(
);
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Wait for observers to be created (effect runs multiple times)
await waitFor(() => {
expect(observerCallbacks.length).toBeGreaterThan(0);
});
// Simulate sentinel becoming visible
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;
// Call the last sentinelObserver (second of the last pair created)
act(() => {
const lastSentinelObserver = observerCallbacks[observerCallbacks.length - 1];
lastSentinelObserver([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');
expect(global.fetch).toHaveBeenCalledWith(`/api/stream?${params.toString()}`, expect.anything());
});
});
});