find asset

This commit is contained in:
2026-09-10 23:16:36 +02:00
parent a25adacaee
commit 4f422a748e
13 changed files with 1036 additions and 13 deletions
+318
View File
@@ -0,0 +1,318 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
assetDefsForCandidate,
assetOriginRank,
collectAssetSearchCandidates,
isEmptyAssetSearchQuery,
parseAssetSearchQuery,
searchAssetCandidates,
} from "../out/features/assetSearch.js";
function makeDef(type, id, file, line, extra = {}) {
return { type, id, file, line, origin: "project", ...extra };
}
function put(map, d) {
let byId = map.get(d.type);
if (!byId) {
byId = new Map();
map.set(d.type, byId);
}
const key = d.id.toLowerCase();
const arr = byId.get(key);
if (arr) arr.push(d);
else byId.set(key, [d]);
}
/** Minimal ModIndex surface used by the search core. */
function makeIndex({ assets = [], local = [] } = {}) {
const index = { assets: new Map() };
for (const d of assets) put(index.assets, d);
if (local.length) {
const localAssets = new Map();
for (const d of local) put(localAssets, d);
index.local = {
assets: localAssets,
assetsById: new Map(),
defines: new Map(),
};
}
return index;
}
const PROJECT = "D:/Mods/Corona";
const SDK = "C:/Apps/RA3-MODSDK-X";
test("parseAssetSearchQuery splits Type:Id, bare id and type-only queries", () => {
assert.deepEqual(parseAssetSearchQuery(" Assault "), {
raw: "Assault",
type: null,
id: "assault",
});
assert.deepEqual(parseAssetSearchQuery("WeaponTemplate:AssaultRifle"), {
raw: "WeaponTemplate:AssaultRifle",
type: "weapontemplate",
id: "assaultrifle",
});
assert.deepEqual(parseAssetSearchQuery("Weapon:"), {
raw: "Weapon:",
type: "weapon",
id: "",
});
// A bare leading colon is just an id search.
assert.deepEqual(parseAssetSearchQuery(":Assault"), {
raw: ":Assault",
type: null,
id: "assault",
});
// Manifest-style qualified names: type = first segment, id = last segment.
assert.deepEqual(parseAssetSearchQuery("W3DContainer:W3DContainer:AU"), {
raw: "W3DContainer:W3DContainer:AU",
type: "w3dcontainer",
id: "au",
});
});
test("parseAssetSearchQuery recognises empty queries", () => {
assert.ok(isEmptyAssetSearchQuery(parseAssetSearchQuery("")));
assert.ok(isEmptyAssetSearchQuery(parseAssetSearchQuery(" ")));
assert.ok(isEmptyAssetSearchQuery(parseAssetSearchQuery(":")));
assert.ok(!isEmptyAssetSearchQuery(parseAssetSearchQuery("Weapon:")));
});
test("collectAssetSearchCandidates dedupes type:id and counts definition sites", () => {
const index = makeIndex({
assets: [
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/weapons.xml`, 4),
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/weapons.xml`, 9),
makeDef("WeaponTemplate", "AssaultRifle", `${SDK}/builtmods/static.manifest`, 0, {
origin: "manifest",
manifestSource: "DATA:globaldata/weapon.xml",
}),
makeDef("GameObject", "AssaultRifle", `${PROJECT}/Data/units.xml`, 2),
],
});
const candidates = collectAssetSearchCandidates(index);
assert.equal(candidates.length, 2);
const weapon = candidates.find((c) => c.def.type === "WeaponTemplate");
assert.equal(weapon.definitionCount, 3);
// The mod definition wins over the manifest one.
assert.equal(weapon.def.origin, "project");
assert.equal(weapon.def.line, 4);
const gameObject = candidates.find((c) => c.def.type === "GameObject");
assert.equal(gameObject.definitionCount, 1);
});
test("collectAssetSearchCandidates merges the local overlay (unsaved wins)", () => {
const index = makeIndex({
assets: [makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 12)],
local: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 12, {
stream: "local",
}),
makeDef("GameObject", "TankPrototype", `${PROJECT}/Data/units.xml`, 40, {
stream: "local",
}),
],
});
const byId = new Map(
collectAssetSearchCandidates(index).map((c) => [c.def.id, c]),
);
assert.equal(byId.size, 2);
assert.equal(byId.get("Tank").def.stream, "local");
// The same file+line from overlay and global index is one definition.
assert.equal(byId.get("Tank").definitionCount, 1);
assert.equal(byId.get("TankPrototype").definitionCount, 1);
});
test("assetOriginRank orders local, project, SDK and manifest definitions", () => {
assert.equal(assetOriginRank(makeDef("A", "a", "f", 1, { stream: "local" })), 0);
assert.equal(assetOriginRank(makeDef("A", "a", "f", 1)), 1);
assert.equal(assetOriginRank(makeDef("A", "a", "f", 1, { origin: "sdk" })), 2);
assert.equal(
assetOriginRank(makeDef("A", "a", "f", 1, { origin: "manifest" })),
3,
);
});
test("searchAssetCandidates finds exact, prefix and partial ids", () => {
const index = makeIndex({
assets: [
makeDef("GameObject", "CrateDebris_01", `${PROJECT}/Data/a.xml`, 1),
makeDef("GameObject", "CrateDebris_02", `${PROJECT}/Data/a.xml`, 5),
makeDef("GameObject", "MyCrateDebris", `${PROJECT}/Data/a.xml`, 9),
makeDef("GameObject", "Unrelated", `${PROJECT}/Data/a.xml`, 13),
],
});
const candidates = collectAssetSearchCandidates(index);
const exact = searchAssetCandidates(
candidates,
parseAssetSearchQuery("CrateDebris_02"),
);
assert.equal(exact.total, 1);
assert.equal(exact.matches[0].def.id, "CrateDebris_02");
const partial = searchAssetCandidates(
candidates,
parseAssetSearchQuery("cratedebris"),
);
assert.equal(partial.total, 3);
// Prefix matches rank before substring matches.
assert.deepEqual(
partial.matches.map((m) => m.def.id),
["CrateDebris_01", "CrateDebris_02", "MyCrateDebris"],
);
const mid = searchAssetCandidates(
candidates,
parseAssetSearchQuery("Debris"),
);
assert.equal(mid.total, 3);
});
test("searchAssetCandidates is case-insensitive and type-aware for bare queries", () => {
const index = makeIndex({
assets: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/a.xml`, 1),
makeDef("WeaponTemplate", "TankGun", `${PROJECT}/Data/b.xml`, 1),
makeDef("GameObject", "OxTank", `${PROJECT}/Data/a.xml`, 5),
],
});
const candidates = collectAssetSearchCandidates(index);
const upper = searchAssetCandidates(candidates, parseAssetSearchQuery("TANK"));
assert.equal(upper.total, 3);
// A bare query that matches a type name lists that type's assets too.
const byType = searchAssetCandidates(
candidates,
parseAssetSearchQuery("GameObject"),
);
assert.equal(byType.total, 2);
assert.deepEqual(
byType.matches.map((m) => m.def.id).sort(),
["OxTank", "Tank"],
);
});
test("searchAssetCandidates honours Type:Id, Type: and prefixed queries", () => {
const index = makeIndex({
assets: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/a.xml`, 1),
makeDef("GameObject", "TankPrototype", `${PROJECT}/Data/a.xml`, 5),
makeDef("WeaponTemplate", "TankGun", `${PROJECT}/Data/b.xml`, 1),
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/b.xml`, 9),
],
});
const candidates = collectAssetSearchCandidates(index);
const typed = searchAssetCandidates(
candidates,
parseAssetSearchQuery("GameObject:Tank"),
);
assert.deepEqual(
typed.matches.map((m) => m.def.id),
["Tank", "TankPrototype"],
);
// Partial type names work as a filter as well.
const partialType = searchAssetCandidates(
candidates,
parseAssetSearchQuery("weapontemplate:assault"),
);
assert.equal(partialType.total, 1);
assert.equal(partialType.matches[0].def.id, "AssaultRifle");
// `Type:` lists every asset of that type.
const typeOnly = searchAssetCandidates(
candidates,
parseAssetSearchQuery("WeaponTemplate:"),
);
assert.equal(typeOnly.total, 2);
// A mismatching type excludes otherwise matching ids.
const mismatch = searchAssetCandidates(
candidates,
parseAssetSearchQuery("WeaponTemplate:Tank"),
);
assert.equal(mismatch.total, 1); // TankGun only (prefix), not GameObject Tank
assert.equal(mismatch.matches[0].def.id, "TankGun");
});
test("searchAssetCandidates ranks mod definitions before vanilla ones", () => {
const index = makeIndex({
assets: [
makeDef("WeaponTemplate", "AssaultRifle", `${SDK}/builtmods/static.manifest`, 0, {
origin: "manifest",
}),
makeDef("WeaponTemplate", "AssaultRifle", `${PROJECT}/Data/weapon.xml`, 7),
],
});
const candidates = collectAssetSearchCandidates(index);
const result = searchAssetCandidates(
candidates,
parseAssetSearchQuery("AssaultRifle"),
);
assert.equal(result.total, 1);
assert.equal(result.matches[0].def.origin, "project");
assert.equal(result.matches[0].definitionCount, 2);
});
test("searchAssetCandidates returns no rows for an empty query and caps results", () => {
const assets = [];
for (let i = 0; i < 25; i++) {
assets.push(
makeDef("GameObject", `Target_${String(i).padStart(2, "0")}`, "f", i + 1),
);
}
const candidates = collectAssetSearchCandidates(makeIndex({ assets }));
const empty = searchAssetCandidates(candidates, parseAssetSearchQuery(""));
assert.equal(empty.total, 0);
assert.equal(empty.matches.length, 0);
const all = searchAssetCandidates(candidates, parseAssetSearchQuery("Target"));
assert.equal(all.total, 25);
assert.equal(all.matches.length, 25);
const capped = searchAssetCandidates(
candidates,
parseAssetSearchQuery("Target"),
10,
);
assert.equal(capped.total, 25);
assert.equal(capped.matches.length, 10);
});
test("assetDefsForCandidate lists distinct sites, local overlay first", () => {
const index = makeIndex({
assets: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 3),
makeDef("GameObject", "Tank", `${SDK}/SageXml/units.xml`, 1, {
origin: "sdk",
}),
makeDef("GameObject", "Tank", `${SDK}/SageXml/units.xml`, 1, {
origin: "sdk",
}),
],
local: [
makeDef("GameObject", "Tank", `${PROJECT}/Data/units.xml`, 3, {
stream: "local",
}),
],
});
const candidate = collectAssetSearchCandidates(index).find(
(c) => c.def.id === "Tank",
);
const defs = assetDefsForCandidate(index, candidate);
assert.equal(defs.length, 2);
assert.equal(defs[0].stream, "local");
assert.equal(defs[1].origin, "sdk");
});