vi.useFakeTimers();
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { apiFetch } from './api';
// Mock api
vi.mock('./api', () => ({
apiFetch: vi.fn()
}));
describe('Core Robustness: Scroll-to-Read, Infinite Scroll, Keyboard Nav', () => {
let mainContent: HTMLElement;
let renderItems: any;
let updateItem: any;
let storeInstance: any;
beforeEach(async () => {
vi.useFakeTimers();
vi.resetModules();
const main = await import('./main');
const storeMod = await import("./store");
storeInstance = storeMod.store;
renderItems = main.renderItems;
updateItem = main.updateItem;
document.body.innerHTML = '
';
mainContent = document.getElementById('main-content')!;
// Mock scrollIntoView
Element.prototype.scrollIntoView = vi.fn();
vi.clearAllMocks();
// Reset store
storeInstance.setItems([]);
storeInstance.setHasMore(true);
storeInstance.setLoading(false);
storeInstance.setSearchQuery('');
storeInstance.setFilter('unread');
// Default successful API mock
vi.mocked(apiFetch).mockResolvedValue({
ok: true,
status: 200,
json: async () => []
} as Response);
});
afterEach(() => {
vi.useRealTimers();
});
// --- SCROLL-TO-READ ROBUSTNESS ---
it('should mark multiple items as read when fast-scrolled past (debounce check)', async () => {
const items = Array.from({ length: 5 }, (_, i) => ({
_id: i + 1, title: `Item ${i + 1}`, read: false, publish_date: '2023-01-01'
})) as any[];
storeInstance.setItems(items);
renderItems();
// Mock container at top 0
mainContent.getBoundingClientRect = vi.fn(() => ({ top: 0, bottom: 800 } as any));
// Mock items scattered past the top
document.querySelectorAll('.feed-item').forEach((el, i) => {
el.getBoundingClientRect = vi.fn(() => ({
top: -100 - (i * 100),
bottom: -10 - (i * 100) // All 5 are past the top
} as any));
});
// Trigger multiple scroll events rapidly
mainContent.dispatchEvent(new Event('scroll'));
mainContent.dispatchEvent(new Event('scroll'));
mainContent.dispatchEvent(new Event('scroll'));
// Advance timers by 250ms (debounce)
vi.advanceTimersByTime(300);
// Should have called updateItem for all 5 items
// We check apiFetch since updateItem calls it
expect(apiFetch).toHaveBeenCalledTimes(5);
items.forEach(item => {
expect(apiFetch).toHaveBeenCalledWith(expect.stringContaining(`/api/item/${item._id}`), expect.anything());
});
});
it('should NOT mark a very tall item as read until its BOTTOM clears the container top', async () => {
storeInstance.setItems([{ _id: 1, title: 'Tall Item', read: false, publish_date: '2023-01-01' }] as any);
renderItems();
mainContent.getBoundingClientRect = vi.fn(() => ({ top: 0, bottom: 800 } as any));
const itemEl = document.querySelector('.feed-item[data-id="1"]')!;
// Scenario: Top is way off screen (-500), but bottom is still visible (+10)
itemEl.getBoundingClientRect = vi.fn(() => ({ top: -500, bottom: 10 } as any));
mainContent.dispatchEvent(new Event('scroll'));
vi.advanceTimersByTime(300);
expect(apiFetch).not.toHaveBeenCalled();
// Scenario: Bottom finally clears (is past top + 5px buffer)
// bottom: -10 means it's 10px above the top
itemEl.getBoundingClientRect = vi.fn(() => ({ top: -1000, bottom: -10 } as any));
mainContent.dispatchEvent(new Event('scroll'));
vi.advanceTimersByTime(300);
expect(apiFetch).toHaveBeenCalledWith(expect.stringContaining('/api/item/1'), expect.anything());
});
// --- INFINITE SCROLL ROBUSTNESS ---
it('should handle "stuck" loads by allowing re-trigger if previous load finished', async () => {
storeInstance.setItems(Array.from({ length: 10 }, (_, i) => ({ _id: i })) as any);
renderItems();
// Mock being at the bottom
Object.defineProperty(mainContent, 'scrollHeight', { value: 1000 });
Object.defineProperty(mainContent, 'scrollTop', { value: 850 });
Object.defineProperty(mainContent, 'clientHeight', { value: 100 });
// scrollHeight(1000) - scrollTop(850) - clientHeight(100) = 50 (< 200)
// Simulate loadMore takes time
let resolvePromise: (v: any) => void;
const pendingPromise = new Promise(resolve => { resolvePromise = resolve; });
vi.mocked(apiFetch).mockReturnValue(pendingPromise as any);
mainContent.dispatchEvent(new Event('scroll'));
expect(apiFetch).toHaveBeenCalledTimes(1); // First trigger
expect(storeInstance.loading).toBe(true);
// Scroll again while loading
mainContent.dispatchEvent(new Event('scroll'));
expect(apiFetch).toHaveBeenCalledTimes(1); // Should NOT trigger again while storeInstance.loading is true
// Finish first load
resolvePromise!({ ok: true, json: async () => [] });
await vi.runAllTicks();
storeInstance.setLoading(false); // Manually set since we're testing the logic flow
// Scroll again
mainContent.dispatchEvent(new Event('scroll'));
expect(apiFetch).toHaveBeenCalledTimes(2); // Should trigger again now that loading is false
});
// --- KEYBOARD NAVIGATION ROBUSTNESS ---
it('should sync activeItemId and scroll into view correctly on j/k', async () => {
storeInstance.setItems([
{ _id: 101, title: 'Item 1', read: false },
{ _id: 102, title: 'Item 2', read: false }
] as any);
renderItems();
const scrollSpy = vi.spyOn(Element.prototype, 'scrollIntoView');
// Press 'j'
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'j' }));
expect(scrollSpy).toHaveBeenCalled();
expect(apiFetch).toHaveBeenCalledWith(expect.stringContaining('/api/item/101'), expect.objectContaining({
body: expect.stringContaining('"read":true')
}));
// Press 'j' again
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'j' }));
expect(apiFetch).toHaveBeenCalledWith(expect.stringContaining('/api/item/102'), expect.anything());
});
// --- API ERROR ENCAPSULATION ---
it('should NOT update DOM classes if API update fails', async () => {
storeInstance.setItems([{ _id: 99, title: 'Error Item', read: false }] as any);
renderItems();
const el = document.querySelector('.feed-item[data-id="99"]')!;
expect(el.classList.contains('unread')).toBe(true);
// Mock API failure
vi.mocked(apiFetch).mockResolvedValue({ ok: false, status: 500 } as Response);
await updateItem(99, { read: true });
// DOM should NOT be updated because res.ok was false
expect(el.classList.contains('read')).toBe(false);
expect(el.classList.contains('unread')).toBe(true);
});
// --- POLLING FALLBACK ---
it('should trigger checkReadItems periodically via polling fallback', async () => {
storeInstance.setItems([{ _id: 55, title: 'Poll Item', read: false }] as any);
renderItems();
mainContent.getBoundingClientRect = vi.fn(() => ({ top: 0, bottom: 800 } as any));
const itemEl = document.querySelector('.feed-item[data-id="55"]')!;
itemEl.getBoundingClientRect = vi.fn(() => ({ top: -100, bottom: -50 } as any));
// Advance 1s (the polling interval in main.ts)
vi.advanceTimersByTime(1100);
expect(apiFetch).toHaveBeenCalledWith(expect.stringContaining('/api/item/55'), expect.anything());
});
// --- EDGE CASE: EMPTY / BOUNDARY NAVIGATION ---
it('should not crash when j/k is pressed on an empty list', () => {
storeInstance.setItems([]);
renderItems();
// Should not throw
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'j' }));
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k' }));
});
it('should handle search queries correctly in stream requests', async () => {
storeInstance.setSearchQuery('rust');
// trigger fetchItems through store logic or manual call
const { fetchItems } = await import('./main');
await fetchItems();
expect(apiFetch).toHaveBeenCalledWith(expect.stringContaining('q=rust'));
});
});