From 76cb9c2a39d477a64824a985ade40507e3bbade1 Mon Sep 17 00:00:00 2001 From: Adam Mathes Date: Fri, 13 Feb 2026 21:34:48 -0800 Subject: feat(vanilla): add testing infrastructure and tests (NK-wjnczv) --- vanilla/app.test.js | 291 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 vanilla/app.test.js (limited to 'vanilla/app.test.js') diff --git a/vanilla/app.test.js b/vanilla/app.test.js new file mode 100644 index 0000000..32d09de --- /dev/null +++ b/vanilla/app.test.js @@ -0,0 +1,291 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { fetchFeeds, fetchItems, renderFeeds, renderItems, toggleStar, toggleRead } from './app.js'; + +// Mock fetch +const fetchMock = vi.fn(); +global.fetch = fetchMock; + +describe('Vanilla JS App', () => { + beforeEach(() => { + document.body.innerHTML = ` +
+ +
+
+

All Items

+
+
+
+
+ `; + fetchMock.mockReset(); + }); + + describe('fetchFeeds', () => { + it('should fetch feeds and render them', async () => { + const mockFeeds = [{ id: 1, title: 'Test Feed', url: 'http://example.com' }]; + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockFeeds, + }); + + await fetchFeeds(); + + expect(fetchMock).toHaveBeenCalledWith('/api/feed/'); + const feedItems = document.querySelectorAll('.feed-item'); + // "All Items", "Unread Items", "Starred Items", plus 1 feed = 4 items + expect(feedItems.length).toBe(4); + expect(feedItems[3].textContent).toBe('Test Feed'); + }); + + it('should handle errors gracefully', async () => { + fetchMock.mockRejectedValue(new Error('Network error')); + await expect(fetchFeeds()).rejects.toThrow('Network error'); + expect(document.getElementById('feeds-nav').innerHTML).toContain('Error loading feeds'); + }); + }); + + describe('fetchItems', () => { + it('should fetch items and render them', async () => { + const mockItems = [{ + id: 101, + title: 'Item 1', + url: 'http://example.com/1', + feed: { title: 'Feed A' }, + starred: false, + read: false + }]; + fetchMock.mockResolvedValue({ + ok: true, + json: async () => mockItems, + }); + + await fetchItems(); + + expect(fetchMock).toHaveBeenCalledWith('/api/stream/'); + const entries = document.querySelectorAll('.entry'); + expect(entries.length).toBe(1); + expect(entries[0].querySelector('.entry-title').textContent).toBe('Item 1'); + }); + + it('should handle filters', async () => { + fetchMock.mockResolvedValue({ ok: true, json: async () => [] }); + + await fetchItems(123, 'unread', 'query'); + + const expectedUrl = '/api/stream/?feed_id=123&read_filter=unread&q=query'; + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('feed_id=123')); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('read_filter=unread')); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=query')); + }); + }); + + describe('renderFeeds', () => { + it('should render system feeds and user feeds', async () => { + const feeds = [ + { id: 1, title: 'Feed 1', url: 'u1' }, + { id: 2, title: 'Feed 2', url: 'u2' } + ]; + // Mock fetch for the click handler + fetchMock.mockResolvedValue({ ok: true, json: async () => [] }); + + renderFeeds(feeds); + + const items = document.querySelectorAll('.feed-item'); + expect(items.length).toBe(5); // All, Unread, Starred, Feed 1, Feed 2 + + // Click handler test: All Items + items[0].click(); + expect(items[0].classList.contains('active')).toBe(true); + expect(document.getElementById('feed-title').textContent).toBe('All Items'); + expect(fetchMock).toHaveBeenCalledWith('/api/stream/'); + + // Click handler test: Unread Items + items[1].click(); + expect(items[1].classList.contains('active')).toBe(true); + expect(document.getElementById('feed-title').textContent).toBe('Unread Items'); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('read_filter=unread')); + + // Click handler test: Starred Items + items[2].click(); + expect(items[2].classList.contains('active')).toBe(true); + expect(document.getElementById('feed-title').textContent).toBe('Starred Items'); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('starred=true')); + + // Click handler test: Specific Feed + items[3].click(); + expect(items[3].classList.contains('active')).toBe(true); + expect(document.getElementById('feed-title').textContent).toBe('Feed 1'); + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('feed_id=1')); + + // Wait for async operations to complete to avoid unhandled rejections + await new Promise(resolve => setTimeout(resolve, 0)); + }); + }); + + describe('renderItems', () => { + it('should render "No items found" if empty', () => { + renderItems([]); + expect(document.getElementById('entries-list').innerHTML).toContain('No items found'); + }); + + it('should render items with correct controls', () => { + const items = [{ + id: 1, + title: 'Test', + url: 'http://test.com', + starred: true, + read: false, + feed: { title: 'Feed' }, + created_at: new Date().toISOString() + }]; + renderItems(items); + + const starBtn = document.querySelector('.btn-star'); + expect(starBtn.textContent).toBe('★'); + expect(starBtn.classList.contains('active')).toBe(true); + + const readBtn = document.querySelector('.btn-read'); + expect(readBtn.textContent).toBe('Mark Read'); + expect(readBtn.classList.contains('unread')).toBe(true); + }); + }); + + describe('Interaction Toggles', () => { + let btn; + beforeEach(() => { + btn = document.createElement('button'); + document.body.appendChild(btn); + }); + afterEach(() => { + if (btn) btn.remove(); + }); + + it('should toggle star status', async () => { + fetchMock.mockResolvedValue({ ok: true }); + + const newStatus = await toggleStar(1, false, btn); + + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/item/1'), expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ id: 1, starred: true }) + })); + expect(newStatus).toBe(true); + expect(btn.textContent).toBe('★'); + }); + + it('should toggle read status', async () => { + fetchMock.mockResolvedValue({ ok: true }); + + // Setup DOM for title dimming + const header = document.createElement('div'); + header.className = 'entry-header'; + const title = document.createElement('a'); + title.className = 'entry-title'; + header.appendChild(btn); // btn inside header + header.appendChild(title); + document.body.appendChild(header); + + const newStatus = await toggleRead(1, false, btn); + + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/item/1'), expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ id: 1, read: true }) + })); + expect(newStatus).toBe(true); + expect(title.classList.contains('read')).toBe(true); + }); + + }); + + describe('Error Handling', () => { + it('fetchItems should handle existing list element error', async () => { + fetchMock.mockRejectedValue(new Error('Fetch failed')); + await expect(fetchItems()).rejects.toThrow('Fetch failed'); + expect(document.getElementById('entries-list').innerHTML).toContain('Error loading items'); + }); + }); + + describe('init', () => { + it('should initialize app if elements exist', async () => { + // Mock fetch for the init calls + fetchMock.mockResolvedValue({ ok: true, json: async () => [] }); + + const addEventListenerSpy = vi.spyOn(document.getElementById('search-input'), 'addEventListener'); + // init is already imported + const { init } = await import('./app.js'); + // Reset mocks + fetchMock.mockClear(); + + init(); + + expect(fetchMock).toHaveBeenCalledTimes(2); // fetchFeeds + fetchItems + expect(addEventListenerSpy).toHaveBeenCalledWith('keypress', expect.any(Function)); + + // Wait for async operations to complete + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + it('should do nothing if feeds-nav missing', async () => { + document.body.innerHTML = ''; // Clear DOM + fetchMock.mockClear(); + const { init } = await import('./app.js'); + + init(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + describe('Search Interaction', () => { + it('should trigger search on Enter', async () => { + // Mock fetch for the init calls & search + fetchMock.mockResolvedValue({ ok: true, json: async () => [] }); + + // Re-setup DOM and Init + const { init } = await import('./app.js'); + init(); + fetchMock.mockClear(); + + const searchInput = document.getElementById('search-input'); + searchInput.value = 'test query'; + + // Create Enter keypress event + const event = new KeyboardEvent('keypress', { key: 'Enter' }); + searchInput.dispatchEvent(event); + + expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('q=test+query')); + expect(document.getElementById('feed-title').textContent).toBe('Search: test query'); + + // Wait for async operations to complete + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + it('should ignore empty search', async () => { + // Mock fetch for the init calls + fetchMock.mockResolvedValue({ ok: true, json: async () => [] }); + + const { init } = await import('./app.js'); + init(); + fetchMock.mockClear(); + + const searchInput = document.getElementById('search-input'); + searchInput.value = ' '; + + const event = new KeyboardEvent('keypress', { key: 'Enter' }); + searchInput.dispatchEvent(event); + + expect(fetchMock).not.toHaveBeenCalled(); + + // Wait for async operations to complete + await new Promise(resolve => setTimeout(resolve, 0)); + }); + }); +}); -- cgit v1.2.3