import assert from "node:assert/strict"; import test from "node:test"; import { buildCoaPromotion, COA_PROMOTION_ARCHIVE, normalizeArchiveNode } from "./promote.mjs"; const classNames = [ "Barbarian", "Witch Doctor", "Felsworn", "Witch Hunter", "Stormbringer", "Knight of Xoroth", "Guardian", "Templar", "Bloodmage", "Ranger", "Chronomancer", "Necromancer", "Pyromancer", "Cultist", "Starcaller", "Sun Cleric", "Tinker", "Venomancer", "Reaper", "Primalist", "Runemaster", ]; function archiveNode({ classId, id, spellId, description = "Archive description", tabId = 30 + classId }) { return { id, classId, tabId, x: 1, y: 2, name: `Class ${classId} node`, iconPath: "Interface\\Icons\\TEST_ICON.blp", nodeType: "SpendSquare", entryType: "Talent", isPassive: 0, maxPoints: 1, aeCost: 0, teCost: 1, reqTabAE: 0, reqTabTE: 0, requiredIds: [], connectedNodeIds: [], requiredLevel: 0, isStartingNode: 0, flags: 0, group: 0, sortOrder: 0, description, rankDescriptions: [{ rank: 1, spellId, description }], }; } function dbc(id, { description = `Base description ${id}`, effects = [] } = {}) { return { id, name: `Base spell ${id}`, rankText: "Rank 1", description, effects }; } function spell(id, classIds, { description = `Classic description ${id}`, effects = [], liveTooltip } = {}) { return { id, classIds, name: `Spell ${id}`, iconBasename: "classic_icon", level: 1, rank: "Rank 1", description, ...(liveTooltip ? { liveTooltip } : {}), sourceIds: [], advancementType: "", advancementTabId: 0, talented: false, excluded: false, dbc: { ...dbc(id), marker: "Area52 overlay row" }, }; } function fixtures({ missingCoaRanks = [] } = {}) { const previousClasses = classNames.map((className, index) => ({ classId: index + 12, className, maxTalentEssence: 25, maxAbilityEssence: 26, tabs: [{ tabId: 30 + index + 12, tabName: "July tab", sortOrder: 1 }], })); const archiveClasses = previousClasses.map((definition, index) => { const classId = definition.classId; const spellId = classId === 12 ? 100 : 200 + classId; const node = archiveNode({ classId, id: 12000 + classId, spellId, description: classId === 12 ? "August & improved
rank" : `August ${classId}`, }); return { classId, class: definition.className, builderClassName: definition.className, essence: { maxTalentEssence: 99, maxAbilityEssence: 98 }, tabs: [ { tabId: node.tabId, tabName: "August tab", sortOrder: 1, entryCount: 1, isEmpty: false }, { tabId: 900 + classId, tabName: "Empty tab", sortOrder: 2, entryCount: 0, isEmpty: true }, ], nodes: [node], }; }); const archiveEntries = archiveClasses.flatMap((definition) => definition.nodes.map(normalizeArchiveNode)); const julyEntries = structuredClone(archiveEntries); julyEntries.find((node) => node.classId === 12).description = "July-only description"; julyEntries.find((node) => node.classId === 12).rankDescriptions[0].description = "July rank description"; const classicChain = { id: "classic-preserved-chain", classId: 1, name: "Classic chain", iconBasename: "classic", talented: false, advancementType: "", trainer: true, ranks: [{ spellId: 100, rank: 1, level: 1 }], }; const coaChain = { id: "coa-stable-chain", classId: 12, name: "Stable CoA chain", iconBasename: "coa", talented: false, advancementType: "", trainer: false, ranks: [ { spellId: 91300, rank: 1, level: 1 }, ...missingCoaRanks.map((spellId, index) => ({ spellId, rank: index + 2, level: index + 2 })), ], }; const sharedSpell = spell(100, [1, 12], { liveTooltip: "stale Area52 tooltip" }); const classicTriggered = spell(101, [5], { liveTooltip: "classic tooltip" }); const classicPayload = spell(102, [5]); const classicClosureSeed = spell(500, [2]); const classicEmptyOwnerChild = spell(501, []); const classicEmptyOwnerPayload = spell(502, []); const staleCoa = spell(999, [12], { liveTooltip: "stale CoA tooltip" }); const previous = { source: { realmSlug: "voljin", spellDbcPath: "C:/client/area-52/patch-D/DBFilesClient/Spell.dbc" }, counts: {}, classes: previousClasses, entries: julyEntries, progressionChains: [classicChain, coaChain], spells: [sharedSpell, classicTriggered, classicPayload, classicClosureSeed, classicEmptyOwnerChild, classicEmptyOwnerPayload, staleCoa], classicTriggeredSpells: [ { id: 500, effects: [{ effectType: 64, triggerSpellId: 501, miscValue: 0 }] }, { id: 501, effects: [{ effectType: 168, triggerSpellId: 0, miscValue: 502 }] }, ], }; const allArchiveRanks = archiveClasses.flatMap((definition) => definition.nodes.flatMap((node) => node.rankDescriptions)); const referenceSpellMap = new Map(); for (const rank of allArchiveRanks) { if (missingCoaRanks.includes(rank.spellId)) continue; let effects = []; if (rank.spellId === 100) { effects = [ { effectType: 64, triggerSpellId: 101, miscValue: 0 }, { effectType: 168, triggerSpellId: 0, miscValue: 102 }, ]; } referenceSpellMap.set(rank.spellId, { id: rank.spellId, classIds: rank.spellId === 100 ? [1, 12] : [12], name: `Base spell ${rank.spellId}`, rank: "Rank 1", level: 1, description: `July reference description ${rank.spellId}`, dbc: dbc(rank.spellId, { effects }), }); } for (const classicSpell of [sharedSpell, classicTriggered, classicPayload, classicClosureSeed, classicEmptyOwnerChild, classicEmptyOwnerPayload]) { referenceSpellMap.set(classicSpell.id, { id: classicSpell.id, classIds: classicSpell.classIds, name: `BASE ${classicSpell.id}`, rank: "Rank 1", level: 1, description: `BASE ${classicSpell.id} description`, dbc: dbc(classicSpell.id, { effects: classicSpell.id === 100 ? [ { effectType: 64, triggerSpellId: 101, miscValue: 0 }, { effectType: 168, triggerSpellId: 0, miscValue: 102 }, ] : [], }), }); } const supplements = { source: { ...COA_PROMOTION_ARCHIVE, capturedAt: COA_PROMOTION_ARCHIVE.capturedAt, }, classes: archiveClasses, }; const reference = { classes: previousClasses, nodes: [], progressionChains: [classicChain, coaChain], spells: [...referenceSpellMap.values()].sort((left, right) => left.id - right.id), sources: [{ id: "global-spell-base", path: "extracts/client-current/patch-T/DBFilesClient/Spell.dbc", role: "base" }], }; return { previous, reference, supplements, archiveEntries, classicChain, coaChain }; } test("normalizeArchiveNode matches the old sync entry shape and cleans archive text", () => { const normalized = normalizeArchiveNode({ ...archiveNode({ classId: 12, id: 1, spellId: 2, description: "One

Two & three" }), name: "@6New Name", iconPath: "Interface\\Icons\\TEST_ICON.TGA", isPassive: 127, isStartingNode: 0, reqTabAE: undefined, reqTabTE: undefined, requiredClassPoints: 5, requiredSpecPoints: 6, }); assert.deepEqual(normalized, { id: 1, classId: 12, tabId: 42, x: 1, y: 2, name: "New Name", iconBasename: "test_icon", nodeType: "SpendSquare", entryType: "Talent", isPassive: true, maxPoints: 1, aeCost: 0, teCost: 1, requiredClassPoints: 5, requiredSpecPoints: 6, requiredIds: [], connectedNodeIds: [], requiredLevel: 0, isStartingNode: false, flags: 0, group: 0, sortOrder: 0, description: "One Two & three", rankDescriptions: [{ rank: 1, spellId: 2, description: "One Two & three" }], }); }); test("promotion retains classic data and old node and chain identities while using August nodes and BASE spells", () => { const { previous, reference, supplements, archiveEntries, classicChain, coaChain } = fixtures(); const { candidate, report } = buildCoaPromotion({ previous, reference, supplements }); const oldClassicSpells = previous.spells.filter((item) => item.classIds.some((classId) => classId <= 11)); assert.deepEqual(candidate.progressionChains[0], classicChain); assert.deepEqual(candidate.spells.filter((item) => oldClassicSpells.some((old) => old.id === item.id)), oldClassicSpells); for (const id of [500, 501, 502]) { assert.deepEqual(candidate.spells.find((item) => item.id === id), previous.spells.find((item) => item.id === id)); } assert.deepEqual(candidate.entries, archiveEntries); assert.deepEqual(candidate.progressionChains[1], coaChain); assert.equal(candidate.classes[0].tabs.length, 1); assert.equal(candidate.classes[0].maxTalentEssence, 25); assert.equal(candidate.entries[0].description, "August & improved rank"); assert.equal(candidate.spells.find((item) => item.id === 100).description, "Classic description 100"); assert.equal(candidate.spells.find((item) => item.id === 100).dbc.marker, "Area52 overlay row"); const overrideById = new Map(candidate.coaSpellOverrides.map((item) => [item.id, item])); for (const id of [100, 101, 102]) { assert.equal(overrideById.has(id), true, `expected CoA-context override for linked spell ${id}`); assert.equal(overrideById.get(id).dbc.id, id); assert.equal(overrideById.get(id).dbc.marker, undefined); assert.deepEqual(overrideById.get(id).classIds, [12]); assert.equal("liveTooltip" in overrideById.get(id), false); } assert.equal(overrideById.get(100).description, "August & improved rank"); assert.equal(candidate.source.realmSlug, "voljin"); assert.equal(candidate.source.spellDbcSources.coaBase.patch, "T"); assert.match(candidate.source.promotion.classicSpellSource, /area-52\/patch-D/); assert.equal(candidate.source.promotion.archiveCapturedAt, "2026-08-12T21:02:51+00:00"); assert.equal(report.preserveChecks.classicProgressionChains.passed, true); assert.equal(report.preserveChecks.classicProtectedSpellRecords.passed, true); assert.equal(report.nodeChanges.unexpectedRemovals.length, 0); assert.equal(report.gaps.missingBaseProgressionRankCount, 1); assert.equal(report.gaps.missingBaseProgressionRankIds.includes(91300), true); assert.equal(candidate.spells.find((item) => item.id === 999).dbc, undefined); assert.equal("liveTooltip" in candidate.spells.find((item) => item.id === 999), false); }); test("promotion reports all missing BASE progression ranks without replacing chain IDs", () => { const missingCoaRanks = Array.from({ length: 13 }, (_, index) => 91400 + index); const { previous, reference, supplements, coaChain } = fixtures({ missingCoaRanks }); const { candidate, report } = buildCoaPromotion({ previous, reference, supplements }); assert.deepEqual(candidate.progressionChains[1], coaChain); assert.equal(report.gaps.missingBaseProgressionRankCount, 14); assert.deepEqual(report.gaps.missingBaseProgressionRankIds, [91300, ...missingCoaRanks]); assert.equal(candidate.spells.some((item) => missingCoaRanks.includes(item.id)), false); }); test("promotion blocks July node removal and cross-class rewiring", () => { const missing = fixtures(); missing.supplements.classes.find((item) => item.classId === 12).nodes = []; assert.throws(() => buildCoaPromotion(missing), /August archive removes July node IDs/); const rewired = fixtures(); const moved = rewired.supplements.classes.find((item) => item.classId === 12).nodes[0]; rewired.supplements.classes.find((item) => item.classId === 12).nodes = []; rewired.supplements.classes.find((item) => item.classId === 13).nodes.push({ ...moved, classId: 13, tabId: 43 }); assert.throws(() => buildCoaPromotion(rewired), /August archive rewires node IDs across classes/); });