197 lines
7.6 KiB
TypeScript
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();
|
|
});
|
|
});
|