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

92 lines
6.0 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 } from '../../src/lib/ui/autoscaling.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).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).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('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');
});
});