deadhead-space-freight-tycoon/apps/web/test/fuel-ui.test.tsx

197 lines
7.6 KiB
TypeScript

import { content } from "@deadhead/content";
import type { Contract, Pilot, PlayerState, Ship } from "@deadhead/schema";
import { emergencyFuel, fuelPriceAt, tripFuel } 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 { useClock } from "../src/clock/clock";
import { formatCredits } from "../src/format";
import { simShip } from "../src/game";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T00:00:00Z");
const [earth, moon] = [content.location("earth")!, content.location("moon")!];
const courier = content.shipModel("courier")!;
const earthPrice = fuelPriceAt(earth, content.config);
const legFu = tripFuel(courier, earth, moon, content.config);
const ship = (fuelFu: number, refuelPolicy: Ship["refuelPolicy"] = "auto"): Ship => ({
id: "s1",
name: "Dusty Mule",
modelId: "courier",
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
fuelFu,
fuelAvgPrice: 15,
refuelPolicy,
pilotId: "avatar",
});
const avatar: Pilot = {
id: "avatar",
name: "Captain",
isAvatar: true,
isVeteran: false,
xp: 0,
level: 0,
skills: {},
skillPointsAvailable: 0,
assignedShipId: "s1",
status: "idle",
locationId: "earth",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
};
const moonJob: Contract = {
id: "c-moon",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 5,
payout: 900,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: NOW,
expiresAt: NOW + 3_600_000,
firstContact: false,
};
/** A one-ship company with `fuelFu` on board and `credits`, whose refuels really fill the tank. */
function company(fuelFu: number, credits = 10_000) {
let state: PlayerState = samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits, loanBalance: 0 },
bays: [{ id: "b1", locationId: "earth" }],
ships: [ship(fuelFu)],
pilots: [avatar],
});
const refuelShip = vi.fn((input: { shipId: string; amountFu?: number }) => {
const s = state.ships[0]!;
const bought = input.amountFu ?? s.stats.tankCapacityFu - s.fuelFu;
const refuelled = { ...s, fuelFu: s.fuelFu + bought };
state = {
...state,
company: {
...state.company,
credits: state.company.credits - Math.round(bought * earthPrice),
},
ships: [refuelled],
};
return { ship: refuelled };
});
const api = fakeApi({
getPlayerState: () => state,
getContractBoard: () => ({ contracts: [moonJob], periodEndsAt: NOW + 3_600_000 }),
refuelShip,
});
return { api, refuelShip, state: () => state };
}
describe("fuel in the client (Step 15.7)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("shows each ship's fuel gauge and refuel policy on the Fleet screen", async () => {
renderApp(company(30).api, "/fleet");
const gauge = await screen.findByRole("progressbar", { name: "Dusty Mule fuel" });
// The gauge runs from empty to the tank's capacity, in fu.
expect(gauge).toHaveAttribute("aria-valuenow", "30");
expect(gauge).toHaveAttribute("aria-valuemax", String(courier.tankCapacityFu));
expect(screen.getByLabelText("Dusty Mule fuel level")).toHaveTextContent("30 fu · 75%");
expect(screen.getByText("Auto top-up")).toBeInTheDocument();
});
it('offers "Refuel here first" for a pair short of fuel, fills the tank and enables it', async () => {
const { api, refuelShip } = company(2);
renderApp(api, "/contracts");
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
const dialog = screen.getByRole("dialog");
const pair = within(dialog).getByText("Dusty Mule").closest("li")!;
expect(within(pair).getByRole("radio")).toBeDisabled();
expect(pair).toHaveTextContent("Not enough fuel");
expect(within(pair).getByLabelText("Fuel")).toHaveTextContent(
`Fuel needed ${Math.round(legFu)} fu · on board 2 fu`,
);
await userEvent.click(within(pair).getByRole("button", { name: "Refuel here first" }));
// A full tank: the credits cover it.
expect(refuelShip).toHaveBeenCalledWith({ shipId: "s1" });
// The sheet stays open and the pair is now flyable and picked.
await waitFor(() => expect(within(dialog).getByRole("radio")).toBeEnabled());
expect(within(dialog).getByRole("radio")).toBeChecked();
expect(screen.getByRole("dialog")).toBe(dialog);
});
it("buys just the leg's fuel when the credits can't fill the tank", async () => {
const { api, refuelShip } = company(2, Math.round(10 * earthPrice));
renderApp(api, "/contracts");
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
const pair = within(screen.getByRole("dialog")).getByText("Dusty Mule").closest("li")!;
await userEvent.click(within(pair).getByRole("button", { name: "Refuel here first" }));
expect(refuelShip).toHaveBeenCalledWith({ shipId: "s1", amountFu: legFu - 2 });
});
it("prices the fuel used at the tank's average price, as the sim does", async () => {
renderApp(company(40).api, "/contracts");
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
const pair = within(screen.getByRole("dialog")).getByText("Dusty Mule").closest("li")!;
expect(pair).toHaveTextContent(
`Fuel used (at your average price)${formatCredits(Math.round(legFu * 15))}`,
);
});
it("highlights the cheapest fuel on the Locations screen", async () => {
const all = samplePlayerState({
tutorial: null,
locations: content.locations.map((l) => ({ id: l.id, discovered: true })),
});
renderApp(fakeApi({ getPlayerState: () => all }), "/locations");
const jupiter = await screen.findByLabelText("Fuel at Jupiter Cloud Platform");
expect(jupiter).toHaveTextContent("6 cr/fu · cheapest");
expect(screen.getByLabelText("Fuel at Earth")).toHaveTextContent("16 cr/fu");
expect(screen.getByLabelText("Fuel at Earth")).not.toHaveTextContent("cheapest");
});
it("shows emergency fuel only when the sim offers it", async () => {
// Stuck at the Moon with 1 fu and no credits: the only way out is Earth.
const stuck = samplePlayerState({
tutorial: null,
company: { id: "c", name: "D", credits: 0, loanBalance: 0 },
ships: [{ ...ship(1), locationId: "moon" }],
pilots: [avatar],
});
const offer = emergencyFuel(
{
ship: simShip(stuck.ships[0]!),
credits: 0,
discoveredIds: new Set(["earth", "moon"]),
},
content,
)!;
const { unmount } = renderApp(fakeApi({ getPlayerState: () => stuck }), "/fleet");
await userEvent.click(await screen.findByRole("button", { name: "Fuel for Dusty Mule" }));
expect(
screen.getByRole("button", { name: /Emergency fuel \(added to loan\)/ }),
).toHaveTextContent(formatCredits(offer.cost));
unmount();
const fine = samplePlayerState({ tutorial: null, ships: [ship(40)], pilots: [avatar] });
renderApp(fakeApi({ getPlayerState: () => fine }), "/fleet");
await userEvent.click(await screen.findByRole("button", { name: "Fuel for Dusty Mule" }));
expect(screen.queryByRole("button", { name: /Emergency fuel/ })).toBeNull();
});
});