import { beforeEach, expect, it, vi } from 'vitest'; import { render } from 'svelte/server'; import type { MetricsData, MapMetrics, MatchMetrics } from '../../src/lib/metrics.js'; import MetricsPage from '../../src/routes/metrics/+page.svelte'; import MetricsSummary from '../../src/lib/ui/components/metrics/MetricsSummary.svelte'; import MetricsAutoscaling from '../../src/lib/ui/components/metrics/MetricsAutoscaling.svelte'; import MetricsTables from '../../src/lib/ui/components/metrics/MetricsTables.svelte'; import MetricsExplore from '../../src/lib/ui/components/metrics/MetricsExplore.svelte'; import MetricsChart from '../../src/lib/ui/components/metrics/MetricsChart.svelte'; import { metricPage } from '../../src/lib/ui/metrics.js'; import { page } from '$app/state'; vi.mock('$app/state', () => ({ page: { url: new URL('http://localhost/metrics'), state: {} } })); vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), afterNavigate: vi.fn(), goto: vi.fn(), replaceState: vi.fn() })); const matches = (changes: Partial = {}): MatchMetrics => ({ started: 271, withRecordedPlayers: 271, averageGroupSize: 2.4, solo: 48, soloShare: (100 * 48) / 271, ...changes }); const map = (i: number, changes: Partial = {}): MapMetrics => ({ running: { average: 2.5, peak: 3, capturedHours: 4 }, game: 't5', map: `Map ${String(i).padStart(2, '0')}`, matches: matches({ started: i, withRecordedPlayers: i, averageGroupSize: 2, solo: 0, soloShare: 0 }), observed: 10, active: 5, idle: 5, unknown: 0, playerHours: i, noEmpty: 1, availabilityObserved: 4, availabilityUnknown: 0, noEmptyAtMaximum: 0, noEmptyBelowMaximum: 0, policy: null, ...changes }); function report(): MetricsData { const now = Date.parse('2026-10-06T12:00:00Z'); const summary = { observed: 10, active: 5, idle: 5, unknown: 2, playerHours: 20 }; return { days: 7, since: now - 7 * 86400000, now, firstRetainedSampleAt: now - 14 * 86400000, latestSampleAt: now, registeredServerCount: 24, autoscalingPaused: false, scope: { game: null, map: null, timeZone: 'America/New_York' }, scopeOptions: { games: ['t5'], maps: [] }, scopeFirstSampleAt: now - 14 * 86400000, scopeLatestSampleAt: now, matches: matches(), autoscaling: { added: 73, removed: 48, failed: 1, cancelled: 2, readinessSamples: 73, readinessMeanSeconds: 72, readinessMaxSeconds: 168 }, summary, running: { average: 12, peak: 24, capturedHours: 1 }, maps: Array.from({ length: 24 }, (_, i) => map(i + 1)), sampledServerCount: 24, points: [ { ...summary, at: now - 86400000, startAt: now - 86400000, endAt: now, partial: true, capturedHours: 1 } ], previous: { since: now - 14 * 86400000, until: now - 7 * 86400000, summary: { ...summary, active: 4, idle: 6, playerHours: 10 }, running: { average: 12, peak: 24, capturedHours: 1 }, firstSampleAt: now - 14 * 86400000, lastSampleAt: now - 7 * 86400000, partialHistory: true }, pooledAvailability: { value: 25, noEmptyMapHours: 1, knownMapHours: 4, unknownMapHours: 0, coveredMaps: 24, totalMaps: 24 }, heatmap: { timeZone: 'America/New_York', cells: [] } }; } function scope(search: string, stateSearch?: string) { Object.assign(page, { url: new URL(`http://localhost/metrics${search}`), state: stateSearch ? { metricsTableSearch: stateSearch } : {} }); } beforeEach(() => scope('')); it('shows Provisioning outcomes and readiness with a policy link, even without occupancy', () => { const data = report(); const section = render(MetricsAutoscaling, { props: { metrics: data.autoscaling } }).body; for (const label of [ 'Lobbies added', 'Lobbies removed', 'Failed operations', 'Cancelled operations' ]) expect(section).toContain(label); for (const value of [73, 48, 1, 2]) expect(section).toContain(`>${value}<`); expect(section).toContain('72 s'); expect(section).toContain('168 s'); expect(section).toContain('href="/autoscaling"'); expect(section.replace(/\s+/g, ' ')).toContain('73 completed creates with recorded timing'); data.summary = { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 }; data.latestSampleAt = null; const body = render(MetricsPage, { props: { data: { ...data, user: null, setupRequired: false } } }).body; expect(body).toContain('No retained observations yet'); expect(body).toContain('Lobbies added'); expect(body).toContain('72 s'); }); it('distinguishes no autoscaling outcomes from unavailable readiness', () => { const body = render(MetricsAutoscaling, { props: { metrics: { added: 0, removed: 0, failed: 0, cancelled: 0, readinessSamples: 0, readinessMeanSeconds: null, readinessMaxSeconds: null } } }).body.replace(/\s+/g, ' '); expect(body).toContain('No completed, failed or cancelled autoscaling operations recorded'); expect(body).toContain('Request-to-ready time is unavailable'); expect(body).not.toContain('0 s'); }); it.each([ [0, 'No captured time in this scope'], [1, 'Waiting for observed time'] ] as const)('uses the sampled-server count %i for the empty state', (sampledServerCount, title) => { const data = report(); data.summary = { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 }; data.running = { average: null, peak: 0, capturedHours: 0 }; data.matches = matches({ started: 0, withRecordedPlayers: 0, averageGroupSize: null, solo: 0, soloShare: null }); data.maps = []; data.sampledServerCount = sampledServerCount; const body = render(MetricsPage, { props: { data: { ...data, user: null, setupRequired: false } } }).body; expect(body).toContain(title); }); it('shows a summary and one paginated breakdown, preserving scope on range links', () => { scope('?game=t5&days=7&view=maps&mapPage=2'); const body = render(MetricsPage, { props: { data: { ...report(), user: null, setupRequired: false } } }).body; expect(body).toContain('aria-label="Metrics breakdown"'); expect(body).toContain('aria-label="Map metrics table"'); expect(body).not.toContain('aria-label="Instance metrics table"'); expect(body.replace(/\s+/g, ' ')).toContain('16–24 of 24 maps'); expect(body).toContain('game=t5&days=30&view=maps'); expect(body).toContain('Partial previous history'); expect(body).toContain('About this report'); expect(body).toContain('Average lobbies running:'); expect(body.replace(/\s+/g, ' ')).toContain('Captured wall time:'); expect(body).not.toContain('Percentage points show'); expect(body).not.toContain('Explore this period'); expect(body).toContain('Updates every minute'); expect(body).not.toContain('>Refresh<'); const heatmap = body.slice( body.indexOf('class="heatmap'), body.indexOf('aria-label="Metrics breakdown"') ); expect(heatmap).toContain(' { const data = report(); data.summary = { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 }; data.running = { average: null, peak: 0, capturedHours: 0 }; data.latestSampleAt = null; data.firstRetainedSampleAt = null; data.scopeFirstSampleAt = null; data.scopeLatestSampleAt = null; data.sampledServerCount = 0; data.maps = [ map(1, { ...data.summary, running: data.running, noEmpty: 0, availabilityObserved: 0, matches: data.matches }) ]; const body = render(MetricsPage, { props: { data: { ...data, user: null, setupRequired: false } } }).body; expect(body).toContain('Matches started'); expect(body).toContain('Average group 2.4 players'); expect(body).toContain('aria-label="Map metrics table"'); expect(body).toContain('Map 01'); expect(body).toContain('No retained observations yet'); expect(body).not.toContain('Lobbies running over time'); expect(body.replace(/\s+/g, ' ')).toContain('including both endpoints and all match statuses'); expect(body.replace(/\s+/g, ' ')).toContain( 'distinct recorded players across each match’s lifetime' ); expect(body.replace(/\s+/g, ' ')).toContain('missing or purged records are not estimated'); }); it('keeps unknown availability last when sorting and distinguishes it from known empty lobbies', () => { scope('?mapSort=noEmpty&mapDirection=asc'); const maps = [ map(1, { availabilityObserved: 0, availabilityUnknown: 4 }), map(2, { noEmpty: 0 }), map(3) ]; const body = render(MetricsTables, { props: { maps } }).body; expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 03')); expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 01')); expect(body).toContain('0.0%'); expect(body).toContain('Lobby availability unavailable'); expect(body).toContain('No empty lobby'); expect(body).not.toContain('All lobbies full'); }); it.each(['', '?view=instances&serverQ=theater&days=30'])( 'shows map lobby metrics without an Instances tab for %s', (search) => { scope(search); const body = render(MetricsPage, { props: { data: { ...report(), user: null, setupRequired: false } } }).body; expect(body).toContain('aria-label="Map metrics table"'); expect(body).toContain('aria-labelledby="metrics-views-maps"'); expect(body).toContain('Lobbies (avg · peak)'); expect(body).not.toContain('Instances'); expect(body).not.toContain('Instance utilization'); expect(body).not.toContain('Historical'); expect(body).not.toContain('grouped by name'); } ); it.each(['asc', 'desc'])( 'sorts average lobby counts numerically %s with missing evidence last and shows empty time', (direction) => { scope(`?mapSort=lobbies&mapDirection=${direction}`); const maps = [ map(1, { running: { average: null, peak: 0, capturedHours: 0 }, observed: 0, idle: 0 }), map(2, { running: { average: 2.5, peak: 4, capturedHours: 2 }, idle: 0 }), map(3, { running: { average: 10, peak: 12, capturedHours: 1 }, idle: 1.2 }), map(4, { running: { average: 3, peak: 3, capturedHours: 1 }, observed: 0, idle: 0, unknown: 3 }) ]; const body = render(MetricsTables, { props: { maps } }).body; const ordered = direction === 'asc' ? [2, 4, 3, 1] : [3, 4, 2, 1]; for (let i = 1; i < ordered.length; i++) { expect(body.indexOf(`Map 0${ordered[i - 1]}`)).toBeLessThan( body.indexOf(`Map 0${ordered[i]}`) ); } expect(body).toContain(`aria-sort="${direction === 'asc' ? 'ascending' : 'descending'}"`); expect(body).toContain('2.5 · 4'); expect(body).toContain('10 · 12'); expect(body).toContain('3 · 3'); expect(body).toContain('0 h empty'); expect(body).toContain('1.2 h empty'); expect(body).toContain('Empty time unavailable'); expect(body).toContain('Captured map time'); expect(body).toMatch(/class="lobbies [^"]*">—\s* { const data = report(); data.maps.push(map(25, { observed: 0.1, active: 0.1, idle: 0, availabilityObserved: 0 })); const body = render(MetricsExplore, { props: { report: data } }).body; const fold = body.indexOf('Other findings'); expect(body.slice(0, fold).match(/class="recommendation /g)).toHaveLength(3); expect(body.indexOf('Map 25')).toBeGreaterThan(fold); expect(body).toContain('Limited evidence'); expect(body).not.toMatch(/]*open/); data.maps = [map(1, { availabilityObserved: 0 })]; expect(render(MetricsExplore, { props: { report: data } }).body).toContain( 'No policy changes meet the evidence threshold' ); }); it('shows a global pause once while keeping Kino’s shortage ahead of quiet maps', () => { const data = report(); data.autoscalingPaused = true; const policy = { id: 't5-all', mode: 'create-destroy' as const, minimum: 1, maximum: 3, spares: 1, pausedReason: null }; data.maps = [ map(1, { map: 'Kino Der Toten', policy, observed: 100, active: 10, idle: 90, availabilityObserved: 100, noEmpty: 4.1, noEmptyAtMaximum: 4.1 }), ...Array.from({ length: 5 }, (_, i) => map(i + 2, { policy, noEmpty: 0 })) ]; let body = render(MetricsExplore, { props: { report: data } }).body; expect(body.match(/Autoscaling is globally paused\./g)).toHaveLength(1); expect(body).toContain('Consider raising the policy maximum'); expect(body).toContain('4.1%'); expect(body).toContain('href="/autoscaling#t5-all"'); expect(body).not.toContain('Review and resume the autoscaling policy'); const highlighted = body.slice(0, body.indexOf('Other findings')); expect(highlighted.match(/class="recommendation /g)).toHaveLength(1); expect(highlighted).toContain('Kino Der Toten'); data.autoscalingPaused = false; body = render(MetricsExplore, { props: { report: data } }).body; expect(body).not.toContain('Autoscaling is globally paused.'); expect(body).toContain('Consider raising the policy maximum'); }); it('keeps accessible chart selection, partial periods and gaps while collapsing inspection controls', () => { const data = report(); data.points.push({ at: data.now, startAt: data.now, endAt: data.now + 1000, partial: false, observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0, capturedHours: 0 }); const body = render(MetricsChart, { props: { points: data.points, days: data.days, timeZone: data.scope.timeZone } }).body; expect(body).toContain('aria-label="Selected period summary"'); expect(body).toContain('No captured time · not evidence of empty lobbies'); expect(body).toContain('tabindex="0"'); expect(body).toContain('Inspect a period'); expect(body).toContain('Partial period'); expect(body).not.toMatch(/]*open/); expect(body).toContain('Weekdays show when each period starts in America/New York.'); expect(body).toContain('Daily periods are 24-hour windows.'); expect(body).toContain('Lobbies running over time'); expect(body).toContain('Average lobbies running'); expect(body).toContain('5 with players · 5 empty spares · 2 unknown on average'); }); it('keeps average lobby bar heights comparable across partial periods and collection gaps', () => { const data = report(); const point = data.points[0]; data.points = [ { ...point, observed: 48, active: 12, idle: 36, unknown: 24, capturedHours: 12, partial: false }, { ...point, at: data.now, startAt: data.now, endAt: data.now + 2 * 3600000, observed: 8, active: 2, idle: 6, unknown: 4, capturedHours: 2, partial: true } ]; const body = render(MetricsChart, { props: { points: data.points, days: data.days, timeZone: data.scope.timeZone } }).body; const bars = body.match(/