deadhead-space-freight-tycoon/apps/web/test/fleet.test.tsx

189 lines
6.1 KiB
TypeScript

import { content } from "@deadhead/content";
import type { Dispatch, PlayerState, Ship } from "@deadhead/schema";
import { createRepositionRecord, dispatchProgress } from "@deadhead/sim";
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { formatCredits, formatDuration } from "../src/format";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const flight: Dispatch = {
id: "d1",
shipId: "s-fly",
pilotId: "p2",
contract: null,
originId: "earth",
destinationId: "moon",
tutorial: false,
departureAt: NOW - 60_000,
arrivalAt: NOW + 180_000,
distanceMillionKm: 0.39,
fuelCost: 50,
expectedPayout: 0,
seed: 1,
status: "in-flight",
resolvedAt: null,
};
const ship = (overrides: Partial<Ship>): Ship => ({
id: "s",
modelId: "courier",
name: "Ship",
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: null,
...overrides,
});
function fleetState(overrides: Partial<PlayerState> = {}): PlayerState {
const base = samplePlayerState({ tutorial: null });
return {
...base,
bays: [
{ id: "b1", locationId: "earth" },
{ id: "b2", locationId: "moon" },
{ id: "b3", locationId: "mars" },
],
ships: [
ship({ id: "s-idle", name: "Dusty Mule", pilotId: "avatar" }),
ship({
id: "s-fly",
name: "Slow Ox",
status: "in-flight",
locationId: null,
dispatchId: "d1",
pilotId: "p2",
}),
],
pilots: [
{ ...base.pilots[0]!, assignedShipId: "s-idle" },
{
...base.pilots[0]!,
id: "p2",
name: "Rae Okafor",
isAvatar: false,
assignedShipId: "s-fly",
status: "in-flight",
locationId: null,
},
],
activeDispatches: [flight],
...overrides,
};
}
describe("fleet screen", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("shows an in-flight ship's progress from the sim, and it advances", async () => {
renderApp(fakeApi({ getPlayerState: () => fleetState() }), "/fleet");
const bar = await screen.findByRole("progressbar", { name: "Slow Ox progress" });
expect(bar).toHaveAttribute(
"aria-valuenow",
String(Math.round(dispatchProgress(flight, NOW) * 100)),
);
expect(bar).toHaveAttribute("aria-valuenow", "25");
act(() => vi.advanceTimersByTime(60_000));
expect(bar).toHaveAttribute("aria-valuenow", "50");
expect(screen.getByText("Earth → Moon", { exact: false })).toBeInTheDocument();
expect(screen.getByText("2m 00s")).toBeInTheDocument();
});
it("lists each ship with model, location and pilot", async () => {
renderApp(fakeApi({ getPlayerState: () => fleetState() }), "/fleet");
const idle = (await screen.findByText("Dusty Mule")).closest("li")!;
expect(idle).toHaveTextContent("Courier");
expect(idle).toHaveTextContent("At Earth");
expect(idle).toHaveTextContent("Captain");
});
/** A crewed Courier idle at the Moon with no Moon bay; Mars discovered but out of range. */
const atMoon = () =>
fleetState({
bays: [{ id: "b1", locationId: "earth" }],
locations: [
{ id: "earth", discovered: true },
{ id: "moon", discovered: true },
{ id: "mars", discovered: true },
{ id: "ceres-station", discovered: false },
{ id: "jupiter-cloud-platform", discovered: false },
],
company: { id: "c", name: "D", credits: 10_000, loanBalance: 0 },
ships: [
ship({ id: "s-idle", name: "Dusty Mule", locationId: "moon", pilotId: "avatar" }),
fleetState().ships[1]!,
],
});
it("lets an idle ship with no bay where it is deadhead to a discovered location", async () => {
const api = fakeApi({ getPlayerState: atMoon });
renderApp(api, "/fleet");
const idle = (await screen.findByText("Dusty Mule")).closest("li")!;
await userEvent.click(within(idle).getByRole("button", { name: "Deadhead (fly empty)" }));
const picker = screen.getByRole("dialog", { name: "Deadhead Dusty Mule" });
const options = within(picker)
.getAllByRole("button")
.map((b) => b.getAttribute("aria-label"))
.filter((label) => label !== null);
expect(options).toEqual(["Earth", "Mars"]);
const mars = within(picker).getByRole("button", { name: "Mars" });
expect(mars).toBeDisabled();
expect(mars).toHaveTextContent("Out of range");
const earth = within(picker).getByRole("button", { name: "Earth" });
const record = createRepositionRecord(
{
id: "r",
ship: { id: "s-idle", modelId: "courier", locationId: "moon", inFlight: false },
pilot: {
id: "avatar",
isAvatar: true,
assignedShipId: "s-idle",
inFlight: false,
skills: {},
restEndsAt: null,
},
destinationId: "earth",
now: NOW,
worldSeed: 1,
},
content,
);
expect(earth).toBeEnabled();
expect(earth).toHaveTextContent(`${formatCredits(record.fuelCost)} fuel`);
expect(earth).toHaveTextContent(formatDuration(record.arrivalAt - record.departureAt));
await userEvent.click(earth);
await waitFor(() =>
expect(api.call).toHaveBeenCalledWith("createDispatch", {
kind: "reposition",
shipId: "s-idle",
pilotId: "avatar",
destinationId: "earth",
}),
);
});
it("does not offer a deadhead to in-flight ships", async () => {
renderApp(fakeApi({ getPlayerState: () => fleetState() }), "/fleet");
const flying = (await screen.findByText("Slow Ox")).closest("li")!;
expect(
within(flying).queryByRole("button", { name: "Deadhead (fly empty)" }),
).not.toBeInTheDocument();
});
});