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 = ` AthenaCannon_Die `; const BASE = ` `; const DIE = ` `; 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 = ` `; 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"))); });