177 lines
7.6 KiB
TypeScript
177 lines
7.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { defaultPlan, parseBetaPlan, requirement, type BetaPlan } from '../../src/lib/beta-plan.js';
|
|
import {
|
|
applySample,
|
|
applyGameEvent,
|
|
coveragePassed,
|
|
emptyMetrics,
|
|
evaluateRequirements
|
|
} from '../../src/lib/server/fleet/beta-evaluation.js';
|
|
import { gameEvent } from '../helpers/telemetry.js';
|
|
|
|
const planWith = (...kinds: Parameters<typeof requirement>[0][]): BetaPlan => ({
|
|
...defaultPlan(),
|
|
requirements: kinds.map((k, i) => requirement(k, `r${i}`))
|
|
});
|
|
|
|
describe('beta requirement evaluation', () => {
|
|
it('credits heartbeat participation during a long round without completing a match', () => {
|
|
const plan = planWith('populated-minutes', 'round', 'matches');
|
|
const m = emptyMetrics();
|
|
applyGameEvent(plan, m, gameEvent('game_start'), 0);
|
|
applyGameEvent(plan, m, gameEvent('round_change', 30), 60_000);
|
|
applyGameEvent(plan, m, gameEvent('heartbeat', 30), 120_000);
|
|
applyGameEvent(plan, m, gameEvent('heartbeat', 30), 180_000);
|
|
expect(m.values).toMatchObject({ r0: 3, r1: 30 });
|
|
expect(m.values.r2 ?? 0).toBe(0);
|
|
expect(m.match).toMatchObject({ started: 0, lastEvent: 180_000, ended: false });
|
|
});
|
|
|
|
it('copies reactive presets without mutating saved requirements', () => {
|
|
const source = defaultPlan();
|
|
const selected = parseBetaPlan(new Proxy(source, {}));
|
|
expect(selected).toEqual(source);
|
|
selected.requirements[0].target = 15;
|
|
expect(source.requirements[0].target).toBe(1);
|
|
});
|
|
it('rejects removed checks, duplicate IDs, fractional counts and unsafe numbers', () => {
|
|
expect(parseBetaPlan(defaultPlan())).toEqual(defaultPlan());
|
|
expect(() => parseBetaPlan({ ...defaultPlan(), requirements: [] })).toThrow();
|
|
expect(() => parseBetaPlan({ ...defaultPlan(), timeoutHours: NaN })).toThrow();
|
|
expect(() =>
|
|
parseBetaPlan({
|
|
...defaultPlan(),
|
|
requirements: [requirement('round', 'same'), requirement('round', 'same')]
|
|
})
|
|
).toThrow();
|
|
for (const kind of ['feature', 'restart-budget', 'clean-logs']) {
|
|
expect(() =>
|
|
parseBetaPlan({
|
|
...defaultPlan(),
|
|
requirements: [{ ...requirement('round', 'r'), kind }]
|
|
})
|
|
).toThrow();
|
|
}
|
|
expect(() =>
|
|
parseBetaPlan({
|
|
...defaultPlan(),
|
|
requirements: [{ ...requirement('round', 'r'), target: 1.5 }]
|
|
})
|
|
).toThrow();
|
|
expect(() =>
|
|
parseBetaPlan({
|
|
...defaultPlan(),
|
|
requirements: [requirement('manual', 'constructor')]
|
|
})
|
|
).toThrow();
|
|
});
|
|
|
|
it('uses qualifying round and population together within the same game, including the final report', () => {
|
|
const plan = planWith('round', 'matches');
|
|
plan.requirements[0].minPlayers = 4;
|
|
plan.requirements[1] = {
|
|
...plan.requirements[1],
|
|
target: 2,
|
|
minRound: 25,
|
|
minPlayers: 4,
|
|
minMinutes: 1
|
|
};
|
|
const m = emptyMetrics();
|
|
applyGameEvent(plan, m, gameEvent('game_start'), 0);
|
|
applyGameEvent(plan, m, gameEvent('round_change', 30, 1), 1000);
|
|
applyGameEvent(plan, m, gameEvent('game_end', 30, 1), 61_000);
|
|
expect(m.values.r0 ?? 0).toBe(0);
|
|
expect(m.values.r1 ?? 0).toBe(0);
|
|
applyGameEvent(plan, m, gameEvent('game_start'), 62_000);
|
|
applyGameEvent(plan, m, gameEvent('game_end', 25), 123_000);
|
|
expect(m.values).toMatchObject({ r0: 25, r1: 1 });
|
|
applyGameEvent(plan, m, gameEvent('game_end', 25), 124_000);
|
|
expect(m.values.r1).toBe(1);
|
|
});
|
|
|
|
it('accepts ordinary rounds and game ends without boot events or a discovery handshake', () => {
|
|
const plan = planWith('round', 'matches');
|
|
const m = emptyMetrics();
|
|
applyGameEvent(plan, m, gameEvent('round_change'), 0);
|
|
applyGameEvent(plan, m, gameEvent('game_end'), 1000);
|
|
expect(m.values).toMatchObject({ r0: 25, r1: 1 });
|
|
applyGameEvent(plan, m, gameEvent('game_start'), 2000);
|
|
applyGameEvent(plan, m, gameEvent('game_end', 1), 3000);
|
|
expect(m.values.r1).toBe(1);
|
|
applyGameEvent(plan, m, gameEvent('game_start'), 4000);
|
|
applyGameEvent(plan, m, gameEvent('game_end', 2), 5000);
|
|
expect(m.values.r1).toBe(2);
|
|
});
|
|
|
|
it('does not combine games or maps to satisfy duration and round qualifiers', () => {
|
|
const plan = planWith('matches');
|
|
plan.requirements[0] = { ...plan.requirements[0], minRound: 25, minMinutes: 1 };
|
|
const m = emptyMetrics();
|
|
applyGameEvent(plan, m, gameEvent('game_start'), 0);
|
|
applyGameEvent(plan, m, gameEvent('round_change'), 1000);
|
|
applyGameEvent(plan, m, gameEvent('game_start'), 60_000);
|
|
applyGameEvent(plan, m, gameEvent('game_end', 1), 120_000);
|
|
expect(m.values.r0 ?? 0).toBe(0);
|
|
applyGameEvent(
|
|
plan,
|
|
m,
|
|
gameEvent('game_end', 30, 4, 't5_zombie_theater_1', 'zombie_moon'),
|
|
180_000
|
|
);
|
|
expect(m.values.r0 ?? 0).toBe(0);
|
|
});
|
|
|
|
it('derives participation only from game reports, even when rounds are many minutes apart', () => {
|
|
const plan = planWith(
|
|
'populated-minutes',
|
|
'player-hours',
|
|
'population',
|
|
'sustained-population'
|
|
);
|
|
plan.requirements[3].minPlayers = 4;
|
|
const m = emptyMetrics();
|
|
applySample(plan, m, 0, true);
|
|
applySample(plan, m, 30_000, true);
|
|
expect(m.values).toEqual({});
|
|
applyGameEvent(plan, m, gameEvent('game_start'), 0);
|
|
applyGameEvent(plan, m, gameEvent('round_change'), 600_000);
|
|
expect(m.values).toMatchObject({ r0: 10, r1: 40 / 60, r2: 4, r3: 10 });
|
|
applyGameEvent(plan, m, gameEvent('game_end', 26, 2), 1_200_000);
|
|
expect(m.values).toMatchObject({ r0: 20, r1: 1, r3: 10 });
|
|
applyGameEvent(plan, m, gameEvent('game_start'), 3_000_000);
|
|
expect(m.values.r0).toBe(20);
|
|
});
|
|
|
|
it('excludes monitoring gaps from healthy time and requires explicit manual sign-off', () => {
|
|
const plan = planWith('healthy-minutes', 'failure-budget', 'manual');
|
|
const m = emptyMetrics();
|
|
applySample(plan, m, 0, true);
|
|
applySample(plan, m, 60_000, true);
|
|
expect(m.values.r0).toBe(1);
|
|
applySample(plan, m, 300_000, true);
|
|
expect(m.values.r0).toBe(0);
|
|
applySample(plan, m, 330_000, false);
|
|
applySample(plan, m, 360_000, false);
|
|
expect(m.outages).toBe(1);
|
|
expect(evaluateRequirements(plan, m, 0)[1].failed).toBe(true);
|
|
expect(evaluateRequirements(plan, m, 0)[2].passed).toBe(false);
|
|
m.manual.r2 = 'Josh: HUD checked';
|
|
expect(evaluateRequirements(plan, m, 0)[2].passed).toBe(true);
|
|
});
|
|
|
|
it('requires each selected coverage group even when many servers on one map pass', () => {
|
|
const servers = [
|
|
{ game: 't5', map: 'a', passed: true },
|
|
{ game: 't5', map: 'a', passed: true },
|
|
{ game: 't6', map: 'b', passed: false }
|
|
];
|
|
expect(coveragePassed({ ...defaultPlan(), scope: 'quorum', quorum: 2 }, servers)).toBe(
|
|
true
|
|
);
|
|
for (const scope of ['all', 'map', 'game', 'map-game'] as const)
|
|
expect(coveragePassed({ ...defaultPlan(), scope }, servers)).toBe(false);
|
|
servers[2].passed = true;
|
|
expect(coveragePassed({ ...defaultPlan(), scope: 'map-game' }, servers)).toBe(true);
|
|
});
|
|
});
|