238 lines
8.2 KiB
TypeScript
238 lines
8.2 KiB
TypeScript
import type { LogEntry, PlayerState, Ship } from "@deadhead/schema";
|
|
import { flightNet } from "@deadhead/sim";
|
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { MemoryRouter, useLocation } from "react-router";
|
|
import { entryToasts } from "../src/components/ToastFeed";
|
|
import { Toasts } from "../src/components/Toasts";
|
|
import { usePresence } from "../src/platform/presence";
|
|
import { usePreferences } from "../src/state/preferences";
|
|
import { batchText, useToasts, type NewToast } from "../src/state/toasts";
|
|
import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render";
|
|
|
|
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
|
|
|
function Where() {
|
|
return <p data-testid="where">{useLocation().pathname}</p>;
|
|
}
|
|
|
|
function renderToasts() {
|
|
return render(
|
|
<MemoryRouter initialEntries={["/fleet"]}>
|
|
<Toasts />
|
|
<Where />
|
|
</MemoryRouter>,
|
|
);
|
|
}
|
|
|
|
const toast = (text: string, over: Partial<NewToast> = {}): NewToast => ({
|
|
text,
|
|
tone: "info",
|
|
to: "/fleet",
|
|
...over,
|
|
});
|
|
|
|
function setWidth(width: number) {
|
|
Object.defineProperty(window, "innerWidth", { configurable: true, value: width });
|
|
window.dispatchEvent(new Event("resize"));
|
|
}
|
|
|
|
const ship: Ship = {
|
|
id: "s1",
|
|
name: "Wren",
|
|
modelId: "courier",
|
|
status: "idle",
|
|
locationId: "earth",
|
|
dispatchId: null,
|
|
pilotId: "p1",
|
|
lastArrivalAt: 0,
|
|
...shipExtras("courier"),
|
|
};
|
|
const state = (): PlayerState =>
|
|
samplePlayerState({
|
|
tutorial: null,
|
|
company: { id: "c", name: "D", credits: 50_000, loanBalance: 0 },
|
|
ships: [ship],
|
|
pilots: [{ ...samplePlayerState().pilots[0]!, id: "p1", name: "Rosa", isAvatar: false }],
|
|
});
|
|
|
|
function delivery(
|
|
id: string,
|
|
at: number,
|
|
over: Partial<Extract<LogEntry, { kind: "flight" }>> = {},
|
|
) {
|
|
return {
|
|
id,
|
|
at,
|
|
pilotId: "p1",
|
|
shipId: "s1",
|
|
seen: false,
|
|
kind: "flight",
|
|
dispatchId: id,
|
|
deadhead: false,
|
|
tutorial: false,
|
|
independent: true,
|
|
originId: "earth",
|
|
destinationId: "moon",
|
|
goodId: "consumer-goods",
|
|
massTonnes: 5,
|
|
shipper: "Pacific Orbital Logistics",
|
|
payout: 1_500,
|
|
pilotPay: 200,
|
|
fuelCost: 100,
|
|
loanRepayment: 0,
|
|
xp: 5,
|
|
scriptedId: null,
|
|
...over,
|
|
} satisfies LogEntry;
|
|
}
|
|
const discovery = (id: string, at: number): LogEntry => ({
|
|
id,
|
|
at,
|
|
pilotId: "p1",
|
|
shipId: "s1",
|
|
seen: false,
|
|
kind: "discovery",
|
|
locationId: "mars",
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
setWidth(1024);
|
|
});
|
|
|
|
describe("toasts on screen (Step 28.3, decision 0.5.161)", () => {
|
|
it("sit top-right on a desktop and top-center below the safe area on a phone", () => {
|
|
setWidth(1280);
|
|
act(() => useToasts.getState().push([toast("Wren delivered Mail to the Moon")]));
|
|
renderToasts();
|
|
const region = screen.getByRole("region", { name: "Notifications" });
|
|
expect(region).toHaveAttribute("data-placement", "top-right");
|
|
expect(region).toHaveAttribute("aria-live", "polite");
|
|
act(() => setWidth(375));
|
|
expect(region).toHaveAttribute("data-placement", "top-center");
|
|
expect(region.className).toContain("safe-area-inset-top");
|
|
});
|
|
|
|
it("closes after 5 seconds, but not while hovered", () => {
|
|
vi.useFakeTimers();
|
|
act(() => useToasts.getState().push([toast("First"), toast("Hovered")]));
|
|
renderToasts();
|
|
const hovered = screen.getByText("Hovered").closest("li")!;
|
|
fireEvent.mouseEnter(hovered);
|
|
act(() => vi.advanceTimersByTime(4_999));
|
|
expect(screen.getByText("First")).toBeInTheDocument();
|
|
act(() => vi.advanceTimersByTime(1));
|
|
expect(screen.queryByText("First")).not.toBeInTheDocument();
|
|
act(() => vi.advanceTimersByTime(20_000));
|
|
expect(screen.getByText("Hovered")).toBeInTheDocument();
|
|
fireEvent.mouseLeave(hovered);
|
|
act(() => vi.advanceTimersByTime(5_000));
|
|
expect(screen.queryByText("Hovered")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("closes early with ✕ or a swipe", () => {
|
|
act(() => useToasts.getState().push([toast("Swiped"), toast("Closed")]));
|
|
renderToasts();
|
|
fireEvent.click(
|
|
within(screen.getByText("Closed").closest("li")!).getByRole("button", { name: "Dismiss" }),
|
|
);
|
|
expect(screen.queryByText("Closed")).not.toBeInTheDocument();
|
|
const swiped = screen.getByText("Swiped").closest("li")!;
|
|
fireEvent.pointerDown(swiped, { clientX: 200 });
|
|
fireEvent.pointerUp(swiped, { clientX: 300 });
|
|
expect(screen.queryByText("Swiped")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("shows at most 3, the newest on top, and the rest as +N more, which opens the Logbook", () => {
|
|
act(() => useToasts.getState().push(["One", "Two", "Three", "Four"].map((t) => toast(t))));
|
|
renderToasts();
|
|
expect(screen.getAllByRole("listitem").map((li) => li.textContent)).toEqual([
|
|
"Four",
|
|
"Three",
|
|
"Two",
|
|
]);
|
|
fireEvent.click(screen.getByRole("button", { name: "+1 more" }));
|
|
expect(screen.getByTestId("where")).toHaveTextContent("/logbook");
|
|
expect(screen.queryByRole("button", { name: /more/ })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("opens the related screen when tapped", () => {
|
|
act(() => useToasts.getState().push(entryToasts([discovery("x1", NOW)], state())));
|
|
renderToasts();
|
|
fireEvent.click(screen.getByRole("button", { name: /opened a new market: Mars/ }));
|
|
expect(screen.getByTestId("where")).toHaveTextContent("/locations");
|
|
expect(screen.queryByText(/opened a new market/)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("appears without sliding with reduced motion", () => {
|
|
usePreferences.setState({ reducedMotion: true });
|
|
act(() => useToasts.getState().push([toast("Still")]));
|
|
renderToasts();
|
|
expect(screen.getByRole("region", { name: "Notifications" })).toHaveAttribute(
|
|
"data-motion",
|
|
"none",
|
|
);
|
|
expect(screen.getByText("Still").closest("li")!.className).not.toMatch(/animate-/);
|
|
usePreferences.setState({ reducedMotion: null });
|
|
});
|
|
});
|
|
|
|
describe("what gets a toast (Step 28.3)", () => {
|
|
it("combines Independent deliveries within 10 seconds, and gives a discovery its own", () => {
|
|
const entries = [delivery("d1", NOW), delivery("d2", NOW + 1_000), discovery("x1", NOW + 2)];
|
|
act(() => useToasts.getState().push(entryToasts(entries, state()), NOW));
|
|
// A third delivery 8 seconds later joins the same toast.
|
|
act(() =>
|
|
useToasts.getState().push(entryToasts([delivery("d3", NOW + 8_000)], state()), NOW + 8_000),
|
|
);
|
|
const net = 3 * flightNet(delivery("d", 0));
|
|
expect(useToasts.getState().toasts.map((t) => t.text)).toEqual([
|
|
"Rosa opened a new market: Mars",
|
|
batchText(3, net),
|
|
]);
|
|
// Past the window, a new toast starts.
|
|
act(() => useToasts.getState().push(entryToasts([delivery("d4", NOW)], state()), NOW + 20_000));
|
|
expect(useToasts.getState().toasts).toHaveLength(3);
|
|
});
|
|
|
|
it("gives a delivery the player sent its own toast, and none to a deadhead", () => {
|
|
const toasts = entryToasts(
|
|
[
|
|
delivery("d1", NOW, { independent: false }),
|
|
delivery("d2", NOW, { independent: false }),
|
|
delivery("d3", NOW, { deadhead: true, goodId: null, payout: 0 }),
|
|
],
|
|
state(),
|
|
);
|
|
expect(toasts).toHaveLength(2);
|
|
expect(toasts.every((t) => t.batch === undefined)).toBe(true);
|
|
});
|
|
|
|
it("toasts the unseen entries from while the player is here and marks them seen", async () => {
|
|
usePresence.setState({ here: true, hereSince: NOW - 60_000 });
|
|
const before = { ...discovery("old", NOW - 120_000) };
|
|
const api = fakeApi({
|
|
getPlayerState: () => state(),
|
|
getLogbook: () => ({
|
|
entries: [
|
|
before,
|
|
delivery("d1", NOW - 10_000, { independent: false }),
|
|
discovery("x1", NOW),
|
|
],
|
|
absence: { from: NOW - 60_000, to: NOW },
|
|
lastAbsence: null,
|
|
problems: [],
|
|
}),
|
|
});
|
|
renderApp(api, "/fleet");
|
|
const region = await screen.findByRole("region", { name: "Notifications" });
|
|
await waitFor(() => expect(within(region).getAllByRole("listitem")).toHaveLength(2));
|
|
expect(region).toHaveTextContent("Wren delivered");
|
|
expect(region).toHaveTextContent("opened a new market: Mars");
|
|
await waitFor(() =>
|
|
expect(api.call).toHaveBeenCalledWith("markLogbookSeen", { entryIds: ["d1", "x1"] }),
|
|
);
|
|
});
|
|
});
|