diff --git a/web/src/lib/components/Shell.svelte b/web/src/lib/components/Shell.svelte new file mode 100644 index 00000000..b37d41fd --- /dev/null +++ b/web/src/lib/components/Shell.svelte @@ -0,0 +1,80 @@ + + + (menuOpen = false)} onkeydown={(e) => e.key === 'Escape' && (menuOpen = false)} /> + + + + Minstrel + + { e.stopPropagation(); menuOpen = !menuOpen; }} + > + {user.value?.username ?? ''} + + {#if menuOpen} + e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="menu" + tabindex="-1" + > + + Log out + + + {/if} + + + + + + {#each navItems as item} + + + {item.label} + + + {/each} + + + + + {@render children?.()} + + diff --git a/web/src/lib/components/Shell.test.ts b/web/src/lib/components/Shell.test.ts new file mode 100644 index 00000000..1ad5a48d --- /dev/null +++ b/web/src/lib/components/Shell.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; + +vi.mock('$app/state', () => ({ + page: { url: new URL('http://localhost/') } +})); + +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); + +vi.mock('$lib/auth/store.svelte', () => ({ + user: { value: { id: '1', username: 'alice', is_admin: false } }, + logout: vi.fn().mockResolvedValue(undefined) +})); + +import Shell from './Shell.svelte'; +import { logout } from '$lib/auth/store.svelte'; +import { goto } from '$app/navigation'; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('Shell', () => { + test('renders the username in the header', () => { + render(Shell); + expect(screen.getByText('alice')).toBeInTheDocument(); + }); + + test('renders three nav items: Library, Search, Playlists', () => { + render(Shell); + expect(screen.getByRole('link', { name: 'Library' })).toHaveAttribute('href', '/'); + expect(screen.getByRole('link', { name: 'Search' })).toHaveAttribute('href', '/search'); + expect(screen.getByRole('link', { name: 'Playlists' })).toHaveAttribute('href', '/playlists'); + }); + + test('user-menu "Log out" calls logout() and navigates to /login', async () => { + render(Shell); + await fireEvent.click(screen.getByRole('button', { name: /alice/i })); + await fireEvent.click(screen.getByRole('button', { name: /log out/i })); + expect(logout).toHaveBeenCalledTimes(1); + expect(goto).toHaveBeenCalledWith('/login', { replaceState: true }); + }); +}); diff --git a/web/tsconfig.json b/web/tsconfig.json index 43447105..4660e525 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -9,6 +9,25 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "moduleResolution": "bundler" - } + "moduleResolution": "bundler", + "types": ["node", "vitest"] + }, + "include": [ + ".svelte-kit/ambient.d.ts", + ".svelte-kit/non-ambient.d.ts", + ".svelte-kit/types/**/$types.d.ts", + "vite.config.js", + "vite.config.ts", + "vitest.config.ts", + "vitest.setup.ts", + "src/**/*.js", + "src/**/*.ts", + "src/**/*.svelte", + "test/**/*.js", + "test/**/*.ts", + "test/**/*.svelte", + "tests/**/*.js", + "tests/**/*.ts", + "tests/**/*.svelte" + ] } diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 7ec8c2f0..a6bc5c75 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -1,8 +1,9 @@ import { sveltekit } from '@sveltejs/kit/vite'; +import { svelteTesting } from '@testing-library/svelte/vite'; import { defineConfig } from 'vitest/config'; export default defineConfig({ - plugins: [sveltekit()], + plugins: [sveltekit(), svelteTesting()], test: { environment: 'jsdom', include: ['src/**/*.test.ts'],