ai agent
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { startLocalServer } from "../out/agent/localServer.js";
|
||||
import { parseLoadedXml } from "../out/agent/forwardRefs.js";
|
||||
|
||||
const PROJECT_A = "D:/Mods/Example";
|
||||
const PROJECT_B = "D:/Mods/Other";
|
||||
|
||||
function makeStats() {
|
||||
return {
|
||||
projectDir: "P",
|
||||
sdkDir: "",
|
||||
phase: "art",
|
||||
complete: true,
|
||||
indexedFiles: 1,
|
||||
parsedFiles: 1,
|
||||
shallowScannedFiles: 0,
|
||||
deferredArtFiles: 0,
|
||||
shallowCacheHits: 0,
|
||||
recordsCacheHits: 0,
|
||||
resolveCacheHits: 0,
|
||||
resolveCalls: 0,
|
||||
snapshotHits: 0,
|
||||
snapshotFallbacks: 0,
|
||||
candidatesMs: 0,
|
||||
walkMs: 0,
|
||||
artScanMs: 0,
|
||||
assetCount: 1,
|
||||
referenceCount: 1,
|
||||
defineCount: 1,
|
||||
manifestFiles: 0,
|
||||
manifestAssetCount: 0,
|
||||
streams: 1,
|
||||
sourceCandidates: 1,
|
||||
elapsedMs: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const CANNON_XML = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<GameObject id="AthenaCannon" inheritFrom="BaseCannon">
|
||||
<CreateObjectDie><CreateObject>AthenaCannon_Die</CreateObject></CreateObjectDie>
|
||||
</GameObject>
|
||||
</AssetDeclaration>`;
|
||||
|
||||
const BASE_XML = `<AssetDeclaration xmlns="uri:ea.com:eala:asset">
|
||||
<GameObject id="BaseCannon">
|
||||
<WeaponSetUpdate><WeaponSlotHardpoint><Weapon Template="AthenaCannonWeapon" /></WeaponSlotHardpoint></WeaponSetUpdate>
|
||||
</GameObject>
|
||||
</AssetDeclaration>`;
|
||||
|
||||
const FILES = {
|
||||
"D:/Mods/Example/Data/AthenaCannon.xml": CANNON_XML,
|
||||
"D:/Mods/Example/Data/BaseCannon.xml": BASE_XML,
|
||||
};
|
||||
|
||||
function makeIndex(projectDir, unitId, extraDefs = []) {
|
||||
const file = `${projectDir}/Data/${unitId}.xml`;
|
||||
const unit = { type: "GameObject", id: unitId, file, line: 2, origin: "project", stream: "static" };
|
||||
const all = [unit, ...extraDefs];
|
||||
const assets = new Map();
|
||||
const assetsById = new Map();
|
||||
for (const d of all) {
|
||||
if (!assets.has(d.type)) assets.set(d.type, new Map());
|
||||
const byId = assets.get(d.type);
|
||||
if (!byId.has(d.id.toLowerCase())) byId.set(d.id.toLowerCase(), []);
|
||||
byId.get(d.id.toLowerCase()).push(d);
|
||||
const key = d.id.toLowerCase();
|
||||
if (!assetsById.has(key)) assetsById.set(key, []);
|
||||
assetsById.get(key).push(d);
|
||||
}
|
||||
return {
|
||||
projectDir,
|
||||
sdkDir: "",
|
||||
complete: true,
|
||||
phase: "art",
|
||||
stale: false,
|
||||
assets,
|
||||
assetsById,
|
||||
defines: new Map([
|
||||
["exampledefine", [{ name: "ExampleDefine", value: "1", file, line: 2, origin: "project" }]],
|
||||
]),
|
||||
files: new Map(),
|
||||
streams: [
|
||||
{
|
||||
name: "static",
|
||||
entry: `${projectDir}/Data/Mod.xml`,
|
||||
files: new Set([file.toLowerCase().replace(/\\/g, "/")]),
|
||||
},
|
||||
],
|
||||
manifests: new Map(),
|
||||
sourceCandidates: [],
|
||||
diagnostics: [],
|
||||
references: new Map([
|
||||
[
|
||||
`GameObject\u0000${unitId.toLowerCase()}\u0000${file}\u00002`,
|
||||
[{ file: `${projectDir}/Data/Other.xml`, line: 3, start: 1, end: 2, kind: "attr" }],
|
||||
],
|
||||
]),
|
||||
recordsHashes: new Map(),
|
||||
stats: makeStats(),
|
||||
};
|
||||
}
|
||||
|
||||
const PROJECT_A_RELATED = [
|
||||
{
|
||||
type: "GameObject",
|
||||
id: "BaseCannon",
|
||||
file: "D:/Mods/Example/Data/BaseCannon.xml",
|
||||
line: 2,
|
||||
origin: "project",
|
||||
stream: "static",
|
||||
},
|
||||
{
|
||||
type: "GameObject",
|
||||
id: "AthenaCannon_Die",
|
||||
file: "D:/Mods/Example/Data/AthenaCannon_Die.xml",
|
||||
line: 2,
|
||||
origin: "project",
|
||||
stream: "static",
|
||||
},
|
||||
{
|
||||
type: "WeaponTemplate",
|
||||
id: "AthenaCannonWeapon",
|
||||
file: "D:/Mods/Example/Data/Weapon.xml",
|
||||
line: 88,
|
||||
origin: "project",
|
||||
stream: "static",
|
||||
},
|
||||
];
|
||||
|
||||
/** Routes to a distinct index per requested project, like the extension does. */
|
||||
function routedServerOptions() {
|
||||
const indexes = new Map([
|
||||
[PROJECT_A, makeIndex(PROJECT_A, "AthenaCannon", PROJECT_A_RELATED)],
|
||||
[PROJECT_B, makeIndex(PROJECT_B, "OtherUnit")],
|
||||
]);
|
||||
return {
|
||||
getIndex: (projectDir) => (projectDir ? indexes.get(projectDir) ?? null : indexes.get(PROJECT_A)),
|
||||
listProjects: () => [...indexes.keys()],
|
||||
loadFile: async (file) => {
|
||||
const text = FILES[file];
|
||||
return text ? parseLoadedXml(text) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function withServer(fn) {
|
||||
const handle = await startLocalServer({ ...routedServerOptions(), token: "test-token" });
|
||||
const base = `http://127.0.0.1:${handle.port}`;
|
||||
const headers = { authorization: "Bearer test-token" };
|
||||
const get = async (path) => (await fetch(`${base}${path}`, { headers })).json();
|
||||
try {
|
||||
await fn({ base, headers, get });
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
test("local server requires the bearer token", async () => {
|
||||
await withServer(async ({ base }) => {
|
||||
const unauthorized = await fetch(`${base}/status`);
|
||||
assert.equal(unauthorized.status, 401);
|
||||
const forbidden = await fetch(`${base}/status`, {
|
||||
headers: { authorization: "Bearer wrong" },
|
||||
});
|
||||
assert.equal(forbidden.status, 401);
|
||||
});
|
||||
});
|
||||
|
||||
test("local server exposes read-only queries", async () => {
|
||||
await withServer(async ({ get }) => {
|
||||
const status = await get(`/status?project=${encodeURIComponent(PROJECT_A)}`);
|
||||
assert.equal(status.state, "ready");
|
||||
assert.equal(status.projectDir, PROJECT_A);
|
||||
|
||||
const asset = await get(
|
||||
`/find_asset?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&type=GameObject`,
|
||||
);
|
||||
assert.equal(asset.data.length, 1);
|
||||
|
||||
const refs = await get(
|
||||
`/find_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon`,
|
||||
);
|
||||
assert.equal(refs.data.length, 1);
|
||||
|
||||
const active = await get(
|
||||
`/is_file_active?project=${encodeURIComponent(PROJECT_A)}&path=D:/Mods/Example/Data/AthenaCannon.xml`,
|
||||
);
|
||||
assert.equal(active.data.active, true);
|
||||
});
|
||||
});
|
||||
|
||||
test("?project= selects the index instead of the active editor's project", async () => {
|
||||
await withServer(async ({ get }) => {
|
||||
const a = await get(`/find_asset?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon`);
|
||||
assert.equal(a.index.projectDir, PROJECT_A);
|
||||
assert.equal(a.data.length, 1);
|
||||
|
||||
const b = await get(`/find_asset?project=${encodeURIComponent(PROJECT_B)}&id=OtherUnit`);
|
||||
assert.equal(b.index.projectDir, PROJECT_B);
|
||||
assert.equal(b.data.length, 1);
|
||||
|
||||
// The same id must not resolve when asked about the other project.
|
||||
const cross = await get(
|
||||
`/find_asset?project=${encodeURIComponent(PROJECT_B)}&id=AthenaCannon`,
|
||||
);
|
||||
assert.equal(cross.data.length, 0);
|
||||
assert.equal(cross.index.projectDir, PROJECT_B);
|
||||
});
|
||||
});
|
||||
|
||||
test("an unknown project reports no_index without faking a projectDir", async () => {
|
||||
await withServer(async ({ get }) => {
|
||||
const unknown = "D:/Mods/Unknown";
|
||||
const result = await get(`/status?project=${encodeURIComponent(unknown)}`);
|
||||
assert.equal(result.state, "no_index");
|
||||
assert.equal(result.projectDir, unknown);
|
||||
});
|
||||
});
|
||||
|
||||
test("/projects lists the known roots", async () => {
|
||||
await withServer(async ({ get }) => {
|
||||
const result = await get("/projects");
|
||||
assert.deepEqual(new Set(result.data), new Set([PROJECT_A, PROJECT_B]));
|
||||
});
|
||||
});
|
||||
|
||||
test("/get_asset_references returns provenance-carrying edges", async () => {
|
||||
await withServer(async ({ get }) => {
|
||||
const result = await get(
|
||||
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&type=GameObject`,
|
||||
);
|
||||
assert.equal(result.index.state, "ready");
|
||||
const data = result.data;
|
||||
assert.ok(data, "expected a data payload");
|
||||
assert.equal(data.edges.length >= 3, true);
|
||||
|
||||
const weapon = data.edges.find((e) => e.to?.id === "AthenaCannonWeapon");
|
||||
assert.ok(weapon, "expected the inherited weapon edge");
|
||||
assert.equal(weapon.via.element, "Weapon");
|
||||
assert.equal(weapon.via.parent, "WeaponSlotHardpoint");
|
||||
assert.equal(weapon.definedIn.id, "BaseCannon");
|
||||
assert.equal(weapon.source.file, "D:/Mods/Example/Data/BaseCannon.xml");
|
||||
|
||||
const die = data.edges.find((e) => e.via.kind === "content");
|
||||
assert.ok(die, "expected the CreateObjectDie content edge");
|
||||
assert.equal(die.to.id, "AthenaCannon_Die");
|
||||
});
|
||||
});
|
||||
|
||||
test("/get_asset_references honours targetTypes and depth parameters", async () => {
|
||||
await withServer(async ({ get }) => {
|
||||
const filtered = await get(
|
||||
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&targetTypes=WeaponTemplate`,
|
||||
);
|
||||
for (const edge of filtered.data.edges) {
|
||||
if (edge.via.kind === "inheritFrom") continue;
|
||||
assert.equal(edge.to.type, "WeaponTemplate");
|
||||
}
|
||||
|
||||
const shallow = await get(
|
||||
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&depth=1`,
|
||||
);
|
||||
for (const edge of shallow.data.edges) {
|
||||
assert.equal(edge.depth, 1);
|
||||
}
|
||||
|
||||
const deep = await get(
|
||||
`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=AthenaCannon&depth=2`,
|
||||
);
|
||||
for (const edge of deep.data.edges) {
|
||||
assert.ok(edge.depth <= 2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("/get_asset_references explains itself when live data is unavailable", async () => {
|
||||
await withServer(async ({ get }) => {
|
||||
const missing = await get(`/get_asset_references?project=${encodeURIComponent(PROJECT_A)}&id=Nope`);
|
||||
assert.equal(missing.data.roots.length, 0);
|
||||
assert.ok(missing.data.warnings.length > 0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user