Files
electric-cherry/tests/unit/ui-activity.test.ts
T

93 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { beforeEach, afterEach, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import { writable, type Writable } from 'svelte/store';
import { page } from '$app/stores';
import ActivityLog from '../../src/lib/ui/components/ActivityLog.svelte';
import ActivityPage from '../../src/routes/activity/+page.svelte';
import { activityApiHref, activityHistoryHref, activityQuery, activityTimeLabel } from '../../src/lib/ui/activity-history.js';
import type { ActivityPage as ActivityReport, ActivityRow } from '../../src/lib/activity.js';
vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/activity') }) }));
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$ui/stores/fleet.js', () => ({ fleet: writable({ servers: [], operations: [], receivedAt: 0 }) }));
vi.mock('$ui/stores/servers.js', () => ({ uiNow: writable(Date.parse('2026-10-07T18:00:00Z')) }));
const row: ActivityRow = { id: 'operation-1', at: '2026-10-07T17:59:45Z', type: 'restart', source: 'maintenance', sources: ['maintenance', 'campaign'], state: 'succeeded', rawState: 'done', title: 'Restart server', detail: 'Verified game readiness.', server: 't6_zombie_town_1', serverDeletedAt: null, hostId: 'host-a', hostName: 'Dallas 1', href: '/provisioning/operation-1' };
const report = (): ActivityReport => ({ rows: [{ ...row }], total: 52, limit: 25, offset: 0, query: { limit: 25, offset: 0 } });
function scope(path = '/activity') { (page as unknown as Writable<{ url: URL }>).set({ url: new URL(`http://localhost${path}`) }); }
beforeEach(() => { scope(); vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-07T18:00:00Z')); });
afterEach(() => vi.useRealTimers());
it('sends only supported normalized filters and enforces scoped identities', () => {
const url = new URL('https://example.test/servers/town?tab=history&server=wrong&source=host&state=failed&offset=50&returnTo=%2F');
const href = activityApiHref(url, { server: 't6_zombie_town_1', source: 'maintenance' });
const params = new URL(href, url).searchParams;
expect(params.get('server')).toBe('t6_zombie_town_1'); expect(params.get('source')).toBe('maintenance');
expect(params.get('state')).toBe('failed'); expect(params.get('offset')).toBe('50'); expect(params.get('limit')).toBe('25');
expect(params.has('tab')).toBe(false); expect(params.has('returnTo')).toBe(false);
expect(activityQuery(new URL('https://example.test/activity?type=unknown&days=0')).type).toBeUndefined();
});
it('resets pagination when filtering and preserves the containing page, hash, and return context', () => {
const current = new URL('https://example.test/servers/town?tab=history&offset=50&source=campaign&returnTo=%2F%3Ffilter%3Dattention#history');
const filtered = new URL(activityHistoryHref(current, { state: 'failed', q: 'Dallas lobby' }), current);
expect(filtered.searchParams.get('offset')).toBeNull(); expect(filtered.searchParams.get('tab')).toBe('history');
expect(filtered.searchParams.get('source')).toBe('campaign'); expect(filtered.searchParams.get('returnTo')).toBe('/?filter=attention');
expect(filtered.hash).toBe('#history'); expect(filtered.searchParams.get('q')).toBe('Dallas lobby');
expect(new URL(activityHistoryHref(filtered, { offset: '25' }), current).searchParams.get('state')).toBe('failed');
});
it('shows relative recent timestamps and preserves accurate older dates and invalid evidence', () => {
const now = Date.parse('2026-10-07T18:00:00Z');
expect(activityTimeLabel('2026-10-07 17:59:45', 'America/New_York', now)).toBe('15 s ago');
expect(activityTimeLabel('2026-10-07T17:50:00Z', 'America/New_York', now)).toBe('10 min ago');
expect(activityTimeLabel('2026-10-07T16:00:00Z', 'America/New_York', now)).toBe('2 h ago');
expect(activityTimeLabel('2026-10-06T17:00:00Z', 'America/New_York', now)).toBe('Yesterday, 1:00 PM');
expect(activityTimeLabel('not a date', 'UTC', now)).toBe('Time unavailable');
expect(activityTimeLabel('2026-10-08T18:00:00Z', 'America/New_York', now)).toBe('Oct 8, 2:00 PM');
});
it('renders one page heading and keeps management tools available only to operators', () => {
const body = render(ActivityPage, { props: { data: { activity: report(), user: { role: 'admin' }, setupRequired: false } as any } }).body;
expect(body.match(/<h1[ >]/g)).toHaveLength(1); expect(body).toContain('Manage servers'); expect(body).toContain('href="/provisioning"');
const viewer = render(ActivityPage, { props: { data: { activity: report(), user: { role: 'viewer' }, setupRequired: false } as any } }).body;
expect(viewer).not.toContain('Manage servers');
});
it('renders named operation links, shared source memberships, friendly resources, and one freshness indicator', () => {
scope('/activity?state=succeeded&offset=25');
const data = report(); data.offset = 25;
const body = render(ActivityLog, { props: { initial: data } }).body;
expect(body).toContain('Town Survival'); expect(body).toContain('Dallas 1'); expect(body).toContain('Restart server · Town Survival · Lobby 1');
expect(body).toContain('Maintenance &amp; recovery · Campaigns'); expect(body).toContain('Completed'); expect(body).toContain('shape-circle');
expect(body).toContain('15 s ago'); expect(body).toContain('datetime="2026-10-07T17:59:45Z"');
expect(body).toContain('returnTo=%2Factivity'); expect(body).toContain('state%3Dsucceeded'); expect(body).toContain('offset%3D25');
expect(body).toContain('26–26 of 52'); expect(body).toContain('offset=50');
expect(body.match(/Updates every 10 seconds/g)).toHaveLength(1); expect(body).not.toContain('aria-live');
});
it('preserves deleted server evidence without linking to a replacement instance', () => {
const data = report(); data.rows = [{ ...row, serverDeletedAt: '2026-10-07T17:59:46Z' }];
const body = render(ActivityLog, { props: { initial: data } }).body;
expect(body).toContain('Town Survival'); expect(body).toContain('Deleted'); expect(body).not.toContain('href="/servers/');
expect(body).toContain('/provisioning/operation-1?returnTo=');
});
it('offers one mobile filter sheet with distinct IDs and hides fixed scope controls', () => {
const body = render(ActivityLog, { props: { initial: report() } }).body;
expect(body).toContain('Activity filters'); expect(body).toContain('id="activity-host-mobile"'); expect(body).toContain('id="activity-search-mobile"');
const ids = [...body.matchAll(/\bid="([^"]+)"/g)].map(match => match[1]); expect(new Set(ids).size).toBe(ids.length);
scope('/maintenance?view=activity&state=failed');
const fixed = render(ActivityLog, { props: { initial: report(), server: row.server!, source: 'maintenance' } }).body;
expect(fixed).not.toContain('id="activity-server'); expect(fixed).not.toContain('id="activity-source');
expect(fixed).toContain('view=activity'); expect(fixed).toContain('state=failed'); expect(fixed).toContain('Filters · 1');
});
it('offers useful empty and out-of-range page recovery', () => {
scope('/activity?q=missing&offset=100');
const data = report(); data.rows = []; data.offset = 100;
const body = render(ActivityLog, { props: { initial: data } }).body;
expect(body).toContain('No matching activity'); expect(body).toContain('Clear filters'); expect(body).toContain('No activities on this page');
expect(body).toContain('offset=75'); expect(body).toContain('Previous');
});