Skip to content

Commit e1b85b2

Browse files
committed
Add sorting to search
1 parent 5ea8189 commit e1b85b2

2 files changed

Lines changed: 340 additions & 45 deletions

File tree

src/utils/filtering.test.ts

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,6 +1194,245 @@ describe("searchDeclarations — field filtering", () => {
11941194
});
11951195
});
11961196

1197+
// -- Search result ranking --
1198+
1199+
describe("search result ranking", () => {
1200+
it("exact name match ranks first", () => {
1201+
const result = searchDeclarations(declarations, parseSearch("CFuncWater"));
1202+
expect(result.length).toBeGreaterThan(0);
1203+
expect(result[0].name).toBe("CFuncWater");
1204+
expect(result[1].name).toBe("CFuncWater");
1205+
});
1206+
1207+
it("exact match is case-insensitive", () => {
1208+
const result = searchDeclarations(declarations, parseSearch("cfuncwater"));
1209+
expect(result[0].name).toBe("CFuncWater");
1210+
expect(result[1].name).toBe("CFuncWater");
1211+
});
1212+
1213+
it("starts-with ranks above substring", () => {
1214+
const result = searchDeclarations(declarations, parseSearch("CFilter"));
1215+
const names = result.map((d) => d.name);
1216+
// CFilterEnemy and CFilterProximity start with "cfilter"
1217+
expect(names[0]).toBe("CFilterEnemy");
1218+
expect(names[1]).toBe("CFilterProximity");
1219+
expect(names[2]).toBe("CFilterProximity");
1220+
});
1221+
1222+
it("declaration-level name match above field-only match (water)", () => {
1223+
const result = searchDeclarations(declarations, parseSearch("water"));
1224+
const names = result.map((d) => d.name);
1225+
// Name matches: C_INIT_CheckParticleForWater, C_OP_WaterImpulseRenderer, CFuncWater (x2)
1226+
const nameMatches = ["C_INIT_CheckParticleForWater", "C_OP_WaterImpulseRenderer", "CFuncWater"];
1227+
// Field-only: C_BaseEntity, C_Fish
1228+
const fieldOnly = ["C_BaseEntity", "C_Fish"];
1229+
1230+
// All name matches should come before all field-only matches
1231+
const lastNameMatchIdx = Math.max(...nameMatches.map((n) => names.lastIndexOf(n)));
1232+
const firstFieldOnlyIdx = Math.min(
1233+
...fieldOnly.map((n) => names.indexOf(n)).filter((i) => i >= 0),
1234+
);
1235+
expect(lastNameMatchIdx).toBeLessThan(firstFieldOnlyIdx);
1236+
});
1237+
1238+
it("declaration-level name match above field-only match (effect)", () => {
1239+
const result = searchDeclarations(declarations, parseSearch("effect"));
1240+
const names = result.map((d) => d.name);
1241+
// CEffectData (client + server) should come before field-only matches
1242+
const lastEffectIdx = names.lastIndexOf("CEffectData");
1243+
const fieldOnly = [
1244+
"C_BaseEntity",
1245+
"C_PathParticleRope",
1246+
"CPathParticleRope",
1247+
"CScriptedSequence",
1248+
];
1249+
const firstFieldOnlyIdx = Math.min(
1250+
...fieldOnly.map((n) => names.indexOf(n)).filter((i) => i >= 0),
1251+
);
1252+
expect(lastEffectIdx).toBeLessThan(firstFieldOnlyIdx);
1253+
});
1254+
1255+
it("alphabetical within same tier (sphere)", () => {
1256+
const result = searchDeclarations(declarations, parseSearch("sphere"));
1257+
const names = result.map((d) => d.name);
1258+
// All are tier 2 (substring match), alphabetical by name then module
1259+
expect(names).toEqual([
1260+
"CAnimationGraphVisualizerSphere",
1261+
"CNavVolumeSphere",
1262+
"CSoundAreaEntitySphere",
1263+
"CSoundEventSphereEntity",
1264+
"C_SoundAreaEntitySphere",
1265+
"C_SoundEventSphereEntity",
1266+
"CastSphereSATParams_t",
1267+
]);
1268+
});
1269+
1270+
it("alphabetical within tier, module as tiebreaker", () => {
1271+
const result = searchDeclarations(declarations, parseSearch("CFuncWater"));
1272+
// Both are exact matches (tier 0), same name → sorted by module
1273+
expect(result[0].module).toBe("client");
1274+
expect(result[1].module).toBe("server");
1275+
});
1276+
1277+
it("same result set regardless of ranking", () => {
1278+
const result = searchDeclarations(declarations, parseSearch("water"));
1279+
// Verify all expected names are present (ranking may reorder them)
1280+
const nameSet = new Set(result.map((d) => d.name));
1281+
expect(nameSet).toEqual(
1282+
new Set([
1283+
"C_BaseEntity",
1284+
"C_Fish",
1285+
"C_INIT_CheckParticleForWater",
1286+
"C_OP_WaterImpulseRenderer",
1287+
"CFuncWater",
1288+
]),
1289+
);
1290+
expect(result).toHaveLength(6); // CFuncWater appears in client + server
1291+
});
1292+
1293+
it("module-only filter preserves tier ordering", () => {
1294+
const result = searchDeclarations(declarations, parseSearch("water module:client"));
1295+
const names = result.map((d) => d.name);
1296+
// Client name matches: CFuncWater
1297+
// Client field-only: C_BaseEntity, C_Fish
1298+
const cfuncIdx = names.indexOf("CFuncWater");
1299+
const baseEntityIdx = names.indexOf("C_BaseEntity");
1300+
const fishIdx = names.indexOf("C_Fish");
1301+
expect(cfuncIdx).toBeLessThan(baseEntityIdx);
1302+
expect(cfuncIdx).toBeLessThan(fishIdx);
1303+
});
1304+
1305+
it("multi-word search", () => {
1306+
const result = searchDeclarations(declarations, parseSearch("sound sphere"));
1307+
const names = result.map((d) => d.name);
1308+
// Only declarations matching both words
1309+
expect(names).toEqual([
1310+
"CSoundAreaEntitySphere",
1311+
"CSoundEventSphereEntity",
1312+
"C_SoundAreaEntitySphere",
1313+
"C_SoundEventSphereEntity",
1314+
]);
1315+
});
1316+
1317+
it("enum exact match ranks first", () => {
1318+
const result = searchDeclarations(declarations, parseSearch("PulseTestEnumColor_t"));
1319+
expect(result).toHaveLength(1);
1320+
expect(result[0].name).toBe("PulseTestEnumColor_t");
1321+
});
1322+
1323+
it("enum starts-with", () => {
1324+
const result = searchDeclarations(declarations, parseSearch("Pulse"));
1325+
// PulseCursorCancelPriority_t and PulseTestEnumColor_t start with "pulse" (tier 1)
1326+
// C_OP_RenderClientPhysicsImpulse and C_OP_WaterImpulseRenderer contain "pulse" (tier 2)
1327+
const names = result.map((d) => d.name);
1328+
expect(names.indexOf("PulseCursorCancelPriority_t")).toBeLessThan(
1329+
names.indexOf("C_OP_RenderClientPhysicsImpulse"),
1330+
);
1331+
expect(names.indexOf("PulseTestEnumColor_t")).toBeLessThan(
1332+
names.indexOf("C_OP_WaterImpulseRenderer"),
1333+
);
1334+
});
1335+
1336+
it("field-only results alphabetical", () => {
1337+
const result = searchDeclarations(declarations, parseSearch("water"));
1338+
// Among field-only matches: C_BaseEntity before C_Fish
1339+
const fieldOnly = result.filter((d) => !d.name.toLowerCase().includes("water"));
1340+
expect(fieldOnly[0].name).toBe("C_BaseEntity");
1341+
expect(fieldOnly[1].name).toBe("C_Fish");
1342+
});
1343+
1344+
it("no name words (module-only) results are alphabetical", () => {
1345+
const result = searchDeclarations(declarations, parseSearch("module:client"));
1346+
const names = result.map((d) => d.name);
1347+
// All get score 2, sorted alphabetically (ASCII order: uppercase before _)
1348+
expect(names).toEqual([
1349+
"CEffectData",
1350+
"CEnvSoundscape",
1351+
"CFilterProximity",
1352+
"CFuncWater",
1353+
"C_BaseEntity",
1354+
"C_CSWeaponBaseGun",
1355+
"C_Fish",
1356+
"C_FuncTrackTrain",
1357+
"C_Hostage",
1358+
"C_PathParticleRope",
1359+
"C_RectLight",
1360+
"C_SoundAreaEntitySphere",
1361+
"C_SoundEventSphereEntity",
1362+
"DOTA_UNIT_TARGET_TEAM",
1363+
"ragdollelement_t",
1364+
"sky3dparams_t",
1365+
]);
1366+
});
1367+
1368+
it("empty result stays empty", () => {
1369+
const result = searchDeclarations(declarations, parseSearch("xyzzy999qqq"));
1370+
expect(result).toEqual([]);
1371+
});
1372+
1373+
it("single result unchanged", () => {
1374+
const result = searchDeclarations(declarations, parseSearch("PulseTestEnumColor_t"));
1375+
expect(result).toHaveLength(1);
1376+
expect(result[0].name).toBe("PulseTestEnumColor_t");
1377+
});
1378+
1379+
it("metadata filter doesn't affect tier", () => {
1380+
const result = searchDeclarations(
1381+
declarations,
1382+
parseSearch("CEnvSoundscape metadata:MNotSaved"),
1383+
);
1384+
// CEnvSoundscape matches by name (tier 0 exact) — metadata just filters fields
1385+
expect(result.length).toBe(2);
1386+
expect(result[0].name).toBe("CEnvSoundscape");
1387+
expect(result[1].name).toBe("CEnvSoundscape");
1388+
});
1389+
1390+
it("starts-with score preserved when offset forces field path", () => {
1391+
// "CFlashbang" starts-with match on CFlashbangProjectile (score 1),
1392+
// offset:2992 forces field-level filtering but shouldn't push score to 3
1393+
const result = searchDeclarations(declarations, parseSearch("CFlashbang offset:2992"));
1394+
expect(result).toHaveLength(1);
1395+
expect(result[0].name).toBe("CFlashbangProjectile");
1396+
// Verify it has filtered fields (field path was used)
1397+
expect((result[0] as SchemaClass).fields.length).toBeGreaterThan(0);
1398+
});
1399+
1400+
it("starts-with score preserved when metadata forces field path", () => {
1401+
// "C_CSWeapon" starts-with match on C_CSWeaponBaseGun (score 1),
1402+
// metadata forces field-level filtering
1403+
const result = searchDeclarations(
1404+
declarations,
1405+
parseSearch("C_CSWeapon metadata:MNetworkEnable"),
1406+
);
1407+
expect(result).toHaveLength(1);
1408+
expect(result[0].name).toBe("C_CSWeaponBaseGun");
1409+
expect((result[0] as SchemaClass).fields.length).toBeGreaterThan(0);
1410+
});
1411+
1412+
it("mixed name+field multi-word query gets field-only score", () => {
1413+
// "weapon" matches C_CSWeaponBaseGun name, "zoom" matches field → score 3
1414+
// Should rank below a pure name match if both were in results
1415+
const result = searchDeclarations(declarations, parseSearch("weapon zoom"));
1416+
expect(result).toHaveLength(1);
1417+
expect(result[0].name).toBe("C_CSWeaponBaseGun");
1418+
expect((result[0] as SchemaClass).fields).toHaveLength(1);
1419+
expect((result[0] as SchemaClass).fields[0].name).toBe("m_zoomLevel");
1420+
});
1421+
1422+
it("field-only metadata results ranked below name matches", () => {
1423+
// "water" + metadata:MNetworkEnable → only field-only matches survive
1424+
// (CFuncWater has no MNetworkEnable fields, so it's excluded)
1425+
const result = searchDeclarations(declarations, parseSearch("water metadata:MNetworkEnable"));
1426+
expect(result.length).toBeGreaterThan(0);
1427+
// All results are field-only matches (score 3)
1428+
expect(
1429+
result.every(
1430+
(d) => !d.name.toLowerCase().includes("water") || (d as SchemaClass).fields.length > 0,
1431+
),
1432+
).toBe(true);
1433+
});
1434+
});
1435+
11971436
// -- Exhaustive visible/hidden checks --
11981437

11991438
describe("field and metadata visibility", () => {

0 commit comments

Comments
 (0)