157 lines
5.6 KiB
TypeScript
157 lines
5.6 KiB
TypeScript
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("Radio calls (1)");
|
||
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");
|
||
});
|
||
});
|