Simplify collection launch review
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { CollectionPreview } from '../../src/lib/deployment-plan.js';
|
||||
import { createCollectionLaunchController, type CollectionLaunchInput, type CollectionLaunchState } from '../../src/lib/ui/collection-launch.js';
|
||||
|
||||
const selection: CollectionLaunchInput = { blueprintId: 'collection-1', mode: 'exact', libraryIds: { t5: 'library-1' }, image: 'runtime:latest' };
|
||||
const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
|
||||
const preview = (overrides: Partial<CollectionPreview> = {}): CollectionPreview => ({
|
||||
id: 'preview-1', expiresAt: new Date(Date.now() + 60_000).toISOString(), blueprintName: 'Zombies',
|
||||
mode: 'exact', launchConcurrency: 2, retainedServers: ['t5_zombie_theater_1'],
|
||||
removals: [{ name: 't5_zombie_theater_3', reason: 'Exceeds the collection target.' }],
|
||||
servers: [{ name: 't5_zombie_theater_2', port: null, entryIndex: 0, hasSavedKey: true }], blockers: [], ready: true,
|
||||
entries: [{ count: 2, existing: 1, missing: 1, existingServers: ['t5_zombie_theater_1'], coverage: [],
|
||||
recipe: { game: 't5', mapSlug: 'kino', mapName: 'Kino Der Toten', expectedMap: 'zombie_theater',
|
||||
capturedAt: new Date().toISOString(), image: 'runtime:latest', configFile: 'server.cfg',
|
||||
configSource: 'T5 Zombies', files: [], fingerprint: 'recipe-1' } }],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
let controller: ReturnType<typeof createCollectionLaunchController>;
|
||||
let state: CollectionLaunchState;
|
||||
let fetchPreview: ReturnType<typeof vi.fn<typeof fetch>>;
|
||||
let changes: ReturnType<typeof vi.fn<(state: CollectionLaunchState) => void>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-10-07T18:00:00Z'));
|
||||
state = { preview: null, checking: false, error: '', changeNotice: '' };
|
||||
fetchPreview = vi.fn<typeof fetch>();
|
||||
changes = vi.fn((next: CollectionLaunchState) => { state = next; });
|
||||
controller = createCollectionLaunchController(changes, { fetch: fetchPreview });
|
||||
});
|
||||
afterEach(() => { controller.dispose(); vi.useRealTimers(); vi.restoreAllMocks(); });
|
||||
|
||||
async function settle(value = preview(), input = selection) {
|
||||
fetchPreview.mockResolvedValueOnce(response(value));
|
||||
controller.select(input);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
}
|
||||
|
||||
describe('automatic collection launch checks', () => {
|
||||
it('debounces selections and checks only the final collection mode and host inputs', async () => {
|
||||
fetchPreview.mockResolvedValueOnce(response(preview({ mode: 'fill' })));
|
||||
controller.select(selection);
|
||||
expect(state).toEqual({ preview: null, checking: true, error: '', changeNotice: '' });
|
||||
await vi.advanceTimersByTimeAsync(150);
|
||||
controller.select({ ...selection, mode: 'fill', libraryIds: { t6: 'library-2', t5: 'library-1' } });
|
||||
await vi.advanceTimersByTimeAsync(199);
|
||||
expect(fetchPreview).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||
expect(fetchPreview.mock.calls[0][0]).toBe('/api/deployment-collections/preview');
|
||||
expect(JSON.parse(fetchPreview.mock.calls[0][1]!.body as string)).toEqual({
|
||||
...selection, mode: 'fill', libraryIds: { t5: 'library-1', t6: 'library-2' },
|
||||
});
|
||||
expect(state.preview?.mode).toBe('fill');
|
||||
expect(state.checking).toBe(false);
|
||||
expect(state.changeNotice).toBe('');
|
||||
});
|
||||
|
||||
it('invalidates a settled preview synchronously when the runtime changes', async () => {
|
||||
await settle();
|
||||
expect(state.preview).not.toBeNull();
|
||||
controller.select({ ...selection, image: 'runtime:other' });
|
||||
expect(state).toEqual({ preview: null, checking: true, error: '', changeNotice: '' });
|
||||
});
|
||||
|
||||
it('deduplicates identical selections and active checks, and refreshes a debounce immediately', async () => {
|
||||
const request = deferred<Response>();
|
||||
fetchPreview.mockReturnValueOnce(request.promise);
|
||||
const libraries = { t5: 'library-1', t6: 'library-2' };
|
||||
controller.select({ ...selection, libraryIds: libraries });
|
||||
controller.refresh();
|
||||
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||
controller.select({ ...selection, libraryIds: { t6: 'library-2', t5: 'library-1' } });
|
||||
controller.refresh();
|
||||
controller.refresh();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||
request.resolve(response(preview()));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
controller.select({ ...selection, libraryIds: libraries });
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each(['success', 'error'] as const)('aborts changed requests and ignores stale %s responses', async (result) => {
|
||||
const first = deferred<Response>();
|
||||
fetchPreview.mockReturnValueOnce(first.promise).mockResolvedValueOnce(response(preview({ id: 'new', mode: 'fill' })));
|
||||
controller.select(selection);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
const signal = fetchPreview.mock.calls[0][1]!.signal!;
|
||||
controller.select({ ...selection, mode: 'fill' });
|
||||
expect(signal.aborted).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
if (result === 'success') first.resolve(response(preview({ id: 'old' })));
|
||||
else first.reject(new Error('Old request failed'));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(state.preview?.id).toBe('new');
|
||||
expect(state.error).toBe('');
|
||||
expect(state.changeNotice).toBe('');
|
||||
expect(state.checking).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 't5_zombie_theater_4', reason: 'Exceeds the collection target.' },
|
||||
{ name: 't5_zombie_theater_3', reason: 'Map is outside this collection.' },
|
||||
])('warns when a removal name or reason changes across a host refresh: %j', async (removal) => {
|
||||
await settle();
|
||||
controller.select(null);
|
||||
expect(state).toEqual({ preview: null, checking: false, error: '', changeNotice: '' });
|
||||
await settle(preview({ id: 'refreshed', removals: [removal] }));
|
||||
expect(state.changeNotice).toMatch(/Servers to remove changed/);
|
||||
expect(state.preview?.id).toBe('refreshed');
|
||||
});
|
||||
|
||||
it('warns when creation identities change without changing the number of servers', async () => {
|
||||
await settle();
|
||||
fetchPreview.mockResolvedValueOnce(response(preview({
|
||||
servers: [{ name: 't5_zombie_theater_4', port: null, entryIndex: 0, hasSavedKey: true }],
|
||||
})));
|
||||
controller.refresh();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(state.changeNotice).toMatch(/Servers to create changed/);
|
||||
});
|
||||
|
||||
it('warns when retained identities change and gives removal changes priority', async () => {
|
||||
await settle();
|
||||
fetchPreview.mockResolvedValueOnce(response(preview({ retainedServers: ['t5_zombie_theater_5'] })));
|
||||
controller.refresh();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(state.changeNotice).toMatch(/Servers to keep changed/);
|
||||
fetchPreview.mockResolvedValueOnce(response(preview({ removals: [] })));
|
||||
controller.refresh();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(state.changeNotice).toMatch(/Servers to remove changed/);
|
||||
});
|
||||
|
||||
it('does not report changed plans for reordering or changes in readiness alone', async () => {
|
||||
const original = preview({
|
||||
retainedServers: ['t5_zombie_theater_1', 't5_zombie_five_1'],
|
||||
removals: [{ name: 't5_zombie_theater_3', reason: 'Excess.' }, { name: 't5_zombie_theater_4', reason: 'Excess.' }],
|
||||
servers: [{ name: 't5_zombie_theater_2', port: null, entryIndex: 0, hasSavedKey: false },
|
||||
{ name: 't5_zombie_theater_5', port: null, entryIndex: 0, hasSavedKey: false }],
|
||||
});
|
||||
await settle(original);
|
||||
fetchPreview.mockResolvedValueOnce(response(preview({
|
||||
retainedServers: [...original.retainedServers].reverse(), removals: [...original.removals].reverse(),
|
||||
servers: [...original.servers].reverse().map(server => ({ ...server, hasSavedKey: true })),
|
||||
})));
|
||||
controller.refresh();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(state.changeNotice).toBe('');
|
||||
});
|
||||
|
||||
it.each(['blueprint', 'mode'] as const)('starts a fresh comparison when the %s changes', async (change) => {
|
||||
await settle();
|
||||
const input = change === 'mode' ? { ...selection, mode: 'fill' as const } : { ...selection, blueprintId: 'other' };
|
||||
await settle(preview({ mode: input.mode, removals: [], retainedServers: [], servers: [] }), input);
|
||||
expect(state.changeNotice).toBe('');
|
||||
});
|
||||
|
||||
it('clears history and ignores a dismissed response when reopening the same collection', async () => {
|
||||
await settle();
|
||||
const dismissed = deferred<Response>();
|
||||
fetchPreview.mockReturnValueOnce(dismissed.promise);
|
||||
controller.refresh();
|
||||
controller.clear();
|
||||
expect(fetchPreview.mock.calls[1][1]!.signal!.aborted).toBe(true);
|
||||
await settle(preview({ id: 'reopened', removals: [], retainedServers: [], servers: [] }));
|
||||
dismissed.resolve(response(preview({ id: 'dismissed' })));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(state.preview?.id).toBe('reopened');
|
||||
expect(state.changeNotice).toBe('');
|
||||
});
|
||||
|
||||
it('keeps failed refreshes unlaunchable and compares a successful retry with the last settled plan', async () => {
|
||||
await settle();
|
||||
fetchPreview.mockResolvedValueOnce(response({ error: 'Host information unavailable.' }, 503));
|
||||
controller.refresh();
|
||||
expect(state.preview).toBeNull();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(state).toEqual({ preview: null, checking: false, error: 'Host information unavailable.', changeNotice: '' });
|
||||
fetchPreview.mockResolvedValueOnce(response(preview({ removals: [] })));
|
||||
controller.refresh();
|
||||
expect(state.error).toBe('');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(state.changeNotice).toMatch(/Servers to remove changed/);
|
||||
});
|
||||
|
||||
it('automatically renews expired plans and compares the updated removal list', async () => {
|
||||
await settle(preview({ expiresAt: new Date(Date.now() + 1000).toISOString() }));
|
||||
const renewal = deferred<Response>();
|
||||
fetchPreview.mockReturnValueOnce(renewal.promise);
|
||||
await vi.advanceTimersByTimeAsync(799);
|
||||
expect(state.preview).not.toBeNull();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(state).toEqual({ preview: null, checking: true, error: '', changeNotice: '' });
|
||||
renewal.resolve(response(preview({ id: 'renewed', removals: [] })));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(state.preview?.id).toBe('renewed');
|
||||
expect(state.changeNotice).toMatch(/Servers to remove changed/);
|
||||
});
|
||||
|
||||
it('rejects expired responses without repeatedly retrying them', async () => {
|
||||
await settle(preview({ expiresAt: new Date(Date.now() + 100).toISOString() }));
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||
expect(state).toEqual({ preview: null, checking: false, error: 'The collection check expired. Try again.', changeNotice: '' });
|
||||
});
|
||||
|
||||
it('preserves the same preview ID while an apply is unconfirmed and resumes after definite rejection', async () => {
|
||||
await settle(preview({ expiresAt: new Date(Date.now() + 1000).toISOString() }));
|
||||
controller.suspend();
|
||||
controller.select({ ...selection });
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(state.preview?.id).toBe('preview-1');
|
||||
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||
fetchPreview.mockResolvedValueOnce(response(preview({ id: 'retry', removals: [] })));
|
||||
controller.refresh();
|
||||
expect(state.preview).toBeNull();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(state.preview?.id).toBe('retry');
|
||||
expect(state.changeNotice).toMatch(/Servers to remove changed/);
|
||||
});
|
||||
|
||||
it('cancels pending selections and prevents disposed responses from publishing', async () => {
|
||||
controller.select(selection);
|
||||
controller.select(null);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(fetchPreview).not.toHaveBeenCalled();
|
||||
const request = deferred<Response>();
|
||||
fetchPreview.mockReturnValueOnce(request.promise);
|
||||
controller.select(selection);
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
const count = changes.mock.calls.length;
|
||||
controller.dispose();
|
||||
request.resolve(response(preview()));
|
||||
controller.select(selection);
|
||||
controller.refresh();
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(changes).toHaveBeenCalledTimes(count);
|
||||
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
||||
expect(fetchPreview.mock.calls[0][1]!.signal!.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { CollectionPreview, LaunchAvailability } from '../../src/lib/deployment-plan.js';
|
||||
import { zombieMapBySlug, type ZombieGame } from '../../src/lib/map-aliases.js';
|
||||
import { collectionCapacitySummary, collectionKeyCoverage, collectionMapGroups } from '../../src/lib/ui/collection-summary.js';
|
||||
|
||||
function entry(game: ZombieGame, mapSlug: string, count: number, existingServers: string[] = []): CollectionPreview['entries'][number] {
|
||||
const map = zombieMapBySlug(game, mapSlug)!;
|
||||
return { count, existing: existingServers.length, missing: Math.max(0, count - existingServers.length), existingServers,
|
||||
coverage: existingServers.map(name => ({ name, health: 'stopped', href: `/servers/${name}` })),
|
||||
recipe: { game, mapSlug, mapName: map.friendly, expectedMap: map.canonical, capturedAt: '', image: 'runtime:latest',
|
||||
configFile: 'server.cfg', configSource: 'Current setup', files: [], fingerprint: '' } };
|
||||
}
|
||||
|
||||
function preview(overrides: Partial<CollectionPreview> = {}): CollectionPreview {
|
||||
return { id: 'preview', expiresAt: '2030-01-01T00:00:00Z', blueprintName: 'Collection', launchConcurrency: 1,
|
||||
mode: 'fill', retainedServers: [], removals: [], entries: [], servers: [], blockers: [], ready: true, ...overrides };
|
||||
}
|
||||
|
||||
function capacity(overrides: Partial<LaunchAvailability> = {}): LaunchAvailability {
|
||||
return { requested: 5, estimatedAvailable: null, updatedAt: null, portsAvailable: null, cpuCoresAvailable: null,
|
||||
memoryBytesAvailable: null, startupSlots: null, startupLimit: null, queued: null, ...overrides };
|
||||
}
|
||||
|
||||
describe('collection key coverage', () => {
|
||||
it('counts coverage separately by game without treating removals as available keys', () => {
|
||||
const plan = preview({ mode: 'exact', entries: [entry('t5', 'theater', 2), entry('t6', 'town', 1)],
|
||||
removals: [{ name: 't5_zombie_moon_1', reason: 'Outside collection' }], servers: [
|
||||
{ entryIndex: 0, name: 't5_zombie_theater_1', hasSavedKey: true, port: null },
|
||||
{ entryIndex: 0, name: 't5_zombie_theater_2', hasSavedKey: false, port: null },
|
||||
{ entryIndex: 1, name: 't6_zombie_town_1', hasSavedKey: true, port: null },
|
||||
] });
|
||||
expect(collectionKeyCoverage(plan)).toEqual([
|
||||
{ game: 't5', label: 'Black Ops I', required: 2, available: 1, unassigned: 0 },
|
||||
{ game: 't6', label: 'Black Ops II', required: 1, available: 1, unassigned: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts an unavailable lobby as required but never covered, and omits games without creations', () => {
|
||||
expect(collectionKeyCoverage(preview({ entries: [entry('t5', 'theater', 1), entry('t6', 'town', 1)],
|
||||
servers: [{ entryIndex: 1, name: null, hasSavedKey: true, port: null }] }))).toEqual([
|
||||
{ game: 't6', label: 'Black Ops II', required: 1, available: 0, unassigned: 1 },
|
||||
]);
|
||||
expect(collectionKeyCoverage(preview({ mode: 'exact', removals: [{ name: 't5_zombie_theater_1', reason: 'Remove' }] }))).toEqual([]);
|
||||
});
|
||||
|
||||
it('distinguishes known key shortages from creations waiting for a lobby', () => {
|
||||
const [pool] = collectionKeyCoverage(preview({ entries: [entry('t5', 'theater', 3)], servers: [
|
||||
{ entryIndex: 0, name: 't5_zombie_theater_19', hasSavedKey: true, port: null },
|
||||
{ entryIndex: 0, name: 't5_zombie_theater_20', hasSavedKey: false, port: null },
|
||||
{ entryIndex: 0, name: null, hasSavedKey: false, port: null },
|
||||
] }));
|
||||
expect(pool).toMatchObject({ required: 3, available: 1, unassigned: 1 });
|
||||
expect(pool.required - pool.unassigned - pool.available).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collection map summaries', () => {
|
||||
it('preserves full existing counts in fill mode, including stopped inventory and excess lobbies', () => {
|
||||
const plan = preview({ entries: [entry('t5', 'theater', 1, ['t5_zombie_theater_1', 't5_zombie_theater_2']), entry('t5', 'moon', 2)] });
|
||||
expect(collectionMapGroups(plan)).toEqual([{ game: 't5', label: 'Black Ops I', rows: [
|
||||
{ key: 't5:theater', mapName: 'Kino Der Toten', target: 1, existing: 2, create: 0, remove: 0 },
|
||||
{ key: 't5:moon', mapName: 'Moon', target: 2, existing: 0, create: 2, remove: 0 },
|
||||
] }]);
|
||||
});
|
||||
|
||||
it('counts ordinary excess once and adds already-pending removals missing from backend coverage', () => {
|
||||
const plan = preview({ mode: 'exact', entries: [entry('t5', 'theater', 1, ['t5_zombie_theater_1', 't5_zombie_theater_2'])],
|
||||
retainedServers: ['t5_zombie_theater_1'], removals: [
|
||||
{ name: 't5_zombie_theater_2', reason: 'Excess' },
|
||||
{ name: 't5_zombie_theater_3', reason: 'Already awaiting removal' },
|
||||
] });
|
||||
expect(collectionMapGroups(plan)[0].rows).toEqual([
|
||||
{ key: 't5:theater', mapName: 'Kino Der Toten', target: 1, existing: 3, create: 0, remove: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows outside maps with target zero across both games without combining T6 variants', () => {
|
||||
const plan = preview({ mode: 'exact', entries: [entry('t6', 'town', 1, ['t6_zombie_town_1'])],
|
||||
retainedServers: ['t6_zombie_town_1'], removals: [
|
||||
{ name: 't5_zombie_moon_1', reason: 'Outside collection' },
|
||||
{ name: 't6_zombie_farm_1', reason: 'Outside collection' },
|
||||
{ name: 't6_zombie_town_grief_1', reason: 'Outside collection' },
|
||||
] });
|
||||
expect(collectionMapGroups(plan)).toEqual([
|
||||
{ game: 't5', label: 'Black Ops I', rows: [{ key: 't5:moon', mapName: 'Moon', target: 0, existing: 1, create: 0, remove: 1 }] },
|
||||
{ game: 't6', label: 'Black Ops II', rows: [
|
||||
{ key: 't6:town', mapName: 'Town Survival', target: 1, existing: 1, create: 0, remove: 0 },
|
||||
{ key: 't6:farm', mapName: 'Farm Survival', target: 0, existing: 1, create: 0, remove: 1 },
|
||||
{ key: 't6:towngrief', mapName: 'Town Grief', target: 0, existing: 1, create: 0, remove: 1 },
|
||||
] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses authoritative coverage before names and resolves friendly legacy aliases', () => {
|
||||
const plan = preview({ mode: 'exact', entries: [entry('t6', 'town', 1, ['custom-town', 't6_zombie_town_1']), entry('t5', 'theater', 1, ['t5_zombie_theater_1'])],
|
||||
removals: [{ name: 'custom-town', reason: 'Excess' }, { name: 'T5_ZOMBIE_KINO_DER_TOTEN_2', reason: 'Awaiting removal' }] });
|
||||
expect(collectionMapGroups(plan).map(group => group.rows)).toEqual([
|
||||
[{ key: 't5:theater', mapName: 'Kino Der Toten', target: 1, existing: 2, create: 0, remove: 1 }],
|
||||
[{ key: 't6:town', mapName: 'Town Survival', target: 1, existing: 2, create: 0, remove: 1 }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps unknown removals visible without guessing a shared engine-map variant', () => {
|
||||
const plan = preview({ mode: 'exact', removals: [
|
||||
{ name: 't6_zombie_zm_transit_1', reason: 'Unknown variant' },
|
||||
{ name: 'custom-instance', reason: 'Outside collection' },
|
||||
] });
|
||||
expect(collectionMapGroups(plan)).toEqual([
|
||||
{ game: 't6', label: 'Black Ops II', rows: [{ key: 't6:server:t6_zombie_zm_transit_1', mapName: 't6_zombie_zm_transit_1', target: 0, existing: 1, create: 0, remove: 1 }] },
|
||||
{ game: null, label: 'Other servers', rows: [{ key: 'other:server:custom-instance', mapName: 'custom-instance', target: 0, existing: 1, create: 0, remove: 1 }] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collection capacity summary', () => {
|
||||
it('keeps unknown and insufficient capacity queueable', () => {
|
||||
expect(collectionCapacitySummary()).toBe('Capacity estimate unavailable; launches can wait for capacity.');
|
||||
expect(collectionCapacitySummary(capacity())).toBe('Capacity estimate unavailable; launches can wait for capacity.');
|
||||
expect(collectionCapacitySummary(capacity({ estimatedAvailable: 0 }))).toBe('Estimated capacity for 0 of 5 servers; launches can wait for capacity.');
|
||||
expect(collectionCapacitySummary(capacity({ estimatedAvailable: 2 }))).toBe('Estimated capacity for 2 of 5 servers; launches can wait for capacity.');
|
||||
expect(collectionCapacitySummary(capacity({ requested: 1, estimatedAvailable: 0 }))).toBe('Estimated capacity for 0 of 1 server; launches can wait for capacity.');
|
||||
});
|
||||
|
||||
it('uses requested counts when enough capacity exists and handles plans without creations', () => {
|
||||
expect(collectionCapacitySummary(capacity({ estimatedAvailable: 20 }))).toBe('Estimated capacity for all 5 servers.');
|
||||
expect(collectionCapacitySummary(capacity({ requested: 1, estimatedAvailable: 1 }))).toBe('Estimated capacity for 1 server.');
|
||||
expect(collectionCapacitySummary(capacity({ requested: 0 }))).toBe('No new server capacity needed.');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user