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

228 lines
7.3 KiB
TypeScript

import { content } from "@deadhead/content";
import type { Contract, Pilot, PlayerState, ResolutionSummary } from "@deadhead/schema";
import { act, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useClock } from "../src/clock/clock";
import { CatchingUp, pilotLogbook } from "../src/components/PilotLogbook";
import { logbookFrom, pilotExtras } from "./fixtures";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const PERIOD_START = Date.parse("2100-01-01T00:00:00Z");
const FIRST_LOOK = content.config.independentPilots.firstLookRealMinutes * 60_000;
const job = (over: Partial<Contract> = {}): Contract => ({
id: "earth:1:0",
originId: "earth",
destinationId: "moon",
cargoTypeId: "standard",
goodId: "consumer-goods",
shipper: "Pacific Orbital Logistics",
priority: false,
deliveryWindowGameHours: null,
tier: 0,
massTonnes: 6,
payout: 1_234,
requiredSkill: null,
requiredLongHaulLevel: 0,
offeredAt: PERIOD_START,
expiresAt: PERIOD_START + 600_000,
firstContact: false,
...over,
});
const pilot = (id: string, name: string, over: Partial<Pilot> = {}): Pilot => ({
id,
name,
isAvatar: false,
isVeteran: false,
xp: 0,
level: 0,
skills: {},
skillPointsAvailable: 0,
assignedShipId: `ship-${id}`,
status: "idle",
locationId: "earth",
dutyHours: 0,
dutyLimit: 12,
resting: false,
restEndsAt: null,
...pilotExtras,
mode: "independent",
...over,
});
function owner(pilots: Pilot[]): PlayerState {
const base = samplePlayerState({ tutorial: null });
return {
...base,
bays: [{ id: "b", locationId: "earth" }],
pilots: [base.pilots[0]!, ...pilots],
ships: pilots.map((p) => ({
id: `ship-${p.id}`,
modelId: "courier",
name: `Ship ${p.id}`,
status: "idle",
locationId: "earth",
dispatchId: null,
lastArrivalAt: 0,
...shipExtras("courier"),
pilotId: p.id,
})),
dispatchOffice: { owned: true, spendingFloor: 500 },
};
}
describe("first-look chip (Step 21.7, decision 0.5.126)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(PERIOD_START + 29_000);
useClock.setState({ offsetMs: 0, synced: true, now: PERIOD_START + 29_000 });
});
afterEach(() => vi.useRealTimers());
const show = (state: PlayerState, contracts: Contract[]) =>
renderApp(
fakeApi({
getPlayerState: () => state,
getContractBoard: () => ({ contracts, periodEndsAt: PERIOD_START + 600_000 }),
}),
"/contracts?location=earth",
);
it("counts down the player's first look, then disappears", async () => {
show(owner([pilot("p1", "Ada")]), [job()]);
const card = (await screen.findByText("1,234 cr")).closest("tr")!;
expect(within(card).getByLabelText("Yours for")).toHaveTextContent("Yours for 02:31");
act(() => {
vi.setSystemTime(PERIOD_START + FIRST_LOOK + 1_000);
useClock.setState({ now: PERIOD_START + FIRST_LOOK + 1_000 });
});
expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument();
});
it("isn't shown without Independent pilots", async () => {
show(owner([pilot("p1", "Ada", { mode: "manual" })]), [job()]);
const card = (await screen.findByText("1,234 cr")).closest("tr")!;
expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument();
});
it("isn't shown on a first contact, which is always the player's", async () => {
show(owner([pilot("p1", "Ada")]), [job({ firstContact: true, destinationId: "mars" })]);
const card = (await screen.findByText("1,234 cr")).closest("tr")!;
expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument();
});
});
describe("catching up indicator (Step 21.7)", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("appears only when a catch-up runs longer than half a second", () => {
const { rerender } = render(<CatchingUp busy />);
act(() => vi.advanceTimersByTime(400));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
act(() => vi.advanceTimersByTime(200));
expect(screen.getByRole("status")).toHaveTextContent("Catching up on your fleet…");
rerender(<CatchingUp busy={false} />);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
// A quick pass never shows it.
rerender(<CatchingUp busy />);
act(() => vi.advanceTimersByTime(300));
rerender(<CatchingUp busy={false} />);
act(() => vi.advanceTimersByTime(1_000));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
});
describe("pilot logbook (Step 21.7)", () => {
const summary = (over: Partial<ResolutionSummary>): ResolutionSummary => ({
dispatchId: "d",
shipId: "ship-p1",
pilotId: "p1",
originId: "earth",
destinationId: "moon",
tutorial: false,
independent: true,
deadhead: false,
fuelCost: 100,
topUp: null,
goodId: "consumer-goods",
massTonnes: 6,
shipper: "Pacific Orbital Logistics",
credits: 600,
loanRepayment: 200,
pilotPay: 200,
pilotXp: 10,
discoveredLocationId: null,
scriptedId: null,
event: null,
restEndsAt: null,
resolvedAt: PERIOD_START,
...over,
});
const ada = pilot("p1", "Ada");
const bo = pilot("p2", "Bo", { locationId: "moon" });
const summaries = [
summary({ dispatchId: "a1", resolvedAt: 1 }),
summary({
dispatchId: "a2",
deadhead: true,
goodId: null,
massTonnes: null,
shipper: null,
credits: 0,
loanRepayment: 0,
pilotPay: 0,
pilotXp: 0,
fuelCost: 40,
resolvedAt: 2,
}),
summary({
dispatchId: "b1",
pilotId: "p2",
shipId: "ship-p2",
destinationId: "earth",
originId: "moon",
credits: 900,
pilotPay: 300,
loanRepayment: 0,
pilotXp: 12,
restEndsAt: PERIOD_START + 3_600_000,
resolvedAt: 3,
}),
// The player's own delivery isn't in the pilots' logbook.
summary({ dispatchId: "you", pilotId: "avatar", independent: false, resolvedAt: 4 }),
];
// The logbook is built from entries now (CR-025): these stand for the summaries above.
const entries = () => logbookFrom({ ...owner([ada, bo]), summaries }).entries;
it("lists each pilot's jobs, deadheads, rests and XP, newest first, and where they are now", () => {
const book = pilotLogbook(owner([ada, bo]), entries());
expect(book.pilots.map((p) => p.name)).toEqual(["Captain", "Ada", "Bo"]);
const [, a, b] = book.pilots;
expect(a!.entries.map((e) => e.kind)).toEqual(["flight", "flight"]);
expect(a!.entries[1]).toMatchObject({ goodId: "consumer-goods", payout: 1_000, pilotPay: 200 });
expect(a!.xp).toBe(10);
expect(a!.now).toBe("At Earth");
expect(b!.entries.map((e) => e.kind)).toEqual(["rest", "flight"]);
expect(b!.now).toBe("At Moon");
});
it("totals the entries for the company", () => {
const book = pilotLogbook(owner([ada, bo]), entries());
expect(book.totals).toEqual({
jobs: 3,
deadheads: 1,
payout: 1_000 + 1_200 + 1_000,
pilotShare: 200 + 300 + 200,
fuelCost: 100 + 40 + 100 + 100,
loanRepaid: 200 + 200,
net: 1_000 + 1_200 + 1_000 - 700 - 340 - 400,
xp: 32,
});
});
});