147 lines
6.1 KiB
TypeScript
147 lines
6.1 KiB
TypeScript
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).not.toContain('href="/activity"');
|
|
expect(body).toContain('href="/provisioning"');
|
|
expect(body).toContain('href="/maintenance"');
|
|
expect(body).toContain('href="/metrics"');
|
|
expect(body).toContain('href="/bank"');
|
|
expect(body).toContain('href="/afk"');
|
|
expect(body).not.toContain('href="/settings"');
|
|
expect(body).not.toContain('href="/server-keys"');
|
|
});
|
|
|
|
it('marks the AFK overview active for viewers', () => {
|
|
currentPage.set({ url: new URL('http://localhost/afk?view=sessions'), data: {} });
|
|
const body = render(Navigation, { props: { role: 'viewer' } }).body;
|
|
expect(body).toMatch(/<a[^>]*href="\/afk"[^>]*aria-current="page"/);
|
|
expect(body).not.toMatch(/<a[^>]*href="\/bank"[^>]*aria-current="page"/);
|
|
});
|
|
|
|
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"'
|
|
);
|
|
});
|
|
|
|
it('counts hosts without servers and clears the badge when the next host summary is healthy', () => {
|
|
fleet.set({ receivedAt: Date.now(), operations: [], servers: [], hostAttention: 2 });
|
|
expect(render(Navigation, { props: { role: 'admin' } }).body).toContain(
|
|
'aria-label="Hosts, 2 need attention"'
|
|
);
|
|
fleet.update((value) => ({ ...value, hostAttention: 0 }));
|
|
expect(render(Navigation, { props: { role: 'admin' } }).body).not.toMatch(
|
|
/aria-label="Hosts, \d/
|
|
);
|
|
});
|
|
});
|
|
|
|
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('explains Super Viewer access while exposing administrator navigation and shortcuts', () => {
|
|
const body = shell({ role: 'super_viewer' });
|
|
expect(body).toContain('Super Viewer access');
|
|
expect(body).toContain('href="/settings/users"');
|
|
expect(body).toContain('href="/server-keys"');
|
|
expect(body).toContain('href="/settings"');
|
|
expect(body).toContain('g h');
|
|
expect(body).toContain('g k');
|
|
});
|
|
|
|
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"');
|
|
expect(body).not.toContain('g d');
|
|
expect(body).not.toContain('Activity log');
|
|
});
|
|
|
|
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=');
|
|
});
|
|
});
|