Add fleet action dialog with server selection
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render } from 'svelte/server';
|
||||
import type { FleetServer } from '../../src/lib/fleet-overview.js';
|
||||
import { fleetActionAvailable, queueFleetAction } from '../../src/lib/ui/fleet-actions.js';
|
||||
import FleetActionsDialog from '../../src/lib/ui/components/FleetActionsDialog.svelte';
|
||||
|
||||
const now = Date.parse('2026-10-07T12:00:00Z');
|
||||
const at = new Date(now - 1000).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: new Date(now - 10_000).toISOString(), last_error: null,
|
||||
container_state: 'running', container_checked_at: at, last_checked_at: at,
|
||||
roster: [], health: 'online', held: [], active_since: null, inactive_since: null,
|
||||
game_started_at: null, game_ended_at: null, host_id: 'host-1',
|
||||
host: { id: 'host-1', name: 'Dallas 1', connected: true, connectionState: 'online', lastHeartbeat: at }
|
||||
};
|
||||
|
||||
describe('fleet actions', () => {
|
||||
it('uses the single-server eligibility rules and permits queued destruction with unknown status', () => {
|
||||
expect(fleetActionAvailable(server, 'restart', now)).toBe(true);
|
||||
expect(fleetActionAvailable(server, 'start', now)).toBe(false);
|
||||
const stopped = { ...server, health: 'offline' as const, container_state: 'stopped' };
|
||||
expect(fleetActionAvailable(stopped, 'start', now)).toBe(true);
|
||||
expect(fleetActionAvailable(stopped, 'restart', now)).toBe(false);
|
||||
expect(fleetActionAvailable(server, 'stop', now + 300_000)).toBe(false);
|
||||
expect(fleetActionAvailable({ ...server, host: null }, 'rebuild', now)).toBe(false);
|
||||
expect(fleetActionAvailable({ ...server, host: null }, 'destroy', now)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['restart', 'start', 'stop', 'rebuild'] as const)('queues %s only for selected servers and deduplicates selections', async action => {
|
||||
const request = vi.fn().mockImplementation(async () => Response.json({ deploymentId: 'job-1' }, { status: 202 }));
|
||||
const result = await queueFleetAction(action, [server.name, 'second', server.name], request);
|
||||
expect(request.mock.calls.map(([url, options]) => [url, options.method, JSON.parse(options.body)])).toEqual([
|
||||
['/api/provisioning', 'POST', { server: server.name, action }],
|
||||
['/api/provisioning', 'POST', { server: 'second', action }]
|
||||
]);
|
||||
expect(result.queued.map(item => item.server)).toEqual([server.name, 'second']);
|
||||
expect(result.failed).toEqual([]);
|
||||
});
|
||||
|
||||
it('destroys only the chosen servers through the existing per-server endpoint', async () => {
|
||||
const request = vi.fn().mockResolvedValue(Response.json({ deploymentId: 'destroy-1' }, { status: 202 }));
|
||||
await queueFleetAction('destroy', ['server/a'], request);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(request).toHaveBeenCalledWith('/api/servers/server%2Fa', { method: 'DELETE' });
|
||||
});
|
||||
|
||||
it('reports partial failures and continues without retrying successful requests', async () => {
|
||||
const request = vi.fn()
|
||||
.mockResolvedValueOnce(Response.json({ error: 'Another job is pending.' }, { status: 409 }))
|
||||
.mockResolvedValueOnce(Response.json({ deploymentId: 'job-2' }, { status: 202 }))
|
||||
.mockRejectedValueOnce(new Error('offline'));
|
||||
const result = await queueFleetAction('restart', ['blocked', 'ready', 'uncertain'], request);
|
||||
expect(result.queued).toEqual([{ server: 'ready', deploymentId: 'job-2' }]);
|
||||
expect(result.failed).toEqual([
|
||||
{ server: 'blocked', error: 'Another job is pending.' },
|
||||
{ server: 'uncertain', error: expect.stringContaining('Check Provisioning before trying again') }
|
||||
]);
|
||||
expect(request).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('does not report missing job IDs as successfully queued or submit an empty selection', async () => {
|
||||
const request = vi.fn().mockResolvedValue(Response.json({}, { status: 202 }));
|
||||
expect((await queueFleetAction('stop', ['one'], request)).queued).toEqual([]);
|
||||
request.mockClear();
|
||||
expect(await queueFleetAction('stop', [], request)).toEqual({ queued: [], failed: [] });
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders a named dialog with all five actions and a disabled empty selection', () => {
|
||||
const body = render(FleetActionsDialog, { props: { open: true, onqueued: vi.fn() } }).body;
|
||||
for (const action of ['Restart', 'Start', 'Stop', 'Rebuild', 'Destroy']) expect(body).toContain(`>${action}</option>`);
|
||||
expect(body).toContain('Fleet actions');
|
||||
expect(body).toContain('Select all eligible');
|
||||
expect(body).toContain('0 selected');
|
||||
const submit = [...body.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].find(match => match[0].includes('Restart 0 servers'))?.[0];
|
||||
expect(submit).toContain('disabled');
|
||||
});
|
||||
});
|
||||
@@ -59,7 +59,7 @@ describe('lifecycle impact copy', () => {
|
||||
|
||||
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. Removal waits for a live check confirming the lobby is empty.');
|
||||
.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.');
|
||||
});
|
||||
|
||||
@@ -89,14 +89,14 @@ describe('contextual lifecycle confirmation', () => {
|
||||
expect(body).not.toContain('Start the existing container');
|
||||
});
|
||||
|
||||
it('uses Remove server consistently and preserves recorded history in the explanation', () => {
|
||||
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('Remove Kino Der Toten · Lobby 1?');
|
||||
expect(body).toContain('Destroy Kino Der Toten · Lobby 1?');
|
||||
expect(body).toContain('4 players are in round 30.');
|
||||
expect(body).toContain('Remove server when empty');
|
||||
expect(body).toContain('Destroy server when empty');
|
||||
expect(body).toContain('recorded history');
|
||||
expect(body).not.toContain('campaign details');
|
||||
expect(actionLabels.destroy).toBe('Remove');
|
||||
expect(actionCompleted.destroy).toBe('Server removed');
|
||||
expect(actionLabels.destroy).toBe('Destroy');
|
||||
expect(actionCompleted.destroy).toBe('Server destroyed');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user