aboutsummaryrefslogtreecommitdiffstats
path: root/frontend/src/components/FeedItems.test.tsx
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-14 08:58:38 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-14 08:58:38 -0800
commite3c379d069ffa9661561d25cdbf2f5894a2f8ee8 (patch)
tree24d0e9f5610dd9c8f873c5b78e6bc1c88d32840a /frontend/src/components/FeedItems.test.tsx
parent4b06155fbde91a1bef6361ef36efb28789861928 (diff)
downloadneko-e3c379d069ffa9661561d25cdbf2f5894a2f8ee8.tar.gz
neko-e3c379d069ffa9661561d25cdbf2f5894a2f8ee8.tar.bz2
neko-e3c379d069ffa9661561d25cdbf2f5894a2f8ee8.zip
Refactor: project structure, implement dependency injection, and align v2 UI with v1
Diffstat (limited to 'frontend/src/components/FeedItems.test.tsx')
-rw-r--r--frontend/src/components/FeedItems.test.tsx431
1 files changed, 226 insertions, 205 deletions
diff --git a/frontend/src/components/FeedItems.test.tsx b/frontend/src/components/FeedItems.test.tsx
index ea68a7c..4d96da9 100644
--- a/frontend/src/components/FeedItems.test.tsx
+++ b/frontend/src/components/FeedItems.test.tsx
@@ -6,220 +6,241 @@ 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;
+ 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(
+ <MemoryRouter initialEntries={['/feed/1']}>
+ <Routes>
+ <Route path="/feed/:feedId" element={<FeedItems />} />
+ </Routes>
+ </MemoryRouter>
+ );
+ 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,
});
- it('renders loading state', () => {
- (global.fetch as any).mockImplementation(() => new Promise(() => { }));
- render(
- <MemoryRouter initialEntries={['/feed/1']}>
- <Routes>
- <Route path="/feed/:feedId" element={<FeedItems />} />
- </Routes>
- </MemoryRouter>
- );
- expect(screen.getByText(/loading items/i)).toBeInTheDocument();
+ render(
+ <MemoryRouter initialEntries={['/feed/1']}>
+ <Routes>
+ <Route path="/feed/:feedId" element={<FeedItems />} />
+ </Routes>
+ </MemoryRouter>
+ );
+
+ 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()}`);
+ });
+
+ 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(
+ <MemoryRouter>
+ <FeedItems />
+ </MemoryRouter>
+ );
+
+ 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 }),
+ })
+ );
+ });
+
+ // 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 }), // toggled to true
+ })
+ );
});
+ });
- 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(
- <MemoryRouter initialEntries={['/feed/1']}>
- <Routes>
- <Route path="/feed/:feedId" element={<FeedItems />} />
- </Routes>
- </MemoryRouter>
- );
-
- 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()}`);
+ 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,
});
- 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(
- <MemoryRouter>
- <FeedItems />
- </MemoryRouter>
- );
-
- 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 }),
- }));
- });
-
- // 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 }), // toggled to true
- }));
- });
+ // Capture the callback
+ let observerCallback: IntersectionObserverCallback = () => {};
+
+ // Override the mock to capture callback
+ class MockIntersectionObserver {
+ constructor(callback: IntersectionObserverCallback) {
+ observerCallback = callback;
+ }
+ observe = vi.fn();
+ unobserve = vi.fn();
+ disconnect = vi.fn();
+ }
+ window.IntersectionObserver = MockIntersectionObserver as any;
+
+ render(
+ <MemoryRouter>
+ <FeedItems />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Item 1')).toBeVisible();
+ });
+
+ // 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;
+
+ // Use vi.waitUntil to wait for callback to be assigned if needed,
+ // though strictly synchronous render + effect should do it.
+ // Direct call:
+ act(() => {
+ observerCallback([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 });
+
+ let observerCallback: IntersectionObserverCallback = () => {};
+ class MockIntersectionObserver {
+ constructor(callback: IntersectionObserverCallback) {
+ observerCallback = callback;
+ }
+ observe = vi.fn();
+ unobserve = vi.fn();
+ disconnect = vi.fn();
+ }
+ window.IntersectionObserver = MockIntersectionObserver as any;
+
+ render(
+ <MemoryRouter>
+ <FeedItems />
+ </MemoryRouter>
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Item 1')).toBeInTheDocument();
});
- 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 the callback
- let observerCallback: IntersectionObserverCallback = () => { };
-
- // Override the mock to capture callback
- class MockIntersectionObserver {
- constructor(callback: IntersectionObserverCallback) {
- observerCallback = callback;
- }
- observe = vi.fn();
- unobserve = vi.fn();
- disconnect = vi.fn();
- }
- window.IntersectionObserver = MockIntersectionObserver as any;
-
- render(
- <MemoryRouter>
- <FeedItems />
- </MemoryRouter>
- );
-
- await waitFor(() => {
- expect(screen.getByText('Item 1')).toBeVisible();
- });
-
- // 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;
-
- // Use vi.waitUntil to wait for callback to be assigned if needed,
- // though strictly synchronous render + effect should do it.
- // Direct call:
- act(() => {
- observerCallback([entry], {} as IntersectionObserver);
- });
-
- await waitFor(() => {
- expect(global.fetch).toHaveBeenCalledWith('/api/item/101', expect.objectContaining({
- method: 'PUT',
- body: JSON.stringify({ read: true, starred: false }),
- }));
- });
+ // 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;
+
+ act(() => {
+ observerCallback([entry], {} as IntersectionObserver);
});
- 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 });
-
- let observerCallback: IntersectionObserverCallback = () => { };
- class MockIntersectionObserver {
- constructor(callback: IntersectionObserverCallback) {
- observerCallback = callback;
- }
- observe = vi.fn();
- unobserve = vi.fn();
- disconnect = vi.fn();
- }
- window.IntersectionObserver = MockIntersectionObserver as any;
-
- render(
- <MemoryRouter>
- <FeedItems />
- </MemoryRouter>
- );
-
- await waitFor(() => {
- expect(screen.getByText('Item 1')).toBeInTheDocument();
- });
-
- // 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;
-
- act(() => {
- observerCallback([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()}`);
- });
+ 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()}`);
});
+ });
});