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

306 lines
11 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, shipExtras, comeBack } 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,
...shipExtras("courier"),
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,
fuelBurnedFu: 0,
expectedPayout: 0,
seed: 1,
status: "in-flight",
resolvedAt: null,
});
describe("Dashboard (CR-020)", () => {
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 every ship on a job, soonest first", 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: "Bridge" });
const statValue = (label: string) => screen.getByText(label).nextElementSibling;
expect(statValue("Ships idle")).toHaveTextContent("1");
expect(statValue("Ships in flight")).toHaveTextContent("2");
const jobs = screen.getByRole("region", { name: "Ships on jobs" });
const rows = within(jobs).getAllByRole("listitem");
expect(rows.map((r) => r.getAttribute("aria-label"))).toEqual(["c", "b"]);
expect(rows[0]).toHaveTextContent("Earth → Moon1m 05s");
expect(rows[1]).toHaveTextContent("2m 05s");
act(() => vi.advanceTimersByTime(10_000));
expect(rows[0]).toHaveTextContent("55s");
});
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.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: "Bridge" });
expect(screen.queryByText("Loan from Halvorsen")).not.toBeInTheDocument();
});
it("shows the lender's cut in a delivery summary", async () => {
comeBack();
const state: PlayerState = {
...owner(2000, 4000),
summaries: [
{
dispatchId: "d1",
shipId: "s1",
pilotId: "avatar",
originId: "earth",
destinationId: "moon",
tutorial: false,
credits: 538,
deadhead: false,
independent: false,
fuelCost: 0,
topUp: null,
goodId: "consumer-goods",
massTonnes: 12,
shipper: "Pacific Orbital Logistics",
loanRepayment: 230,
pilotPay: 0,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,
event: null,
restEndsAt: null,
resolvedAt: 1,
},
],
};
renderApp(
fakeApi({ getPlayerState: () => state, acknowledgeSummaries: () => ({ acknowledged: 1 }) }),
);
const dialog = await screen.findByRole("dialog", { name: /While you were away/ });
// The pop-up shows the net, after the lender's cut; the cut itself is in the Logbook (CR-025).
await waitFor(() =>
expect(dialog).toHaveTextContent("delivered Consumer Goods to Moon, +538 cr"),
);
});
});
describe("your ship on the Dashboard (CR-020)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
const avatar = samplePlayerState().pilots[0]!;
const mine = (overrides: Partial<Ship> = {}): Ship => ({
...ship("Iron Mule", "idle"),
pilotId: "avatar",
...overrides,
});
const owner = (overrides: Partial<PlayerState>) =>
samplePlayerState({
tutorial: null,
pilots: [{ ...avatar, assignedShipId: "Iron Mule", dutyHours: 3, dutyLimit: 12 }],
...overrides,
});
const panel = async () => screen.findByRole("region", { name: "Your ship" });
it("shows an idle ship's place, fuel, the pilot's duty and where to find work", async () => {
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine()] }) }));
const p = await panel();
expect(p).toHaveTextContent("Iron Mule");
expect(p).toHaveTextContent("Idle");
expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("At Earth");
expect(within(p).getByRole("progressbar", { name: "Iron Mule fuel" })).toBeInTheDocument();
expect(within(p).getByRole("progressbar", { name: "Your duty" })).toHaveAttribute(
"aria-valuenow",
"3",
);
expect(within(p).getByRole("link", { name: "Find work here" })).toHaveAttribute(
"href",
"/contracts?location=earth",
);
expect(within(p).queryByLabelText("Your ship low fuel")).not.toBeInTheDocument();
});
it("warns when your ship is low on fuel", async () => {
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine({ fuelFu: 1 })] }) }));
expect(await screen.findByLabelText("Your ship low fuel")).toHaveTextContent("Low fuel");
});
it("shows your ship's trip, cargo and arrival in flight", async () => {
const flight: Dispatch = {
...dispatch("Iron Mule", NOW + 90_000),
contract: {
id: "c1",
originId: "earth",
destinationId: "moon",
cargoTypeId: "high-value",
goodId: "medical-supplies",
shipper: "Meridian Health",
priority: false,
deliveryWindowGameHours: null,
tier: 1,
massTonnes: 6,
payout: 900,
requiredSkill: { skillId: "expensive-cargo", level: 1 },
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + 600_000,
firstContact: false,
},
};
const flying = mine({ status: "in-flight", locationId: null, dispatchId: flight.id });
renderApp(
fakeApi({ getPlayerState: () => owner({ ships: [flying], activeDispatches: [flight] }) }),
);
const p = await panel();
expect(p).toHaveTextContent("In flight");
expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("Earth → Moon");
expect(within(p).getByLabelText("Your cargo")).toHaveTextContent(
"6 t Medical Supplies for Meridian Health",
);
expect(p).toHaveTextContent("Arrives in 1m 30s");
});
it("shows a ship in the shop and a resting pilot", async () => {
const shopped = mine({ inShopUntil: NOW + 120_000 });
renderApp(
fakeApi({
getPlayerState: () =>
owner({
ships: [shopped],
pilots: [
{ ...avatar, assignedShipId: "Iron Mule", resting: true, restEndsAt: NOW + 300_000 },
],
}),
}),
);
const p = await panel();
expect(p).toHaveTextContent("In the shop — ready in 2m 00s");
expect(within(p).getByLabelText("Your pilot")).toHaveTextContent("Resting · wakes in 5m 00s");
});
it("says so when you aren't flying a ship", async () => {
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [] }) }));
const p = await panel();
expect(p).toHaveTextContent("You're not flying a ship right now.");
expect(within(p).getByRole("link", { name: "Assign yourself in Crew" })).toHaveAttribute(
"href",
"/pilots",
);
});
it("shows the company loaner during the tutorial", async () => {
renderApp(fakeApi({ getPlayerState: () => samplePlayerState() }));
const p = await panel();
expect(p).toHaveTextContent("Company loaner");
expect(p).toHaveTextContent("Supplied by Halvorsen");
expect(within(p).getByLabelText("Your ship's status")).toHaveTextContent("At Earth");
});
it("has an Alerts panel, empty for now", async () => {
renderApp(fakeApi({ getPlayerState: () => owner({ ships: [mine()] }) }));
const alerts = await screen.findByRole("region", { name: "Alerts" });
expect(alerts).toHaveTextContent("No alerts.");
});
});