171 lines
6.2 KiB
TypeScript
171 lines
6.2 KiB
TypeScript
import { content } from "@deadhead/content";
|
|
import type { Contract } from "@deadhead/schema";
|
|
import { deliveryXp, MS_PER_HOUR, routeDistance, travelTimeGameHours } 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 { formatCredits, formatDuration } from "../src/format";
|
|
import { fakeApi, renderApp, samplePlayerState, sampleTutorialBoard, comeBack } from "./render";
|
|
|
|
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
|
const loaner = content.shipModel("company-courier")!;
|
|
|
|
/** What the sim predicts for the loaner flying this contract now. */
|
|
function expected(contract: Contract) {
|
|
const distance = routeDistance(
|
|
content.location(contract.originId)!,
|
|
content.location(contract.destinationId)!,
|
|
);
|
|
const hours = travelTimeGameHours(loaner, distance, contract.massTonnes, content.config);
|
|
return {
|
|
pay: formatCredits(contract.payout),
|
|
time: formatDuration((hours * MS_PER_HOUR) / content.config.timeCompression),
|
|
xp: `+${deliveryXp(distance, contract.tier, content.config)}`,
|
|
};
|
|
}
|
|
|
|
describe("tutorial contract board", () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
|
vi.setSystemTime(NOW);
|
|
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
|
});
|
|
afterEach(() => vi.useRealTimers());
|
|
|
|
it("shows pay, trip time and XP side by side for all three contracts, matching the sim", async () => {
|
|
const board = sampleTutorialBoard(NOW);
|
|
renderApp(
|
|
fakeApi({ getPlayerState: () => samplePlayerState(), getTutorialBoard: () => board }),
|
|
"/contracts",
|
|
);
|
|
const items = await screen.findAllByRole("listitem");
|
|
expect(items).toHaveLength(3);
|
|
items.forEach((item, i) => {
|
|
const want = expected(board.contracts[i]!);
|
|
expect(within(item).getByLabelText("Pay")).toHaveTextContent(want.pay);
|
|
expect(within(item).getByLabelText("Trip time")).toHaveTextContent(want.time);
|
|
expect(within(item).getByLabelText("XP")).toHaveTextContent(want.xp);
|
|
});
|
|
// The heavy run takes longer than the light one.
|
|
expect(expected(board.contracts[2]!).time).not.toBe(expected(board.contracts[0]!).time);
|
|
});
|
|
|
|
it("styles the scripted contract and shows its name", async () => {
|
|
renderApp(
|
|
fakeApi({
|
|
getPlayerState: () => samplePlayerState(),
|
|
getTutorialBoard: () => sampleTutorialBoard(NOW, { scriptedMedium: true }),
|
|
}),
|
|
"/contracts",
|
|
);
|
|
const items = await screen.findAllByRole("listitem");
|
|
expect(items[1]).toHaveAttribute("data-scripted", "true");
|
|
expect(items[1]).toHaveTextContent("Unmarked crates");
|
|
expect(items[0]).not.toHaveAttribute("data-scripted");
|
|
});
|
|
|
|
it("dispatches the tutorial run with Fly it", async () => {
|
|
const api = fakeApi({
|
|
getPlayerState: () => samplePlayerState(),
|
|
getTutorialBoard: () => sampleTutorialBoard(NOW),
|
|
});
|
|
renderApp(api, "/contracts");
|
|
const [, medium] = await screen.findAllByRole("listitem");
|
|
await userEvent.click(within(medium!).getByRole("button", { name: "Fly it" }));
|
|
await waitFor(() =>
|
|
expect(api.call).toHaveBeenCalledWith("createDispatch", {
|
|
kind: "tutorial",
|
|
contractId: "tutorial:earth:1:0:1",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("disables Fly it while a tutorial run is in flight", async () => {
|
|
renderApp(
|
|
fakeApi({
|
|
getPlayerState: () =>
|
|
samplePlayerState({
|
|
tutorial: {
|
|
loanerLocationId: "earth",
|
|
runInFlight: true,
|
|
runsCompleted: 1,
|
|
dealAvailable: false,
|
|
},
|
|
}),
|
|
getTutorialBoard: () => sampleTutorialBoard(NOW),
|
|
}),
|
|
"/contracts",
|
|
);
|
|
await screen.findAllByRole("listitem");
|
|
for (const button of screen.getAllByRole("button", { name: "Fly it" })) {
|
|
expect(button).toBeDisabled();
|
|
}
|
|
expect(screen.getByText(/Your run is in flight/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("shows the server's message when dispatch fails", async () => {
|
|
renderApp(
|
|
fakeApi({
|
|
getPlayerState: () => samplePlayerState(),
|
|
getTutorialBoard: () => sampleTutorialBoard(NOW),
|
|
createDispatch: () => {
|
|
throw new ApiError(409, "tutorial-run-in-flight", "Your tutorial run is still flying.");
|
|
},
|
|
}),
|
|
"/contracts",
|
|
);
|
|
const [first] = await screen.findAllByRole("listitem");
|
|
await userEvent.click(within(first!).getByRole("button", { name: "Fly it" }));
|
|
expect(await screen.findByRole("alert")).toHaveTextContent(
|
|
"Your tutorial run is still flying.",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("scripted delivery summary", () => {
|
|
it("shows the scripted contract's flavor message after it resolves", async () => {
|
|
comeBack();
|
|
const flavor = content.scriptedTutorialContracts.find(
|
|
(s) => s.id === "unmarked-crates",
|
|
)!.flavorMessage;
|
|
renderApp(
|
|
fakeApi({
|
|
getPlayerState: () =>
|
|
samplePlayerState({
|
|
summaries: [
|
|
{
|
|
dispatchId: "d3",
|
|
shipId: "loaner",
|
|
pilotId: "avatar",
|
|
originId: "earth",
|
|
destinationId: "moon",
|
|
tutorial: true,
|
|
credits: 461,
|
|
deadhead: false,
|
|
independent: false,
|
|
fuelCost: 0,
|
|
topUp: null,
|
|
goodId: "consumer-goods",
|
|
massTonnes: 12,
|
|
shipper: "Pacific Orbital Logistics",
|
|
loanRepayment: 0,
|
|
pilotPay: 0,
|
|
pilotXp: 10,
|
|
discoveredLocationId: null,
|
|
scriptedId: "unmarked-crates",
|
|
event: null,
|
|
restEndsAt: null,
|
|
resolvedAt: NOW,
|
|
},
|
|
],
|
|
}),
|
|
acknowledgeSummaries: () => ({ acknowledged: 1 }),
|
|
}),
|
|
);
|
|
const dialog = await screen.findByRole("dialog", { name: /While you were away/ });
|
|
await waitFor(() => expect(dialog).toHaveTextContent(flavor));
|
|
});
|
|
});
|