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 { 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/servers/town?tab=history') }) })); 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 = '/servers/town?tab=history') { (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.each([ ['', '0', 'All retained history'], ['0', '0', 'All retained history'], ['1', '1', 'Last 24 hours'], ['7', '7', 'Last 7 days'], ['30', '30', 'Last 30 days'], ['90', '90', 'Last 90 days'], ['14', '14', 'Last 14 days'] ])('selects the current date range for days=%s on desktop and mobile', (days, value, label) => { scope(days ? `/servers/town?tab=history&days=${days}` : '/servers/town?tab=history'); const body = render(ActivityLog, { props: { initial: report() } }).body; for (const suffix of ['', '-mobile']) { const select = body.match( new RegExp(`]*id="activity-days${suffix}"[^>]*>([\\s\\S]*?)`) )?.[1]; expect(select).toBeDefined(); expect(select).toContain(``); expect(select?.match(/\bselected=""/g)).toHaveLength(1); } }); 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/servers/town?tab=history&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('keeps provisioning excluded from log polling after filtering or clearing filters', () => { const url = new URL( 'http://localhost/servers/town?tab=history&type=create&excludeProvisioning=false' ); const scope = { excludeProvisioning: true }; expect(new URL(activityApiHref(url, scope), url).searchParams.get('excludeProvisioning')).toBe( 'true' ); const cleared = new URL(activityHistoryHref(url, { type: '', source: '', offset: '' }), url); expect(activityQuery(cleared, scope).excludeProvisioning).toBe(true); expect( activityQuery(new URL('http://localhost/servers/town?tab=history')).excludeProvisioning ).toBeUndefined(); }); it('keeps maintenance excluded from log polling while preserving the Maintenance history scope', () => { const url = new URL( 'http://localhost/servers/town?tab=history&source=maintenance&excludeMaintenance=false' ); const logScope = { excludeProvisioning: true, excludeMaintenance: true }; expect( new URL(activityApiHref(url, logScope), url).searchParams.get('excludeMaintenance') ).toBe('true'); const cleared = new URL(activityHistoryHref(url, { type: '', source: '', offset: '' }), url); expect(activityQuery(cleared, logScope).excludeMaintenance).toBe(true); const maintenance = new URL('http://localhost/maintenance?view=activity'); const query = new URL( activityApiHref(maintenance, { source: 'maintenance', excludeMaintenance: false }), maintenance ).searchParams; expect(query.get('source')).toBe('maintenance'); expect(query.has('excludeMaintenance')).toBe(false); }); 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, 2026, 2:00 PM EDT' ); }); it('renders named operation links, shared source memberships, friendly resources, and one freshness indicator', () => { scope('/servers/town?tab=history&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 & 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=%2Fservers%2Ftown%3Ftab%3Dhistory'); 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', () => { scope('/maintenance?view=activity'); 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('