import { describe, expect, it, vi } from 'vitest'; import { render } from 'svelte/server'; import { get } from 'svelte/store'; import type { FleetServer } from '../../src/lib/fleet-overview.js'; import { applicableRuntimeActions, lifecycleImpact } from '../../src/lib/ui/lifecycle-actions.js'; import { actionCompleted, actionLabels } from '../../src/lib/provisioning.js'; import { uiNow } from '../../src/lib/ui/stores/servers.js'; import LifecycleDialog from '../../src/lib/ui/components/LifecycleDialog.svelte'; import DestroyServerModal from '../../src/lib/ui/components/DestroyServerModal.svelte'; vi.mock('$app/navigation', () => ({ goto: vi.fn() })); const now = get(uiNow); const iso = (offset = 0) => new Date(now + offset).toISOString(); const server: FleetServer = { name: 't5_zombie_theater_1', game: 't5', map: 'zombie_theater', last_map: 'zombie_theater', last_gametype: 'zclassic', last_players: 4, last_seen_at: iso(-10_000), last_error: null, container_state: 'running', container_checked_at: iso(-5_000), last_checked_at: iso(-5_000), roster: [], health: 'online', held: [], active_since: iso(-120_000), inactive_since: null, game_started_at: iso(-120_000), game_ended_at: null, host_id: 'host-1', host: { id: 'host-1', name: 'Dallas 1', connected: true, connectionState: 'online', lastHeartbeat: iso(-1_000) } }; describe('lifecycle action applicability', () => { it('offers Restart, Stop and Rebuild for a responding game, without Start', () => { expect(applicableRuntimeActions(server, now)).toEqual(['restart', 'stop', 'rebuild']); }); it('offers Start only for a confirmed stopped container with a reachable host', () => { const stopped = { ...server, health: 'offline' as const, container_state: 'stopped' }; expect(applicableRuntimeActions(stopped, now)).toEqual(['start', 'rebuild']); for (const change of [ { container_checked_at: iso(-91_000) }, { container_checked_at: null }, { telemetry_after: iso(-2_000) }, { last_seen_at: iso(-2_000) }, { host: null }, { host: { ...server.host!, connected: false } } ]) expect(applicableRuntimeActions({ ...stopped, ...change }, now)).not.toContain('start'); }); it('permits recovery of an unresponsive game only with a current running-container observation', () => { const unresponsive = { ...server, health: 'stale' as const, last_seen_at: iso(-300_000), last_error: 'timeout:no reply' }; expect(applicableRuntimeActions(unresponsive, now)).toEqual(['restart', 'stop', 'rebuild']); expect(applicableRuntimeActions({ ...unresponsive, container_checked_at: iso(-91_000) }, now)).toEqual([]); expect(applicableRuntimeActions({ ...unresponsive, telemetry_after: iso(-2_000) }, now)).toEqual([]); }); it('does not offer runtime mutations during startup or without evidence', () => { for (const change of [ { health: 'starting' as const }, { health: 'provisioning' as const }, { host: { ...server.host!, connected: false } }, { health: 'offline' as const, container_state: 'unknown', last_seen_at: null } ]) expect(applicableRuntimeActions({ ...server, ...change }, now)).toEqual([]); }); }); describe('lifecycle impact copy', () => { it('names current players and round with correct singular grammar', () => { expect(lifecycleImpact('restart', 4, 30)).toBe('4 players are in round 30. Restart waits until the lobby is empty.'); expect(lifecycleImpact('stop', 1, null)).toBe('1 player is currently online. Stop waits until the lobby is empty.'); }); it('does not confuse unknown or invalid player counts with an empty lobby', () => { for (const players of [null, -1, NaN, 1.5]) expect(lifecycleImpact('destroy', players, 30)) .toBe('Player count is unknown. Destruction waits for a live check confirming the lobby is empty.'); expect(lifecycleImpact('rebuild', 0, 30)).toBe('The lobby is currently empty. A live check will confirm this before rebuild begins.'); }); it('does not claim a round or occupancy wait when starting a stopped container', () => { expect(lifecycleImpact('start', null, null)).toBe('Start the existing container, then verify that the game is responding.'); expect(lifecycleImpact('restart', 4, NaN)).toContain('4 players are currently online.'); }); }); describe('contextual lifecycle confirmation', () => { it('names the server and impact without re-asking the instance or action', () => { const body = render(LifecycleDialog, { props: { open: true, initialServer: server.name, initialAction: 'restart', serverContext: server, players: 4, round: 30 } }).body; expect(body).toContain('Restart Kino Der Toten · Lobby 1?'); expect(body).toContain('4 players are in round 30.'); expect(body).toContain('Restart when empty'); expect(body).not.toContain('Manage server<'); }); it('explains an unavailable action when the evidence no longer supports it', () => { const body = render(LifecycleDialog, { props: { open: true, initialServer: server.name, initialAction: 'start', serverContext: server } }).body; expect(body).toContain('Start is unavailable. The game is responding.'); const startButton = [...body.matchAll(/]*>[\s\S]*?<\/button>/g)].find(match => match[0].includes('Start server'))?.[0]; expect(startButton).toContain('disabled'); expect(body).not.toContain('Start the existing container'); }); it('uses Destroy server consistently and preserves recorded history in the explanation', () => { const body = render(DestroyServerModal, { props: { open: true, name: server.name, players: 4, round: 30 } }).body; expect(body).toContain('Destroy Kino Der Toten · Lobby 1?'); expect(body).toContain('4 players are in round 30.'); expect(body).toContain('Destroy server when empty'); expect(body).toContain('recorded history'); expect(body).not.toContain('campaign details'); expect(actionLabels.destroy).toBe('Destroy'); expect(actionCompleted.destroy).toBe('Server destroyed'); }); });