Replace instance metrics with map lobby counts (Refs #139)
CI / checks (push) Successful in 4m55s
CI / checks (push) Successful in 4m55s
This commit is contained in:
@@ -13,6 +13,7 @@ const policy = {
|
||||
const map = (values: Partial<MapMetrics> = {}): MapMetrics => ({
|
||||
game: 't5',
|
||||
map: 'Moon',
|
||||
running: { average: 2, peak: 2, capturedHours: 5 },
|
||||
matches: {
|
||||
started: 0,
|
||||
withRecordedPlayers: 0,
|
||||
|
||||
@@ -155,6 +155,7 @@ describe('metrics background refresh', () => {
|
||||
game: 't5',
|
||||
map: 'Moon',
|
||||
matches,
|
||||
running: { average: null, peak: 0, capturedHours: 0 },
|
||||
noEmpty: 0,
|
||||
availabilityObserved: 0,
|
||||
availabilityUnknown: 0,
|
||||
@@ -205,6 +206,7 @@ describe('metrics background refresh', () => {
|
||||
game: 't5',
|
||||
map: 'Moon',
|
||||
matches: emptyMatches,
|
||||
running: { average: 1, peak: 1, capturedHours: 1 },
|
||||
noEmpty: 0,
|
||||
availabilityObserved: 0,
|
||||
availabilityUnknown: 1,
|
||||
@@ -232,6 +234,19 @@ describe('metrics background refresh', () => {
|
||||
]
|
||||
};
|
||||
const invalid = [
|
||||
{ ...value, maps: [{ ...value.maps[0], running: undefined }] },
|
||||
{
|
||||
...value,
|
||||
maps: [{ ...value.maps[0], running: { average: -1, peak: 1, capturedHours: 1 } }]
|
||||
},
|
||||
{
|
||||
...value,
|
||||
maps: [{ ...value.maps[0], running: { average: 1, peak: '1', capturedHours: 1 } }]
|
||||
},
|
||||
{
|
||||
...value,
|
||||
maps: [{ ...value.maps[0], running: { average: 1, peak: 1, capturedHours: null } }]
|
||||
},
|
||||
{ ...value, autoscalingPaused: undefined },
|
||||
{ ...value, running: undefined },
|
||||
{ ...value, previous: { ...value.previous, running: undefined } },
|
||||
|
||||
@@ -77,6 +77,7 @@ describe('historical metrics', () => {
|
||||
});
|
||||
expect(result.maps).toHaveLength(1);
|
||||
expect(result.maps[0].matches).toEqual(result.matches);
|
||||
expect(result.maps[0].running).toEqual({ average: null, peak: 0, capturedHours: 0 });
|
||||
expect(result.registeredServerCount).toBe(0);
|
||||
expect(result.servers).toEqual([]);
|
||||
expect(result.scopeOptions.maps).not.toContainEqual({ game: 't5', map: 'Future Map' });
|
||||
@@ -365,6 +366,8 @@ describe('historical metrics', () => {
|
||||
expect(later.maps[0].availabilityUnknown * 3600).toBeCloseTo(90);
|
||||
expect(later.pooledAvailability.value).toBeNull();
|
||||
expect(initial.running).toEqual({ average: null, peak: 0, capturedHours: 0 });
|
||||
expect(initial.maps[0].running).toEqual(initial.running);
|
||||
expect(later.maps[0].running).toEqual({ average: 1, peak: 1, capturedHours: 90 / 3600 });
|
||||
});
|
||||
|
||||
it('returns clipped interval boundaries and marks only partial UTC buckets', async () => {
|
||||
@@ -597,6 +600,7 @@ describe('historical metrics', () => {
|
||||
expect(result.running.average).toBeCloseTo(1.6);
|
||||
expect(result.running.peak).toBe(3);
|
||||
expect(result.running.capturedHours * 3600).toBeCloseTo(150);
|
||||
expect(result.maps[0].running).toEqual(result.running);
|
||||
expect(result.previous.running.average).toBe(4);
|
||||
expect(result.previous.running.peak).toBe(4);
|
||||
expect(result.previous.running.capturedHours * 3600).toBeCloseTo(90);
|
||||
@@ -610,6 +614,50 @@ describe('historical metrics', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('counts concurrent lobbies per recorded game/map across reused names, clipped intervals and collection gaps', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
const now = Date.UTC(2026, 0, 3, 12);
|
||||
const since = now - 86400_000;
|
||||
const one = { server: 'one', game: 't5', map: 'Moon', players: 0, capacity: null };
|
||||
const two = { ...one, server: 'two', players: null };
|
||||
const otherGame = { ...one, server: 't6-one', game: 't6' };
|
||||
recordMetrics([one, two, otherGame], since - 30_000);
|
||||
recordMetrics([{ ...one, server: 'three', players: 2 }], since + 15_000);
|
||||
recordMetrics(
|
||||
[
|
||||
otherGame,
|
||||
{ ...otherGame, server: 't6-two' },
|
||||
{ ...otherGame, server: 't6-three' },
|
||||
{ ...one, map: 'Kino Der Toten' }
|
||||
],
|
||||
since + 45_000
|
||||
);
|
||||
// Both lobby names are reused after the map was absent from a collection.
|
||||
recordMetrics([one, { ...two, players: 0 }], now - 30_000);
|
||||
// A snapshot with no elapsed time must not inflate the captured peak.
|
||||
recordMetrics([one, two, { ...one, server: 'three' }], now);
|
||||
const result = readMetrics(1, now);
|
||||
const moon = result.maps.find((map) => map.game === 't5' && map.map === 'Moon')!;
|
||||
expect(moon.running.average).toBeCloseTo(1.6);
|
||||
expect(moon.running.peak).toBe(2);
|
||||
expect(moon.running.capturedHours * 3600).toBeCloseTo(75);
|
||||
expect(moon.idle * 3600).toBeCloseTo(75);
|
||||
expect(moon.unknown * 3600).toBeCloseTo(15);
|
||||
expect(moon.observed + moon.unknown).toBeCloseTo(
|
||||
moon.running.average! * moon.running.capturedHours
|
||||
);
|
||||
const t6 = result.maps.find((map) => map.game === 't6')!;
|
||||
expect(t6.running.average).toBeCloseTo(285 / 105);
|
||||
expect(t6.running.peak).toBe(3);
|
||||
expect(t6.running.capturedHours * 3600).toBeCloseTo(105);
|
||||
const kino = result.maps.find((map) => map.map === 'Kino Der Toten')!;
|
||||
expect(kino.running).toEqual({ average: 1, peak: 1, capturedHours: 90 / 3600 });
|
||||
const scoped = readMetrics({ days: 1, game: 't5', map: 'Moon', timeZone: 'UTC' }, now);
|
||||
expect(scoped.maps).toEqual([moon]);
|
||||
expect(scoped.running).toEqual(moon.running);
|
||||
});
|
||||
|
||||
it('matches current policy context through map aliases and prefers the durable owner', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
|
||||
@@ -30,6 +30,7 @@ const matches = (changes: Partial<MatchMetrics> = {}): MatchMetrics => ({
|
||||
...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({
|
||||
@@ -189,11 +190,11 @@ it('shows concurrent lobbies and empty spare share with unknown lobby-hours in t
|
||||
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, servers: data.servers } }).body;
|
||||
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, servers: data.servers } }).body;
|
||||
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<');
|
||||
@@ -245,7 +246,7 @@ it('sorts map match counts numerically, including maps without occupancy or reco
|
||||
map(4, { matches: { ...noRoster, started: 0 } })
|
||||
];
|
||||
scope('?mapSort=matches&mapDirection=asc');
|
||||
let body = render(MetricsTables, { props: { maps, servers: [] } }).body;
|
||||
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'));
|
||||
@@ -255,7 +256,7 @@ it('sorts map match counts numerically, including maps without occupancy or reco
|
||||
expect(body.replace(/\s+/g, ' ')).toContain('0 of 100');
|
||||
|
||||
scope('?mapSort=matches&mapDirection=desc');
|
||||
body = render(MetricsTables, { props: { maps, servers: [] } }).body;
|
||||
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'));
|
||||
@@ -274,6 +275,7 @@ it('keeps match summaries and map rows visible without any retained occupancy sa
|
||||
data.maps = [
|
||||
map(1, {
|
||||
...data.summary,
|
||||
running: data.running,
|
||||
noEmpty: 0,
|
||||
availabilityObserved: 0,
|
||||
matches: data.matches
|
||||
@@ -302,7 +304,7 @@ it('keeps unknown availability last when sorting and distinguishes it from known
|
||||
map(2, { noEmpty: 0 }),
|
||||
map(3)
|
||||
];
|
||||
const body = render(MetricsTables, { props: { maps, servers: [] } }).body;
|
||||
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%');
|
||||
@@ -311,18 +313,56 @@ it('keeps unknown availability last when sorting and distinguishes it from known
|
||||
expect(body).not.toContain('All lobbies full');
|
||||
});
|
||||
|
||||
it('preserves instance return context and distinguishes historic names without current links', () => {
|
||||
scope('?view=instances&serverQ=theater&days=30');
|
||||
const body = render(MetricsTables, {
|
||||
props: { maps: [], servers: [server(1), server(2)], view: 'instances' }
|
||||
}).body;
|
||||
expect(body).toContain('Historical');
|
||||
expect(body).not.toContain('href="/servers/t5_zombie_theater_1');
|
||||
expect(body).toContain('href="/servers/t5_zombie_theater_2?returnTo=');
|
||||
expect(body).toContain('serverQ%3Dtheater');
|
||||
expect(body).not.toContain('>Idle</button>');
|
||||
expect(body).not.toContain('Open current server');
|
||||
});
|
||||
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();
|
||||
@@ -475,13 +515,13 @@ it('renders whole-lobby axis ticks without rounding fractional averages or other
|
||||
});
|
||||
|
||||
it('uses shallow URL state for the selected breakdown while retaining the common summary', () => {
|
||||
scope('?view=maps', '?view=instances&serverQ=theater&serverPage=2');
|
||||
scope('?view=insights', '?view=maps&mapPage=2');
|
||||
let body = render(MetricsPage, {
|
||||
props: { data: { ...report(), user: null, setupRequired: false } }
|
||||
}).body;
|
||||
expect(body).toContain('aria-label="Instance metrics table"');
|
||||
expect(body).not.toContain('aria-label="Map metrics table"');
|
||||
expect(body.replace(/\s+/g, ' ')).toContain('16–24 of 24 instances');
|
||||
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 } }
|
||||
|
||||
Reference in New Issue
Block a user