Files
healer-man/scripts/coa-reference/promote.test.mjs

293 lines
12 KiB
JavaScript

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 ? "<b>August &amp; improved</b><br>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: "<span>One</span><br><br>Two &amp; 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/);
});