import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createDefaultAppearance } from "./characterCatalog"; import { createCharacter, listCharacters, updateCharacterProgression, type StorageLike, } from "./accountRepository"; import { beginMainMenuCharacterEditing } from "./mainMenuCharacterEditing"; import { selectedCharacter, useShellStore } from "./shellStore"; import { useCombatStore } from "../game/combatStore"; import { talentNodesForTree } from "../game/talentCatalog"; import { WOW335_GLYPH_OF_SWIFTMEND_ID } from "../game/wow335GlyphCatalog"; class MemoryStorage implements StorageLike { values = new Map(); getItem(key: string) { return this.values.get(key) ?? null; } setItem(key: string, value: string) { this.values.set(key, value); } removeItem(key: string) { this.values.delete(key); } } describe("main-menu character editing", () => { let storage: MemoryStorage; let stop: (() => void) | null; const holyTalent = talentNodesForTree("priest-holy")[0]; beforeEach(() => { storage = new MemoryStorage(); stop = null; const created = createCharacter({ ownerId: "offline-roster", name: "Menuhealer", classId: "priest", raceId: "human", gender: "female", appearance: createDefaultAppearance(), specializationId: "priest:holy", }, storage).character!; const character = updateCharacterProgression(created.ownerId, created.id, { level: 17, experience: 123, talentRanks: {}, }, storage)!; useShellStore.setState({ phase: "main-menu", activeCharacter: null, selectedCharacterId: character.id, session: { kind: "offline", ownerId: character.ownerId, displayName: "Offline" }, characters: [character], notice: "", }); useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 }); }); afterEach(() => { stop?.(); }); it("loads the selected character without rewriting their saved profile or starting a dungeon", () => { const before = [...storage.values]; stop = beginMainMenuCharacterEditing(storage); expect(stop).not.toBeNull(); expect(useCombatStore.getState()).toMatchObject({ classId: "priest", level: 17, xp: 123 }); expect([...storage.values]).toEqual(before); expect(useShellStore.getState()).toMatchObject({ phase: "main-menu", activeCharacter: null }); }); it("persists talent allocation and reset, and reloads the saved build when reopened", () => { stop = beginMainMenuCharacterEditing(storage); expect(useCombatStore.getState().allocateTalent(holyTalent.id)).toBe(true); expect(listCharacters("offline-roster", storage)[0].talentRanks[holyTalent.id]).toBe(1); expect(selectedCharacter(useShellStore.getState())?.talentRanks[holyTalent.id]).toBe(1); stop!(); stop = beginMainMenuCharacterEditing(storage); expect(useCombatStore.getState().talentRanks[holyTalent.id]).toBe(1); useCombatStore.getState().resetTalents(); expect(listCharacters("offline-roster", storage)[0].talentRanks).toEqual({}); expect(listCharacters("offline-roster", storage)[0]).toMatchObject({ level: 17, experience: 123 }); }); it("persists a glyph toggle and reloads it when the editor is reopened", () => { const druid = createCharacter({ ownerId: "offline-roster", name: "Leafkeeper", classId: "druid", raceId: "night-elf", gender: "female", appearance: createDefaultAppearance(), specializationId: "druid:restoration", }, storage).character!; useShellStore.setState({ selectedCharacterId: druid.id, characters: [druid], }); stop = beginMainMenuCharacterEditing(storage); expect(useCombatStore.getState().glyphIds).not.toContain(WOW335_GLYPH_OF_SWIFTMEND_ID); expect(useCombatStore.getState().classId).toBe("druid"); expect(useCombatStore.getState().setGlyphActive(WOW335_GLYPH_OF_SWIFTMEND_ID, true)).toBe(true); expect(useCombatStore.getState().glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]); expect(listCharacters("offline-roster", storage).find((character) => character.id === druid.id)?.glyphIds) .toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]); expect(selectedCharacter(useShellStore.getState())?.glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]); stop!(); stop = beginMainMenuCharacterEditing(storage); expect(useCombatStore.getState().glyphIds).toEqual([WOW335_GLYPH_OF_SWIFTMEND_ID]); useCombatStore.getState().setGlyphActive(WOW335_GLYPH_OF_SWIFTMEND_ID, false); expect(listCharacters("offline-roster", storage).find((character) => character.id === druid.id)?.glyphIds).toEqual([]); }); it("persists controller assignments and reloads them without touching progression or equipment", () => { const profile = selectedCharacter(useShellStore.getState())!; stop = beginMainMenuCharacterEditing(storage); expect(useCombatStore.getState().setActionBinding("primary", "face-bottom", "priest-flash-heal")).toBe(true); const saved = listCharacters("offline-roster", storage)[0]; expect(saved.actionBindings["primary:face-bottom"]).toBe("priest-flash-heal"); expect(saved).toMatchObject({ level: profile.level, experience: profile.experience, talentRanks: profile.talentRanks, inventory: profile.inventory, equipment: profile.equipment }); stop!(); stop = beginMainMenuCharacterEditing(storage); expect(useCombatStore.getState().actionBindings["primary:face-bottom"]).toBe("priest-flash-heal"); }); it.each(["phase", "owner", "selection", "closed"])("stops saving after the editing context changes: %s", (change) => { stop = beginMainMenuCharacterEditing(storage); const before = [...storage.values]; if (change === "phase") useShellStore.setState({ phase: "game" }); if (change === "owner") useShellStore.setState({ session: { kind: "offline", ownerId: "another-owner", displayName: "Other" } }); if (change === "selection") useShellStore.setState({ selectedCharacterId: null }); if (change === "closed") stop!(); useCombatStore.getState().allocateTalent(holyTalent.id); useCombatStore.getState().setActionBinding("primary", "face-bottom", "priest-flash-heal"); expect([...storage.values]).toEqual(before); }); it("never saves combat XP as a side effect of a talent edit", () => { stop = beginMainMenuCharacterEditing(storage); useCombatStore.setState({ xp: 999 }); useCombatStore.getState().allocateTalent(holyTalent.id); expect(listCharacters("offline-roster", storage)[0].experience).toBe(123); }); it("rejects editing without a selected, owned main-menu character", () => { useShellStore.setState({ selectedCharacterId: null }); expect(beginMainMenuCharacterEditing(storage)).toBeNull(); expect(useCombatStore.getState().classId).toBe("mage"); }); it("reports storage failures instead of updating the roster as though the save succeeded", () => { stop = beginMainMenuCharacterEditing(storage); storage.setItem = () => { throw new Error("Storage full"); }; useCombatStore.getState().allocateTalent(holyTalent.id); expect(useShellStore.getState().notice).toMatch(/could not save/i); expect(selectedCharacter(useShellStore.getState())?.talentRanks).toEqual({}); }); });