import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PLAYER_AGGRO_ID } from "./aggro"; import { useCombatStore } from "./combatStore"; import { appendCombatEvent } from "./combatEvents"; import { connectDpsMeter, currentDpsMeterSnapshot, useDpsMeterStore } from "./dpsMeterStore"; import { useGameStore } from "./store"; import { useUiLayoutStore } from "./uiLayoutStore"; import { currentCompanionSnapshot, executeCompanionCommand } from "../platform/dualScreenSync"; describe("live DPS tracking and companion editor", () => { let disconnect: () => void; beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(1_000); useGameStore.setState({ overlay: null, paused: false, companionOpen: false }); useCombatStore.getState().initializeCharacter({ classId: "mage", level: 5 }); useCombatStore.getState().registerMob("dps-target", { name: "Target", maxHealth: 1_000_000 }); disconnect = connectDpsMeter(); }); afterEach(() => { disconnect(); useUiLayoutStore.getState().finish(false); vi.useRealTimers(); }); it("tracks real damage while hidden, caps overkill, freezes on defeat, and clears on reset", () => { useUiLayoutStore.getState().begin(); useUiLayoutStore.getState().updateDraft((layout) => ({ ...layout, elements: { ...layout.elements, dps: { ...layout.elements.dps, visible: false } } })); useCombatStore.getState().damageMob("dps-target", 100, Date.now()); vi.advanceTimersByTime(2_000); expect(currentDpsMeterSnapshot()).toMatchObject({ totalDamage: 100, totalDps: 50 }); const remaining = useCombatStore.getState().mobs["dps-target"].health; useCombatStore.getState().damageMob("dps-target", 2_000_000, Date.now()); expect(currentDpsMeterSnapshot()).toMatchObject({ active: false, totalDamage: 100 + remaining }); vi.advanceTimersByTime(10_000); expect(currentDpsMeterSnapshot().durationSeconds).toBe(2); useCombatStore.getState().resetEncounter(); expect(useDpsMeterStore.getState().startedAt).toBeNull(); }); it("merges summon damage into its owner and excludes hostile and friendly-target damage", () => { const actors = useCombatStore.getState().actors; useCombatStore.setState({ actors: { ...actors, "meter-pet": { ...actors[PLAYER_AGGRO_ID], id: "meter-pet", kind: "pet", ownerId: PLAYER_AGGRO_ID } } }); for (const [id, sourceActorId, targetActorId] of [[1, "meter-pet", "dps-target"], [2, "dps-target", PLAYER_AGGRO_ID], [3, PLAYER_AGGRO_ID, PLAYER_AGGRO_ID]] as const) { appendCombatEvent([], { occurredAt: Date.now(), kind: "damage", sourceActorId, targetActorId, rawAmount: 100, effectiveAmount: 80, overheal: 0, absorbed: 20, critical: false }, id); } expect(currentDpsMeterSnapshot().totalDamage).toBe(80); expect(currentDpsMeterSnapshot().rows.find((row) => row.total > 0)?.id).toBe(PLAYER_AGGRO_ID); }); it("does not dilute DPS while a local game menu pauses combat", () => { useCombatStore.getState().damageMob("dps-target", 100, Date.now()); vi.advanceTimersByTime(1_000); useGameStore.setState({ paused: true, overlay: "ui-layout" }); vi.advanceTimersByTime(10_000); expect(currentDpsMeterSnapshot()).toMatchObject({ durationSeconds: 1, totalDps: 100 }); useGameStore.setState({ paused: false, overlay: null }); vi.advanceTimersByTime(1_000); expect(currentDpsMeterSnapshot()).toMatchObject({ durationSeconds: 2, totalDps: 50 }); }); it("publishes draft visibility and accepts lower-screen movement only while editing", () => { useUiLayoutStore.getState().begin(); const command = { name: "editDpsMeter", update: { position: { x: .7, y: .8 }, width: 300 } } as const; executeCompanionCommand(command); expect(useUiLayoutStore.getState().draft?.elements.dps.position).toBeNull(); useGameStore.setState({ overlay: "ui-layout" }); executeCompanionCommand(command); expect(currentCompanionSnapshot().game.dpsMeter).toMatchObject({ editing: true, layout: { position: { x: .7, y: .8 }, width: 300 } }); }); });