deadhead-space-freight-tycoon/apps/web/test/radio-calls.test.tsx

157 lines
5.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 { Pilot, PlayerState, RadioCall, Ship } from "@deadhead/schema";
import { act, 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 { useRadioLater } from "../src/components/RadioCalls";
import { tabAttention } from "../src/layout/attention";
import { pilotExtras } from "./fixtures";
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
const NOW = Date.parse("2100-01-01T12:00:00Z");
const rosa: Pilot = {
...samplePlayerState().pilots[0]!,
id: "p1",
name: "Rosa",
isAvatar: false,
assignedShipId: "s1",
status: "in-flight",
locationId: null,
...pilotExtras,
};
const ship: Ship = {
id: "s1",
modelId: "clipper",
name: "Heron",
status: "in-flight",
locationId: null,
dispatchId: "d1",
lastArrivalAt: 0,
...shipExtras("clipper"),
pilotId: "p1",
};
const call = (over: Partial<RadioCall> = {}): RadioCall => ({
dispatchId: "d1",
shipId: "s1",
pilotId: "p1",
eventId: "debris-field",
fireAt: NOW - 60_000,
windowEndsAt: NOW + 12 * 60_000 + 34_000,
options: [
{ id: "go-around", available: true },
{ id: "push-through", available: true },
],
defaultOptionId: "go-around",
independent: false,
...over,
});
const owner = (calls: RadioCall[]): PlayerState =>
samplePlayerState({
tutorial: null,
pilots: [samplePlayerState().pilots[0]!, rosa],
ships: [ship],
radioCalls: calls,
});
/** The app with `calls` waiting; answering removes the call answered. */
function render(calls: RadioCall[], path = "/fleet") {
let waiting = calls;
const api = fakeApi({
getPlayerState: () => owner(waiting),
answerRadioCall: ({ dispatchId }) => {
waiting = waiting.filter((c) => c.dispatchId !== dispatchId);
return { radioCalls: waiting };
},
});
renderApp(api, path);
return { api, expire: () => (waiting = []) };
}
describe("radio calls (Step 26.6, decision 0.5.144)", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
vi.setSystemTime(NOW);
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
useRadioLater.setState({ later: new Set() });
});
afterEach(() => vi.useRealTimers());
it("shows a waiting call's story, options with what they might do, and the time left", async () => {
render([call()]);
const sheet = await screen.findByRole("dialog", { name: "Debris field ahead" });
expect(sheet).toHaveTextContent("Rosa reports a field of rock and wreckage across the route.");
expect(within(sheet).getByLabelText("Time to answer")).toHaveTextContent(
"Answer within 12:34 — then Rosa will go around.",
);
const options = within(sheet).getByRole("list", { name: "Options" });
expect(
within(options)
.getAllByRole("button")
.map((b) => b.textContent),
).toEqual([
"Go around (if you don't answer)+3 h",
"Push through35% chance: hull −10–20% · 10% chance: lose 10% of the pay",
]);
});
it("answers through the engine, and the call leaves the queue", async () => {
const { api } = render([call()]);
const sheet = await screen.findByRole("dialog", { name: "Debris field ahead" });
await userEvent.click(within(sheet).getByRole("button", { name: /^Push through/ }));
expect(api.call).toHaveBeenCalledWith("answerRadioCall", {
dispatchId: "d1",
optionId: "push-through",
});
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
it("goes away when the window closes unanswered", async () => {
const { expire } = render([call()]);
await screen.findByRole("dialog", { name: "Debris field ahead" });
expire();
act(() => useClock.setState({ now: NOW + 13 * 60_000 }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
it("queues several calls as Radio calls (2)", async () => {
render([
call(),
call({ dispatchId: "d2", eventId: "storm-approach", windowEndsAt: NOW + 20 * 60_000 }),
]);
const sheet = await screen.findByRole("dialog", { name: "Debris field ahead" });
expect(sheet).toHaveTextContent("Radio calls (2)");
});
it("badges the Bridge and lists the calls there, reopening one put off", async () => {
expect(tabAttention(owner([call(), call({ dispatchId: "d2" })]))["/"]).toBe(2);
render([call()], "/");
const sheet = await screen.findByRole("dialog", { name: "Debris field ahead" });
await userEvent.click(within(sheet).getByRole("button", { name: "Decide later" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
const panel = screen.getByLabelText("Radio calls");
expect(panel).toHaveTextContent("Debris field ahead");
await userEvent.click(within(panel).getByRole("button", { name: "Answer" }));
expect(await screen.findByRole("dialog", { name: "Debris field ahead" })).toBeInTheDocument();
});
it("shows an option the trip can't take as not possible", async () => {
render([
call({
eventId: "derelict-pod",
options: [
{ id: "salvage", available: false },
{ id: "log-it", available: true },
],
defaultOptionId: "log-it",
}),
]);
const sheet = await screen.findByRole("dialog", { name: "Derelict cargo pod" });
const salvage = within(sheet).getByRole("button", { name: /^Salvage it/ });
expect(salvage).toBeDisabled();
expect(salvage).toHaveTextContent("Not possible on this trip");
});
});