Files
electric-cherry/tests/unit/ui-metrics-page.test.ts
T
2026-10-10 16:40:07 -04:00

573 lines
23 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, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import type {
MetricsData,
MapMetrics,
ServerMetrics,
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 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> = {}): MatchMetrics => ({
started: 271,
withRecordedPlayers: 271,
averageGroupSize: 2.4,
solo: 48,
soloShare: (100 * 48) / 271,
...changes
});
const map = (i: number, changes: Partial<MapMetrics> = {}): 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
});
const server = (i: number): ServerMetrics => ({
server: `t5_zombie_theater_${i}`,
currentRegistered: i !== 1,
observed: 10,
active: i / 10,
idle: 10 - i / 10,
unknown: 0,
playerHours: i
});
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(),
summary,
running: { average: 12, peak: 24, capturedHours: 1 },
maps: Array.from({ length: 24 }, (_, i) => map(i + 1)),
servers: Array.from({ length: 24 }, (_, i) => server(i + 1)),
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 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&amp;days=30&amp;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('<h2');
expect(heatmap).toContain('Activity by weekday and hour');
expect(body).not.toContain('class="weekly');
});
it('shows concurrent lobbies and empty spare share with unknown lobby-hours in the denominator', () => {
const data = report();
let body = render(MetricsSummary, { props: { report: data } }).body;
expect(body).toContain('12 avg');
expect(body).toContain('Peak 24 lobbies at once');
expect(body.replace(/\s+/g, ' ')).toContain(
'5 h of 12 h captured lobby-hours were empty spares (41.7%)'
);
expect(body).toContain('+100%');
expect(body).toContain('Time with no empty lobby');
expect(body).toContain('25.0%');
expect(body).not.toContain('capacity data');
data.pooledAvailability = {
value: null,
noEmptyMapHours: 0,
knownMapHours: 0,
unknownMapHours: 4,
coveredMaps: 0,
totalMaps: 24
};
data.maps = [map(1, { availabilityObserved: 0, availabilityUnknown: 4 })];
data.previous.summary.playerHours = 0;
body = render(MetricsSummary, { props: { report: data } }).body;
expect(body).toContain('Lobby availability unavailable');
expect(body).toContain('New · no previous player-hours');
expect(body).not.toContain('Infinity');
data.summary = { observed: 0, active: 0, idle: 0, playerHours: 0, unknown: 12 };
body = render(MetricsSummary, { props: { report: data } }).body;
expect(body).toContain('Comparison unavailable');
expect(body).toContain('12 avg');
expect(body.replace(/\s+/g, ' ')).toContain(
'0 h of 12 h captured lobby-hours were empty spares (0.0%)'
);
data.running = { average: null, peak: 0, capturedHours: 0 };
data.summary.unknown = 0;
body = render(MetricsSummary, { props: { report: data } }).body;
expect(body).toContain('Peak unavailable');
expect(body).not.toContain('Peak 0 lobbies');
});
it('searches and sorts the complete map set before pagination and clamps invalid pages', () => {
const data = report();
scope('?mapPage=2');
let body = render(MetricsTables, { props: { maps: data.maps } }).body;
expect(body).toContain('Map 09');
expect(body).not.toContain('Map 24');
scope('?mapPage=2', '?mapQ=Map+24&mapPage=100&mapSort=playerHours');
body = render(MetricsTables, { props: { maps: data.maps } }).body;
expect(body).toContain('Map 24');
expect(body.replace(/\s+/g, ' ')).toContain('1–1 of 1 maps');
expect(body).not.toContain('>Next<');
expect(metricPage(data.maps, '-1').page).toBe(1);
expect(metricPage(data.maps, 'Infinity').page).toBe(1);
expect(metricPage([], '20')).toMatchObject({ page: 1, start: 0, end: 0, total: 0 });
});
it('shows match counts, average group and solo share with explicit missing roster coverage', () => {
const data = report();
let body = render(MetricsSummary, { props: { report: data } }).body.replace(/\s+/g, ' ');
expect(body).toContain('Matches started');
expect(body).toContain('>271<');
expect(body).toContain('Average group 2.4 players');
expect(body).toContain('48 solo · 17.7% of matches with recorded players');
data.matches = matches({ started: 300 });
body = render(MetricsSummary, { props: { report: data } }).body.replace(/\s+/g, ' ');
expect(body).toContain('48 solo · 17.7% of matches with recorded players');
expect(body).toContain('Players recorded for 271 of 300 matches');
data.matches = matches({
started: 3,
withRecordedPlayers: 0,
averageGroupSize: null,
solo: 0,
soloShare: null
});
body = render(MetricsSummary, { props: { report: data } }).body.replace(/\s+/g, ' ');
expect(body).toContain('>3<');
expect(body).toContain('Average group unavailable');
expect(body).toContain('Solo share unavailable');
expect(body).toContain('Players recorded for 0 of 3 matches');
expect(body).not.toContain('0 solo');
});
it('sorts map match counts numerically, including maps without occupancy or recorded players', () => {
const noRoster = matches({
started: 100,
withRecordedPlayers: 0,
averageGroupSize: null,
solo: 0,
soloShare: null
});
const maps = [
map(1, { matches: noRoster, observed: 0, active: 0, idle: 0, playerHours: 0 }),
map(2, { matches: matches({ started: 2, withRecordedPlayers: 2 }) }),
map(3, { matches: matches({ started: 10, withRecordedPlayers: 10 }) }),
map(4, { matches: { ...noRoster, started: 0 } })
];
scope('?mapSort=matches&mapDirection=asc');
let body = render(MetricsTables, { props: { maps } }).body;
expect(body.indexOf('Map 04')).toBeLessThan(body.indexOf('Map 02'));
expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 03'));
expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 01'));
expect(body).toContain('aria-sort="ascending"');
expect(body).toContain('avg group 2.4');
expect(body).toContain('Average group unavailable');
expect(body.replace(/\s+/g, ' ')).toContain('0 of 100');
scope('?mapSort=matches&mapDirection=desc');
body = render(MetricsTables, { props: { maps } }).body;
expect(body.indexOf('Map 01')).toBeLessThan(body.indexOf('Map 03'));
expect(body.indexOf('Map 03')).toBeLessThan(body.indexOf('Map 02'));
expect(body.indexOf('Map 02')).toBeLessThan(body.indexOf('Map 04'));
expect(body).toContain('aria-sort="descending"');
});
it('keeps match summaries and map rows visible without any retained occupancy samples', () => {
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.servers = [];
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*<small/);
}
);
it('highlights at most three supported policy changes and collapses tentative findings', () => {
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(/<details[^>]*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(/<details[^>]*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(/<button class="bar\b[\s\S]*?<\/button>/g) ?? [];
expect(bars).toHaveLength(2);
const heights = bars.map((bar) =>
[...bar.matchAll(/style="height:\s*([^"]+)"/g)].map((match) => match[1])
);
expect(heights[0]).toHaveLength(3);
expect(heights[0]).toEqual(heights[1]);
expect(heights[0]).toContain('50%;');
expect(
bars.every((bar) => bar.includes('1 with players · 3 empty spares · 2 unknown on average'))
).toBe(true);
});
it('renders whole-lobby axis ticks without rounding fractional averages or other measures', () => {
const data = report();
data.points[0] = { ...data.points[0], active: 1.5, idle: 1, observed: 2.5, unknown: 1.25 };
const renderChart = () =>
render(MetricsChart, {
props: { points: data.points, days: data.days, timeZone: data.scope.timeZone }
}).body;
const axisLabels = (body: string) => {
const axis = body.match(/<div class="y-axis[\s\S]*?<\/div>/)?.[0] ?? '';
return [...axis.matchAll(/<span[^>]*>([^<]+)<\/span>/g)].map((match) => match[1]);
};
let body = renderChart();
expect(axisLabels(body)).toEqual(['4', '3', '2', '1', '0']);
expect(body).toContain('1.5 with players');
scope('?chartMode=percent');
body = renderChart();
expect(axisLabels(body)).toEqual(['100%', '75%', '50%', '25%', '0%']);
scope('?chartMode=hours');
body = renderChart();
expect(axisLabels(body)).toEqual(['4', '3', '2', '1', '0']);
data.points[0] = { ...data.points[0], active: 0.5, idle: 0, observed: 0.5, unknown: 0 };
expect(axisLabels(renderChart())).toContain('0.375');
});
it('uses shallow URL state for the selected breakdown while retaining the common summary', () => {
scope('?view=insights', '?view=maps&mapPage=2');
let body = render(MetricsPage, {
props: { data: { ...report(), user: null, setupRequired: false } }
}).body;
expect(body).toContain('aria-label="Map metrics table"');
expect(body).not.toContain('id="metrics-explore-title"');
expect(body.replace(/\s+/g, ' ')).toContain('16–24 of 24 maps');
scope('?view=maps', '?view=insights');
body = render(MetricsPage, {
props: { data: { ...report(), user: null, setupRequired: false } }
}).body;
expect(body).toContain('id="metrics-explore-title"');
expect(body).toContain('Time with no empty lobby');
expect(body).toContain('Autoscaling signals');
expect(body).not.toContain('aria-label="Map metrics table"');
expect(body).not.toContain('aria-label="Instance metrics table"');
});
it('filters other signals without hiding the highlighted supported policy changes', () => {
scope('?view=insights&insightQ=Map+24&insightSort=map');
const body = render(MetricsExplore, { props: { report: report() } }).body;
expect(body).toContain('Map 01');
expect(body).toContain('Map 24');
expect(body).not.toContain('Map 23');
});
it('links autoscaling signals to current policies and explains their historical comparison', () => {
const data = report();
data.maps = [
map(1, {
noEmptyAtMaximum: 0.75,
noEmptyBelowMaximum: 0.25,
policy: {
id: 'policy-1',
mode: 'create-destroy',
minimum: 1,
maximum: 3,
spares: 1,
pausedReason: null
}
})
];
let body = render(MetricsExplore, { props: { report: data } }).body;
expect(body).toContain('href="/autoscaling#policy-1"');
expect(body).toContain('Review policy');
expect(body).toContain('Current policy: 1–3 lobbies, 1 spare');
expect(body.replace(/\s+/g, ' ')).toContain(
'Current settings are applied to historical lobby counts'
);
expect(body).not.toContain('player limits, and observation');
data.maps[0].policy = null;
body = render(MetricsExplore, { props: { report: data } }).body;
expect(body).toContain('href="/autoscaling"');
expect(body).toContain('Add policy');
});