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 { 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 { 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_bus_depot_survival_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:busdepot', mapName: 'Bus Depot Survival', 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.' ); }); });