304 lines
11 KiB
TypeScript
304 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 } 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: "Dashboard" });
|
|
|
|
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: "Dashboard" });
|
|
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,
|
|
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,
|
|
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 on Pilots" })).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.");
|
|
});
|
|
});
|