Files
electric-cherry/tests/unit/ui-lifecycle-actions.test.ts

166 lines
6.8 KiB
TypeScript

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('<select');
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(/<button\b[^>]*>[\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');
});
});