|
7 | 7 | matchesMetadataKeys, |
8 | 8 | matchesMetadataValues, |
9 | 9 | searchDeclarations, |
| 10 | + fuzzyScore, |
10 | 11 | EMPTY_PARSED, |
11 | 12 | } from "./filtering"; |
12 | 13 | import type { SchemaClass, SchemaEnum } from "../data/types"; |
@@ -1247,18 +1248,18 @@ describe("search result ranking", () => { |
1247 | 1248 | expect(lastEffectIdx).toBeLessThan(firstFieldOnlyIdx); |
1248 | 1249 | }); |
1249 | 1250 |
|
1250 | | - it("alphabetical within same tier (sphere)", () => { |
| 1251 | + it("substring position affects ranking (sphere)", () => { |
1251 | 1252 | const result = searchDeclarations(declarations, parseSearch("sphere")); |
1252 | 1253 | const names = result.map((d) => d.name); |
1253 | | - // All are tier 2 (substring match), alphabetical by name then module |
| 1254 | + // Sorted by substring position (earlier = better), then alphabetical |
1254 | 1255 | expect(names).toEqual([ |
1255 | | - "CAnimationGraphVisualizerSphere", |
| 1256 | + "CastSphereSATParams_t", |
1256 | 1257 | "CNavVolumeSphere", |
1257 | | - "CSoundAreaEntitySphere", |
1258 | 1258 | "CSoundEventSphereEntity", |
1259 | | - "C_SoundAreaEntitySphere", |
1260 | 1259 | "C_SoundEventSphereEntity", |
1261 | | - "CastSphereSATParams_t", |
| 1260 | + "CSoundAreaEntitySphere", |
| 1261 | + "C_SoundAreaEntitySphere", |
| 1262 | + "CAnimationGraphVisualizerSphere", |
1262 | 1263 | ]); |
1263 | 1264 | }); |
1264 | 1265 |
|
@@ -1300,12 +1301,12 @@ describe("search result ranking", () => { |
1300 | 1301 | it("multi-word search", () => { |
1301 | 1302 | const result = searchDeclarations(declarations, parseSearch("sound sphere")); |
1302 | 1303 | const names = result.map((d) => d.name); |
1303 | | - // Only declarations matching both words |
| 1304 | + // Only declarations matching both words, scored by combined substring positions |
1304 | 1305 | expect(names).toEqual([ |
1305 | | - "CSoundAreaEntitySphere", |
1306 | 1306 | "CSoundEventSphereEntity", |
1307 | | - "C_SoundAreaEntitySphere", |
1308 | 1307 | "C_SoundEventSphereEntity", |
| 1308 | + "CSoundAreaEntitySphere", |
| 1309 | + "C_SoundAreaEntitySphere", |
1309 | 1310 | ]); |
1310 | 1311 | }); |
1311 | 1312 |
|
@@ -2846,3 +2847,161 @@ describe("boundary and edge cases", () => { |
2846 | 2847 | expect(result.every((d) => d.name !== "CEnvSoundscape")).toBe(true); |
2847 | 2848 | }); |
2848 | 2849 | }); |
| 2850 | + |
| 2851 | +// -- fuzzyScore -- |
| 2852 | + |
| 2853 | +describe("fuzzyScore", () => { |
| 2854 | + it("returns 0 for exact match", () => { |
| 2855 | + expect(fuzzyScore("cbaseentity", "CBaseEntity")).toBe(0); |
| 2856 | + }); |
| 2857 | + |
| 2858 | + it("returns 0 for exact match same length", () => { |
| 2859 | + expect(fuzzyScore("abc", "abc")).toBe(0); |
| 2860 | + }); |
| 2861 | + |
| 2862 | + it("returns prefix score for prefix match", () => { |
| 2863 | + const score = fuzzyScore("cbase", "CBaseEntity")!; |
| 2864 | + expect(score).toBeGreaterThanOrEqual(100); |
| 2865 | + expect(score).toBeLessThan(200); |
| 2866 | + }); |
| 2867 | + |
| 2868 | + it("shorter target ranks higher for prefix", () => { |
| 2869 | + const short = fuzzyScore("cbase", "CBaseEnt")!; |
| 2870 | + const long = fuzzyScore("cbase", "CBaseEntity")!; |
| 2871 | + expect(short).toBeLessThan(long); |
| 2872 | + }); |
| 2873 | + |
| 2874 | + it("returns substring score for contiguous substring", () => { |
| 2875 | + const score = fuzzyScore("entity", "CBaseEntity")!; |
| 2876 | + expect(score).toBeGreaterThanOrEqual(200); |
| 2877 | + expect(score).toBeLessThan(1000); |
| 2878 | + }); |
| 2879 | + |
| 2880 | + it("earlier substring position scores better", () => { |
| 2881 | + const early = fuzzyScore("base", "CBaseEntity")!; // index 1 |
| 2882 | + const late = fuzzyScore("base", "SomeClassBase")!; // index 9 |
| 2883 | + expect(early).toBeLessThan(late); |
| 2884 | + }); |
| 2885 | + |
| 2886 | + it("returns null for no match", () => { |
| 2887 | + expect(fuzzyScore("xyz", "CBaseEntity")).toBeNull(); |
| 2888 | + }); |
| 2889 | + |
| 2890 | + it("returns null for pattern longer than target", () => { |
| 2891 | + expect(fuzzyScore("cbaseentitylong", "CBase")).toBeNull(); |
| 2892 | + }); |
| 2893 | + |
| 2894 | + it("returns fuzzy score for non-contiguous match", () => { |
| 2895 | + const score = fuzzyScore("cbe", "CBaseEntity")!; |
| 2896 | + expect(score).toBeGreaterThanOrEqual(1000); |
| 2897 | + expect(score).toBeLessThan(5000); |
| 2898 | + }); |
| 2899 | + |
| 2900 | + it("does not fuzzy-match 1-char patterns", () => { |
| 2901 | + // 'c' exists in 'Base' but single chars are substring-only |
| 2902 | + expect(fuzzyScore("c", "Base")).toBeNull(); |
| 2903 | + }); |
| 2904 | + |
| 2905 | + it("does not fuzzy-match 2-char patterns", () => { |
| 2906 | + expect(fuzzyScore("cb", "CxxxxxByyy")).toBeNull(); |
| 2907 | + // But substring still works |
| 2908 | + expect(fuzzyScore("cb", "xcby")).toBe(201); |
| 2909 | + }); |
| 2910 | + |
| 2911 | + it("fuzzy matches 3+ char patterns", () => { |
| 2912 | + expect(fuzzyScore("cbe", "CBaseEntity")).not.toBeNull(); |
| 2913 | + }); |
| 2914 | + |
| 2915 | + it("boundary matches score better than scattered", () => { |
| 2916 | + // CBE -> CBaseEntity (all boundary hits: C, B, E) |
| 2917 | + const boundary = fuzzyScore("cbe", "CBaseEntity")!; |
| 2918 | + // cbe -> xCxxxBxxxxxExx (scattered) |
| 2919 | + const scattered = fuzzyScore("cbe", "xCxxxBxxxxxExx")!; |
| 2920 | + expect(boundary).toBeLessThan(scattered); |
| 2921 | + }); |
| 2922 | + |
| 2923 | + it("exact always beats prefix", () => { |
| 2924 | + const exact = fuzzyScore("cbase", "CBase")!; |
| 2925 | + const prefix = fuzzyScore("cbase", "CBaseEntity")!; |
| 2926 | + expect(exact).toBeLessThan(prefix); |
| 2927 | + }); |
| 2928 | + |
| 2929 | + it("prefix always beats substring", () => { |
| 2930 | + const prefix = fuzzyScore("base", "BaseEntity")!; |
| 2931 | + const substr = fuzzyScore("base", "CBaseEntity")!; |
| 2932 | + expect(prefix).toBeLessThan(substr); |
| 2933 | + }); |
| 2934 | + |
| 2935 | + it("substring always beats fuzzy", () => { |
| 2936 | + const substr = fuzzyScore("base", "CBaseEntity")!; |
| 2937 | + const fuz = fuzzyScore("bse", "CBaseEntity")!; |
| 2938 | + expect(substr).toBeLessThan(fuz); |
| 2939 | + }); |
| 2940 | + |
| 2941 | + it("matches camelCase boundaries", () => { |
| 2942 | + // "cswb" -> C_CSWeaponBase (C, S, W, B at boundaries) |
| 2943 | + const score = fuzzyScore("cswb", "C_CSWeaponBase"); |
| 2944 | + expect(score).not.toBeNull(); |
| 2945 | + expect(score!).toBeGreaterThanOrEqual(1000); |
| 2946 | + }); |
| 2947 | + |
| 2948 | + it("handles m_ prefix naturally", () => { |
| 2949 | + // "fl" is a substring of m_flFoo |
| 2950 | + const score = fuzzyScore("fl", "m_flFalloff"); |
| 2951 | + expect(score).not.toBeNull(); |
| 2952 | + expect(score!).toBeGreaterThanOrEqual(200); |
| 2953 | + expect(score!).toBeLessThan(1000); |
| 2954 | + }); |
| 2955 | + |
| 2956 | + it("handles initfromsnapshot pattern", () => { |
| 2957 | + const score = fuzzyScore("initfromsnapshot", "C_INIT_InitFromCPSnapshot"); |
| 2958 | + expect(score).not.toBeNull(); |
| 2959 | + expect(score!).toBeGreaterThanOrEqual(1000); |
| 2960 | + }); |
| 2961 | + |
| 2962 | + it("case-insensitive matching", () => { |
| 2963 | + expect(fuzzyScore("cbase", "CBASE")).toBe(0); |
| 2964 | + expect(fuzzyScore("cbase", "cbase")).toBe(0); |
| 2965 | + }); |
| 2966 | + |
| 2967 | + it("empty pattern returns 0", () => { |
| 2968 | + expect(fuzzyScore("", "anything")).toBe(0); |
| 2969 | + }); |
| 2970 | +}); |
| 2971 | + |
| 2972 | +// -- fuzzy search integration -- |
| 2973 | + |
| 2974 | +describe("fuzzy search integration", () => { |
| 2975 | + it("fuzzy query finds declarations with non-contiguous match", () => { |
| 2976 | + const result = searchDeclarations(declarations, parseSearch("cnvsph")); |
| 2977 | + const names = result.map((d) => d.name); |
| 2978 | + expect(names).toContain("CNavVolumeSphere"); |
| 2979 | + }); |
| 2980 | + |
| 2981 | + it("exact match ranks above fuzzy match", () => { |
| 2982 | + const result = searchDeclarations(declarations, parseSearch("CEffectData")); |
| 2983 | + expect(result[0].name).toBe("CEffectData"); |
| 2984 | + }); |
| 2985 | + |
| 2986 | + it("prefix ranks above substring which ranks above fuzzy", () => { |
| 2987 | + const result = searchDeclarations(declarations, parseSearch("CFilter")); |
| 2988 | + const names = result.map((d) => d.name); |
| 2989 | + // Both CFilterEnemy and CFilterProximity are prefix matches, shorter name scores better |
| 2990 | + const enemyIdx = names.indexOf("CFilterEnemy"); |
| 2991 | + const proxIdx = names.indexOf("CFilterProximity"); |
| 2992 | + expect(enemyIdx).toBeLessThan(proxIdx); |
| 2993 | + // Fuzzy match (C_OP_RemapTransformVisibilityToVector) ranks after prefix matches |
| 2994 | + const fuzzyIdx = names.indexOf("C_OP_RemapTransformVisibilityToVector"); |
| 2995 | + if (fuzzyIdx >= 0) { |
| 2996 | + expect(proxIdx).toBeLessThan(fuzzyIdx); |
| 2997 | + } |
| 2998 | + }); |
| 2999 | + |
| 3000 | + it("two-char query uses substring only, no fuzzy", () => { |
| 3001 | + // "cb" with 2 chars: fuzzyScore returns null for non-substring matches |
| 3002 | + // But field-level substring matching can still find "cb" in field names like m_CBodyComponent |
| 3003 | + const result = searchDeclarations(declarations, parseSearch("cb")); |
| 3004 | + // All results should have "cb" somewhere — in declaration name or in a field/member name |
| 3005 | + expect(result.length).toBeGreaterThan(0); |
| 3006 | + }); |
| 3007 | +}); |
0 commit comments