Add sidebar navigation and global command search

This commit is contained in:
2026-10-07 18:59:39 -04:00
parent 9999e80226
commit e4f37f6964
33 changed files with 660 additions and 151 deletions
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from 'vitest';
import { loadPaletteDirectory, paletteDirectoryResults, paletteMatchJump, palettePageResults, searchPaletteRecords } from '../../src/lib/ui/command-palette.js';
describe('command palette destinations', () => {
it('keeps administrator destinations and operations out of inappropriate roles', () => {
const viewer = palettePageResults('viewer', '');
expect(viewer.some(item => item.group === 'Actions' || item.href.startsWith('/settings') || item.href === '/server-keys')).toBe(false);
expect(palettePageResults('operator', '').filter(item => item.group === 'Actions').map(item => item.href)).toEqual(['/?action=create']);
expect(palettePageResults('admin', 'enroll')).toContainEqual(expect.objectContaining({ href: '/hosts?action=add' }));
expect(palettePageResults('admin', 'campaigns')).toContainEqual(expect.objectContaining({ label: 'Campaigns', href: '/campaigns' }));
});
it('searches friendly server names and hosts while encoding identifiers and bounding groups', () => {
const directory = { servers: Array.from({ length: 10 }, (_, i) => ({ name: `t5_zombie_theater_${i + 1}`, host: { name: 'Dallas 1' } })),
hosts: [{ id: 'host / 1', name: 'Dallas 1', hostname: 'dallas.example', connectionState: 'offline' }], unavailable: [] };
expect(paletteDirectoryResults('', directory)).toEqual([]);
expect(paletteDirectoryResults('kino lobby 2', directory)).toEqual([{ key: 'server:t5_zombie_theater_2', group: 'Servers', label: 'Kino Der Toten · Lobby 2', detail: 'Dallas 1', href: '/servers/t5_zombie_theater_2' }]);
const found = paletteDirectoryResults('Dallas', directory);
expect(found.filter(item => item.group === 'Servers')).toHaveLength(6);
expect(found).toContainEqual(expect.objectContaining({ href: '/hosts/host%20%2F%201', detail: 'Host agent offline' }));
expect(new Set(found.map(item => item.key)).size).toBe(found.length);
});
it.each(['42', '#42', 'match 42', 'Match #42'])('opens a positive match ID directly: %s', query => {
expect(paletteMatchJump(query)).toEqual([expect.objectContaining({ key: 'match:42', href: '/matches/42' })]);
});
it.each(['0', '-1', '1.5', 'match one', '9007199254740993', 'https://example.com'])('does not invent an invalid match destination: %s', query => {
expect(paletteMatchJump(query)).toEqual([]);
});
});
describe('bounded palette requests', () => {
it('does not fetch histories for an empty or one-character query', async () => {
const fetcher = vi.fn();
expect(await searchPaletteRecords('x', new AbortController().signal, fetcher)).toEqual({ items: [], unavailable: [] });
expect(fetcher).not.toHaveBeenCalled();
});
it('queries at most five players and completed matches, and propagates cancellation', async () => {
const controller = new AbortController();
const fetcher = vi.fn(async (url: string) => Response.json(url.startsWith('/api/leaderboard') ? { entries: [{ guid: 'id / 1', name: 'Cherry' }] }
: { matches: [{ id: 2, map: 'Kino Der Toten', server: 't5_zombie_theater_1', status: 'completed' }, { id: 3, map: 'Town', server: 'server', status: 'active' }] }));
const result = await searchPaletteRecords('Cherry & Co', controller.signal, fetcher);
expect(fetcher).toHaveBeenCalledTimes(2);
for (const call of fetcher.mock.calls) {
const url = new URL(call[0], 'http://localhost');
expect(url.searchParams.get('limit')).toBe('5'); expect(url.searchParams.get('q')).toBe('Cherry & Co');
}
expect(fetcher).toHaveBeenCalledWith(expect.stringContaining('/api/matches?'), { signal: controller.signal, cache: 'no-store' });
expect(result.items.map(item => item.href)).toEqual(['/leaderboard/players/id%20%2F%201', '/matches/2']);
expect(result.unavailable).toEqual([]);
});
it('preserves results from a successful source when another source fails', async () => {
const fetcher = vi.fn(async (url: string) => url === '/api/hosts' ? new Response('', { status: 503 }) : Response.json({ servers: [{ name: 'server' }] }));
expect(await loadPaletteDirectory(new AbortController().signal, fetcher)).toEqual({ servers: [{ name: 'server' }], hosts: [], unavailable: ['Host'] });
const records = await searchPaletteRecords('Cherry', new AbortController().signal, async url => url.startsWith('/api/leaderboard')
? new Response('', { status: 500 }) : Response.json({ matches: [{ id: 5, status: 'completed', map: 'Town', server: 'server' }] }));
expect(records.items.map(item => item.href)).toEqual(['/matches/5']); expect(records.unavailable).toEqual(['Player']);
});
});
+17 -11
View File
@@ -6,30 +6,36 @@ describe('application navigation', () => {
['/servers/t5_zombie_theater_1', 'fleet', '/'],
['/collections/launches/42', 'fleet', '/collections'],
['/hosts/42', 'fleet', '/hosts'],
['/hosts/operations', 'operations', '/hosts/operations'],
['/provisioning', 'operations', '/provisioning'],
['/betas/new', 'operations', '/betas'],
['/campaigns/42', 'operations', '/campaigns'],
['/configs/42', 'library', '/configs'],
['/leaderboard/players/42', 'analytics', '/leaderboard'],
['/settings/users', 'administration', '/settings/users'],
['/server-keys', 'administration', '/server-keys']
['/hosts/operations', 'activity', '/hosts/operations'],
['/provisioning', 'activity', '/provisioning'],
['/betas/new', 'releases', '/betas'],
['/campaigns/42', 'releases', '/campaigns'],
['/configs/42', 'releases', '/configs'],
['/leaderboard/players/42', 'players', '/leaderboard'],
['/settings/users', 'admin', '/settings/users'],
['/server-keys', 'admin', '/server-keys']
])('gives %s exactly one canonical home', (path, group, href) => {
expect(navigationLocation(path, 'admin')?.group.id).toBe(group);
expect(navigationGroups.flatMap(group => group.items).filter(item => navigationItemActive(item, path)).map(item => item.href)).toEqual([href]);
});
it('opens Operations at Provisioning and redirects legacy overview links', async () => {
expect(navigationGroups.find(group => group.id === 'operations')?.items[0].href).toBe('/provisioning');
it('keeps Operations reachable from Activity and redirects legacy overview links', async () => {
expect(navigationGroups.find(group => group.id === 'activity')?.items[0].href).toBe('/provisioning');
const { load } = await import('../../src/routes/operations/+page.server.js');
expect(load).toThrow(expect.objectContaining({ status: 308, location: '/provisioning' }));
});
it('keeps administration and its shortcuts out of operator and viewer navigation', () => {
for (const role of ['operator', 'viewer'] as const) {
expect(visibleNavigation(role).map(group => group.id)).not.toContain('administration');
expect(visibleNavigation(role).map(group => group.id)).not.toContain('admin');
expect(navigationLocation('/server-keys', role)).toBeNull();
expect(visibleNavigation(role).flatMap(group => group.items).filter(item => ['h', 'k'].includes(item.shortcut ?? ''))).toEqual([]);
}
});
it('groups file releases together and retains Campaigns as its own destination', () => {
expect(navigationGroups.map(group => group.label)).toEqual(['Fleet', 'Releases', 'Activity', 'Players', 'Admin']);
expect(navigationGroups.find(group => group.id === 'releases')?.items.map(item => item.label)).toEqual(['Scripts', 'Configs', 'Plugins', 'Game files', 'Betas', 'Campaigns']);
expect(navigationGroups.flatMap(group => group.items).some(item => /rollout/i.test(item.label))).toBe(false);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
const { page } = await vi.hoisted(async () => {
const { writable } = await import('svelte/store');
return { page: writable({ url: new URL('http://localhost/servers/t5_zombie_theater_1'), data: { user: { role: 'admin' } } }) };
});
vi.mock('$app/stores', () => ({ page }));
import Breadcrumbs from '../../src/lib/ui/components/primitives/Breadcrumbs.svelte';
const items = [{ label: 'Fleet', href: '/' }, { label: 'Kino Der Toten' }];
describe('contextual breadcrumbs', () => {
it('restores the parent list filters in its only parent link', () => {
page.set({ url: new URL('http://localhost/servers/t5_zombie_theater_1?returnTo=%2F%3Ffilter%3Doccupied%26sort%3Dround'), data: { user: { role: 'admin' } } });
const html = render(Breadcrumbs, { props: { items } }).body;
expect(html).toContain('href="/?filter=occupied&sort=round"');
expect(html.match(/<a\b/g)).toHaveLength(1);
expect(html).toContain('aria-current="page"');
});
it('names a contextual parent and retains its saved section', () => {
page.set({ url: new URL('http://localhost/servers/t5_zombie_theater_1?returnTo=%2Fmaintenance%3Fview%3Dactivity'), data: { user: { role: 'admin' } } });
const html = render(Breadcrumbs, { props: { items } }).body;
expect(html).toContain('href="/maintenance?view=activity"');
expect(html).toContain('Maintenance');
});
it('rejects external and API destinations while preserving the filtered fallback', () => {
for (const returnTo of ['https://evil.invalid/', '/api/auth/logout']) {
page.set({ url: new URL(`http://localhost/servers/t5_zombie_theater_1?returnTo=${encodeURIComponent(returnTo)}`), data: { user: { role: 'admin' } } });
const html = render(Breadcrumbs, { props: { items, fallback: '/?filter=pinned' } }).body;
expect(html).toContain('href="/?filter=pinned"');
expect(html).not.toContain(returnTo);
}
});
});
+24
View File
@@ -0,0 +1,24 @@
import { expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import CommandPalette from '../../src/lib/ui/components/CommandPalette.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
it('connects the search field to a labelled option list and its selected result', () => {
const body = render(CommandPalette, { props: { open: true, role: 'operator' } }).body;
expect(body).toContain('role="combobox"');
expect(body).toContain('aria-autocomplete="list"');
expect(body).toContain('aria-expanded="true"');
const list = /aria-controls="([^"]+)"/.exec(body)?.[1];
const active = /aria-activedescendant="([^"]+)"/.exec(body)?.[1];
expect(list).toBeTruthy(); expect(active).toBeTruthy();
expect(body).toContain(`id="${list}" role="listbox"`);
expect(body).toContain(`id="${active}" role="option" tabindex="-1" aria-selected="true"`);
expect(body).toContain('Create server'); expect(body).not.toContain('Add host');
});
it('keeps the navigation palette useful for viewers without offering write actions', () => {
const body = render(CommandPalette, { props: { open: true, role: 'viewer' } }).body;
expect(body).toContain('Campaigns'); expect(body).toContain('Leaderboard');
expect(body).not.toContain('Create server'); expect(body).not.toContain('Add host');
});
+66
View File
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import { writable } from 'svelte/store';
import { createRawSnippet } from 'svelte';
import Navigation from '../../src/lib/ui/components/Navigation.svelte';
import Layout from '../../src/routes/+layout.svelte';
import { fleet } from '../../src/lib/ui/stores/fleet.js';
import type { AuthUser } from '../../src/lib/auth.js';
const currentPage = writable({ url: new URL('http://localhost/campaigns/42'), data: {} });
vi.mock('$app/stores', () => ({ page: { subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run) } }));
vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn(), afterNavigate: vi.fn(), onNavigate: vi.fn() }));
afterEach(() => { fleet.set({ servers: [], operations: [], receivedAt: 0 }); currentPage.set({ url: new URL('http://localhost/campaigns/42'), data: {} }); });
describe('sidebar navigation', () => {
it('exposes every allowed page in one navigation and marks the current page', () => {
const body = render(Navigation, { props: { role: 'operator' } }).body;
expect(body.match(/<nav\b/g)).toHaveLength(1);
expect(body).toContain('Main navigation');
expect(body).toMatch(/<a[^>]*href="\/campaigns"[^>]*aria-current="page"/);
expect(body).toContain('href="/betas"');
expect(body).toContain('href="/hosts/operations"');
expect(body).toContain('href="/metrics"');
expect(body).not.toContain('href="/settings"');
expect(body).not.toContain('href="/server-keys"');
});
it('does not show invented attention counts before the fleet snapshot loads', () => {
expect(render(Navigation, { props: { role: 'admin' } }).body).not.toMatch(/needs? attention/);
fleet.set({ receivedAt: Date.now(), operations: [], servers: [{
name: 't5_zombie_theater_1', game: 't5', map: 'zombie_theater', last_players: null,
last_map: null, last_gametype: null, last_seen_at: null, last_error: null, roster: [],
health: 'offline', held: [], active_since: null, inactive_since: null, game_started_at: null, game_ended_at: null
}] });
expect(render(Navigation, { props: { role: 'admin' } }).body).toContain('aria-label="Servers, 1 needs attention"');
});
});
const user: AuthUser = { id: '1', username: 'operator', displayName: 'Operator', role: 'operator', disabled: false,
mustChangePassword: false, createdAt: '', updatedAt: '', lastLoginAt: null };
const children = createRawSnippet(() => ({ render: () => '<h1>Page content</h1>' }));
const shell = (overrides: Partial<AuthUser> = {}) => render(Layout, { props: { children, data: { user: { ...user, ...overrides }, setupRequired: false } } }).body;
describe('application shell', () => {
it('offers collapsible desktop navigation, a labelled mobile drawer, search and help', () => {
const body = shell();
expect(body).toContain('id="desktop-navigation"');
expect(body).toContain('aria-label="Collapse navigation"');
expect(body).toContain('aria-label="Open navigation"');
expect(body).toContain('id="mobile-navigation"');
expect(body).toContain('aria-keyshortcuts="Meta+k Control+k"');
expect(body).toContain('aria-label="Keyboard shortcuts"');
expect(body).not.toContain('class="primary-navigation"');
expect(body).not.toContain('variant="secondary"');
});
it('preserves the viewer notice and hides navigation while a password change is required', () => {
const viewer = shell({ role: 'viewer' });
expect(viewer).toContain('Viewer access');
expect(viewer).not.toContain('href="/server-keys"');
const password = shell({ mustChangePassword: true });
expect(password).not.toContain('id="desktop-navigation"');
expect(password).not.toContain('id="mobile-navigation"');
expect(password).not.toContain('aria-keyshortcuts=');
});
});
+2 -2
View File
@@ -26,7 +26,7 @@ describe('server health presentation', () => {
{ ...server.host!, lastHeartbeat: iso(-91_000) }]) {
expect(serverHealth({ ...server, host, health: 'offline', container_state: 'stopped' }, now)).toEqual({
state: 'host-offline', label: 'Host offline', reason: "Dallas 1's host agent is unavailable. The game may still be running.",
actionLabel: 'Check host', actionHref: '/hosts/host-1', since: host.lastHeartbeat
shortReason: 'Dallas 1 unavailable; game may still run.', actionLabel: 'Check host', actionHref: '/hosts/host-1', since: host.lastHeartbeat
});
}
});
@@ -57,7 +57,7 @@ describe('server health presentation', () => {
it('separates a running container with failed game checks from a stopped or missing container', () => {
expect(serverHealth({ ...server, health: 'offline', last_seen_at: iso(-300_000), last_error: 'timeout:Internal endpoint 10.0.0.1 timed out' }, now)).toEqual({
state: 'not-responding', label: 'Not responding', reason: 'The game did not answer the latest status check.',
actionLabel: 'Test connection', actionHref: `/servers/${server.name}?tab=diagnostics`, since: iso(-300_000)
shortReason: 'No reply to the status check.', actionLabel: 'Test connection', actionHref: `/servers/${server.name}?tab=diagnostics`, since: iso(-300_000)
});
expect(serverHealth({ ...server, health: 'offline', container_state: 'missing', last_seen_at: null }, now).state).toBe('unknown');
});