ai agent
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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")));
|
||||
});
|
||||
Reference in New Issue
Block a user