Files
electric-cherry/tests/unit/ui-autoscaling.test.ts
T

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');
});
});