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

165 lines
6.0 KiB
TypeScript

import { content } from "@deadhead/content";
import type { Dispatch, PlayerState, Ship } from "@deadhead/schema";
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 { fakeApi, renderApp, samplePlayerState } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const ship = (id: string, status: Ship["status"]): Ship => ({
id,
modelId: "courier",
name: id,
status,
locationId: status === "idle" ? "earth" : null,
dispatchId: status === "idle" ? null : `d-${id}`,
lastArrivalAt: 0,
pilotId: null,
});
const dispatch = (shipId: string, arrivalAt: number): Dispatch => ({
id: `d-${shipId}`,
shipId,
pilotId: "avatar",
contract: null,
originId: "earth",
destinationId: "moon",
tutorial: false,
departureAt: NOW - 60_000,
arrivalAt,
distanceMillionKm: 0.39,
fuelCost: 50,
expectedPayout: 0,
seed: 1,
status: "in-flight",
resolvedAt: null,
});
describe("HQ screen", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("shows idle and in-flight counts and counts down to the next arrival", async () => {
const state = samplePlayerState({
tutorial: null,
ships: [ship("a", "idle"), ship("b", "in-flight"), ship("c", "in-flight")],
activeDispatches: [dispatch("b", NOW + 125_000), dispatch("c", NOW + 65_000)],
});
renderApp(fakeApi({ getPlayerState: () => state }));
await screen.findByRole("heading", { level: 1, name: "HQ" });
const statValue = (label: string) => screen.getByText(label).nextElementSibling;
expect(statValue("Ships idle")).toHaveTextContent("1");
expect(statValue("Ships in flight")).toHaveTextContent("2");
const next = screen.getByRole("heading", { name: "Next arrival" }).parentElement!;
expect(within(next).getByText("1m 05s")).toBeInTheDocument();
act(() => vi.advanceTimersByTime(10_000));
expect(within(next).getByText("55s")).toBeInTheDocument();
});
it("shows tutorial run progress toward the deal during the tutorial", async () => {
const state = samplePlayerState({
tutorial: {
loanerLocationId: "moon",
runInFlight: false,
runsCompleted: 3,
dealAvailable: false,
},
});
renderApp(fakeApi({ getPlayerState: () => state }));
expect(await screen.findByLabelText("Company runs")).toHaveTextContent(
`Company runs: 3 of ${content.config.tutorialRunsBeforeDeal}`,
);
expect(screen.queryByText("Loan from Halvorsen")).not.toBeInTheDocument();
});
});
describe("loan on HQ", () => {
const total = content.config.dealLoanAmount;
const owner = (credits: number, loanBalance: number): PlayerState =>
samplePlayerState({ tutorial: null, company: { id: "c", name: "D", credits, loanBalance } });
it("shows the balance and a paid-off progress bar matching state", async () => {
renderApp(fakeApi({ getPlayerState: () => owner(2000, total * 0.75) }));
expect(await screen.findByLabelText("Loan balance")).toHaveTextContent(
`${(total * 0.75).toLocaleString("en-US")} cr left`,
);
expect(screen.getByRole("progressbar", { name: "Loan paid off" })).toHaveAttribute(
"aria-valuenow",
"25",
);
});
it("pays down with the entered amount", async () => {
const api = fakeApi({
getPlayerState: () => owner(2000, 4000),
repayLoan: ({ amount }) => ({
company: { id: "c", name: "D", credits: 2000 - amount, loanBalance: 4000 - amount },
}),
});
renderApp(api);
await userEvent.type(await screen.findByLabelText("Pay down amount"), "750");
await userEvent.click(screen.getByRole("button", { name: "Pay down" }));
await waitFor(() => expect(api.call).toHaveBeenCalledWith("repayLoan", { amount: 750 }));
});
it("blocks an amount above the balance", async () => {
const api = fakeApi({ getPlayerState: () => owner(5000, 300) });
renderApp(api);
await userEvent.type(await screen.findByLabelText("Pay down amount"), "400");
expect(screen.getByRole("button", { name: "Pay down" })).toBeDisabled();
expect(screen.getByText("You only owe 300 cr.")).toBeInTheDocument();
expect(api.call).not.toHaveBeenCalledWith("repayLoan", expect.anything());
});
it("blocks an amount above the player's credits", async () => {
const api = fakeApi({ getPlayerState: () => owner(200, 3000) });
renderApp(api);
await userEvent.type(await screen.findByLabelText("Pay down amount"), "250");
expect(screen.getByRole("button", { name: "Pay down" })).toBeDisabled();
expect(screen.getByText("You only have 200 cr.")).toBeInTheDocument();
expect(api.call).not.toHaveBeenCalledWith("repayLoan", expect.anything());
});
it("is hidden once the loan is paid off", async () => {
renderApp(fakeApi({ getPlayerState: () => owner(2000, 0) }));
await screen.findByRole("heading", { level: 1, name: "HQ" });
expect(screen.queryByText("Loan from Halvorsen")).not.toBeInTheDocument();
});
it("shows the lender's cut in a delivery summary", async () => {
const state: PlayerState = {
...owner(2000, 4000),
summaries: [
{
dispatchId: "d1",
shipId: "s1",
pilotId: "avatar",
originId: "earth",
destinationId: "moon",
tutorial: false,
credits: 538,
loanRepayment: 230,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,
restEndsAt: null,
resolvedAt: 1,
},
],
};
renderApp(
fakeApi({ getPlayerState: () => state, acknowledgeSummaries: () => ({ acknowledged: 1 }) }),
);
const dialog = await screen.findByRole("dialog", { name: "While you were away" });
expect(dialog).toHaveTextContent("230 cr went to the lender");
});
});