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 => ({ 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() { let resolve!: (value: T) => void; let reject!: (error: Error) => void; const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); return { promise, resolve, reject }; } let controller: ReturnType; let state: CollectionLaunchState; let fetchPreview: ReturnType>; let changes: ReturnType void>>; beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-10-07T18:00:00Z')); state = { preview: null, checking: false, error: '', changeNotice: '' }; fetchPreview = vi.fn(); 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(); 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(); 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(); 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(); 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(); 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); }); });