Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -1,92 +1,257 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { scalingSettings, type ScalingMember, type ScalingPolicy, type ScalingSnapshot } from '../../src/lib/autoscaling.js';
|
||||
import {
|
||||
scalingSettings,
|
||||
type ScalingMember,
|
||||
type ScalingPolicy,
|
||||
type ScalingSnapshot
|
||||
} from '../../src/lib/autoscaling.js';
|
||||
import { emptyObservation, planScaling } from '../../src/lib/server/autoscaling/planner.js';
|
||||
import { mapIdentity } from '../../src/lib/server/autoscaling/snapshot.js';
|
||||
|
||||
const now = 2_000_000;
|
||||
const policy: ScalingPolicy = { ...scalingSettings({ game: 't6', mapSlug: 'town', maximum: 4, mode: 'create-destroy' }), id: 'policy', revision: 1, pausedReason: null, updatedAt: '' };
|
||||
const policy: ScalingPolicy = {
|
||||
...scalingSettings({ game: 't6', mapSlug: 'town', maximum: 4, mode: 'create-destroy' }),
|
||||
id: 'policy',
|
||||
revision: 1,
|
||||
pausedReason: null,
|
||||
updatedAt: ''
|
||||
};
|
||||
const name = (n: number) => `t6_zombie_town_${n}`;
|
||||
const member = (n: number, state: ScalingMember['state'] = 'empty'): ScalingMember => ({ name: name(n), identity: `instance:${n}`, state, players: state === 'occupied' ? 1 : state === 'empty' ? 0 : null, observedAt: now, source: 'rcon', emptySince: 1, blocker: null, operationId: null });
|
||||
const snapshot = (...members: ScalingMember[]): ScalingSnapshot => ({ members, reservedNames: members.map(m => m.name), keyPool: { total: 4, available: 4 - members.length }, blockers: [] });
|
||||
const member = (n: number, state: ScalingMember['state'] = 'empty'): ScalingMember => ({
|
||||
name: name(n),
|
||||
identity: `instance:${n}`,
|
||||
state,
|
||||
players: state === 'occupied' ? 1 : state === 'empty' ? 0 : null,
|
||||
observedAt: now,
|
||||
source: 'rcon',
|
||||
emptySince: 1,
|
||||
blocker: null,
|
||||
operationId: null
|
||||
});
|
||||
const snapshot = (...members: ScalingMember[]): ScalingSnapshot => ({
|
||||
members,
|
||||
reservedNames: members.map((m) => m.name),
|
||||
keyPool: { total: 4, available: 4 - members.length },
|
||||
blockers: []
|
||||
});
|
||||
|
||||
describe('autoscaling policy planning', () => {
|
||||
it('requires explicit bounded maximum and valid variant', () => {
|
||||
for (const input of [{}, { game: 't6', mapSlug: 'town' }, { game: 't5', mapSlug: 'town', maximum: 4 }, { game: 't6', mapSlug: 'town', maximum: 21 }, { ...policy, minimum: 5 }, { ...policy, spares: 0 }, { ...policy, emptyMinutes: 1.5 }]) expect(() => scalingSettings(input)).toThrow();
|
||||
for (const input of [
|
||||
{},
|
||||
{ game: 't6', mapSlug: 'town' },
|
||||
{ game: 't5', mapSlug: 'town', maximum: 4 },
|
||||
{ game: 't6', mapSlug: 'town', maximum: 21 },
|
||||
{ ...policy, minimum: 5 },
|
||||
{ ...policy, spares: 0 },
|
||||
{ ...policy, emptyMinutes: 1.5 }
|
||||
])
|
||||
expect(() => scalingSettings(input)).toThrow();
|
||||
expect(scalingSettings({ game: 't6', mapSlug: 'town', maximum: 4 }).mode).toBe('off');
|
||||
});
|
||||
it('creates a spare even with free player slots and bootstraps an empty map', () => {
|
||||
expect(planScaling(policy, snapshot(member(1, 'occupied')), now)).toMatchObject({ action: 'create', server: name(2), desired: 2 });
|
||||
expect(planScaling(policy, snapshot(), now)).toMatchObject({ action: 'create', desired: 1 });
|
||||
expect(planScaling(policy, snapshot(member(1, 'occupied'), member(2)), now).action).toBe('wait');
|
||||
expect(planScaling(policy, snapshot(member(1, 'occupied')), now)).toMatchObject({
|
||||
action: 'create',
|
||||
server: name(2),
|
||||
desired: 2
|
||||
});
|
||||
expect(planScaling(policy, snapshot(), now)).toMatchObject({
|
||||
action: 'create',
|
||||
desired: 1
|
||||
});
|
||||
expect(planScaling(policy, snapshot(member(1, 'occupied'), member(2)), now).action).toBe(
|
||||
'wait'
|
||||
);
|
||||
});
|
||||
it('selects the first free identity independently of the game key pool size', () => {
|
||||
expect(planScaling(policy, { ...snapshot(), reservedNames: [name(1), name(3)], keyPool: { total: 1, available: 1 } }, now).server).toBe(name(2));
|
||||
expect(planScaling(policy, { ...snapshot(), keyPool: { total: 0, available: 0 } }, now).reason).toContain('No Plutonium keys');
|
||||
expect(planScaling(policy, { ...snapshot(), keyPool: { total: 2, available: 0 } }, now).reason).toContain('keys are in use');
|
||||
expect(
|
||||
planScaling(
|
||||
policy,
|
||||
{
|
||||
...snapshot(),
|
||||
reservedNames: [name(1), name(3)],
|
||||
keyPool: { total: 1, available: 1 }
|
||||
},
|
||||
now
|
||||
).server
|
||||
).toBe(name(2));
|
||||
expect(
|
||||
planScaling(policy, { ...snapshot(), keyPool: { total: 0, available: 0 } }, now).reason
|
||||
).toContain('No Plutonium keys');
|
||||
expect(
|
||||
planScaling(policy, { ...snapshot(), keyPool: { total: 2, available: 0 } }, now).reason
|
||||
).toContain('keys are in use');
|
||||
});
|
||||
it('still removes eligible surplus lobbies when the game pool is exhausted', () => {
|
||||
expect(planScaling(policy, { ...snapshot(member(1), member(2)), keyPool: { total: 2, available: 0 } }, now)).toMatchObject({ action: 'destroy', server: name(2) });
|
||||
expect(
|
||||
planScaling(
|
||||
policy,
|
||||
{ ...snapshot(member(1), member(2)), keyPool: { total: 2, available: 0 } },
|
||||
now
|
||||
)
|
||||
).toMatchObject({ action: 'destroy', server: name(2) });
|
||||
});
|
||||
it('holds for pending creation, unknown, stopped, or protected members', () => {
|
||||
for (const state of ['creating', 'removing', 'unknown', 'stopped', 'protected'] as const) expect(planScaling(policy, snapshot(member(1, state)), now).action).toBe('wait');
|
||||
expect(planScaling(policy, { ...snapshot(), blockers: ['Unresolved instance'] }, now).action).toBe('wait');
|
||||
for (const state of ['creating', 'removing', 'unknown', 'stopped', 'protected'] as const)
|
||||
expect(planScaling(policy, snapshot(member(1, state)), now).action).toBe('wait');
|
||||
expect(
|
||||
planScaling(policy, { ...snapshot(), blockers: ['Unresolved instance'] }, now).action
|
||||
).toBe('wait');
|
||||
});
|
||||
it('holds for stale and future samples and disabled policies', () => {
|
||||
for (const stamp of [null, now - 90_001, now + 1]) expect(planScaling(policy, snapshot({ ...member(1, 'occupied'), observedAt: stamp }), now).action).toBe('wait');
|
||||
for (const stamp of [null, now - 90_001, now + 1])
|
||||
expect(
|
||||
planScaling(policy, snapshot({ ...member(1, 'occupied'), observedAt: stamp }), now)
|
||||
.action
|
||||
).toBe('wait');
|
||||
expect(planScaling({ ...policy, mode: 'off' }, snapshot(), now).action).toBe('wait');
|
||||
});
|
||||
it('never sacrifices occupied lobbies or minimum/spare capacity to a maximum', () => {
|
||||
expect(planScaling(policy, snapshot(...[1, 2, 3, 4].map(n => member(n, 'occupied'))), now)).toMatchObject({ action: 'wait', desired: 4 });
|
||||
expect(planScaling({ ...policy, maximum: 1 }, snapshot(member(1, 'occupied'), member(2)), now).action).toBe('wait');
|
||||
expect(planScaling({ ...policy, minimum: 3 }, snapshot(member(1), member(2), member(3)), now).action).toBe('wait');
|
||||
expect(
|
||||
planScaling(policy, snapshot(...[1, 2, 3, 4].map((n) => member(n, 'occupied'))), now)
|
||||
).toMatchObject({ action: 'wait', desired: 4 });
|
||||
expect(
|
||||
planScaling({ ...policy, maximum: 1 }, snapshot(member(1, 'occupied'), member(2)), now)
|
||||
.action
|
||||
).toBe('wait');
|
||||
expect(
|
||||
planScaling({ ...policy, minimum: 3 }, snapshot(member(1), member(2), member(3)), now)
|
||||
.action
|
||||
).toBe('wait');
|
||||
});
|
||||
it('trims one highest-numbered surplus lobby and requires a full observed interval', () => {
|
||||
expect(planScaling(policy, snapshot(member(1, 'occupied'), member(2), member(3), member(4)), now)).toMatchObject({ action: 'destroy', server: name(4), desired: 2 });
|
||||
expect(planScaling(policy, snapshot({ ...member(1), emptySince: now }, { ...member(2), emptySince: now }), now).action).toBe('wait');
|
||||
expect(
|
||||
planScaling(
|
||||
policy,
|
||||
snapshot(member(1, 'occupied'), member(2), member(3), member(4)),
|
||||
now
|
||||
)
|
||||
).toMatchObject({ action: 'destroy', server: name(4), desired: 2 });
|
||||
expect(
|
||||
planScaling(
|
||||
policy,
|
||||
snapshot({ ...member(1), emptySince: now }, { ...member(2), emptySince: now }),
|
||||
now
|
||||
).action
|
||||
).toBe('wait');
|
||||
});
|
||||
it('keeps Lobby 1 when it has been empty longer than a newly empty Lobby 2', () => {
|
||||
const members = [{ ...member(2), emptySince: now }, member(1)];
|
||||
expect(planScaling(policy, snapshot(...members), now)).toMatchObject({ action: 'wait', server: null, desired: 1 });
|
||||
expect(planScaling(policy, snapshot(...members), now)).toMatchObject({
|
||||
action: 'wait',
|
||||
server: null,
|
||||
desired: 1
|
||||
});
|
||||
const later = now + policy.emptyMinutes * 60_000;
|
||||
expect(planScaling(policy, snapshot(...members.map(m => ({ ...m, observedAt: later }))), later))
|
||||
.toMatchObject({ action: 'destroy', server: name(2), desired: 1 });
|
||||
expect(
|
||||
planScaling(
|
||||
policy,
|
||||
snapshot(...members.map((m) => ({ ...m, observedAt: later }))),
|
||||
later
|
||||
)
|
||||
).toMatchObject({ action: 'destroy', server: name(2), desired: 1 });
|
||||
});
|
||||
it.each([1, 2])('retains the lowest empty spare while Lobby %i is occupied', occupied => {
|
||||
const members = [member(1, occupied === 1 ? 'occupied' : 'empty'), member(2, occupied === 2 ? 'occupied' : 'empty')];
|
||||
expect(planScaling(policy, snapshot(...members), now)).toMatchObject({ action: 'wait', desired: 2 });
|
||||
expect(planScaling(policy, snapshot(...members, { ...member(3), emptySince: now }), now)).toMatchObject({ action: 'wait', desired: 2 });
|
||||
expect(planScaling(policy, snapshot(...members, member(3)), now)).toMatchObject({ action: 'destroy', server: name(3), desired: 2 });
|
||||
it.each([1, 2])('retains the lowest empty spare while Lobby %i is occupied', (occupied) => {
|
||||
const members = [
|
||||
member(1, occupied === 1 ? 'occupied' : 'empty'),
|
||||
member(2, occupied === 2 ? 'occupied' : 'empty')
|
||||
];
|
||||
expect(planScaling(policy, snapshot(...members), now)).toMatchObject({
|
||||
action: 'wait',
|
||||
desired: 2
|
||||
});
|
||||
expect(
|
||||
planScaling(policy, snapshot(...members, { ...member(3), emptySince: now }), now)
|
||||
).toMatchObject({ action: 'wait', desired: 2 });
|
||||
expect(planScaling(policy, snapshot(...members, member(3)), now)).toMatchObject({
|
||||
action: 'destroy',
|
||||
server: name(3),
|
||||
desired: 2
|
||||
});
|
||||
});
|
||||
it('preserves occupied higher-numbered lobbies while trimming empty surplus', () => {
|
||||
expect(planScaling(policy, snapshot(member(1), member(2), member(3), member(4, 'occupied')), now))
|
||||
.toMatchObject({ action: 'destroy', server: name(3), desired: 2 });
|
||||
expect(planScaling(policy, snapshot(member(1), member(4, 'occupied')), now)).toMatchObject({ action: 'wait', desired: 2 });
|
||||
expect(
|
||||
planScaling(
|
||||
policy,
|
||||
snapshot(member(1), member(2), member(3), member(4, 'occupied')),
|
||||
now
|
||||
)
|
||||
).toMatchObject({ action: 'destroy', server: name(3), desired: 2 });
|
||||
expect(planScaling(policy, snapshot(member(1), member(4, 'occupied')), now)).toMatchObject({
|
||||
action: 'wait',
|
||||
desired: 2
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
{ minimum: 3, spares: 1, occupied: null, surplus: 4 },
|
||||
{ minimum: 3, spares: 1, occupied: 4, surplus: 3 },
|
||||
{ minimum: 1, spares: 2, occupied: 4, surplus: 3 }
|
||||
])('retains low-numbered empties for minimum $minimum and spares $spares with occupied Lobby $occupied', ({ minimum, spares, occupied, surplus }) => {
|
||||
const settings = { ...policy, minimum, spares };
|
||||
const members = [4, 3, 2, 1].map(n => member(n, n === occupied ? 'occupied' : 'empty'));
|
||||
expect(planScaling(settings, snapshot(...members.map(m => m.name === name(surplus) ? { ...m, emptySince: now } : m)), now))
|
||||
.toMatchObject({ action: 'wait', desired: 3 });
|
||||
expect(planScaling(settings, snapshot(...members), now)).toMatchObject({ action: 'destroy', server: name(surplus), desired: 3 });
|
||||
expect(planScaling(settings, snapshot(...members.filter(m => m.name !== name(surplus))), now)).toMatchObject({ action: 'wait', desired: 3 });
|
||||
});
|
||||
])(
|
||||
'retains low-numbered empties for minimum $minimum and spares $spares with occupied Lobby $occupied',
|
||||
({ minimum, spares, occupied, surplus }) => {
|
||||
const settings = { ...policy, minimum, spares };
|
||||
const members = [4, 3, 2, 1].map((n) =>
|
||||
member(n, n === occupied ? 'occupied' : 'empty')
|
||||
);
|
||||
expect(
|
||||
planScaling(
|
||||
settings,
|
||||
snapshot(
|
||||
...members.map((m) =>
|
||||
m.name === name(surplus) ? { ...m, emptySince: now } : m
|
||||
)
|
||||
),
|
||||
now
|
||||
)
|
||||
).toMatchObject({ action: 'wait', desired: 3 });
|
||||
expect(planScaling(settings, snapshot(...members), now)).toMatchObject({
|
||||
action: 'destroy',
|
||||
server: name(surplus),
|
||||
desired: 3
|
||||
});
|
||||
expect(
|
||||
planScaling(
|
||||
settings,
|
||||
snapshot(...members.filter((m) => m.name !== name(surplus))),
|
||||
now
|
||||
)
|
||||
).toMatchObject({ action: 'wait', desired: 3 });
|
||||
}
|
||||
);
|
||||
it('orders lobby numbers numerically when choosing survivors and surplus', () => {
|
||||
expect(planScaling(policy, snapshot(member(10), member(2)), now)).toMatchObject({ action: 'destroy', server: name(10) });
|
||||
expect(planScaling(policy, snapshot(member(2), member(10), member(1)), now)).toMatchObject({ action: 'destroy', server: name(10) });
|
||||
expect(planScaling(policy, snapshot(member(10), member(2)), now)).toMatchObject({
|
||||
action: 'destroy',
|
||||
server: name(10)
|
||||
});
|
||||
expect(planScaling(policy, snapshot(member(2), member(10), member(1)), now)).toMatchObject({
|
||||
action: 'destroy',
|
||||
server: name(10)
|
||||
});
|
||||
});
|
||||
it('keeps an existing solo Lobby 2 and fills Lobby 1 when occupancy requires growth', () => {
|
||||
expect(planScaling(policy, snapshot(member(2)), now)).toMatchObject({ action: 'wait', server: null, desired: 1 });
|
||||
expect(planScaling(policy, snapshot(member(2, 'occupied')), now)).toMatchObject({ action: 'create', server: name(1), desired: 2 });
|
||||
expect(planScaling(policy, snapshot(member(2), member(1)), now)).toMatchObject({ action: 'destroy', server: name(2), desired: 1 });
|
||||
expect(planScaling(policy, snapshot(member(2)), now)).toMatchObject({
|
||||
action: 'wait',
|
||||
server: null,
|
||||
desired: 1
|
||||
});
|
||||
expect(planScaling(policy, snapshot(member(2, 'occupied')), now)).toMatchObject({
|
||||
action: 'create',
|
||||
server: name(1),
|
||||
desired: 2
|
||||
});
|
||||
expect(planScaling(policy, snapshot(member(2), member(1)), now)).toMatchObject({
|
||||
action: 'destroy',
|
||||
server: name(2),
|
||||
desired: 1
|
||||
});
|
||||
});
|
||||
it('preserves map variants and refuses ambiguous engine maps', () => {
|
||||
expect(mapIdentity({ name: 'legacy', game: 't6', map: 'zm_transit' })).toBeNull();
|
||||
expect(mapIdentity({ name: name(1), map: 'zm_transit' })?.mapSlug).toBe('town');
|
||||
expect(mapIdentity({ name: 'legacy', game: 't6', map_slug: 'farm', map: 'zm_transit' })?.mapSlug).toBe('farm');
|
||||
expect(
|
||||
mapIdentity({ name: 'legacy', game: 't6', map_slug: 'farm', map: 'zm_transit' })
|
||||
?.mapSlug
|
||||
).toBe('farm');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,14 +260,32 @@ describe('continuous empty evidence', () => {
|
||||
const previous = emptyObservation(member(1), undefined, now);
|
||||
const reread = emptyObservation(member(1), previous, now + 60_000);
|
||||
expect(reread).toMatchObject({ observedAt: now, emptySince: now });
|
||||
expect(emptyObservation({ ...member(1), observedAt: now + 60_000 }, previous, now + 60_000).emptySince).toBe(now);
|
||||
expect(
|
||||
emptyObservation({ ...member(1), observedAt: now + 60_000 }, previous, now + 60_000)
|
||||
.emptySince
|
||||
).toBe(now);
|
||||
});
|
||||
it('resets on unknown, players, lifecycle replacement, and observation gaps', () => {
|
||||
const previous = emptyObservation(member(1), undefined, now);
|
||||
expect(emptyObservation(member(1, 'occupied'), previous, now).emptySince).toBeNull();
|
||||
expect(emptyObservation(member(1, 'unknown'), previous, now).emptySince).toBeNull();
|
||||
expect(emptyObservation({ ...member(1), identity: 'replacement', observedAt: now + 1 }, previous, now + 1).emptySince).toBe(now + 1);
|
||||
expect(emptyObservation({ ...member(1), observedAt: now + 120_000 }, previous, now + 120_000).emptySince).toBe(now + 120_000);
|
||||
expect(emptyObservation({ ...member(1), emptyStartedAt: now + 10_000, observedAt: now + 15_000 }, previous, now + 15_000).emptySince).toBe(now + 15_000);
|
||||
expect(
|
||||
emptyObservation(
|
||||
{ ...member(1), identity: 'replacement', observedAt: now + 1 },
|
||||
previous,
|
||||
now + 1
|
||||
).emptySince
|
||||
).toBe(now + 1);
|
||||
expect(
|
||||
emptyObservation({ ...member(1), observedAt: now + 120_000 }, previous, now + 120_000)
|
||||
.emptySince
|
||||
).toBe(now + 120_000);
|
||||
expect(
|
||||
emptyObservation(
|
||||
{ ...member(1), emptyStartedAt: now + 10_000, observedAt: now + 15_000 },
|
||||
previous,
|
||||
now + 15_000
|
||||
).emptySince
|
||||
).toBe(now + 15_000);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user