293 lines
12 KiB
JavaScript
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 & 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 & 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/);
|
|
});
|