309 lines
10 KiB
JavaScript
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")));
|
|
});
|