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

233 lines
9.4 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 { pilotExtras } from "./fixtures";
import { content } from "@deadhead/content";
import type { Contract, Pilot, PlayerState, Ship } from "@deadhead/schema";
import { emergencyFuel, fuelPriceAt, refuel, tripFuel } from "@deadhead/sim";
import { formatFu } from "../src/components/Fuel";
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,
...pilotExtras,
};
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`,
);
// The button says what it buys and what it costs: here a full tank.
const fill = refuel(
{ fuelFu: 2, avgPrice: 16 },
courier.tankCapacityFu,
courier.tankCapacityFu - 2,
earthPrice,
);
const button = within(pair).getByRole("button", { name: /^Refuel here first/ });
expect(button).toHaveTextContent(
`Refuel here first · ${formatFu(courier.tankCapacityFu - 2)} for ${formatCredits(fill.cost)}`,
);
await userEvent.click(button);
// 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")!;
const leg = refuel({ fuelFu: 2, avgPrice: 16 }, courier.tankCapacityFu, legFu - 2, earthPrice);
const button = within(pair).getByRole("button", { name: /^Refuel here first/ });
expect(button).toHaveTextContent(`· ${formatFu(legFu - 2)} for ${formatCredits(leg.cost)}`);
await userEvent.click(button);
expect(refuelShip).toHaveBeenCalledWith({ shipId: "s1", amountFu: legFu - 2 });
});
it("disables Refuel here first, saying why, when the credits can't cover the leg's fuel", async () => {
const { api } = company(2, 5);
renderApp(api, "/contracts");
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
const pair = within(screen.getByRole("dialog")).getByText("Dusty Mule").closest("li")!;
expect(within(pair).getByRole("button", { name: /^Refuel here first/ })).toBeDisabled();
expect(pair).toHaveTextContent("Not enough credits for the fuel this trip needs.");
});
it("opens sheets below the top bar, so the credits stay in view", async () => {
renderApp(company(2).api, "/contracts");
await userEvent.click(await screen.findByRole("button", { name: "Dispatch" }));
const backdrop = screen.getByRole("dialog").parentElement!;
expect(backdrop.className).toContain("top-14");
expect(backdrop.className).not.toContain("inset-0");
expect(screen.getByText(formatCredits(10_000))).toBeInTheDocument();
});
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();
});
});