From 5669961d674b2764082c7c9585484cb090b71e45 Mon Sep 17 00:00:00 2001
From: Adam Mathes <adam@adammathes.com>
Date: Fri, 13 Feb 2026 10:04:17 -0800
Subject: Implement frontend parity features: Unread view, shortcuts,
 scroll-to-read, filters

---
 frontend/src/components/FeedItems.test.tsx | 128 ++++++++++++++++++++++++++++-
 1 file changed, 125 insertions(+), 3 deletions(-)

(limited to 'frontend/src/components/FeedItems.test.tsx')

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 }),
+            }));
+        });
     });
 });
-- 
cgit v1.2.3

