250 lines
9.1 KiB
TypeScript
250 lines
9.1 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { render } from 'svelte/server';
|
|
import PolicyCard from '../../src/lib/ui/components/autoscaling/PolicyCard.svelte';
|
|
import {
|
|
lobbyGroups,
|
|
lobbyLabel,
|
|
policyStatus,
|
|
scalingActivity,
|
|
observationAge
|
|
} from '../../src/lib/ui/autoscaling.js';
|
|
import { zombieMaps } from '../../src/lib/map-aliases.js';
|
|
import type { ScalingHistory, ScalingView } from '../../src/lib/autoscaling.js';
|
|
|
|
const now = Date.parse('2026-10-07T20:00:00Z');
|
|
function view(): ScalingView {
|
|
return {
|
|
policy: {
|
|
id: 'policy',
|
|
game: 't5',
|
|
mapSlug: '*',
|
|
mode: 'create-destroy',
|
|
minimum: 1,
|
|
maximum: 3,
|
|
spares: 1,
|
|
emptyMinutes: 15,
|
|
revision: 1,
|
|
pausedReason: null,
|
|
updatedAt: ''
|
|
},
|
|
snapshot: {
|
|
members: [
|
|
{
|
|
name: 't5_zombie_theater_1',
|
|
mapSlug: 'theater',
|
|
identity: 'one',
|
|
state: 'empty',
|
|
players: 0,
|
|
observedAt: now,
|
|
source: 'rcon',
|
|
emptySince: now,
|
|
blocker: null,
|
|
operationId: null
|
|
},
|
|
{
|
|
name: 't5_zombie_cosmodrome_1',
|
|
mapSlug: 'cosmodrome',
|
|
identity: 'two',
|
|
state: 'creating',
|
|
players: null,
|
|
observedAt: null,
|
|
source: null,
|
|
emptySince: null,
|
|
blocker: 'Provisioning job-1: Checking container readiness',
|
|
operationId: 'job-1'
|
|
}
|
|
],
|
|
reservedNames: [],
|
|
keyPool: { available: 13, total: 15 },
|
|
blockers: []
|
|
},
|
|
decision: {
|
|
action: 'wait',
|
|
reason: 'Provisioning job-1: Checking container readiness',
|
|
server: null,
|
|
occupied: 0,
|
|
empty: 1,
|
|
pending: 1,
|
|
unknown: 0,
|
|
deficit: 9,
|
|
desired: 10
|
|
},
|
|
unsettled: [
|
|
{
|
|
id: 'job-1',
|
|
action: 'create',
|
|
state: 'awaiting-health',
|
|
stage: 'Checking container readiness'
|
|
}
|
|
],
|
|
blockers: [],
|
|
ownsMap: true,
|
|
history: [],
|
|
lastEvaluatedAt: now,
|
|
totals: { observedMinutes: 12, emptyLobbyHours: 0.2, deficitMinutes: 5, unknownMinutes: 0 },
|
|
operations: {
|
|
creates: 1,
|
|
removals: 0,
|
|
cancelled: 0,
|
|
failed: 0,
|
|
readinessSamples: 0,
|
|
readinessMeanSeconds: null,
|
|
readinessMaxSeconds: null
|
|
}
|
|
};
|
|
}
|
|
function body(data = view(), canOperate = true, globallyPaused = false) {
|
|
return render(PolicyCard, {
|
|
props: {
|
|
view: data,
|
|
canOperate,
|
|
globallyPaused,
|
|
busy: false,
|
|
onedit: () => {},
|
|
ontoggle: () => {},
|
|
ondelete: () => {}
|
|
}
|
|
}).body;
|
|
}
|
|
function history(
|
|
id: string,
|
|
at: number,
|
|
action: ScalingHistory['decision']['action'],
|
|
reason: string,
|
|
operationId: string | null = null
|
|
): ScalingHistory {
|
|
return {
|
|
id,
|
|
at,
|
|
lastSeenAt: at,
|
|
decision: {
|
|
...view().decision,
|
|
action,
|
|
reason,
|
|
server: action === 'create' ? 't5_zombie_cosmodrome_1' : null
|
|
},
|
|
operationId,
|
|
operationState: operationId ? 'done' : null
|
|
};
|
|
}
|
|
|
|
describe('autoscaling presentation', () => {
|
|
it('shows a provisioning stage once and keeps diagnostic job links without routine deletion warnings', () => {
|
|
const html = body();
|
|
expect(html.split('Checking container readiness')).toHaveLength(2);
|
|
expect(html.replace(/\s+/g, ' ')).toContain('Creating Ascension · Lobby 1');
|
|
expect(html).toContain('href="/provisioning/job-1"');
|
|
expect(html).not.toContain('Work is still settling');
|
|
expect(html).not.toContain('Provisioning job-1:');
|
|
expect(html).not.toContain('No current source');
|
|
expect(html).not.toContain('before deleting');
|
|
expect(html).not.toContain('<summary');
|
|
});
|
|
it('includes uncovered maps and preserves lobbies with unrecognized identities', () => {
|
|
const data = view();
|
|
data.snapshot.members.push({
|
|
...data.snapshot.members[0],
|
|
name: 'legacy-server',
|
|
mapSlug: undefined
|
|
});
|
|
const groups = lobbyGroups(data);
|
|
expect(groups.find((group) => group.slug === 'theater')).toMatchObject({
|
|
ready: 1,
|
|
target: 1
|
|
});
|
|
expect(groups.find((group) => group.slug === 'moon')).toMatchObject({
|
|
ready: 0,
|
|
target: 1,
|
|
members: []
|
|
});
|
|
expect(groups.find((group) => group.slug === 'unassigned')?.members[0].name).toBe(
|
|
'legacy-server'
|
|
);
|
|
expect(body()).toContain('1 / 10 maps meet spare target');
|
|
});
|
|
it('does not imply global readiness from one satisfied map', () => {
|
|
const data = view();
|
|
data.decision.reason = 'Kino Der Toten: Empty spare target is satisfied.';
|
|
expect(policyStatus(data, false).label).not.toBe('Spare targets satisfied');
|
|
data.decision.deficit = 0;
|
|
expect(policyStatus(data, false).label).toBe('Spare targets satisfied');
|
|
});
|
|
it('preserves attention and pause notices while a job is outstanding', () => {
|
|
const data = view();
|
|
data.policy.pausedReason = 'Uncertain host command. Review Provisioning recovery.';
|
|
data.unsettled[0].state = 'failed';
|
|
const html = body(data);
|
|
expect(html).toContain(data.policy.pausedReason);
|
|
expect(html.replace(/\s+/g, ' ')).toContain('Review Ascension · Lobby 1 job');
|
|
expect(body(view(), true, true)).toContain('Globally paused');
|
|
data.policy.pausedReason = null;
|
|
data.policy.mode = 'off';
|
|
expect(body(data)).toContain(
|
|
'No new scaling requests. Outstanding jobs are still tracked.'
|
|
);
|
|
});
|
|
it('groups job wait decisions with their original request without swallowing unrelated blockers', () => {
|
|
const data = view();
|
|
data.history = [
|
|
history('wait-2', now + 30, 'wait', 'Provisioning job-1: Checking readiness'),
|
|
history('blocked', now + 20, 'wait', 'No available game keys.'),
|
|
history('wait-1', now + 10, 'wait', 'Provisioning job-1: Host command running'),
|
|
history('create', now, 'create', 'Replenish empty lobby capacity.', 'job-1')
|
|
];
|
|
const activity = scalingActivity(data);
|
|
expect(activity).toHaveLength(2);
|
|
expect(activity[0]).toMatchObject({
|
|
operationId: 'job-1',
|
|
action: 'create',
|
|
at: now,
|
|
lastSeenAt: now + 30,
|
|
evaluations: 3,
|
|
operationState: 'done'
|
|
});
|
|
expect(activity[1].reason).toBe('No available game keys.');
|
|
});
|
|
it('consolidates a fully covered game into one healthy summary', () => {
|
|
const data = view();
|
|
data.unsettled = [];
|
|
data.snapshot.members = zombieMaps('t5').map((map) => ({
|
|
...data.snapshot.members[0],
|
|
name: `t5_zombie_${map.slug}_1`,
|
|
mapSlug: map.slug
|
|
}));
|
|
data.decision = {
|
|
...data.decision,
|
|
reason: 'Empty spare target is satisfied.',
|
|
deficit: 0,
|
|
empty: 10,
|
|
pending: 0
|
|
};
|
|
const html = body(data);
|
|
expect(html).toContain('All 10 maps meet their spare target.');
|
|
expect(html).not.toContain('10 / 10 maps meet spare target');
|
|
expect(html).not.toContain('Spare targets satisfied');
|
|
expect(html.match(/<tr[ >]/g)).toHaveLength(11); // One header plus ten lobby rows.
|
|
});
|
|
it('ages observations into an explicit stale state even without fresh poll data', () => {
|
|
expect(observationAge(now - 5000, now)).toEqual({ label: '5s ago', stale: false });
|
|
expect(observationAge(now - 90000, now).stale).toBe(false);
|
|
expect(observationAge(now - 90001, now)).toEqual({ label: 'Stale · 1m ago', stale: true });
|
|
expect(observationAge(now, now + 3600000)).toEqual({
|
|
label: 'Stale · 1h ago',
|
|
stale: true
|
|
});
|
|
expect(observationAge(now + 5000, now)).toEqual({
|
|
label: 'Check observation time',
|
|
stale: true
|
|
});
|
|
});
|
|
it('renders accessible tabs and hides mutation controls from viewers', () => {
|
|
const html = body(view(), false);
|
|
expect(html).toContain('role="tablist"');
|
|
expect(html).toContain('aria-labelledby="tab-policy-lobbies"');
|
|
expect(html).toContain('role="tabpanel"');
|
|
expect(html).not.toContain('Edit policy');
|
|
expect(html).not.toContain('More actions');
|
|
expect(lobbyLabel('legacy-server')).toBe('legacy-server');
|
|
});
|
|
});
|