179 lines
5.7 KiB
TypeScript
179 lines
5.7 KiB
TypeScript
import { content } from "@deadhead/content";
|
|
import type { Contract, Pilot, Ship } from "@deadhead/schema";
|
|
import {
|
|
MS_PER_HOUR,
|
|
realToGame,
|
|
routeDistance,
|
|
travelTimeGameHours,
|
|
tripFuel,
|
|
pilotShare,
|
|
} from "@deadhead/sim";
|
|
import { screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ApiError } from "../src/api/client";
|
|
import { useClock } from "../src/clock/clock";
|
|
import { formatGameTime } from "../src/clock/GameClock";
|
|
import { formatCredits, formatDuration } from "../src/format";
|
|
import { formatLocalTime } from "../src/screens/DispatchSheet";
|
|
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
|
|
|
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
|
|
|
const ship = (id: string, name: string, modelId: string, pilotId: string | null): Ship => ({
|
|
id,
|
|
name,
|
|
modelId,
|
|
status: "idle",
|
|
locationId: "earth",
|
|
dispatchId: null,
|
|
lastArrivalAt: 0,
|
|
...shipExtras(modelId),
|
|
pilotId,
|
|
});
|
|
|
|
const pilot = (id: string, name: string, shipId: string, isAvatar: boolean): Pilot => ({
|
|
id,
|
|
name,
|
|
isAvatar,
|
|
isVeteran: false,
|
|
xp: 0,
|
|
level: 0,
|
|
skills: {},
|
|
skillPointsAvailable: 0,
|
|
assignedShipId: shipId,
|
|
status: "idle",
|
|
locationId: "earth",
|
|
dutyHours: 0,
|
|
dutyLimit: 12,
|
|
resting: false,
|
|
restEndsAt: null,
|
|
});
|
|
|
|
const marsJob: Contract = {
|
|
id: "c-mars",
|
|
originId: "earth",
|
|
destinationId: "mars",
|
|
cargoTypeId: "standard",
|
|
goodId: "consumer-goods",
|
|
shipper: "Pacific Orbital Logistics",
|
|
priority: false,
|
|
deliveryWindowGameHours: null,
|
|
tier: 0,
|
|
massTonnes: 15,
|
|
payout: 500_000,
|
|
requiredSkill: null,
|
|
requiredLongHaulLevel: 0,
|
|
offeredAt: NOW,
|
|
expiresAt: NOW + 3_600_000,
|
|
firstContact: false,
|
|
};
|
|
|
|
const state = samplePlayerState({
|
|
tutorial: null,
|
|
company: { id: "c", name: "D", credits: 1_000_000, loanBalance: 0 },
|
|
bays: [{ id: "b1", locationId: "earth" }],
|
|
ships: [
|
|
ship("s-courier", "Dusty Mule", "courier", "avatar"),
|
|
ship("s-freighter", "Slow Ox", "freighter", "p-rae"),
|
|
ship("s-spare", "Spare Kettle", "courier", null),
|
|
],
|
|
pilots: [
|
|
pilot("avatar", "Captain", "s-courier", true),
|
|
pilot("p-rae", "Rae Okafor", "s-freighter", false),
|
|
],
|
|
});
|
|
|
|
function api(createDispatch?: () => never) {
|
|
return fakeApi({
|
|
getPlayerState: () => state,
|
|
getContractBoard: () => ({ contracts: [marsJob], periodEndsAt: NOW + 3_600_000 }),
|
|
...(createDispatch ? { createDispatch } : {}),
|
|
});
|
|
}
|
|
|
|
async function openSheet() {
|
|
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
|
|
return screen.getByRole("dialog");
|
|
}
|
|
|
|
const option = (dialog: HTMLElement, shipName: string) =>
|
|
within(dialog).getByText(shipName).closest("li")!;
|
|
|
|
describe("dispatch sheet", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
|
vi.setSystemTime(NOW);
|
|
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
|
});
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
it("shows the sim's trip time, fuel, net profit and ETAs for an eligible pair", async () => {
|
|
renderApp(api(), "/contracts");
|
|
const dialog = await openSheet();
|
|
const freighter = option(dialog, "Slow Ox");
|
|
|
|
const model = content.shipModel("freighter")!;
|
|
const earth = content.location("earth")!;
|
|
const mars = content.location("mars")!;
|
|
const tripMs =
|
|
(travelTimeGameHours(model, routeDistance(earth, mars), marsJob.massTonnes, content.config) *
|
|
MS_PER_HOUR) /
|
|
content.config.timeCompression;
|
|
// The fixtures' tanks were filled at 16 cr/fu (CR-015).
|
|
const fuel = Math.round(tripFuel(model, earth, mars, content.config) * 16);
|
|
const net = marsJob.payout - fuel - pilotShare(marsJob.payout, fuel, content.config);
|
|
|
|
expect(within(freighter).getByRole("radio")).toBeEnabled();
|
|
expect(freighter).toHaveTextContent(`Trip${formatDuration(tripMs)}`);
|
|
expect(freighter).toHaveTextContent(`Fuel${formatCredits(fuel)}`);
|
|
expect(freighter).toHaveTextContent(`Net profit${formatCredits(net)}`);
|
|
expect(freighter).toHaveTextContent(
|
|
`ETA (game)${formatGameTime(realToGame(NOW + tripMs, content.config), "locale")}`,
|
|
);
|
|
expect(freighter).toHaveTextContent(`ETA (real)${formatLocalTime(NOW + tripMs)}`);
|
|
});
|
|
|
|
it("lists ineligible pairs disabled, with the reason", async () => {
|
|
renderApp(api(), "/contracts");
|
|
const dialog = await openSheet();
|
|
|
|
const courier = option(dialog, "Dusty Mule");
|
|
expect(within(courier).getByRole("radio")).toBeDisabled();
|
|
expect(courier).toHaveTextContent("Out of range");
|
|
|
|
const spare = option(dialog, "Spare Kettle");
|
|
expect(within(spare).getByRole("radio")).toBeDisabled();
|
|
expect(spare).toHaveTextContent("No pilot assigned");
|
|
});
|
|
|
|
it("sends the dispatch for the chosen pair on Confirm", async () => {
|
|
const fake = api();
|
|
renderApp(fake, "/contracts");
|
|
const dialog = await openSheet();
|
|
await userEvent.click(within(dialog).getByRole("button", { name: "Confirm" }));
|
|
await waitFor(() =>
|
|
expect(fake.call).toHaveBeenCalledWith("createDispatch", {
|
|
kind: "contract",
|
|
contractId: "c-mars",
|
|
shipId: "s-freighter",
|
|
pilotId: "p-rae",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("shows the server's message when the dispatch is refused", async () => {
|
|
renderApp(
|
|
api(() => {
|
|
throw new ApiError(409, "contract-expired", "This contract has expired.");
|
|
}),
|
|
"/contracts",
|
|
);
|
|
const dialog = await openSheet();
|
|
await userEvent.click(within(dialog).getByRole("button", { name: "Confirm" }));
|
|
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
|
|
"This contract has expired.",
|
|
);
|
|
});
|
|
});
|