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, comeBack } 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 => ({ id: `j${n}`, at: at(hoursAgo), pilotId, shipId: `s-${pilotId}`, seen: false, 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", seen: false, kind: "rest", until: restUntil, }, { id: "f1", at: at(3), pilotId: "ada", shipId: "s-ada", seen: false, kind: "refuel", locationId: "earth", boughtFu: 12, cost: 96, source: "pilot", }, { id: "u1", at: at(4), pilotId: "bo", shipId: "s-bo", seen: false, kind: "upgrade", upgradeId: "engines", level: 2, }, { id: "x1", at: at(4), pilotId: "bo", shipId: "s-bo", seen: false, kind: "discovery", locationId: "mars", }, { id: "l1", at: at(4), pilotId: "bo", shipId: "s-bo", seen: false, 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, seen: false, 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 () => { comeBack({ from: ABSENCE.from, to: ABSENCE.to + 1 }); 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(), "/"); await userEvent.click(await screen.findByRole("link", { name: "Open the Logbook" })); expect(await screen.findByRole("heading", { level: 1, name: "Logbook" })).toBeInTheDocument(); }); });