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