From 5669961d674b2764082c7c9585484cb090b71e45 Mon Sep 17 00:00:00 2001 From: Adam Mathes 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( + + + + ); + + 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( + + + + ); + + 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