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"); });