aboutsummaryrefslogtreecommitdiffstats
path: root/frontend-vanilla/src/main.test.ts
diff options
context:
space:
mode:
authorAdam Mathes <adam@adammathes.com>2026-02-15 19:36:03 -0800
committerAdam Mathes <adam@adammathes.com>2026-02-15 19:36:03 -0800
commit59743dcaa87920a5125915454e0afa0a22b05ee7 (patch)
tree11666ff75c615face8dee0f479d0b3c7c79bb187 /frontend-vanilla/src/main.test.ts
parenta113bc13e569049c59baa2165d28a992d7bdde7b (diff)
downloadneko-59743dcaa87920a5125915454e0afa0a22b05ee7.tar.gz
neko-59743dcaa87920a5125915454e0afa0a22b05ee7.tar.bz2
neko-59743dcaa87920a5125915454e0afa0a22b05ee7.zip
Vanilla JS (v3): Redesign to 2-pane glassmorphism, fix CSP errors, fix Settings view, and achieve 80% test coverage
Diffstat (limited to 'frontend-vanilla/src/main.test.ts')
-rw-r--r--frontend-vanilla/src/main.test.ts249
1 files changed, 249 insertions, 0 deletions
diff --git a/frontend-vanilla/src/main.test.ts b/frontend-vanilla/src/main.test.ts
new file mode 100644
index 0000000..be5a076
--- /dev/null
+++ b/frontend-vanilla/src/main.test.ts
@@ -0,0 +1,249 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { store } from './store';
+import { router } from './router';
+import {
+ renderLayout,
+ renderFeeds,
+ renderTags,
+ renderFilters,
+ renderItems,
+ renderSettings,
+ fetchFeeds,
+ fetchTags,
+ fetchItems,
+ init,
+ logout
+} from './main';
+import { apiFetch } from './api';
+
+// Mock api
+vi.mock('./api', () => ({
+ apiFetch: vi.fn()
+}));
+
+// Mock IntersectionObserver
+const mockObserver = vi.fn(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+}));
+vi.stubGlobal('IntersectionObserver', mockObserver);
+
+describe('main application logic', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '<div id="app"></div>';
+ vi.stubGlobal('location', {
+ href: 'http://localhost/v3/',
+ pathname: '/v3/',
+ search: '',
+ assign: vi.fn(),
+ replace: vi.fn()
+ });
+ vi.stubGlobal('history', {
+ pushState: vi.fn()
+ });
+ // Mock scrollIntoView which is missing in JSDOM
+ Element.prototype.scrollIntoView = vi.fn();
+ vi.clearAllMocks();
+ // Reset store
+ store.setFeeds([]);
+ store.setTags([]);
+ store.setItems([]);
+
+ // Setup default auth response
+ vi.mocked(apiFetch).mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => []
+ } as Response);
+ });
+
+ it('renderLayout should create sidebar and main content', () => {
+ renderLayout();
+ expect(document.getElementById('sidebar')).not.toBeNull();
+ expect(document.getElementById('content-area')).not.toBeNull();
+ expect(document.getElementById('logo-link')).not.toBeNull();
+ });
+
+ it('renderFeeds should populate feed list', () => {
+ renderLayout();
+ store.setFeeds([{ _id: 1, title: 'Test Feed', url: 'test', web_url: 'test', category: 'tag' }]);
+ renderFeeds();
+ const feedList = document.getElementById('feed-list');
+ expect(feedList?.innerHTML).toContain('Test Feed');
+ });
+
+ it('renderTags should populate tag list', () => {
+ renderLayout();
+ store.setTags([{ title: 'Test Tag' } as any]);
+ renderTags();
+ const tagList = document.getElementById('tag-list');
+ expect(tagList?.innerHTML).toContain('Test Tag');
+ });
+
+ it('renderFilters should update active filter', () => {
+ renderLayout();
+ store.setFilter('starred');
+ renderFilters();
+ const starredFilter = document.querySelector('[data-filter="starred"]');
+ expect(starredFilter?.classList.contains('active')).toBe(true);
+ });
+
+ it('renderItems should populate content area', () => {
+ renderLayout();
+ store.setItems([{ _id: 1, title: 'Item 1', url: 'test', publish_date: '2023-01-01' } as any]);
+ renderItems();
+ const contentArea = document.getElementById('content-area');
+ expect(contentArea?.innerHTML).toContain('Item 1');
+ });
+
+ it('renderSettings should show theme and font options', () => {
+ renderLayout();
+ renderSettings();
+ expect(document.querySelector('.settings-view')).not.toBeNull();
+ expect(document.getElementById('font-selector')).not.toBeNull();
+ });
+
+ it('fetchFeeds should update store', async () => {
+ vi.mocked(apiFetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [{ _id: 1, title: 'API Feed' }]
+ } as Response);
+
+ await fetchFeeds();
+ expect(store.feeds).toHaveLength(1);
+ expect(store.feeds[0].title).toBe('API Feed');
+ });
+
+ it('fetchTags should update store', async () => {
+ vi.mocked(apiFetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [{ title: 'API Tag' }]
+ } as Response);
+
+ await fetchTags();
+ expect(store.tags).toHaveLength(1);
+ expect(store.tags[0].title).toBe('API Tag');
+ });
+
+ it('fetchItems should update store items', async () => {
+ vi.mocked(apiFetch).mockResolvedValueOnce({
+ ok: true,
+ json: async () => [{ _id: 1, title: 'API Item' }]
+ } as Response);
+
+ renderLayout();
+ await fetchItems();
+ expect(store.items).toHaveLength(1);
+ expect(store.items[0].title).toBe('API Item');
+ });
+
+ it('init should coordinate startup', async () => {
+ vi.mocked(apiFetch).mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => []
+ } as Response);
+
+ await init();
+ expect(document.getElementById('sidebar')).not.toBeNull();
+ });
+
+ it('should handle search input', () => {
+ renderLayout();
+ const searchInput = document.getElementById('search-input') as HTMLInputElement;
+ const spy = vi.spyOn(router, 'updateQuery');
+ searchInput.value = 'query';
+ searchInput.dispatchEvent(new Event('input'));
+ expect(spy).toHaveBeenCalledWith({ q: 'query' });
+ });
+
+ it('should handle sidebar navigation clicking', () => {
+ renderLayout();
+ const spy = vi.spyOn(router, 'updateQuery');
+ const filterLink = document.querySelector('[data-nav="filter"]') as HTMLElement;
+ filterLink.click();
+ expect(spy).toHaveBeenCalled();
+ });
+
+ it('should handle item star toggle', async () => {
+ renderLayout();
+ const mockItem = { _id: 1, title: 'Item 1', starred: false, publish_date: '2023-01-01' } as any;
+ store.setItems([mockItem]);
+ renderItems();
+
+ vi.mocked(apiFetch).mockResolvedValue({ ok: true } as Response);
+
+ const starBtn = document.querySelector('[data-action="toggle-star"]') as HTMLElement;
+ starBtn.click();
+
+ expect(apiFetch).toHaveBeenCalledWith(expect.stringContaining('/api/item/1'), expect.objectContaining({
+ method: 'PUT',
+ body: expect.stringContaining('"starred":true')
+ }));
+ });
+
+ it('should handle theme change in settings', () => {
+ renderLayout();
+ renderSettings();
+ const darkBtn = document.querySelector('[data-theme="dark"]') as HTMLElement;
+ const spy = vi.spyOn(store, 'setTheme');
+ darkBtn.click();
+ expect(spy).toHaveBeenCalledWith('dark');
+ });
+
+ it('should handle logout', async () => {
+ vi.mocked(apiFetch).mockResolvedValue({ ok: true } as Response);
+ await logout();
+ expect(apiFetch).toHaveBeenCalledWith('/api/logout', { method: 'POST' });
+ expect(window.location.href).toBe('/login/');
+ });
+
+ it('should handle keyboard navigation j/k', () => {
+ const mockItems = [
+ { _id: 1, title: 'Item 1', publish_date: '2023-01-01', read: false },
+ { _id: 2, title: 'Item 2', publish_date: '2023-01-01', read: false }
+ ] as any;
+ store.setItems(mockItems);
+ renderLayout();
+ renderItems();
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'j' }));
+ expect(apiFetch).toHaveBeenCalled(); // mark as read
+
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k' }));
+ // should go back to first item
+ });
+
+ it('should handle toggle star/read with keyboard', async () => {
+ const mockItem = { _id: 1, title: 'Item 1', publish_date: '2023-01-01', read: true, starred: false } as any;
+ store.setItems([mockItem]);
+ renderLayout();
+ renderItems();
+
+ // Already read, so 'j' won't trigger updateItem for read=true
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'j' }));
+
+ vi.mocked(apiFetch).mockResolvedValue({ ok: true } as Response);
+
+ // Toggle star
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 's' }));
+ expect(apiFetch).toHaveBeenCalledWith(expect.stringContaining('/api/item/1'), expect.objectContaining({
+ body: expect.stringContaining('"starred":true')
+ }));
+
+ // Toggle read (currently true -> false)
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: 'r' }));
+ expect(apiFetch).toHaveBeenLastCalledWith(expect.stringContaining('/api/item/1'), expect.objectContaining({
+ body: expect.stringContaining('"read":false')
+ }));
+ });
+
+ it('should focus search with /', () => {
+ renderLayout();
+ const searchInput = document.getElementById('search-input') as HTMLInputElement;
+ const spy = vi.spyOn(searchInput, 'focus');
+ window.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }));
+ expect(spy).toHaveBeenCalled();
+ });
+});