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() { let resolve!: (value: T) => void; const promise = new Promise((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(); 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(); 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(); 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); }); });