150 lines
4.8 KiB
TypeScript
150 lines
4.8 KiB
TypeScript
import { createElement } from "react";
|
|
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { toast } from "sonner";
|
|
import { GAME_TICK_EVENT } from "@/hooks/useGameTick";
|
|
import { MissionReminderToasts } from "@/components/frontend/intelligence/MissionReminderToasts";
|
|
|
|
const routerPush = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("next/navigation", () => ({
|
|
useRouter: () => ({ push: routerPush }),
|
|
}));
|
|
|
|
vi.mock("sonner", () => {
|
|
const mockedToast = Object.assign(vi.fn(), { dismiss: vi.fn() });
|
|
return { toast: mockedToast };
|
|
});
|
|
|
|
type Leader = {
|
|
id: number;
|
|
username: string;
|
|
displayName: string;
|
|
};
|
|
|
|
type Reminder = {
|
|
missionId: number;
|
|
missionName: string;
|
|
missionCodeName: string;
|
|
completedAt: string;
|
|
leaders: readonly Leader[];
|
|
};
|
|
|
|
const reminder: Reminder = {
|
|
missionId: 42,
|
|
missionName: "Operation Nightfall",
|
|
missionCodeName: "OP:NIGHTFALL",
|
|
completedAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
|
leaders: [
|
|
{ id: 7, username: "leader-one", displayName: "Leader One" },
|
|
{ id: 8, username: "leader-two", displayName: "Leader Two" },
|
|
],
|
|
};
|
|
|
|
const fetchMock = vi.fn<typeof fetch>();
|
|
const mockedToast = vi.mocked(toast);
|
|
|
|
function responseFor(reminders: readonly Reminder[]): Response {
|
|
return new Response(JSON.stringify({ reminders }), {
|
|
status: 200,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
|
|
function firstToastOptions() {
|
|
const options = mockedToast.mock.calls[0]?.[1];
|
|
if (!options) throw new Error("Expected the first reminder toast to have options.");
|
|
return options;
|
|
}
|
|
|
|
function invokeButton(button: unknown): void {
|
|
if (typeof button !== "object" || button === null || !("onClick" in button)) {
|
|
throw new Error("Expected a Sonner button contract.");
|
|
}
|
|
const onClick = button.onClick;
|
|
if (typeof onClick !== "function") throw new Error("Expected a Sonner button handler.");
|
|
onClick(new MouseEvent("click"));
|
|
}
|
|
|
|
describe("MissionReminderToasts", () => {
|
|
beforeEach(() => {
|
|
cleanup();
|
|
vi.clearAllMocks();
|
|
localStorage.clear();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
mockedToast.mockReturnValue("mission-reminder:42:7");
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
it("shows eligible leaders one at a time and links the action to the leader profile", async () => {
|
|
fetchMock.mockResolvedValue(responseFor([reminder]));
|
|
|
|
render(createElement(MissionReminderToasts));
|
|
|
|
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(1));
|
|
expect(firstToastOptions().action).toMatchObject({ label: "Rate leader" });
|
|
expect(firstToastOptions().cancel).toMatchObject({ label: "Dismiss reminder" });
|
|
|
|
invokeButton(firstToastOptions().action);
|
|
expect(routerPush).toHaveBeenCalledWith("/profile/leader-one");
|
|
|
|
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(2));
|
|
expect(mockedToast.mock.calls[1]?.[1]?.description).toContain("Leader Two");
|
|
});
|
|
|
|
it("does not issue a second request while a realtime refresh overlaps the initial poll", async () => {
|
|
let resolveFetch: ((value: Response) => void) | undefined;
|
|
fetchMock.mockImplementation(
|
|
() =>
|
|
new Promise<Response>((resolve) => {
|
|
resolveFetch = resolve;
|
|
}),
|
|
);
|
|
|
|
render(createElement(MissionReminderToasts));
|
|
act(() => window.dispatchEvent(new Event(GAME_TICK_EVENT)));
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
resolveFetch?.(responseFor([reminder]));
|
|
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(1));
|
|
});
|
|
|
|
it("persists an explicit dismissal for that mission and leader only", async () => {
|
|
fetchMock.mockResolvedValue(responseFor([reminder]));
|
|
render(createElement(MissionReminderToasts));
|
|
|
|
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(1));
|
|
invokeButton(firstToastOptions().cancel);
|
|
|
|
const stored = localStorage.getItem("ptf:mission-reminder:dismissals");
|
|
expect(stored).toContain('"42:7"');
|
|
expect(stored).not.toContain('"42:8"');
|
|
|
|
cleanup();
|
|
mockedToast.mockClear();
|
|
fetchMock.mockResolvedValue(responseFor([reminder]));
|
|
render(createElement(MissionReminderToasts));
|
|
|
|
await waitFor(() => expect(mockedToast).toHaveBeenCalledTimes(1));
|
|
expect(mockedToast.mock.calls[0]?.[1]?.description).toContain("Leader Two");
|
|
});
|
|
|
|
it("does not show a reminder whose completion window has expired", async () => {
|
|
const expiredReminder = {
|
|
...reminder,
|
|
completedAt: new Date(Date.now() - 73 * 60 * 60 * 1000).toISOString(),
|
|
};
|
|
fetchMock.mockResolvedValue(responseFor([expiredReminder]));
|
|
|
|
render(createElement(MissionReminderToasts));
|
|
|
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
expect(mockedToast).not.toHaveBeenCalled();
|
|
});
|
|
});
|