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

198 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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");
// Base price 5 cr/fu (CR-018): Jupiter × 0.6, Earth × 1.6.
expect(jupiter).toHaveTextContent("3 cr/fu · cheapest");
expect(screen.getByLabelText("Fuel at Earth")).toHaveTextContent("8 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();
});
});