135 lines
7.4 KiB
TypeScript
135 lines
7.4 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { useDungeonRaceStore, type RaceServerState } from "./dungeonRaceStore";
|
|
import type { CharacterProfile, PlayerSession } from "./types";
|
|
import { appendCombatEvent } from "../game/combatEvents";
|
|
import { startRaceCombatLog } from "../game/dungeonRaceCombatLog";
|
|
|
|
const request = vi.hoisted(() => vi.fn());
|
|
vi.mock("./onlineAccountClient", () => ({ requestOnlineJson: request }));
|
|
|
|
const session: PlayerSession = {
|
|
kind: "account", ownerId: "player-1", displayName: "Racer", accessToken: "race-token",
|
|
};
|
|
const character = { id: "character-1" } as CharacterProfile;
|
|
const queued: RaceServerState = { status: "queued", serverTime: Date.now(), joinedAt: Date.now() };
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((done) => { resolve = done; });
|
|
return { promise, resolve };
|
|
}
|
|
|
|
describe("dungeon race exit", () => {
|
|
beforeEach(() => {
|
|
request.mockReset();
|
|
useDungeonRaceStore.getState().reset();
|
|
});
|
|
afterEach(() => useDungeonRaceStore.getState().reset());
|
|
|
|
it("clears an active race immediately and waits for the server leave before requeuing", async () => {
|
|
const deleteRequest = deferred<RaceServerState>();
|
|
request.mockImplementation((path: string, init: RequestInit) =>
|
|
path === "/api/dungeon-races/queue" && init.method === "DELETE"
|
|
? deleteRequest.promise : Promise.resolve(queued));
|
|
await useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
|
|
useDungeonRaceStore.setState({ status: "active", inGame: true, matchId: "old-match",
|
|
dungeonId: "bite-sized-stormwind-stockade", startAt: Date.now() - 10_000 });
|
|
|
|
const leaving = useDungeonRaceStore.getState().leave();
|
|
expect(useDungeonRaceStore.getState()).toMatchObject({ mode: null, status: "idle", inGame: false,
|
|
matchId: null, startAt: null });
|
|
const requeue = useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
|
|
await Promise.resolve();
|
|
expect(request.mock.calls.filter(([, init]) => init.method === "POST")).toHaveLength(1);
|
|
|
|
deleteRequest.resolve(queued);
|
|
await Promise.all([leaving, requeue]);
|
|
expect(request.mock.calls.map(([, init]) => init.method)).toEqual(["POST", "DELETE", "POST"]);
|
|
expect(useDungeonRaceStore.getState().status).toBe("queued");
|
|
});
|
|
|
|
it("cancels a queue request in flight without restoring its late response", async () => {
|
|
const firstQueue = deferred<RaceServerState>();
|
|
request.mockImplementation((path: string, init: RequestInit) =>
|
|
path === "/api/dungeon-races/queue" && init.method === "POST"
|
|
? firstQueue.promise : Promise.resolve(queued));
|
|
const starting = useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
|
|
const leaving = useDungeonRaceStore.getState().leave();
|
|
expect(useDungeonRaceStore.getState().mode).toBeNull();
|
|
firstQueue.resolve(queued);
|
|
await Promise.all([starting, leaving]);
|
|
expect(useDungeonRaceStore.getState()).toMatchObject({ mode: null, status: "idle", matchId: null });
|
|
expect(request.mock.calls.map(([, init]) => init.method)).toEqual(["POST", "DELETE"]);
|
|
});
|
|
|
|
it("ignores an older preparation response after the match has started", async () => {
|
|
const stale = deferred<RaceServerState>();
|
|
request.mockImplementation((path: string) => path === "/api/dungeon-races/state"
|
|
? stale.promise : Promise.resolve(queued));
|
|
await useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
|
|
useDungeonRaceStore.setState({ status: "active", matchId: "race-1", inGame: true });
|
|
const syncing = useDungeonRaceStore.getState().sync();
|
|
stale.resolve({ status: "preparing", matchId: "race-1", serverTime: Date.now() });
|
|
await syncing;
|
|
expect(useDungeonRaceStore.getState().status).toBe("active");
|
|
});
|
|
|
|
it("ends offline practice locally without a server request", async () => {
|
|
useDungeonRaceStore.setState({ mode: "practice", status: "settled", inGame: true,
|
|
matchId: "practice-1", startAt: Date.now() - 30_000, result: "loss" });
|
|
await useDungeonRaceStore.getState().leave();
|
|
expect(useDungeonRaceStore.getState()).toMatchObject({ mode: null, status: "idle", inGame: false,
|
|
matchId: null, startAt: null, result: null });
|
|
expect(request).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("sends the full recorded stream with a completed online clear", async () => {
|
|
request.mockResolvedValue(queued);
|
|
await useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
|
|
const startedAt = Date.now() - 1_000;
|
|
useDungeonRaceStore.setState({ mode: "online", status: "active", inGame: true,
|
|
matchId: "race-combat-clear", seed: "race-combat-clear",
|
|
dungeonId: "bite-sized-stormwind-stockade", startAt: startedAt, serverOffset: 0 });
|
|
startRaceCombatLog("race-combat-clear", "bite-sized-stormwind-stockade");
|
|
appendCombatEvent([], { occurredAt: startedAt + 100, kind: "damage",
|
|
sourceActorId: "player", targetActorId: "mob-1", rawAmount: 20,
|
|
effectiveAmount: 20, overheal: 0, absorbed: 0, critical: false }, 1);
|
|
await useDungeonRaceStore.getState().reportClear(["boss-1"]);
|
|
const finish = request.mock.calls.find(([path]) => path === "/api/dungeon-races/race-combat-clear/finish");
|
|
expect(finish).toBeDefined();
|
|
const body = JSON.parse(finish![1].body);
|
|
expect(body.combatLog).toMatchObject({ completed: true, runId: "race-combat-clear",
|
|
events: [{ kind: "damage", effective: 20 }] });
|
|
});
|
|
|
|
it("uploads a partial log before leaving a started online race", async () => {
|
|
request.mockResolvedValue(queued);
|
|
await useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
|
|
useDungeonRaceStore.setState({ mode: "online", status: "active", inGame: true,
|
|
matchId: "race-combat-leave", seed: "race-combat-leave",
|
|
dungeonId: "bite-sized-stormwind-stockade", startAt: Date.now() - 1_000, serverOffset: 0 });
|
|
startRaceCombatLog("race-combat-leave", "bite-sized-stormwind-stockade");
|
|
await useDungeonRaceStore.getState().leave();
|
|
const partial = request.mock.calls.find(([path]) => path === "/api/dungeon-races/race-combat-leave/combat-log");
|
|
expect(partial).toBeDefined();
|
|
expect(JSON.parse(partial![1].body).combatLog.completed).toBe(false);
|
|
expect(request.mock.calls.map(([path, init]) => `${init.method}:${path}`)).toEqual([
|
|
"POST:/api/dungeon-races/queue",
|
|
"POST:/api/dungeon-races/race-combat-leave/combat-log",
|
|
"DELETE:/api/dungeon-races/queue",
|
|
]);
|
|
});
|
|
|
|
it("rejects an unknown server mechanic pair before entering or readying", async () => {
|
|
request.mockImplementation((path: string) => Promise.resolve(path === "/api/dungeon-races/queue"
|
|
? { status: "preparing", serverTime: Date.now(), matchId: "future-race",
|
|
seed: "future-race", dungeonId: "bite-sized-stormwind-stockade",
|
|
affixIds: ["unknown-kill", "falling-stars"] } : queued));
|
|
await useDungeonRaceStore.getState().startOnline(session, character, "healer", "authored");
|
|
await vi.waitFor(() => expect(useDungeonRaceStore.getState().error).toContain("does not support"));
|
|
expect(useDungeonRaceStore.getState()).toMatchObject({ mode: null, status: "idle", inGame: false, ready: false });
|
|
expect(request.mock.calls.map(([path]) => path)).toContain("/api/dungeon-races/queue");
|
|
expect(request.mock.calls.some(([, init]) => init.method === "DELETE")).toBe(true);
|
|
});
|
|
});
|