Make metrics reflect autoscaling policies (Refs #137)
CI / checks (push) Successful in 4m49s
CI / checks (push) Successful in 4m49s
This commit is contained in:
@@ -138,3 +138,32 @@ it('rejects a database from a newer application without changing its version or
|
||||
inspect.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('adds policy report invalidation when upgrading a database at version 12', async () => {
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
for (const table of ['autoscaling_policies', 'settings'])
|
||||
for (const action of ['INSERT', 'UPDATE', 'DELETE'])
|
||||
handle.exec(`DROP TRIGGER report_metrics_${table}_${action}`);
|
||||
handle.pragma('user_version = 12');
|
||||
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
|
||||
.prepare(
|
||||
"INSERT INTO settings(key,value) VALUES('autoscaling_paused','1') ON CONFLICT(key) DO UPDATE SET value='1'"
|
||||
)
|
||||
.run();
|
||||
expect(revision()).toBeGreaterThan(before);
|
||||
const paused = revision();
|
||||
upgraded.prepare("INSERT INTO settings(key,value) VALUES('unrelated-setting','1')").run();
|
||||
expect(revision()).toBe(paused);
|
||||
upgraded.prepare("DELETE FROM settings WHERE key='autoscaling_paused'").run();
|
||||
expect(revision()).toBeGreaterThan(paused);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,14 @@ import { describe, expect, it } from 'vitest';
|
||||
import { metricRecommendations } from '../../src/lib/metrics-insights.js';
|
||||
import type { MapMetrics } from '../../src/lib/metrics.js';
|
||||
|
||||
const policy = {
|
||||
id: 't5-policy',
|
||||
mode: 'create-destroy' as const,
|
||||
minimum: 1,
|
||||
maximum: 3,
|
||||
spares: 1,
|
||||
pausedReason: null
|
||||
};
|
||||
const map = (values: Partial<MapMetrics> = {}): MapMetrics => ({
|
||||
game: 't5',
|
||||
map: 'Moon',
|
||||
@@ -10,44 +18,148 @@ const map = (values: Partial<MapMetrics> = {}): MapMetrics => ({
|
||||
idle: 5,
|
||||
unknown: 0,
|
||||
playerHours: 10,
|
||||
full: 0,
|
||||
capacityObserved: 5,
|
||||
noEmpty: 0,
|
||||
availabilityObserved: 5,
|
||||
availabilityUnknown: 0,
|
||||
noEmptyAtMaximum: 0,
|
||||
noEmptyBelowMaximum: 0,
|
||||
policy,
|
||||
...values
|
||||
});
|
||||
|
||||
describe('advisory hosting experiments', () => {
|
||||
it('uses map capacity time rather than summed server time for expansion', () => {
|
||||
describe('autoscaling policy signals', () => {
|
||||
it('compares map availability with the current maximum without needing player limits', () => {
|
||||
const [result] = metricRecommendations([
|
||||
map({ observed: 100, active: 50, idle: 50, full: 1, capacityObserved: 4 })
|
||||
map({
|
||||
observed: 100,
|
||||
active: 50,
|
||||
idle: 50,
|
||||
noEmpty: 0.1,
|
||||
availabilityObserved: 4,
|
||||
noEmptyAtMaximum: 0.1
|
||||
})
|
||||
]);
|
||||
expect(result.kind).toBe('expand');
|
||||
expect(result.evidence).toContain('25.0%');
|
||||
expect(result.tentative).toBe(false);
|
||||
expect(result).toMatchObject({
|
||||
kind: 'maximum',
|
||||
tentative: false,
|
||||
href: '/autoscaling#t5-policy',
|
||||
action: 'Review policy'
|
||||
});
|
||||
expect(result.evidence).toContain('2.5%');
|
||||
expect(result.evidence).toContain('At or above the current maximum (3)');
|
||||
expect(result.policySummary).toBe('Current policy: 1–3 lobbies, 1 spare');
|
||||
});
|
||||
|
||||
it('offers a tentative trial even with a tiny recorded sample', () => {
|
||||
it('reviews the spare target and create time for no-empty time below the maximum', () => {
|
||||
const [result] = metricRecommendations([
|
||||
map({ noEmpty: 1, noEmptyBelowMaximum: 0.9, noEmptyAtMaximum: 0.1 })
|
||||
]);
|
||||
expect(result.kind).toBe('spares');
|
||||
expect(result.reason).toContain('below the current maximum of 3');
|
||||
expect(result.reason).toContain('raising the spare target from 1');
|
||||
expect(result.title).toContain('create time');
|
||||
expect(result.evidence).toContain('below it: 0.9 h');
|
||||
});
|
||||
|
||||
it('uses the larger no-empty duration and favors maximum review on a tie', () => {
|
||||
const [result] = metricRecommendations([
|
||||
map({ noEmpty: 1, noEmptyBelowMaximum: 0.5, noEmptyAtMaximum: 0.5 })
|
||||
]);
|
||||
expect(result.kind).toBe('maximum');
|
||||
expect(result.reason).toContain('At least half');
|
||||
});
|
||||
|
||||
it('uses the two-percent threshold without treating occupied time as demand', () => {
|
||||
expect(
|
||||
metricRecommendations([
|
||||
map({ active: 9.99, idle: 0.01, noEmpty: 0.099, noEmptyAtMaximum: 0.099 })
|
||||
])[0].kind
|
||||
).toBe('watch');
|
||||
expect(metricRecommendations([map({ noEmpty: 0.1, noEmptyAtMaximum: 0.1 })])[0].kind).toBe(
|
||||
'maximum'
|
||||
);
|
||||
expect(metricRecommendations([map({ active: 0.1, idle: 9.9 })])[0].kind).toBe('watch');
|
||||
});
|
||||
|
||||
it('marks a tiny recorded sample as tentative', () => {
|
||||
const [result] = metricRecommendations([
|
||||
map({
|
||||
observed: 1 / 120,
|
||||
active: 1 / 120,
|
||||
idle: 0,
|
||||
full: 1 / 120,
|
||||
capacityObserved: 1 / 120
|
||||
noEmpty: 1 / 120,
|
||||
availabilityObserved: 1 / 120,
|
||||
noEmptyAtMaximum: 1 / 120
|
||||
})
|
||||
]);
|
||||
expect(result.kind).toBe('expand');
|
||||
expect(result.kind).toBe('maximum');
|
||||
expect(result.tentative).toBe(true);
|
||||
expect(result.evidence).toContain('30 s');
|
||||
});
|
||||
|
||||
it('uses explicitly tentative occupancy advice when capacity is missing', () => {
|
||||
const [result] = metricRecommendations([map({ active: 8, idle: 2, capacityObserved: 0 })]);
|
||||
expect(result.kind).toBe('expand');
|
||||
it('measures evidence coverage with map availability time', () => {
|
||||
const [result] = metricRecommendations([
|
||||
map({
|
||||
noEmpty: 1,
|
||||
noEmptyBelowMaximum: 1,
|
||||
availabilityObserved: 5,
|
||||
availabilityUnknown: 2,
|
||||
observed: 100
|
||||
})
|
||||
]);
|
||||
expect(result.kind).toBe('spares');
|
||||
expect(result.tentative).toBe(true);
|
||||
expect(result.reason).toContain('not evidence of saturation');
|
||||
expect(
|
||||
metricRecommendations([map({ availabilityObserved: 4, availabilityUnknown: 1 })])[0]
|
||||
.tentative
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('does not confuse unknown occupancy with an idle fleet', () => {
|
||||
it('suggests adding a policy for a map without one', () => {
|
||||
const [result] = metricRecommendations([map({ policy: null })]);
|
||||
expect(result).toMatchObject({
|
||||
kind: 'policy',
|
||||
title: 'Consider adding an autoscaling policy',
|
||||
href: '/autoscaling',
|
||||
action: 'Add policy',
|
||||
tentative: false
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps mixed no-empty evidence tentative when most lobby occupancy is unknown', () => {
|
||||
const [result] = metricRecommendations([
|
||||
map({
|
||||
observed: 2,
|
||||
active: 2,
|
||||
idle: 0,
|
||||
unknown: 18,
|
||||
availabilityObserved: 2,
|
||||
availabilityUnknown: 0,
|
||||
noEmpty: 2,
|
||||
noEmptyAtMaximum: 2
|
||||
})
|
||||
]);
|
||||
expect(result.kind).toBe('maximum');
|
||||
expect(result.tentative).toBe(true);
|
||||
expect(result.evidence).toContain('18 h of lobby time has unknown occupancy');
|
||||
});
|
||||
|
||||
it('reviews paused or disabled policies before suggesting limit changes', () => {
|
||||
const values = { noEmpty: 1, noEmptyAtMaximum: 1 };
|
||||
const [paused] = metricRecommendations([
|
||||
map({ ...values, policy: { ...policy, pausedReason: 'Review a failed host command.' } })
|
||||
]);
|
||||
expect(paused.kind).toBe('policy');
|
||||
expect(paused.title).toBe('Review and resume the autoscaling policy');
|
||||
expect(paused.reason).toContain('Review a failed host command.');
|
||||
const [disabled] = metricRecommendations([
|
||||
map({ ...values, policy: { ...policy, mode: 'off' } })
|
||||
]);
|
||||
expect(disabled.kind).toBe('policy');
|
||||
expect(disabled.title).toBe('Review and enable the autoscaling policy');
|
||||
});
|
||||
|
||||
it('does not confuse all-unknown occupancy with an idle fleet or a policy signal', () => {
|
||||
const [result] = metricRecommendations([
|
||||
map({
|
||||
observed: 0,
|
||||
@@ -55,37 +167,33 @@ describe('advisory hosting experiments', () => {
|
||||
idle: 0,
|
||||
unknown: 10,
|
||||
playerHours: 0,
|
||||
capacityObserved: 0
|
||||
availabilityObserved: 0,
|
||||
availabilityUnknown: 5,
|
||||
policy: null
|
||||
})
|
||||
]);
|
||||
expect(result.kind).toBe('capacity');
|
||||
expect(result.kind).toBe('reporting');
|
||||
expect(result.title).toBe('Check occupancy reporting');
|
||||
expect(result.tentative).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps advice scoped to input maps and considers idle trials with incomplete capacity', () => {
|
||||
const [result] = metricRecommendations([
|
||||
map({ game: 't6', map: 'Town', active: 1, idle: 9, capacityObserved: 0 })
|
||||
]);
|
||||
expect(result).toMatchObject({
|
||||
game: 't6',
|
||||
map: 'Town',
|
||||
kind: 'consolidate',
|
||||
tentative: true
|
||||
});
|
||||
expect(result.reason).toContain('preserve access');
|
||||
expect(metricRecommendations([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('distinguishes a zero-duration sample from failed occupancy reporting', () => {
|
||||
const [result] = metricRecommendations([
|
||||
map({ observed: 0, active: 0, idle: 0, playerHours: 0, capacityObserved: 0 })
|
||||
map({ observed: 0, active: 0, idle: 0, playerHours: 0, availabilityObserved: 0 })
|
||||
]);
|
||||
expect(result.title).toBe('Let observations accumulate');
|
||||
expect(result.reason).toContain('no elapsed observations');
|
||||
expect(result.reason).toContain('no elapsed availability observations');
|
||||
});
|
||||
|
||||
it('leaves the lineup alone when measured evidence does not trigger a rule', () => {
|
||||
expect(metricRecommendations([map({ active: 9, idle: 1, full: 0 })])[0].kind).toBe('watch');
|
||||
it('keeps advice scoped to input maps and sorts policy actions before quiet findings', () => {
|
||||
const results = metricRecommendations([
|
||||
map({ map: 'Moon' }),
|
||||
map({ game: 't6', map: 'Town', noEmpty: 1, noEmptyBelowMaximum: 1 })
|
||||
]);
|
||||
expect(results.map(({ game, map, kind }) => ({ game, map, kind }))).toEqual([
|
||||
{ game: 't6', map: 'Town', kind: 'spares' },
|
||||
{ game: 't5', map: 'Moon', kind: 'watch' }
|
||||
]);
|
||||
expect(metricRecommendations([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ const report = (days: number) => ({
|
||||
now: 1000,
|
||||
since: 0,
|
||||
summary: totals,
|
||||
running: { average: null, peak: 0, capturedHours: 0 },
|
||||
points: [],
|
||||
maps: [],
|
||||
servers: [],
|
||||
@@ -22,12 +23,20 @@ const report = (days: number) => ({
|
||||
since: -1000,
|
||||
until: 0,
|
||||
summary: totals,
|
||||
running: { average: null, peak: 0, capturedHours: 0 },
|
||||
firstSampleAt: null,
|
||||
lastSampleAt: null,
|
||||
partialHistory: true
|
||||
},
|
||||
heatmap: { timeZone: 'UTC', cells: [] },
|
||||
pooledPressure: { value: null, fullMapHours: 0, knownMapHours: 0, coveredMaps: 0, totalMaps: 0 }
|
||||
pooledAvailability: {
|
||||
value: null,
|
||||
noEmptyMapHours: 0,
|
||||
knownMapHours: 0,
|
||||
unknownMapHours: 0,
|
||||
coveredMaps: 0,
|
||||
totalMaps: 0
|
||||
}
|
||||
});
|
||||
function setup(fetcher: typeof fetch) {
|
||||
let query = 'days=7';
|
||||
@@ -124,6 +133,63 @@ describe('metrics background refresh', () => {
|
||||
expect(refresh.onLoading).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('accepts availability reports and rejects incomplete running or policy evidence', async () => {
|
||||
const value = {
|
||||
...report(7),
|
||||
maps: [
|
||||
{
|
||||
...totals,
|
||||
game: 't5',
|
||||
map: 'Moon',
|
||||
noEmpty: 0,
|
||||
availabilityObserved: 0,
|
||||
availabilityUnknown: 1,
|
||||
noEmptyAtMaximum: 0,
|
||||
noEmptyBelowMaximum: 0,
|
||||
policy: {
|
||||
id: 'moon',
|
||||
mode: 'create-destroy',
|
||||
minimum: 1,
|
||||
maximum: 3,
|
||||
spares: 1,
|
||||
pausedReason: null
|
||||
}
|
||||
}
|
||||
],
|
||||
points: [
|
||||
{
|
||||
...totals,
|
||||
at: 0,
|
||||
startAt: 0,
|
||||
endAt: 1000,
|
||||
partial: true,
|
||||
capturedHours: 1 / 3600
|
||||
}
|
||||
]
|
||||
};
|
||||
const invalid = [
|
||||
{ ...value, running: undefined },
|
||||
{ ...value, previous: { ...value.previous, running: undefined } },
|
||||
{ ...value, maps: [{ ...value.maps[0], policy: { id: 'moon' } }] },
|
||||
{ ...value, maps: [{ ...value.maps[0], availabilityUnknown: undefined }] },
|
||||
{ ...value, points: [{ ...value.points[0], capturedHours: undefined }] },
|
||||
{ ...value, pooledAvailability: undefined }
|
||||
];
|
||||
const fetcher = vi.fn();
|
||||
for (const incomplete of invalid) fetcher.mockResolvedValueOnce(Response.json(incomplete));
|
||||
fetcher.mockResolvedValueOnce(Response.json(value));
|
||||
const refresh = setup(fetcher);
|
||||
for (const _ of invalid) {
|
||||
await refresh.refresh();
|
||||
expect(refresh.onData).not.toHaveBeenCalled();
|
||||
expect(refresh.onError).toHaveBeenLastCalledWith(
|
||||
'The metrics response was incomplete.'
|
||||
);
|
||||
}
|
||||
await refresh.refresh();
|
||||
expect(refresh.onData).toHaveBeenLastCalledWith(value);
|
||||
});
|
||||
|
||||
it('times out with retry feedback and clears loading', async () => {
|
||||
vi.useFakeTimers();
|
||||
const fetcher = vi.fn(
|
||||
|
||||
+241
-24
@@ -31,18 +31,59 @@ describe('historical metrics', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('only counts map saturation when every lobby has known capacity and is full', async () => {
|
||||
it('counts no-empty map time from players even without capacity or with unknown lobbies', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
const at = Date.UTC(2026, 0, 2, 12);
|
||||
const sample = { server: 'one', game: 't5', map: 'Moon', players: 4, capacity: 4 };
|
||||
recordMetrics([sample, { ...sample, server: 'two' }], at);
|
||||
recordMetrics([sample, { ...sample, server: 'two', players: 2 }], at + 30_000);
|
||||
recordMetrics([sample, { ...sample, server: 'two', capacity: null }], at + 60_000);
|
||||
recordMetrics(
|
||||
[
|
||||
{ ...sample, players: 1 },
|
||||
{ ...sample, server: 'two', players: 2 }
|
||||
],
|
||||
at + 30_000
|
||||
);
|
||||
recordMetrics(
|
||||
[
|
||||
{ ...sample, capacity: null },
|
||||
{ ...sample, server: 'two', capacity: null }
|
||||
],
|
||||
at + 60_000
|
||||
);
|
||||
recordMetrics([sample, { ...sample, server: 'two', players: null }], at + 90_000);
|
||||
const result = readMetrics(1, at + 120_000);
|
||||
expect(result.maps[0].full * 3600).toBeCloseTo(30);
|
||||
expect(result.maps[0].capacityObserved * 3600).toBeCloseTo(60);
|
||||
expect(result.maps[0].noEmpty * 3600).toBeCloseTo(120);
|
||||
expect(result.maps[0].availabilityObserved * 3600).toBeCloseTo(120);
|
||||
expect(result.maps[0].availabilityUnknown).toBe(0);
|
||||
});
|
||||
|
||||
it('treats an empty spare as available and all-unknown map observations as unknown', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
const at = Date.UTC(2026, 0, 2, 12);
|
||||
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
|
||||
recordMetrics([sample, { ...sample, server: 'two', players: 0 }], at);
|
||||
recordMetrics(
|
||||
[
|
||||
{ ...sample, players: null },
|
||||
{ ...sample, server: 'two', players: 0 }
|
||||
],
|
||||
at + 30_000
|
||||
);
|
||||
recordMetrics(
|
||||
[
|
||||
{ ...sample, players: null },
|
||||
{ ...sample, server: 'two', players: null }
|
||||
],
|
||||
at + 60_000
|
||||
);
|
||||
const result = readMetrics(1, at + 90_000);
|
||||
expect(result.maps[0].noEmpty).toBe(0);
|
||||
expect(result.maps[0].availabilityObserved * 3600).toBeCloseTo(60);
|
||||
expect(result.maps[0].availabilityUnknown * 3600).toBeCloseTo(30);
|
||||
expect(result.pooledAvailability.value).toBe(0);
|
||||
expect(result.pooledAvailability.unknownMapHours * 3600).toBeCloseTo(30);
|
||||
});
|
||||
|
||||
it('clips the reporting window and splits observations across UTC chart boundaries', async () => {
|
||||
@@ -127,7 +168,10 @@ describe('historical metrics', () => {
|
||||
const later = readMetrics(1, at + 120_000);
|
||||
expect(later.summary.observed).toBe(0);
|
||||
expect(later.summary.unknown * 3600).toBeCloseTo(90);
|
||||
expect(later.maps[0].capacityObserved).toBe(0);
|
||||
expect(later.maps[0].availabilityObserved).toBe(0);
|
||||
expect(later.maps[0].availabilityUnknown * 3600).toBeCloseTo(90);
|
||||
expect(later.pooledAvailability.value).toBeNull();
|
||||
expect(initial.running).toEqual({ average: null, peak: 0, capturedHours: 0 });
|
||||
});
|
||||
|
||||
it('returns clipped interval boundaries and marks only partial UTC buckets', async () => {
|
||||
@@ -149,7 +193,7 @@ describe('historical metrics', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('uses complete collection boundaries across fleet changes and keeps map pressure in wall time', async () => {
|
||||
it('uses complete collection boundaries across fleet changes and keeps map availability in wall time', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
const at = Date.UTC(2026, 0, 2, 23, 59, 40);
|
||||
@@ -169,9 +213,11 @@ describe('historical metrics', () => {
|
||||
).toBeCloseTo(30);
|
||||
const town = result.maps.find((map) => map.game === 't6' && map.map === 'Town')!;
|
||||
expect(town.observed * 3600).toBeCloseTo(60);
|
||||
expect(town.capacityObserved * 3600).toBeCloseTo(30);
|
||||
expect(town.full * 3600).toBeCloseTo(30);
|
||||
expect(result.maps.find((map) => map.game === 't5')!.capacityObserved).toBe(0);
|
||||
expect(town.availabilityObserved * 3600).toBeCloseTo(30);
|
||||
expect(town.noEmpty * 3600).toBeCloseTo(30);
|
||||
expect(
|
||||
result.maps.find((map) => map.game === 't5')!.availabilityObserved * 3600
|
||||
).toBeCloseTo(30);
|
||||
for (const field of ['observed', 'active', 'idle', 'unknown', 'playerHours'] as const) {
|
||||
expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo(
|
||||
result.summary[field]
|
||||
@@ -290,7 +336,12 @@ describe('historical metrics', () => {
|
||||
});
|
||||
expect(result.scopeFirstSampleAt).toBe(since - 30_000);
|
||||
expect(result.scopeLatestSampleAt).toBe(now - 30_000);
|
||||
expect(result.pooledPressure.value).toBeCloseTo(100 / 3);
|
||||
expect(result.pooledAvailability.value).toBeCloseTo(100 / 3);
|
||||
expect(result.running.average).toBe(1);
|
||||
expect(result.running.peak).toBe(1);
|
||||
expect(result.running.capturedHours * 3600).toBeCloseTo(45);
|
||||
expect(result.previous.running.average).toBe(1);
|
||||
expect(result.previous.running.capturedHours * 3600).toBeCloseTo(30);
|
||||
for (const field of ['observed', 'active', 'idle', 'unknown', 'playerHours'] as const) {
|
||||
expect(result.points.reduce((sum, point) => sum + point[field], 0)).toBeCloseTo(
|
||||
result.summary[field]
|
||||
@@ -301,6 +352,9 @@ describe('historical metrics', () => {
|
||||
}
|
||||
const gameOnly = readMetrics({ ...query, map: null }, now);
|
||||
expect(gameOnly.maps.map((map) => map.map).sort()).toEqual(['Moon', 'Town']);
|
||||
expect(gameOnly.running.average).toBeCloseTo(4 / 3);
|
||||
expect(gameOnly.running.peak).toBe(2);
|
||||
expect(gameOnly.running.capturedHours * 3600).toBeCloseTo(45);
|
||||
const missing = readMetrics({ ...query, map: 'Unrecorded' }, now);
|
||||
expect(missing).toMatchObject({
|
||||
scopeFirstSampleAt: null,
|
||||
@@ -308,7 +362,8 @@ describe('historical metrics', () => {
|
||||
summary: { observed: 0, unknown: 0 },
|
||||
maps: [],
|
||||
servers: [],
|
||||
pooledPressure: { value: null },
|
||||
pooledAvailability: { value: null },
|
||||
running: { average: null, peak: 0, capturedHours: 0 },
|
||||
previous: { firstSampleAt: null, lastSampleAt: null, partialHistory: true }
|
||||
});
|
||||
expect(missing.scopeOptions).toEqual(result.scopeOptions);
|
||||
@@ -332,6 +387,160 @@ describe('historical metrics', () => {
|
||||
expect(result.scopeLatestSampleAt).toBe(at - 30_000);
|
||||
});
|
||||
|
||||
it('averages concurrent tracked lobbies over captured wall time, including unknowns and excluding 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: 1, capacity: null };
|
||||
const two = { ...one, server: 'two', players: 0 };
|
||||
const three = { ...one, server: 'three', players: null };
|
||||
const four = { ...one, server: 'four', players: null };
|
||||
recordMetrics([one, two, three, four], since - 120_000);
|
||||
recordMetrics([one, two, three], now - 180_000);
|
||||
recordMetrics([three], now - 150_000);
|
||||
recordMetrics([one, two], now - 30_000);
|
||||
const result = readMetrics(1, now);
|
||||
expect(result.running.average).toBeCloseTo(1.6);
|
||||
expect(result.running.peak).toBe(3);
|
||||
expect(result.running.capturedHours * 3600).toBeCloseTo(150);
|
||||
expect(result.previous.running.average).toBe(4);
|
||||
expect(result.previous.running.peak).toBe(4);
|
||||
expect(result.previous.running.capturedHours * 3600).toBeCloseTo(90);
|
||||
expect(result.points.reduce((sum, point) => sum + point.capturedHours, 0)).toBeCloseTo(
|
||||
result.running.capturedHours
|
||||
);
|
||||
const point = result.points.find((point) => point.capturedHours > 0)!;
|
||||
expect((point.observed + point.unknown) / point.capturedHours).toBeCloseTo(1.6);
|
||||
expect(result.summary.observed + result.summary.unknown).toBeCloseTo(
|
||||
result.running.average! * result.running.capturedHours
|
||||
);
|
||||
});
|
||||
|
||||
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');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const at = Date.UTC(2026, 0, 3, 12);
|
||||
const insert = db().prepare(
|
||||
`INSERT INTO autoscaling_policies
|
||||
(id,game,map_slug,settings,mode,paused_reason,owns_map,updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?)`
|
||||
);
|
||||
insert.run(
|
||||
't5-all',
|
||||
't5',
|
||||
'*',
|
||||
JSON.stringify({ minimum: 1, maximum: 4, spares: 2 }),
|
||||
'create-destroy',
|
||||
null,
|
||||
1,
|
||||
'now'
|
||||
);
|
||||
insert.run(
|
||||
'moon',
|
||||
't5',
|
||||
'moon',
|
||||
JSON.stringify({ minimum: 1, maximum: 2, spares: 1 }),
|
||||
'off',
|
||||
'Operator paused.',
|
||||
0,
|
||||
'now'
|
||||
);
|
||||
insert.run(
|
||||
'town',
|
||||
't6',
|
||||
'town',
|
||||
JSON.stringify({ minimum: 2, maximum: 3, spares: 1 }),
|
||||
'create-destroy',
|
||||
null,
|
||||
1,
|
||||
'now'
|
||||
);
|
||||
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
|
||||
recordMetrics(
|
||||
[
|
||||
sample,
|
||||
{ ...sample, server: 'two', map: 'Kino Der Toten' },
|
||||
{ ...sample, server: 'three', game: 't6', map: 'Town Survival' },
|
||||
{ ...sample, server: 'four', game: 't6', map: 'zm_transit' },
|
||||
{ ...sample, server: 'five', game: 't6', map: 'Moon' },
|
||||
{ ...sample, server: 'six', map: 'zombie_cosmodrome' },
|
||||
{ ...sample, server: 'seven', map: 'unknown-map' }
|
||||
],
|
||||
at
|
||||
);
|
||||
const result = readMetrics(1, at + 30_000);
|
||||
expect(result.maps.find((map) => map.game === 't5' && map.map === 'Moon')!.policy).toEqual({
|
||||
id: 't5-all',
|
||||
mode: 'create-destroy',
|
||||
minimum: 1,
|
||||
maximum: 4,
|
||||
spares: 2,
|
||||
pausedReason: null
|
||||
});
|
||||
expect(result.maps.find((map) => map.map === 'Kino Der Toten')!.policy?.id).toBe('t5-all');
|
||||
expect(result.maps.find((map) => map.map === 'zombie_cosmodrome')!.policy?.id).toBe(
|
||||
't5-all'
|
||||
);
|
||||
expect(result.maps.find((map) => map.map === 'Town Survival')!.policy?.id).toBe('town');
|
||||
expect(result.maps.find((map) => map.map === 'zm_transit')!.policy).toBeNull();
|
||||
expect(
|
||||
result.maps.find((map) => map.game === 't6' && map.map === 'Moon')!.policy
|
||||
).toBeNull();
|
||||
expect(result.maps.find((map) => map.map === 'unknown-map')!.policy).toBeNull();
|
||||
db().prepare('UPDATE autoscaling_policies SET owns_map=0').run();
|
||||
expect(
|
||||
readMetrics(1, at + 30_000).maps.find((map) => map.game === 't5' && map.map === 'Moon')!
|
||||
.policy
|
||||
).toMatchObject({ id: 'moon', mode: 'off', pausedReason: 'Operator paused.' });
|
||||
db().prepare("INSERT INTO settings(key,value) VALUES ('autoscaling_paused','1')").run();
|
||||
expect(
|
||||
readMetrics(1, at + 30_000).maps.find((map) => map.game === 't5' && map.map === 'Moon')!
|
||||
.policy?.pausedReason
|
||||
).toBe('Autoscaling is globally paused. Operator paused.');
|
||||
});
|
||||
|
||||
it('compares no-empty intervals with the current maximum, counting unknown lobbies in the lineup', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const at = Date.UTC(2026, 0, 3, 12);
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO autoscaling_policies
|
||||
(id,game,map_slug,settings,mode,owns_map,updated_at) VALUES (?,?,?,?,?,?,?)`
|
||||
)
|
||||
.run(
|
||||
'moon',
|
||||
't5',
|
||||
'moon',
|
||||
JSON.stringify({ minimum: 1, maximum: 2, spares: 1 }),
|
||||
'create-destroy',
|
||||
1,
|
||||
'now'
|
||||
);
|
||||
const one = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null };
|
||||
const two = { ...one, server: 'two', players: null };
|
||||
recordMetrics([one], at);
|
||||
recordMetrics([one, two], at + 30_000);
|
||||
recordMetrics([one, two, { ...one, server: 'three' }], at + 60_000);
|
||||
recordMetrics([one, { ...two, players: 0 }], at + 90_000);
|
||||
recordMetrics([{ ...one, players: null }, two], at + 120_000);
|
||||
const result = readMetrics(1, at + 150_000);
|
||||
expect(result.maps[0].noEmpty * 3600).toBeCloseTo(90);
|
||||
expect(result.maps[0].noEmptyAtMaximum * 3600).toBeCloseTo(60);
|
||||
expect(result.maps[0].noEmptyBelowMaximum * 3600).toBeCloseTo(30);
|
||||
expect(result.maps[0].availabilityObserved * 3600).toBeCloseTo(120);
|
||||
expect(result.maps[0].availabilityUnknown * 3600).toBeCloseTo(30);
|
||||
db()
|
||||
.prepare("UPDATE autoscaling_policies SET settings=json_set(settings,'$.maximum',4)")
|
||||
.run();
|
||||
const revised = readMetrics(1, at + 150_000).maps[0];
|
||||
expect(revised.noEmptyAtMaximum).toBe(0);
|
||||
expect(revised.noEmptyBelowMaximum * 3600).toBeCloseTo(90);
|
||||
});
|
||||
|
||||
it('compares adjacent equal windows and splits an observation at their shared boundary', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
@@ -391,35 +600,43 @@ describe('historical metrics', () => {
|
||||
expect(result.previous.summary.unknown).toBeLessThan(24);
|
||||
});
|
||||
|
||||
it('pools pressure by known map-hours rather than server-hours or fleet elapsed time', async () => {
|
||||
it('pools availability by known map-hours rather than lobby-hours or fleet elapsed time', async () => {
|
||||
const { recordMetrics, readMetrics } =
|
||||
await import('../../src/lib/server/fleet/metrics.js');
|
||||
const at = Date.UTC(2026, 0, 3, 12);
|
||||
const full = { server: 'one', game: 't5', map: 'Moon', players: 4, capacity: 4 };
|
||||
const occupied = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 };
|
||||
recordMetrics(
|
||||
[
|
||||
full,
|
||||
{ ...full, server: 'two' },
|
||||
{ ...full, server: 'three', map: 'Town', players: 0 },
|
||||
{ ...full, server: 'four', map: 'Farm', players: 2, capacity: null }
|
||||
occupied,
|
||||
{ ...occupied, server: 'two' },
|
||||
{ ...occupied, server: 'three', map: 'Town', players: 0 },
|
||||
{ ...occupied, server: 'four', map: 'Farm', players: 2, capacity: null },
|
||||
{ ...occupied, server: 'five', map: 'Unknown', players: null }
|
||||
],
|
||||
at
|
||||
);
|
||||
const result = readMetrics(1, at + 30_000);
|
||||
expect(result.pooledPressure).toMatchObject({ value: 50, coveredMaps: 2, totalMaps: 3 });
|
||||
expect(result.pooledPressure.fullMapHours * 3600).toBeCloseTo(30);
|
||||
expect(result.pooledPressure.knownMapHours * 3600).toBeCloseTo(60);
|
||||
expect(result.pooledAvailability).toMatchObject({ coveredMaps: 3, totalMaps: 4 });
|
||||
expect(result.pooledAvailability.value).toBeCloseTo(200 / 3);
|
||||
expect(result.pooledAvailability.noEmptyMapHours * 3600).toBeCloseTo(60);
|
||||
expect(result.pooledAvailability.knownMapHours * 3600).toBeCloseTo(90);
|
||||
expect(result.pooledAvailability.unknownMapHours * 3600).toBeCloseTo(30);
|
||||
expect(result.summary.observed * 3600).toBeCloseTo(120);
|
||||
const empty = readMetrics(
|
||||
parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Town' })),
|
||||
at + 30_000
|
||||
);
|
||||
expect(empty.pooledPressure.value).toBe(0);
|
||||
const missing = readMetrics(
|
||||
expect(empty.pooledAvailability.value).toBe(0);
|
||||
const missingCapacity = readMetrics(
|
||||
parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Farm' })),
|
||||
at + 30_000
|
||||
);
|
||||
expect(missing.pooledPressure.value).toBeNull();
|
||||
expect(missingCapacity.pooledAvailability.value).toBe(100);
|
||||
const unknown = readMetrics(
|
||||
parseMetricsQuery(new URLSearchParams({ days: '1', game: 't5', map: 'Unknown' })),
|
||||
at + 30_000
|
||||
);
|
||||
expect(unknown.pooledAvailability.value).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -34,3 +34,59 @@ it('runs reports off the request thread and invalidates cached history after wri
|
||||
expect(changed).toEqual(readMetrics(query, now));
|
||||
expect(changed.summary.playerHours).toBeCloseTo((await first).summary.playerHours * 3);
|
||||
});
|
||||
|
||||
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');
|
||||
const { metricsReport } = await import('../../src/lib/server/reports/index.js');
|
||||
const now = Date.UTC(2026, 9, 4, 12);
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
recordMetrics(
|
||||
[{ server: 'one', game: 't5', map: 'Moon', players: 1, capacity: null }],
|
||||
now - 60_000
|
||||
);
|
||||
const query = parseMetricsQuery(new URLSearchParams('days=1'));
|
||||
expect((await metricsReport(query)).maps[0].policy).toBeNull();
|
||||
const settings = {
|
||||
game: 't5',
|
||||
mapSlug: '*',
|
||||
mode: 'create-destroy',
|
||||
minimum: 1,
|
||||
maximum: 1,
|
||||
spares: 1,
|
||||
emptyMinutes: 15
|
||||
};
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO autoscaling_policies
|
||||
(id,game,map_slug,settings,mode,owns_map,updated_at) VALUES(?,?,?,?,?,1,?)`
|
||||
)
|
||||
.run(
|
||||
'moon-policy',
|
||||
't5',
|
||||
'*',
|
||||
JSON.stringify(settings),
|
||||
settings.mode,
|
||||
new Date(now).toISOString()
|
||||
);
|
||||
const atMaximum = (await metricsReport(query)).maps[0];
|
||||
expect(atMaximum.policy?.maximum).toBe(1);
|
||||
expect(atMaximum.noEmptyAtMaximum).toBeCloseTo(1 / 60);
|
||||
db()
|
||||
.prepare('UPDATE autoscaling_policies SET settings=? WHERE id=?')
|
||||
.run(JSON.stringify({ ...settings, maximum: 3 }), 'moon-policy');
|
||||
const belowMaximum = (await metricsReport(query)).maps[0];
|
||||
expect(belowMaximum.policy?.maximum).toBe(3);
|
||||
expect(belowMaximum.noEmptyAtMaximum).toBe(0);
|
||||
expect(belowMaximum.noEmptyBelowMaximum).toBeCloseTo(1 / 60);
|
||||
db()
|
||||
.prepare(
|
||||
"INSERT INTO settings(key,value) VALUES('autoscaling_paused','1') ON CONFLICT(key) DO UPDATE SET value='1'"
|
||||
)
|
||||
.run();
|
||||
expect((await metricsReport(query)).maps[0].policy?.pausedReason).toBeTruthy();
|
||||
db().prepare("UPDATE settings SET value='0' WHERE key='autoscaling_paused'").run();
|
||||
expect((await metricsReport(query)).maps[0].policy?.pausedReason).toBeNull();
|
||||
db().prepare("DELETE FROM autoscaling_policies WHERE id='moon-policy'").run();
|
||||
expect((await metricsReport(query)).maps[0].policy).toBeNull();
|
||||
});
|
||||
|
||||
@@ -228,9 +228,8 @@ describe('useful synthetic fixture data', () => {
|
||||
expect(metrics.samples).toBe(7 * 24 * 60);
|
||||
expect(metrics.newest).toBe(NOW);
|
||||
expect(metrics.newest - metrics.oldest).toBe((metrics.samples - 1) * 60_000);
|
||||
expect(
|
||||
count('SELECT COUNT(*) n FROM metric_samples WHERE players<0 OR players>capacity')
|
||||
).toBe(0);
|
||||
expect(count('SELECT COUNT(*) n FROM metric_samples WHERE players<0 OR players>4')).toBe(0);
|
||||
expect(count('SELECT COUNT(*) n FROM metric_samples WHERE capacity IS NOT NULL')).toBe(0);
|
||||
});
|
||||
|
||||
it('supports actual leaderboard pagination and evidence for every exact squad size', async () => {
|
||||
|
||||
@@ -24,8 +24,12 @@ const map = (i: number, changes: Partial<MapMetrics> = {}): MapMetrics => ({
|
||||
idle: 5,
|
||||
unknown: 0,
|
||||
playerHours: i,
|
||||
full: 1,
|
||||
capacityObserved: 4,
|
||||
noEmpty: 1,
|
||||
availabilityObserved: 4,
|
||||
availabilityUnknown: 0,
|
||||
noEmptyAtMaximum: 0,
|
||||
noEmptyBelowMaximum: 0,
|
||||
policy: null,
|
||||
...changes
|
||||
});
|
||||
const server = (i: number): ServerMetrics => ({
|
||||
@@ -52,23 +56,33 @@ function report(): MetricsData {
|
||||
scopeFirstSampleAt: now - 14 * 86400000,
|
||||
scopeLatestSampleAt: now,
|
||||
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 }
|
||||
{
|
||||
...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
|
||||
},
|
||||
pooledPressure: {
|
||||
pooledAvailability: {
|
||||
value: 25,
|
||||
fullMapHours: 1,
|
||||
noEmptyMapHours: 1,
|
||||
knownMapHours: 4,
|
||||
unknownMapHours: 0,
|
||||
coveredMaps: 24,
|
||||
totalMaps: 24
|
||||
},
|
||||
@@ -95,6 +109,9 @@ it('shows a summary and one paginated breakdown, preserving scope on range links
|
||||
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<');
|
||||
@@ -107,28 +124,44 @@ it('shows a summary and one paginated breakdown, preserving scope on range links
|
||||
expect(body).not.toContain('class="weekly');
|
||||
});
|
||||
|
||||
it('keeps percentage points, relative changes and unavailable capacity distinct', () => {
|
||||
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('+10 percentage points');
|
||||
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%');
|
||||
data.pooledPressure = {
|
||||
expect(body).toContain('Time with no empty lobby');
|
||||
expect(body).toContain('25.0%');
|
||||
expect(body).not.toContain('capacity data');
|
||||
data.pooledAvailability = {
|
||||
value: null,
|
||||
fullMapHours: 0,
|
||||
noEmptyMapHours: 0,
|
||||
knownMapHours: 0,
|
||||
unknownMapHours: 4,
|
||||
coveredMaps: 0,
|
||||
totalMaps: 24
|
||||
};
|
||||
data.maps = [map(1, { capacityObserved: 0 })];
|
||||
data.maps = [map(1, { availabilityObserved: 0, availabilityUnknown: 4 })];
|
||||
data.previous.summary.playerHours = 0;
|
||||
body = render(MetricsSummary, { props: { report: data } }).body;
|
||||
expect(body).toContain('Capacity data unavailable');
|
||||
expect(body).toContain('Lobby availability unavailable');
|
||||
expect(body).toContain('New · no previous player-hours');
|
||||
expect(body.replace(/\s+/g, ' ')).toContain('1 of 1 maps lack capacity data');
|
||||
expect(body).not.toContain('Infinity');
|
||||
data.summary.observed = 0;
|
||||
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', () => {
|
||||
@@ -147,14 +180,20 @@ 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('keeps missing capacity last when sorting and never presents it as zero full time', () => {
|
||||
scope('?mapSort=full&mapDirection=asc');
|
||||
const maps = [map(1, { capacityObserved: 0 }), map(2, { full: 0 }), map(3)];
|
||||
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, servers: [] } }).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('Capacity data unavailable');
|
||||
expect(body).toContain('Lobby availability unavailable');
|
||||
expect(body).toContain('No empty lobby');
|
||||
expect(body).not.toContain('All lobbies full');
|
||||
});
|
||||
|
||||
it('preserves instance return context and distinguishes historic names without current links', () => {
|
||||
@@ -170,18 +209,18 @@ it('preserves instance return context and distinguishes historic names without c
|
||||
expect(body).not.toContain('Open current server');
|
||||
});
|
||||
|
||||
it('highlights at most three supported trials and collapses tentative findings', () => {
|
||||
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, capacityObserved: 0 }));
|
||||
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, { capacityObserved: 0 })];
|
||||
data.maps = [map(1, { availabilityObserved: 0 })];
|
||||
expect(render(MetricsExplore, { props: { report: data } }).body).toContain(
|
||||
'No hosting trials meet the evidence threshold'
|
||||
'No policy changes meet the evidence threshold'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -196,20 +235,65 @@ it('keeps accessible chart selection, partial periods and gaps while collapsing
|
||||
active: 0,
|
||||
idle: 0,
|
||||
unknown: 0,
|
||||
playerHours: 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 idle servers');
|
||||
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('Server activity over time');
|
||||
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('uses shallow URL state for the selected breakdown while retaining the common summary', () => {
|
||||
@@ -225,15 +309,46 @@ it('uses shallow URL state for the selected breakdown while retaining the common
|
||||
props: { data: { ...report(), user: null, setupRequired: false } }
|
||||
}).body;
|
||||
expect(body).toContain('id="metrics-explore-title"');
|
||||
expect(body).toContain('Time all lobbies were full');
|
||||
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 insights without hiding the highlighted supported trials', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
compareMetricValues,
|
||||
metricAverageLobbies,
|
||||
metricDuration,
|
||||
metricNumber,
|
||||
metricPercent,
|
||||
@@ -8,6 +9,14 @@ import {
|
||||
} from '../../src/lib/ui/metrics.js';
|
||||
|
||||
describe('metric presentation', () => {
|
||||
it('normalizes partial buckets by captured wall time and preserves unknown lobby counts', () => {
|
||||
expect(metricAverageLobbies(14 * 24, 24)).toBe(14);
|
||||
expect(metricAverageLobbies(14 * 0.5, 0.5)).toBe(14);
|
||||
expect(metricAverageLobbies(12 + 2, 1)).toBe(14);
|
||||
expect(metricAverageLobbies(0, 1)).toBe(0);
|
||||
expect(metricAverageLobbies(0, 0)).toBeNull();
|
||||
expect(metricAverageLobbies(4, Number.NaN)).toBeNull();
|
||||
});
|
||||
it('distinguishes unavailable evidence, zero, and tiny nonzero values', () => {
|
||||
expect(metricPercent(0, 0)).toBe('—');
|
||||
expect(metricPercent(0, 1)).toBe('0.0%');
|
||||
|
||||
Reference in New Issue
Block a user