This commit is contained in:
@@ -167,3 +167,41 @@ it('adds policy report invalidation when upgrading a database at version 12', as
|
||||
upgraded.prepare("DELETE FROM settings WHERE key='autoscaling_paused'").run();
|
||||
expect(revision()).toBeGreaterThan(paused);
|
||||
});
|
||||
|
||||
it('adds match report invalidation on upgrade without invalidating for score heartbeats', async () => {
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
for (const table of ['leaderboard_matches', 'leaderboard_match_players'])
|
||||
for (const action of ['INSERT', 'UPDATE', 'DELETE'])
|
||||
handle.exec(`DROP TRIGGER report_metrics_${table}_${action}`);
|
||||
handle.pragma('user_version = 13');
|
||||
closeDb();
|
||||
const upgraded = db();
|
||||
const revision = () =>
|
||||
(
|
||||
upgraded
|
||||
.prepare("SELECT revision FROM report_revisions WHERE topic='metrics'")
|
||||
.get() as { revision: number }
|
||||
).revision;
|
||||
const before = revision();
|
||||
upgraded.exec(`INSERT INTO leaderboard_matches
|
||||
(id,server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint)
|
||||
VALUES(1,'removed','t5','zombie_moon','t5:zombie_moon','Moon',
|
||||
'2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z','game_start','start');`);
|
||||
expect(revision()).toBeGreaterThan(before);
|
||||
const started = revision();
|
||||
upgraded.exec(`INSERT INTO leaderboard_match_players (match_id,guid,first_seen_at,last_seen_at)
|
||||
VALUES(1,'player','2026-10-10T12:00:00.000Z','2026-10-10T12:00:00.000Z');`);
|
||||
expect(revision()).toBeGreaterThan(started);
|
||||
const joined = revision();
|
||||
upgraded.exec(`UPDATE leaderboard_matches SET highest_round=2,last_event='heartbeat',
|
||||
last_received_at='2026-10-10T12:00:30.000Z' WHERE id=1;
|
||||
UPDATE leaderboard_match_players SET kills=10,present=0 WHERE match_id=1;`);
|
||||
expect(revision()).toBe(joined);
|
||||
upgraded.exec("UPDATE leaderboard_matches SET map_name='Custom Moon' WHERE id=1");
|
||||
expect(revision()).toBeGreaterThan(joined);
|
||||
const renamed = revision();
|
||||
upgraded.exec('DELETE FROM leaderboard_matches WHERE id=1');
|
||||
expect(revision()).toBeGreaterThan(renamed);
|
||||
expect(upgraded.prepare('SELECT * FROM leaderboard_match_players').all()).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -13,6 +13,13 @@ const policy = {
|
||||
const map = (values: Partial<MapMetrics> = {}): MapMetrics => ({
|
||||
game: 't5',
|
||||
map: 'Moon',
|
||||
matches: {
|
||||
started: 0,
|
||||
withRecordedPlayers: 0,
|
||||
averageGroupSize: null,
|
||||
solo: 0,
|
||||
soloShare: null
|
||||
},
|
||||
observed: 10,
|
||||
active: 5,
|
||||
idle: 5,
|
||||
|
||||
@@ -3,12 +3,20 @@ import { createMetricsRefresh } from '../../src/lib/ui/metrics-refresh.js';
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
const totals = { observed: 0, active: 0, idle: 0, unknown: 0, playerHours: 0 };
|
||||
const emptyMatches = {
|
||||
started: 0,
|
||||
withRecordedPlayers: 0,
|
||||
averageGroupSize: null,
|
||||
solo: 0,
|
||||
soloShare: null
|
||||
};
|
||||
const report = (days: number) => ({
|
||||
days,
|
||||
now: 1000,
|
||||
since: 0,
|
||||
summary: totals,
|
||||
running: { average: null, peak: 0, capturedHours: 0 },
|
||||
matches: emptyMatches,
|
||||
points: [],
|
||||
maps: [],
|
||||
servers: [],
|
||||
@@ -134,6 +142,60 @@ describe('metrics background refresh', () => {
|
||||
expect(refresh.onLoading).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('preserves successful match reports when fleet or map match data is malformed', async () => {
|
||||
const matches = {
|
||||
started: 3,
|
||||
withRecordedPlayers: 2,
|
||||
averageGroupSize: 1.5,
|
||||
solo: 1,
|
||||
soloShare: 50
|
||||
};
|
||||
const map = {
|
||||
...totals,
|
||||
game: 't5',
|
||||
map: 'Moon',
|
||||
matches,
|
||||
noEmpty: 0,
|
||||
availabilityObserved: 0,
|
||||
availabilityUnknown: 0,
|
||||
noEmptyAtMaximum: 0,
|
||||
noEmptyBelowMaximum: 0,
|
||||
policy: null
|
||||
};
|
||||
const value = { ...report(7), matches, maps: [map] };
|
||||
const invalidMatches = [
|
||||
undefined,
|
||||
null,
|
||||
{ ...matches, withRecordedPlayers: undefined },
|
||||
{ ...matches, started: -1 },
|
||||
{ ...matches, solo: 0.5 },
|
||||
{ ...matches, averageGroupSize: '1.5' },
|
||||
{ ...matches, soloShare: 101 }
|
||||
];
|
||||
const invalid = invalidMatches.flatMap((incomplete) => [
|
||||
{ ...value, matches: incomplete },
|
||||
{ ...value, maps: [{ ...map, matches: incomplete }] }
|
||||
]);
|
||||
const fetcher = vi.fn().mockResolvedValueOnce(Response.json(value));
|
||||
for (const incomplete of invalid) fetcher.mockResolvedValueOnce(Response.json(incomplete));
|
||||
const empty = { ...report(7), maps: [{ ...map, matches: emptyMatches }] };
|
||||
fetcher.mockResolvedValueOnce(Response.json(empty));
|
||||
const refresh = setup(fetcher);
|
||||
await refresh.refresh();
|
||||
expect(refresh.onData).toHaveBeenLastCalledWith(value);
|
||||
for (const _ of invalid) {
|
||||
await refresh.refresh();
|
||||
expect(refresh.onData).toHaveBeenCalledTimes(1);
|
||||
expect(refresh.onError).toHaveBeenLastCalledWith(
|
||||
'The metrics response was incomplete.'
|
||||
);
|
||||
}
|
||||
await refresh.refresh();
|
||||
expect(refresh.onData).toHaveBeenLastCalledWith(empty);
|
||||
expect(refresh.onData).toHaveBeenCalledTimes(2);
|
||||
expect(refresh.onError).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('accepts availability reports and rejects incomplete running or policy evidence', async () => {
|
||||
const value = {
|
||||
...report(7),
|
||||
@@ -142,6 +204,7 @@ describe('metrics background refresh', () => {
|
||||
...totals,
|
||||
game: 't5',
|
||||
map: 'Moon',
|
||||
matches: emptyMatches,
|
||||
noEmpty: 0,
|
||||
availabilityObserved: 0,
|
||||
availabilityUnknown: 1,
|
||||
|
||||
@@ -12,7 +12,200 @@ afterEach(async () => {
|
||||
await fixture.cleanup();
|
||||
});
|
||||
|
||||
async function seedMatches(
|
||||
records: Array<{
|
||||
id: number;
|
||||
at: number;
|
||||
players: number;
|
||||
game?: string;
|
||||
map?: string;
|
||||
status?: 'active' | 'completed' | 'interrupted';
|
||||
}>
|
||||
) {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
const match = handle.prepare(`INSERT INTO leaderboard_matches
|
||||
(id, server, server_deleted_at, game, map, map_key, map_name, started_at,
|
||||
last_received_at, status, start_observed, start_players, last_event, last_fingerprint)
|
||||
VALUES (?, 'removed-server', ?, ?, 'zm_transit', 'shared-key', ?, ?, ?, ?, 0, 4, 'round_change', 'test')`);
|
||||
const player = handle.prepare(`INSERT INTO leaderboard_match_players
|
||||
(match_id, guid, present, has_stats, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, 0, 0, ?, ?)`);
|
||||
for (const record of records) {
|
||||
const at = new Date(record.at).toISOString();
|
||||
match.run(
|
||||
record.id,
|
||||
at,
|
||||
record.game ?? 't5',
|
||||
record.map ?? 'Moon',
|
||||
at,
|
||||
at,
|
||||
record.status ?? 'completed'
|
||||
);
|
||||
for (let index = 0; index < record.players; index++) {
|
||||
player.run(record.id, `player-${index}`, at, at);
|
||||
}
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
|
||||
describe('historical metrics', () => {
|
||||
it.each([1, 7, 30] as const)(
|
||||
'counts retained matches started within the inclusive %i-day window regardless of status or observed start',
|
||||
async (days) => {
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const now = Date.UTC(2026, 1, 1, 12);
|
||||
const since = now - days * 86400_000;
|
||||
const handle = await seedMatches([
|
||||
{ id: 1, at: since - 1, players: 4 },
|
||||
{ id: 2, at: since, players: 1, status: 'completed' },
|
||||
{ id: 3, at: now, players: 2, status: 'active' },
|
||||
{ id: 4, at: since + 1, players: 0, status: 'interrupted' },
|
||||
{ id: 5, at: now + 1, players: 4, map: 'Future Map' }
|
||||
]);
|
||||
// A recent update does not bring an earlier match into the selected window.
|
||||
handle
|
||||
.prepare('UPDATE leaderboard_matches SET last_received_at=?, ended_at=? WHERE id=1')
|
||||
.run(new Date(now).toISOString(), new Date(now).toISOString());
|
||||
const result = readMetrics(days, now);
|
||||
expect(result.matches).toEqual({
|
||||
started: 3,
|
||||
withRecordedPlayers: 2,
|
||||
averageGroupSize: 1.5,
|
||||
solo: 1,
|
||||
soloShare: 50
|
||||
});
|
||||
expect(result.maps).toHaveLength(1);
|
||||
expect(result.maps[0].matches).toEqual(result.matches);
|
||||
expect(result.registeredServerCount).toBe(0);
|
||||
expect(result.servers).toEqual([]);
|
||||
expect(result.scopeOptions.maps).not.toContainEqual({ game: 't5', map: 'Future Map' });
|
||||
}
|
||||
);
|
||||
|
||||
it('weights group sizes by matches and counts unique participants per match across repeated updates', async () => {
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const now = Date.UTC(2026, 1, 1, 12);
|
||||
const handle = await seedMatches([
|
||||
{ id: 1, at: now - 5000, players: 1 },
|
||||
{ id: 2, at: now - 4000, players: 1 },
|
||||
{ id: 3, at: now - 3000, players: 4 },
|
||||
{ id: 4, at: now - 2000, players: 0 },
|
||||
{ id: 5, at: now - 1000, players: 4, map: 'Town' }
|
||||
]);
|
||||
for (let update = 0; update < 3; update++) {
|
||||
handle
|
||||
.prepare(
|
||||
`INSERT INTO leaderboard_match_players
|
||||
(match_id, guid, first_seen_at, last_seen_at) VALUES (1, 'player-0', ?, ?)
|
||||
ON CONFLICT(match_id, guid) DO UPDATE SET kills=kills+1, last_seen_at=excluded.last_seen_at`
|
||||
)
|
||||
.run(new Date(now).toISOString(), new Date(now).toISOString());
|
||||
}
|
||||
const result = readMetrics(1, now);
|
||||
expect(result.matches).toEqual({
|
||||
started: 5,
|
||||
withRecordedPlayers: 4,
|
||||
averageGroupSize: 2.5,
|
||||
solo: 2,
|
||||
soloShare: 50
|
||||
});
|
||||
const moon = result.maps.find((map) => map.map === 'Moon')!.matches;
|
||||
expect(moon).toMatchObject({
|
||||
started: 4,
|
||||
withRecordedPlayers: 3,
|
||||
averageGroupSize: 2,
|
||||
solo: 2
|
||||
});
|
||||
expect(moon.soloShare).toBeCloseTo((2 / 3) * 100);
|
||||
expect(result.maps.find((map) => map.map === 'Town')!.matches).toEqual({
|
||||
started: 1,
|
||||
withRecordedPlayers: 1,
|
||||
averageGroupSize: 4,
|
||||
solo: 0,
|
||||
soloShare: 0
|
||||
});
|
||||
expect(result.firstRetainedSampleAt).toBeNull();
|
||||
expect(result.maps.every((map) => map.observed === 0 && map.playerHours === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('distinguishes no matches, unknown rosters, and occupancy-only maps', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
const now = Date.UTC(2026, 1, 1, 12);
|
||||
const empty = {
|
||||
started: 0,
|
||||
withRecordedPlayers: 0,
|
||||
averageGroupSize: null,
|
||||
solo: 0,
|
||||
soloShare: null
|
||||
};
|
||||
expect(readMetrics(1, now).matches).toEqual(empty);
|
||||
recordMetrics(
|
||||
[{ server: 'sample-only', game: 't5', map: 'Moon', players: 1, capacity: 4 }],
|
||||
now - 30_000
|
||||
);
|
||||
await seedMatches([{ id: 1, at: now - 60_000, players: 0, map: 'Town' }]);
|
||||
const result = readMetrics(1, now);
|
||||
expect(result.matches).toEqual({ ...empty, started: 1 });
|
||||
expect(result.maps.find((map) => map.map === 'Moon')!.matches).toEqual(empty);
|
||||
expect(result.maps.find((map) => map.map === 'Town')).toMatchObject({
|
||||
matches: { ...empty, started: 1 },
|
||||
observed: 0,
|
||||
active: 0,
|
||||
idle: 0,
|
||||
unknown: 0,
|
||||
playerHours: 0,
|
||||
noEmpty: 0,
|
||||
availabilityObserved: 0,
|
||||
availabilityUnknown: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('filters matches by game and exact recorded map label while retaining historical match scope options', async () => {
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const now = Date.UTC(2026, 1, 1, 12);
|
||||
await seedMatches([
|
||||
{ id: 1, at: now - 1000, players: 1, game: 't6', map: 'Town Survival' },
|
||||
{ id: 2, at: now - 2000, players: 2, game: 't6', map: 'Town Grief' },
|
||||
{ id: 3, at: now - 3000, players: 4, game: 't5', map: 'Town Survival' },
|
||||
{ id: 4, at: now - 2 * 86400_000, players: 3, game: 't6', map: 'Farm Survival' },
|
||||
{ id: 5, at: now + 1, players: 4, game: 't6', map: 'Future Map' }
|
||||
]);
|
||||
const query = parseMetricsQuery(
|
||||
new URLSearchParams({ days: '1', game: 't6', map: 'Town Survival' })
|
||||
);
|
||||
const selected = readMetrics(query, now);
|
||||
expect(selected.matches).toEqual({
|
||||
started: 1,
|
||||
withRecordedPlayers: 1,
|
||||
averageGroupSize: 1,
|
||||
solo: 1,
|
||||
soloShare: 100
|
||||
});
|
||||
expect(selected.maps.map((map) => [map.game, map.map])).toEqual([['t6', 'Town Survival']]);
|
||||
expect(selected.scopeOptions).toEqual({
|
||||
games: ['t5', 't6'],
|
||||
maps: [
|
||||
{ game: 't5', map: 'Town Survival' },
|
||||
{ game: 't6', map: 'Farm Survival' },
|
||||
{ game: 't6', map: 'Town Grief' },
|
||||
{ game: 't6', map: 'Town Survival' }
|
||||
]
|
||||
});
|
||||
const game = readMetrics({ ...query, map: null }, now);
|
||||
expect(game.matches).toMatchObject({ started: 2, averageGroupSize: 1.5, soloShare: 50 });
|
||||
expect(game.maps.map((map) => map.map).sort()).toEqual(['Town Grief', 'Town Survival']);
|
||||
expect(readMetrics({ ...query, days: 7, map: null }, now).matches.started).toBe(3);
|
||||
expect(readMetrics({ ...query, game: 't5' }, now).matches.averageGroupSize).toBe(4);
|
||||
expect(readMetrics({ ...query, map: 'zm_transit' }, now).matches.started).toBe(0);
|
||||
expect(readMetrics({ ...query, map: 'shared-key' }, now).matches.started).toBe(0);
|
||||
expect(readMetrics({ ...query, map: 'Farm Survival' }, now)).toMatchObject({
|
||||
matches: { started: 0, averageGroupSize: null, soloShare: null },
|
||||
maps: []
|
||||
});
|
||||
});
|
||||
|
||||
it('weights occupancy by time and excludes failed checks and collection gaps', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
|
||||
@@ -35,6 +35,70 @@ it('runs reports off the request thread and invalidates cached history after wri
|
||||
expect(changed.summary.playerHours).toBeCloseTo((await first).summary.playerHours * 3);
|
||||
});
|
||||
|
||||
it('refreshes cached match metrics after active matches and recorded participants change without new samples', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const { metricsReport } = await import('../../src/lib/server/reports/index.js');
|
||||
const now = Date.UTC(2026, 9, 4, 12);
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
const at = new Date(now - 30_000).toISOString();
|
||||
const query = parseMetricsQuery(new URLSearchParams('days=1'));
|
||||
const report = async () => {
|
||||
const result = await metricsReport(query);
|
||||
expect(result).toEqual(readMetrics(query, now));
|
||||
return result;
|
||||
};
|
||||
expect((await report()).matches.started).toBe(0);
|
||||
const match = db().prepare(`INSERT INTO leaderboard_matches
|
||||
(id, server, game, map, map_key, map_name, started_at, last_received_at,
|
||||
status, last_event, last_fingerprint)
|
||||
VALUES (?, 'removed-server', 't5', 'zombie_moon', 't5:moon', 'Moon', ?, ?, 'active', 'game_start', 'test')`);
|
||||
match.run(1, at, at);
|
||||
expect((await report()).matches).toEqual({
|
||||
started: 1,
|
||||
withRecordedPlayers: 0,
|
||||
averageGroupSize: null,
|
||||
solo: 0,
|
||||
soloShare: null
|
||||
});
|
||||
const player = db().prepare(`INSERT INTO leaderboard_match_players
|
||||
(match_id, guid, first_seen_at, last_seen_at) VALUES (1, ?, ?, ?)`);
|
||||
player.run('one', at, at);
|
||||
expect((await report()).matches).toMatchObject({ averageGroupSize: 1, soloShare: 100 });
|
||||
player.run('two', at, at);
|
||||
expect((await report()).matches).toMatchObject({ averageGroupSize: 2, soloShare: 0 });
|
||||
match.run(2, at, at);
|
||||
expect((await report()).matches).toMatchObject({ started: 2, withRecordedPlayers: 1 });
|
||||
db().prepare("UPDATE leaderboard_match_players SET match_id=2 WHERE guid='two'").run();
|
||||
expect((await report()).matches).toMatchObject({
|
||||
withRecordedPlayers: 2,
|
||||
averageGroupSize: 1,
|
||||
solo: 2,
|
||||
soloShare: 100
|
||||
});
|
||||
db().prepare("DELETE FROM leaderboard_match_players WHERE guid='one'").run();
|
||||
expect((await report()).matches).toMatchObject({ withRecordedPlayers: 1, solo: 1 });
|
||||
db().prepare('DELETE FROM leaderboard_matches WHERE id=2').run();
|
||||
expect((await report()).matches).toMatchObject({
|
||||
started: 1,
|
||||
withRecordedPlayers: 0,
|
||||
averageGroupSize: null
|
||||
});
|
||||
db().prepare("UPDATE leaderboard_matches SET map_name='Town Survival', game='t6'").run();
|
||||
expect((await report()).maps.map((map) => [map.game, map.map])).toEqual([
|
||||
['t6', 'Town Survival']
|
||||
]);
|
||||
db()
|
||||
.prepare('UPDATE leaderboard_matches SET started_at=?')
|
||||
.run(new Date(now - 2 * 86400_000).toISOString());
|
||||
const outsideWindow = await report();
|
||||
expect(outsideWindow.matches.started).toBe(0);
|
||||
expect(outsideWindow.maps).toEqual([]);
|
||||
expect(outsideWindow.scopeOptions.maps).toEqual([{ game: 't6', map: 'Town Survival' }]);
|
||||
db().prepare('DELETE FROM leaderboard_matches').run();
|
||||
expect((await report()).scopeOptions).toEqual({ games: [], maps: [] });
|
||||
});
|
||||
|
||||
it('refreshes policy signals after policy edits and global pauses without new samples', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { recordMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
Reference in New Issue
Block a user