import { describe, expect, it, vi } from "vitest"; import type { AbilityDefinition } from "./abilityCatalog"; import { ascensionSpellById, coaSpellById, type CoaProgressionChain, } from "./coaLiveCatalog"; import { ascensionProgressionAbility } from "./coaAbilityCatalog"; const fixture = vi.hoisted(() => { const makeEffect = (effectType: number, triggerSpellId = 0, basePoints = 1) => ({ index: 0, effectType, auraType: 0, basePoints, dieSides: 0, pointsPerLevel: 0, mechanic: 0, implicitTargetA: 6, implicitTargetB: 0, radiusIndex: 0, radius: 0, periodMs: 0, valueMultiplier: 0, chainTargets: 0, itemType: 0, miscValue: 0, miscValueB: 0, triggerSpellId, pointsPerCombo: 0, coefficient: 0, }); const makeDbc = ( id: number, name: string, effects: ReturnType[] = [], startRecoveryTimeMs = 1_500, ) => ({ id, name, rankText: "Rank 1", description: `${name} fixture description`, categoryId: 0, dispelType: 0, mechanic: 0, attributes: Array(8).fill(0), castTimeIndex: 0, castTimeMs: 0, recoveryTimeMs: 0, categoryRecoveryTimeMs: 0, interruptFlags: 0, auraInterruptFlags: 0, channelInterruptFlags: 0, procFlags: 0, procChance: 0, procCharges: 0, maxLevel: 80, baseLevel: 1, spellLevel: 1, durationIndex: 0, durationMs: 0, powerType: 0, powerCost: 0, powerCostPerLevel: 0, powerCostPerSecond: 0, powerCostPerSecondPerLevel: 0, rangeIndex: 0, rangeMin: 0, rangeMax: 30, projectileSpeed: 0, stackAmount: 0, equippedItemClass: 0, equippedItemSubclassMask: 0, equippedItemInventoryMask: 0, iconId: 0, powerCostPercentage: 0, startRecoveryCategory: 0, startRecoveryTimeMs, maximumTargets: 0, damageClass: 0, preventionType: 0, schoolMask: 1, runeCostId: 0, powerDisplayId: 0, effects, }); const makeLiveSpell = (id: number, name: string, dbc?: ReturnType) => ({ id, classIds: [11, 27], name, iconBasename: "fixture", level: 1, rank: "Rank 1", description: `${name} live description`, sourceIds: [], advancementType: "", advancementTabId: 0, talented: false, excluded: false, ...(dbc ? { dbc } : {}), }); const mainSpells = [ makeLiveSpell(900, "Shared Classic Root", makeDbc(900, "Classic Root", [], 700)), makeLiveSpell(910, "Shared Trigger Root", makeDbc(910, "Classic Trigger Root", [makeEffect(64, 920)])), makeLiveSpell(920, "Shared First Payload", makeDbc(920, "Classic First Payload", [makeEffect(64, 930)])), makeLiveSpell(930, "Shared Nested Payload", makeDbc(930, "Classic Nested Payload", [makeEffect(2)])), makeLiveSpell(940, "Shared Missing CoA Base", makeDbc(940, "Area52 Overlay Payload", [makeEffect(2)])), { ...makeLiveSpell(950, "Classic Only Root", makeDbc(950, "Classic Only Root", [makeEffect(2)])), classIds: [11] }, ]; const coaOverrides = [ makeLiveSpell(900, "Shared CoA Root", makeDbc(900, "CoA Root", [], 0)), makeLiveSpell(910, "Shared CoA Trigger Root", makeDbc(910, "CoA Trigger Root", [makeEffect(64, 920)])), makeLiveSpell(920, "Shared CoA First Payload", makeDbc(920, "CoA First Payload", [makeEffect(64, 930)])), makeLiveSpell(930, "Shared CoA Nested Payload", makeDbc(930, "CoA Nested Payload", [makeEffect(67)])), makeLiveSpell(940, "Shared CoA Missing Base"), ]; const overlayPayload = makeDbc(940, "Area52 Overlay Payload", [makeEffect(2)]); return { liveData: { source: { builderUrl: "fixture", databaseClassUrl: "fixture", realmId: 1, realmSlug: "fixture", realmName: "Fixture", maximumLevel: 80, schemaVersion: {}, synchronizedAt: "2026-01-01T00:00:00.000Z", spellDbcPath: "fixture/classic/Spell.dbc", supportDbcDirectory: "fixture/support", }, counts: {}, classes: [], entries: [], progressionChains: [], spells: mainSpells, coaSpellOverrides: coaOverrides, }, triggeredCatalog: { spells: [overlayPayload], classicSpells: [overlayPayload], }, }; }); vi.mock("./coaLiveData.generated.json", () => ({ default: fixture.liveData })); vi.mock("./generated/coaTriggeredSpellCatalog.json", () => ({ default: fixture.triggeredCatalog })); function chain(spellId: number): CoaProgressionChain { return { id: `scope-${spellId}`, classId: 11, name: `Scope ${spellId}`, iconBasename: "fixture", talented: false, advancementType: "", trainer: false, ranks: [{ spellId, rank: 1, level: 1 }], }; } function ability(spellId: number, classId: "druid" | "sun-cleric"): AbilityDefinition { const classic = classId === "druid"; const result = ascensionProgressionAbility( chain(spellId), classId, "mana", classic ? "ascension-progression" : "coa-progression", ); if (!result) throw new Error(`Fixture spell ${spellId} did not produce an ability`); return result; } describe("classic and CoA spell source scope", () => { it("excludes classic-only records from CoA lookup", () => { expect(ascensionSpellById(950)?.dbc?.name).toBe("Classic Only Root"); expect(coaSpellById(950)).toBeNull(); expect(ability(950, "druid").effects.some((effect) => effect.kind === "damage")).toBe(true); expect(ability(950, "sun-cleric").effects.some((effect) => effect.kind === "damage")).toBe(false); }); it("resolves a shared root spell through separate classic and CoA wrappers", () => { expect(ascensionSpellById(900)?.dbc?.name).toBe("Classic Root"); expect(coaSpellById(900)?.dbc?.name).toBe("CoA Root"); expect(ability(900, "druid").ranks?.[0].gcdMs).toBe(700); expect(ability(900, "sun-cleric").ranks?.[0].gcdMs).toBe(0); }); it("keeps nested triggered payloads in the root spell's class scope", () => { const classicEffects = ability(910, "druid").ranks?.[0].effects ?? []; const coaEffects = ability(910, "sun-cleric").ranks?.[0].effects ?? []; expect(classicEffects).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "trigger-spell", spellId: 920 }), expect.objectContaining({ kind: "trigger-spell", spellId: 930 }), expect.objectContaining({ kind: "damage" }), ])); expect(classicEffects.some((effect) => effect.kind === "heal")).toBe(false); expect(coaEffects).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "trigger-spell", spellId: 920 }), expect.objectContaining({ kind: "trigger-spell", spellId: 930 }), expect.objectContaining({ kind: "heal" }), ])); expect(coaEffects.some((effect) => effect.kind === "damage")).toBe(false); }); it("does not fall back to classic overlay DBC when a CoA override has no base row", () => { expect(ascensionSpellById(940)?.dbc?.name).toBe("Area52 Overlay Payload"); expect(coaSpellById(940)).toMatchObject({ id: 940, name: "Shared CoA Missing Base" }); expect(coaSpellById(940)?.dbc).toBeUndefined(); const classicEffects = ability(940, "druid").ranks?.[0].effects ?? []; const coaEffects = ability(940, "sun-cleric").ranks?.[0].effects ?? []; expect(classicEffects.some((effect) => effect.kind === "damage")).toBe(true); expect(coaEffects.some((effect) => effect.kind === "damage")).toBe(false); }); });