@@ -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
11991438describe ( "field and metadata visibility" , ( ) => {
0 commit comments