deadhead-space-freight-tycoon/apps/web/test/logbook-screen.test.tsx

246 lines
7.6 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 type { LogbookResponse, LogEntry, Pilot, ResolutionSummary, Ship } from "@deadhead/schema";
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 { formatLocalTime } from "../src/screens/DispatchSheet";
import { pilotExtras } from "./fixtures";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-08T12:00:00Z");
const HOUR = 3_600_000;
const ABSENCE = { from: NOW - 5 * HOUR, to: NOW };
const pilot = (id: string, name: string): Pilot => ({
...samplePlayerState().pilots[0]!,
id,
name,
isAvatar: false,
assignedShipId: `s-${id}`,
locationId: "earth",
...pilotExtras,
mode: "independent",
});
const ship = (id: string, name: string, pilotId: string): Ship => ({
id,
modelId: "courier",
name,
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId,
});
let n = 0;
const at = (hoursAgo: number) => NOW - hoursAgo * HOUR + n++;
const job = (pilotId: string, hoursAgo: number, over: Partial<LogEntry> = {}): LogEntry =>
({
id: `j${n}`,
at: at(hoursAgo),
pilotId,
shipId: `s-${pilotId}`,
kind: "flight",
dispatchId: `d${n}`,
deadhead: false,
tutorial: false,
independent: true,
originId: "earth",
destinationId: "moon",
goodId: "medical-supplies",
massTonnes: 6,
shipper: "Meridian Health",
payout: 1_000,
pilotPay: 150,
fuelCost: 60,
loanRepayment: 90,
xp: 12,
scriptedId: null,
...over,
}) as LogEntry;
const restUntil = NOW - HOUR;
const entries: LogEntry[] = [
job("ada", 2),
job("ada", 1, {
deadhead: true,
goodId: null,
payout: 0,
pilotPay: 0,
loanRepayment: 0,
xp: 0,
fuelCost: 40,
}),
{ id: "r1", at: at(2), pilotId: "ada", shipId: "s-ada", kind: "rest", until: restUntil },
{
id: "f1",
at: at(3),
pilotId: "ada",
shipId: "s-ada",
kind: "refuel",
locationId: "earth",
boughtFu: 12,
cost: 96,
source: "pilot",
},
{
id: "u1",
at: at(4),
pilotId: "bo",
shipId: "s-bo",
kind: "upgrade",
upgradeId: "engines",
level: 2,
},
{ id: "x1", at: at(4), pilotId: "bo", shipId: "s-bo", kind: "discovery", locationId: "mars" },
{
id: "l1",
at: at(4),
pilotId: "bo",
shipId: "s-bo",
kind: "level-up",
level: 3,
skillPoints: 1,
},
job("bo", 4),
// Before this absence, inside the last 24 hours.
job("bo", 20),
// Older than a day.
job("ada", 100),
{
id: "g1",
at: at(1),
pilotId: null,
shipId: null,
kind: "goal-ready",
goalId: "first-own-delivery",
},
];
const pending: ResolutionSummary = {
dispatchId: "d-pending",
shipId: "s-ada",
pilotId: "ada",
originId: "earth",
destinationId: "moon",
tutorial: false,
independent: true,
deadhead: false,
fuelCost: 60,
topUp: null,
goodId: "medical-supplies",
massTonnes: 6,
shipper: "Meridian Health",
credits: 760,
loanRepayment: 90,
pilotPay: 150,
pilotXp: 12,
discoveredLocationId: null,
scriptedId: null,
event: null,
restEndsAt: null,
resolvedAt: NOW - HOUR,
};
/** Ada and Bo flying Independent; the summary still open unless `seen`. */
function app(seen = true) {
let closed = seen;
const state = () =>
samplePlayerState({
tutorial: null,
pilots: [samplePlayerState().pilots[0]!, pilot("ada", "Ada"), pilot("bo", "Bo")],
ships: [ship("s-ada", "Kite", "ada"), ship("s-bo", "Wren", "bo")],
summaries: closed ? [] : [pending],
});
const book = (): LogbookResponse => ({
entries,
absence: closed ? { from: NOW, to: NOW } : ABSENCE,
lastAbsence: closed ? ABSENCE : null,
problems: [],
});
return fakeApi({
getPlayerState: state,
getLogbook: book,
acknowledgeSummaries: () => {
closed = true;
return { acknowledged: 1 };
},
});
}
const log = (name: string) => screen.findByRole("list", { name: `${name} log` });
describe("the Logbook screen (Step 25.4, decision 0.5.141)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
});
afterEach(() => vi.useRealTimers());
it("shows each pilot's jobs with payout, share, fuel, lender and XP, and deadheads, refuels and rests", async () => {
renderApp(app(), "/logbook?range=24h");
const ada = await log("Ada");
const lines = within(ada)
.getAllByRole("listitem")
.map((li) => li.textContent);
// Newest first.
expect(lines).toEqual([
expect.stringContaining("DeadheadEarth → Moonfuel 40 cr"),
// The rest came with the job on landing, so it is the newer line.
expect.stringContaining(`Rested until ${formatLocalTime(restUntil)}`),
expect.stringContaining(
"Medical SuppliesEarth → Moon1,000 crshare 150 crfuel 60 crlender 90 cr+12 XP",
),
expect.stringContaining("12 fu at Earth, 96 cr (bought by the pilot)"),
]);
const bo = await log("Bo");
expect(bo).toHaveTextContent("Wren: Engine Tuning level 2 finished");
expect(bo).toHaveTextContent("Discovered Mars");
expect(bo).toHaveTextContent("Reached level 3 · 1 skill point");
expect(await log("Company")).toHaveTextContent("Goal ready:");
});
it("narrows to one pilot and to a time range", async () => {
renderApp(app(), "/logbook?range=24h");
await log("Ada");
const totals = () => screen.getByLabelText("Logbook totals");
// Last 24 h: Ada's job and deadhead, Bo's two jobs.
expect(totals()).toHaveTextContent("3 jobs1 deadheads");
await userEvent.click(screen.getByRole("radio", { name: "Last 7 days" }));
await waitFor(() => expect(totals()).toHaveTextContent("4 jobs1 deadheads"));
await userEvent.selectOptions(screen.getByRole("combobox", { name: "Pilot" }), "bo");
await waitFor(() => expect(screen.queryByRole("list", { name: "Ada log" })).toBeNull());
expect(totals()).toHaveTextContent("2 jobs0 deadheads");
expect(screen.queryByRole("list", { name: "Company log" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("radio", { name: "This absence" }));
await waitFor(() => expect(totals()).toHaveTextContent("1 jobs0 deadheads"));
});
it("opens from View logbook on This absence, with the pop-up's totals", async () => {
renderApp(app(false), "/");
const popup = await screen.findByRole("dialog", { name: /While you were away/ });
const figures = await within(popup).findByLabelText("Company totals");
const net = within(figures).getByText(/^[+−][\d,]+ cr$/).textContent!;
expect(figures).toHaveTextContent("Deliveries2");
await userEvent.click(within(popup).getByRole("button", { name: "View logbook" }));
expect(await screen.findByRole("heading", { level: 1, name: "Logbook" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "This absence" })).toBeChecked();
const totals = await screen.findByLabelText("Logbook totals");
await waitFor(() => expect(totals).toHaveTextContent("2 jobs1 deadheads"));
expect(totals).toHaveTextContent(`${net.slice(1)} net`);
});
it("is reachable from the Bridge", async () => {
renderApp(app(), "/");
const card = await screen.findByLabelText("Logbook");
await userEvent.click(within(card).getByRole("link", { name: "Open the Logbook" }));
expect(await screen.findByRole("heading", { level: 1, name: "Logbook" })).toBeInTheDocument();
});
});