aboutsummaryrefslogtreecommitdiffstats
path: root/frontend/src/components/FeedItems.test.tsx
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-13 10:04:17 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-13 10:04:17 -0800
commit5669961d674b2764082c7c9585484cb090b71e45 (patch)
tree12cb5fadb849ecb725e7484e3717b9740800e929 /frontend/src/components/FeedItems.test.tsx
parent23a48e1d498680be769e931f46ddb1fd44f38d1a (diff)
downloadneko-5669961d674b2764082c7c9585484cb090b71e45.tar.gz
neko-5669961d674b2764082c7c9585484cb090b71e45.tar.bz2
neko-5669961d674b2764082c7c9585484cb090b71e45.zip
Implement frontend parity features: Unread view, shortcuts, scroll-to-read, filters
Diffstat (limited to 'frontend/src/components/FeedItems.test.tsx')
-rw-r--r--frontend/src/components/FeedItems.test.tsx128
1 files changed, 125 insertions, 3 deletions
diff --git a/frontend/src/components/FeedItems.test.tsx b/frontend/src/components/FeedItems.test.tsx
index 9c271c4..00118fa 100644
--- a/frontend/src/components/FeedItems.test.tsx
+++ b/frontend/src/components/FeedItems.test.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import '@testing-library/jest-dom';
-import { render, screen, waitFor } from '@testing-library/react';
+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';
@@ -9,6 +9,15 @@ 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', () => {
@@ -44,9 +53,122 @@ describe('FeedItems Component', () => {
await waitFor(() => {
expect(screen.getByText('Item One')).toBeInTheDocument();
- expect(screen.getByText('Item Two')).toBeInTheDocument();
+ // Title should now be "Feed Items" based on logic
+ expect(screen.getByText('Feed Items')).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('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,
});
- expect(global.fetch).toHaveBeenCalledWith('/api/stream?feed_id=1');
+ // 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 }),
+ }));
+ });
});
});