Files
Ra3ModXmlExt/test/agentForwardRefs.test.mjs
T
2026-09-10 17:03:10 +02:00

309 lines
10 KiB
JavaScript

import { test } from "node:test";
import assert from "node:assert/strict";
import {
collectAssetReferences,
parseLoadedXml,
} from "../out/agent/forwardRefs.js";
// ── Fixture files ────────────────────────────────────────────────────
// AthenaCannon has no WeaponSetUpdate of its own: it inherits BaseCannon,
// which owns the weapon slot, and has its own die-object content reference.
const ATHENA = `<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="AthenaCannon" inheritFrom="BaseCannon">
<CreateObjectDie>
<CreateObject>AthenaCannon_Die</CreateObject>
</CreateObjectDie>
</GameObject>
</AssetDeclaration>`;
const BASE = `<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="BaseCannon">
<WeaponSetUpdate>
<WeaponSlotHardpoint>
<Weapon Template="AthenaCannonWeapon" />
</WeaponSlotHardpoint>
</WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
const DIE = `<?xml version="1.0" encoding="utf-8"?>
<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="AthenaCannon_Die">
<WeaponSetUpdate>
<WeaponSlotHardpoint>
<Weapon Template="DieExplosionWeapon" />
</WeaponSlotHardpoint>
</WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
const FILES = {
"D:/Mods/Example/Data/AthenaCannon.xml": ATHENA,
"D:/Mods/Example/Data/BaseCannon.xml": BASE,
"D:/Mods/Example/Data/AthenaCannon_Die.xml": DIE,
};
function def(type, id, file, line) {
return { type, id, file, line, origin: "project", stream: "static" };
}
const ATHENA_DEF = def("GameObject", "AthenaCannon", "D:/Mods/Example/Data/AthenaCannon.xml", 3);
const BASE_DEF = def("GameObject", "BaseCannon", "D:/Mods/Example/Data/BaseCannon.xml", 3);
const DIE_DEF = def("GameObject", "AthenaCannon_Die", "D:/Mods/Example/Data/AthenaCannon_Die.xml", 3);
const WEAPON_DEF = def(
"WeaponTemplate",
"AthenaCannonWeapon",
"D:/Mods/Example/Data/Weapon.xml",
88,
);
const DIE_WEAPON_DEF = def(
"WeaponTemplate",
"DieExplosionWeapon",
"D:/Mods/Example/Data/Weapon.xml",
120,
);
function makeIndex() {
const all = [ATHENA_DEF, BASE_DEF, DIE_DEF, WEAPON_DEF, DIE_WEAPON_DEF];
const assetsById = new Map();
for (const d of all) {
const key = d.id.toLowerCase();
if (!assetsById.has(key)) assetsById.set(key, []);
assetsById.get(key).push(d);
}
const assets = new Map([
["GameObject", new Map([["athenacannon", [ATHENA_DEF]], ["basecannon", [BASE_DEF]], ["athenacannon_die", [DIE_DEF]]])],
["WeaponTemplate", new Map([["athenacannonweapon", [WEAPON_DEF]], ["dieexplosionweapon", [DIE_WEAPON_DEF]]])],
]);
return {
projectDir: "D:/Mods/Example",
sdkDir: "",
complete: true,
phase: "art",
assets,
assetsById,
defines: new Map(),
files: new Map(),
streams: [],
manifests: new Map(),
sourceCandidates: [],
diagnostics: [],
references: new Map(),
recordsHashes: new Map(),
stats: {},
};
}
function loader(map = FILES) {
return async (file) => {
const text = map[file];
return text ? parseLoadedXml(text) : null;
};
}
function findEdge(edges, predicate) {
return edges.find(predicate);
}
test("depth 1 returns only the queried asset's own edges", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{},
);
assert.equal(result.roots.length, 1);
// Own content ref + inherited weapon ref + the inheritFrom edge itself.
assert.ok(result.edges.length >= 3);
assert.deepEqual(
[...new Set(result.edges.map((e) => e.depth))],
[1],
"all edges must be at depth 1",
);
assert.equal(result.truncated, false);
});
test("attribute references carry element, parent and attribute provenance", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
);
const weapon = findEdge(
result.edges,
(e) => e.via.kind === "attribute" && e.to?.id === "AthenaCannonWeapon",
);
assert.ok(weapon, "weapon edge should exist");
assert.equal(weapon.via.element, "Weapon");
assert.equal(weapon.via.parent, "WeaponSlotHardpoint");
assert.equal(weapon.via.attribute, "Template");
assert.equal(weapon.to.type, "WeaponTemplate");
assert.equal(weapon.to.line, 88);
assert.ok(weapon.source.file.endsWith("BaseCannon.xml"));
assert.ok(weapon.source.line > 0);
});
test("content references (CreateObjectDie) are reported with kind=content", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
);
const die = findEdge(result.edges, (e) => e.via.kind === "content");
assert.ok(die, "die-object content edge should exist");
assert.equal(die.via.element, "CreateObject");
assert.equal(die.via.parent, "CreateObjectDie");
assert.equal(die.via.attribute, null);
assert.equal(die.to.type, "GameObject");
assert.equal(die.to.id, "AthenaCannon_Die");
assert.ok(die.source.file.endsWith("AthenaCannon.xml"));
});
test("inheritFrom is walked and marked with definedIn", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
);
const inherit = findEdge(result.edges, (e) => e.via.kind === "inheritFrom");
assert.ok(inherit, "inheritFrom edge should exist");
assert.equal(inherit.to.id, "BaseCannon");
assert.equal(inherit.value, "BaseCannon");
assert.equal(inherit.definedIn, undefined, "the inheritFrom edge itself is on AthenaCannon");
const inheritedWeapon = findEdge(
result.edges,
(e) => e.to?.id === "AthenaCannonWeapon",
);
assert.ok(inheritedWeapon, "weapon from the ancestor must still be reported");
assert.deepEqual(inheritedWeapon.definedIn, { type: "GameObject", id: "BaseCannon" });
assert.equal(inheritedWeapon.from.id, "AthenaCannon", "edge is attributed to the queried asset");
});
test("targetTypes filters edges, but inheritFrom edges always survive", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{ targetTypes: ["WeaponTemplate"] },
);
assert.ok(result.edges.length > 0);
assert.ok(
result.edges.some((e) => e.to.type === "WeaponTemplate"),
"expected at least one WeaponTemplate edge",
);
for (const edge of result.edges) {
// inheritFrom is kept so the caller can see where the weapon is written.
if (edge.via.kind === "inheritFrom") continue;
assert.equal(edge.to.type, "WeaponTemplate", `${edge.via.element} should be filtered out`);
}
// Nodes mirror the kept edges, so the inheritFrom target may appear too.
const inheritIds = new Set(
result.edges
.filter((e) => e.via.kind === "inheritFrom")
.map((e) => e.to.id.toLowerCase()),
);
for (const node of result.nodes) {
assert.ok(
node.type === "WeaponTemplate" || inheritIds.has(node.id.toLowerCase()),
`unexpected node ${node.type}:${node.id}`,
);
}
});
test("depth 2 expands into referenced assets", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{ depth: 2 },
);
const depths = new Set(result.edges.map((e) => e.depth));
assert.ok(depths.has(2), "expected depth-2 edges from the die-object GameObject");
const dieWeapon = findEdge(result.edges, (e) => e.to?.id === "DieExplosionWeapon");
assert.ok(dieWeapon, "the die object's weapon should appear at depth 2");
assert.equal(dieWeapon.depth, 2);
assert.equal(dieWeapon.from.id, "AthenaCannon_Die");
});
test("depth is clamped to the max of 3", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{ depth: 99 },
);
for (const edge of result.edges) {
assert.ok(edge.depth <= 3, `depth ${edge.depth} exceeded the clamp`);
}
});
test("maxEdges truncates and reports what was dropped", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
loader(),
{ maxEdges: 1 },
);
assert.equal(result.edges.length, 1);
assert.equal(result.truncated, true);
const omitted = Object.values(result.omittedByTargetType).reduce((a, b) => a + b, 0);
assert.ok(omitted >= 1, "expected the dropped edges to be summarised");
});
test("unresolved references are opt-in", async () => {
const text = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
<GameObject id="Ghost">
<WeaponSetUpdate><WeaponSlotHardpoint><Weapon Template="DoesNotExist" /></WeaponSlotHardpoint></WeaponSetUpdate>
</GameObject>
</AssetDeclaration>`;
const ghost = def("GameObject", "Ghost", "D:/Mods/Example/Data/Ghost.xml", 2);
const index = makeIndex();
index.assetsById.set("ghost", [ghost]);
const ghostLoader = loader({ "D:/Mods/Example/Data/Ghost.xml": text });
const without = await collectAssetReferences(index, "Ghost", "GameObject", ghostLoader);
assert.equal(without.edges.length, 0);
const withUnresolved = await collectAssetReferences(index, "Ghost", "GameObject", ghostLoader, {
includeUnresolved: true,
});
assert.equal(withUnresolved.edges.length, 1);
assert.equal(withUnresolved.edges[0].to, null);
assert.equal(withUnresolved.edges[0].value, "DoesNotExist");
});
test("unknown ids produce a warning instead of throwing", async () => {
const result = await collectAssetReferences(
makeIndex(),
"NoSuchAsset",
"GameObject",
loader(),
);
assert.equal(result.edges.length, 0);
assert.equal(result.roots.length, 0);
assert.equal(result.warnings.length, 1);
assert.match(result.warnings[0], /No definition found/);
});
test("unreadable files produce a warning and no edges", async () => {
const result = await collectAssetReferences(
makeIndex(),
"AthenaCannon",
"GameObject",
async () => null,
);
assert.equal(result.edges.length, 0);
assert.ok(result.warnings.some((w) => w.includes("Could not read")));
});