Make metrics reflect autoscaling policies (Refs #137)
CI / checks (push) Successful in 4m49s
CI / checks (push) Successful in 4m49s
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user