import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { store } from './store';
import { apiFetch } from './api';
import { renderItems, renderLayout } from './main';
import { createFeedItem } from './components/FeedItem';
// Mock api
vi.mock('./api', () => ({
apiFetch: vi.fn()
}));
// Mock IntersectionObserver
class MockIntersectionObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
describe('Scroll-to-Read Regression Tests', () => {
beforeEach(() => {
document.body.innerHTML = '
';
// Mock scrollIntoView
Element.prototype.scrollIntoView = vi.fn();
vi.clearAllMocks();
// Reset store state thoroughly
store.setItems([]);
store.setLoading(false);
store.setHasMore(true);
store.setActiveFeed(null);
store.setActiveTag(null);
store.setFilter('unread');
store.setSearchQuery('');
// Setup default auth response
vi.mocked(apiFetch).mockResolvedValue({
ok: true,
status: 200,
json: async () => []
} as Response);
});
afterEach(() => {
vi.useRealTimers();
});
it('should mark item as read when existing in store and fully scrolled past (bottom < container top)', async () => {
vi.useRealTimers();
const mockItem = {
_id: 999,
title: 'Regression Test Item',
read: false,
url: 'http://example.com/regression',
publish_date: '2023-01-01'
} as any;
store.setItems([mockItem]);
// Manual setup
const mainContent = document.getElementById('main-content');
expect(mainContent).not.toBeNull();
renderItems();
if (mainContent) {
mainContent.getBoundingClientRect = vi.fn(() => ({
top: 0, bottom: 800, height: 800, left: 0, right: 0, width: 0, x: 0, y: 0, toJSON: () => { }
}));
}
const itemEl = document.querySelector(`.feed-item[data-id="999"]`);
expect(itemEl).not.toBeNull();
if (itemEl) {
itemEl.getBoundingClientRect = vi.fn(() => ({
top: -150, bottom: -50, height: 100, left: 0, right: 0, width: 0, x: 0, y: 0, toJSON: () => { }
}));
}
vi.mocked(apiFetch).mockResolvedValue({ ok: true } as Response);
mainContent?.dispatchEvent(new Event('scroll'));
// Wait for throttle (250ms) + buffer
await new Promise(resolve => setTimeout(resolve, 300));
expect(apiFetch).toHaveBeenCalledWith(expect.stringContaining('/api/item/999'), expect.objectContaining({
method: 'PUT',
body: expect.stringContaining('"read":true')
}));
});
it('should NOT mark item as read if only partially scrolled past', async () => {
vi.useRealTimers();
const mockItem = {
_id: 777,
title: 'Partial Test Item',
read: false,
url: 'http://example.com/partial',
publish_date: '2023-01-01'
} as any;
store.setItems([mockItem]);
renderItems();
const mainContent = document.getElementById('main-content');
if (mainContent) {
mainContent.getBoundingClientRect = vi.fn(() => ({
top: 0, bottom: 800, height: 800, left: 0, right: 0, width: 0, x: 0, y: 0, toJSON: () => { }
}));
}
const itemEl = document.querySelector(`.feed-item[data-id="777"]`);
if (itemEl) {
itemEl.getBoundingClientRect = vi.fn(() => ({
top: -50, bottom: 50, height: 100, left: 0, right: 0, width: 0, x: 0, y: 0, toJSON: () => { }
}));
}
vi.mocked(apiFetch).mockResolvedValue({ ok: true } as Response);
mainContent?.dispatchEvent(new Event('scroll'));
await new Promise(resolve => setTimeout(resolve, 300));
expect(apiFetch).not.toHaveBeenCalledWith(expect.stringContaining('/api/item/777'), expect.anything());
});
it('should mark item as read when WINDOW scrolls (robustness fallback)', async () => {
vi.useRealTimers();
const mockItem = {
_id: 12345,
title: 'Window Scroll Item',
read: false,
url: 'http://example.com/window',
publish_date: '2023-01-01'
} as any;
store.setItems([mockItem]);
renderItems();
const mainContent = document.getElementById('main-content');
if (mainContent) {
mainContent.getBoundingClientRect = vi.fn(() => ({
top: 0, bottom: 800, height: 800, left: 0, right: 0, width: 0, x: 0, y: 0, toJSON: () => { }
}));
}
const itemEl = document.querySelector(`.feed-item[data-id="12345"]`);
if (itemEl) {
itemEl.getBoundingClientRect = vi.fn(() => ({
top: -150, bottom: -50, height: 100, left: 0, right: 0, width: 0, x: 0, y: 0, toJSON: () => { }
}));
}
vi.mocked(apiFetch).mockResolvedValue({ ok: true } as Response);
window.dispatchEvent(new Event('scroll'));
// The window scroll listener triggers checkReadItems in 1s interval (wait > 1s)
await new Promise(resolve => setTimeout(resolve, 1100));
expect(apiFetch).toHaveBeenCalledWith(expect.stringContaining('/api/item/12345'), expect.objectContaining({
method: 'PUT',
body: expect.stringContaining('"read":true')
}));
});
});
// NK-t8qnrh: Links in feed item descriptions should have no underlines (match v1 style)
describe('NK-t8qnrh: Feed item description links have no underlines', () => {
it('item-description should be rendered inside feed items', () => {
const item = {
_id: 1,
title: 'Test',
url: 'http://example.com',
description: 'Text with a link
',
read: false,
starred: false,
publish_date: '2024-01-01',
} as any;
const html = createFeedItem(item);
expect(html).toContain('class="item-description"');
expect(html).toContain('a link');
});
});
// NK-mcl01m: Sidebar order should be filters → search → "+ new" → Feeds
describe('NK-mcl01m: Sidebar section order', () => {
beforeEach(() => {
document.body.innerHTML = '';
vi.mocked(apiFetch).mockResolvedValue({ ok: true, status: 200, json: async () => [] } as Response);
renderLayout();
});
it('filter-list appears before section-feeds in the sidebar', () => {
const sidebar = document.getElementById('sidebar');
expect(sidebar).not.toBeNull();
const filterList = sidebar!.querySelector('#filter-list');
const sectionFeeds = sidebar!.querySelector('#section-feeds');
expect(filterList).not.toBeNull();
expect(sectionFeeds).not.toBeNull();
const position = filterList!.compareDocumentPosition(sectionFeeds!);
expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('search input appears after filter-list and before section-feeds', () => {
const sidebar = document.getElementById('sidebar');
const filterList = sidebar!.querySelector('#filter-list');
const searchInput = sidebar!.querySelector('#search-input');
const sectionFeeds = sidebar!.querySelector('#section-feeds');
expect(searchInput).not.toBeNull();
const pos1 = filterList!.compareDocumentPosition(searchInput!);
expect(pos1 & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
const pos2 = searchInput!.compareDocumentPosition(sectionFeeds!);
expect(pos2 & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('sidebar has a "+ new" link pointing to settings', () => {
const newLink = document.querySelector('.new-feed-link');
expect(newLink).not.toBeNull();
expect(newLink!.textContent?.trim()).toBe('+ new');
});
});
// NK-z1czaq: Main content should fill full width (sidebar overlays, never shifts content)
describe('NK-z1czaq: Sidebar overlays content, does not shift layout', () => {
beforeEach(() => {
document.body.innerHTML = '';
vi.mocked(apiFetch).mockResolvedValue({ ok: true, status: 200, json: async () => [] } as Response);
});
it('sidebar is a sibling of main-content inside .layout (not flex-shifting)', () => {
renderLayout();
const sidebar = document.querySelector('.sidebar');
const mainContent = document.querySelector('.main-content');
expect(sidebar).not.toBeNull();
expect(mainContent).not.toBeNull();
expect(sidebar!.parentElement?.classList.contains('layout')).toBe(true);
expect(mainContent!.parentElement?.classList.contains('layout')).toBe(true);
});
});
// Infinite scroll: uses scroll-position check (like v1) instead of IntersectionObserver.
describe('Infinite scroll: scroll near bottom triggers loadMore', () => {
beforeEach(() => {
document.body.innerHTML = '';
Element.prototype.scrollIntoView = vi.fn();
vi.clearAllMocks();
store.setItems([]);
store.setHasMore(true);
store.setLoading(false);
vi.mocked(apiFetch).mockResolvedValue({
ok: true,
status: 200,
json: async () => [],
} as Response);
renderItems();
});
function simulateScrollNearBottom(mainContent: HTMLElement) {
Object.defineProperty(mainContent, 'scrollHeight', { value: 2000, configurable: true });
Object.defineProperty(mainContent, 'clientHeight', { value: 800, configurable: true });
mainContent.scrollTop = 1050;
mainContent.dispatchEvent(new Event('scroll'));
}
it('should call loadMore (apiFetch /api/stream) when scrolled near the bottom', () => {
const items = Array.from({ length: 50 }, (_, i) => ({
_id: i + 1,
title: `Item ${i + 1}`,
url: `http://example.com/${i + 1}`,
read: false,
publish_date: '2024-01-01',
}));
store.setItems(items as any);
vi.clearAllMocks();
const mainContent = document.getElementById('main-content')!;
simulateScrollNearBottom(mainContent);
expect(apiFetch).toHaveBeenCalledWith(
expect.stringContaining('/api/stream'),
);
});
});