Add sidebar navigation and global command search
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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=');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user