Show match demand in metrics (Refs #138)
CI / checks (push) Successful in 4m44s

This commit is contained in:
2026-10-10 16:26:32 -04:00
parent ea932ec563
commit 616b1937ed
15 changed files with 775 additions and 42 deletions
+115 -1
View File
@@ -1,6 +1,11 @@
import { beforeEach, expect, it, vi } from 'vitest';
import { render } from 'svelte/server';
import type { MetricsData, MapMetrics, ServerMetrics } from '../../src/lib/metrics.js';
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';
@@ -16,9 +21,24 @@ vi.mock('$app/navigation', () => ({
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 => ({
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,
@@ -56,6 +76,7 @@ function report(): MetricsData {
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)),
@@ -181,6 +202,99 @@ it('searches and sorts the complete map set before pagination and clamps invalid
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, servers: [] } }).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, servers: [] } }).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,
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 = [