mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-08 03:08:13 +00:00
test(bm25): add unit tests for top-3 aggregation and nodeIds propagation
Covers the new logic paths added in the previous commit: - top-3 score aggregation (file with 5+ matches → only top-3 contribute) - nodeIds propagation through BM25SearchResult - empty nodeId filtering - cross-table merge for the same file - result ranking by aggregated score Also fixes in-place entries.sort() mutation (bm25-index.ts:125) to use [...entries].sort() so the Map value is not silently modified.
This commit is contained in:
parent
aac7c36918
commit
6e02766b2d
2 changed files with 127 additions and 2 deletions
|
|
@ -122,7 +122,7 @@ export const searchFTSFromLbug = async (
|
|||
// matches (e.g. test files) over files with a single highly-relevant symbol.
|
||||
const merged = new Map<string, { filePath: string; score: number; nodeIds: string[] }>();
|
||||
for (const [filePath, entries] of fileNodeScores) {
|
||||
const top3 = entries.sort((a, b) => b.score - a.score).slice(0, 3);
|
||||
const top3 = [...entries].sort((a, b) => b.score - a.score).slice(0, 3);
|
||||
merged.set(filePath, {
|
||||
filePath,
|
||||
score: top3.reduce((acc, e) => acc + e.score, 0),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { searchFTSFromLbug, type BM25SearchResult } from '../../src/core/search/bm25-index.js';
|
||||
|
||||
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
||||
queryFTS: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
describe('BM25 search', () => {
|
||||
describe('searchFTSFromLbug', () => {
|
||||
it('returns empty array when LadybugDB is not initialized', async () => {
|
||||
|
|
@ -32,5 +36,126 @@ describe('BM25 search', () => {
|
|||
expect(result.score).toBe(1.5);
|
||||
expect(result.rank).toBe(1);
|
||||
});
|
||||
|
||||
it('accepts optional nodeIds field', () => {
|
||||
const result: BM25SearchResult = {
|
||||
filePath: 'src/index.ts',
|
||||
score: 1.5,
|
||||
rank: 1,
|
||||
nodeIds: ['func:id1', 'func:id2'],
|
||||
};
|
||||
expect(result.nodeIds).toEqual(['func:id1', 'func:id2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('score aggregation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('sums only top-3 scoring nodes per file when more than 3 match', async () => {
|
||||
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
// File table: empty; Function table: 5 hits for the same file; rest: empty
|
||||
vi.mocked(queryFTS)
|
||||
.mockResolvedValueOnce([]) // File
|
||||
.mockResolvedValueOnce([ // Function — 5 hits, scores 10/9/8/7/6
|
||||
{ filePath: 'src/views.py', score: 10, nodeId: 'func:node1', name: 'get_queryset' },
|
||||
{ filePath: 'src/views.py', score: 9, nodeId: 'func:node2', name: 'post' },
|
||||
{ filePath: 'src/views.py', score: 8, nodeId: 'func:node3', name: 'delete' },
|
||||
{ filePath: 'src/views.py', score: 7, nodeId: 'func:node4', name: 'patch' },
|
||||
{ filePath: 'src/views.py', score: 6, nodeId: 'func:node5', name: 'put' },
|
||||
])
|
||||
.mockResolvedValueOnce([]) // Class
|
||||
.mockResolvedValueOnce([]) // Method
|
||||
.mockResolvedValueOnce([]); // Interface
|
||||
|
||||
const results = await searchFTSFromLbug('queryset');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].filePath).toBe('src/views.py');
|
||||
// Only top-3 scores (10+9+8=27), not naive sum of all 5 (10+9+8+7+6=40)
|
||||
expect(results[0].score).toBe(27);
|
||||
expect(results[0].nodeIds).toEqual(['func:node1', 'func:node2', 'func:node3']);
|
||||
});
|
||||
|
||||
it('propagates nodeIds for files with fewer than 3 matching nodes', async () => {
|
||||
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
vi.mocked(queryFTS)
|
||||
.mockResolvedValueOnce([]) // File
|
||||
.mockResolvedValueOnce([ // Function — 2 hits
|
||||
{ filePath: 'src/models.py', score: 5, nodeId: 'func:m1', name: 'save' },
|
||||
{ filePath: 'src/models.py', score: 3, nodeId: 'func:m2', name: 'delete' },
|
||||
])
|
||||
.mockResolvedValueOnce([]) // Class
|
||||
.mockResolvedValueOnce([]) // Method
|
||||
.mockResolvedValueOnce([]); // Interface
|
||||
|
||||
const results = await searchFTSFromLbug('model');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].score).toBe(8); // 5+3
|
||||
expect(results[0].nodeIds).toEqual(['func:m1', 'func:m2']);
|
||||
});
|
||||
|
||||
it('filters out empty nodeIds', async () => {
|
||||
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
vi.mocked(queryFTS)
|
||||
.mockResolvedValueOnce([]) // File
|
||||
.mockResolvedValueOnce([ // Function — nodes with no id
|
||||
{ filePath: 'src/utils.py', score: 5, nodeId: '', name: 'helper' },
|
||||
{ filePath: 'src/utils.py', score: 3, nodeId: '', name: 'util' },
|
||||
])
|
||||
.mockResolvedValueOnce([]) // Class
|
||||
.mockResolvedValueOnce([]) // Method
|
||||
.mockResolvedValueOnce([]); // Interface
|
||||
|
||||
const results = await searchFTSFromLbug('util');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].nodeIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('merges hits across multiple index tables for the same file', async () => {
|
||||
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
vi.mocked(queryFTS)
|
||||
.mockResolvedValueOnce([ // File table
|
||||
{ filePath: 'src/auth.py', score: 4, nodeId: 'file:auth', name: 'auth.py' },
|
||||
])
|
||||
.mockResolvedValueOnce([ // Function table
|
||||
{ filePath: 'src/auth.py', score: 9, nodeId: 'func:login', name: 'login' },
|
||||
])
|
||||
.mockResolvedValueOnce([ // Class table
|
||||
{ filePath: 'src/auth.py', score: 7, nodeId: 'cls:User', name: 'User' },
|
||||
])
|
||||
.mockResolvedValueOnce([]) // Method
|
||||
.mockResolvedValueOnce([]); // Interface
|
||||
|
||||
const results = await searchFTSFromLbug('auth');
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
// All 3 hits (scores 9+7+4=20) — each from a different table, all top-3
|
||||
expect(results[0].score).toBe(20);
|
||||
expect(results[0].nodeIds).toEqual(['func:login', 'cls:User', 'file:auth']);
|
||||
});
|
||||
|
||||
it('ranks files by aggregated score descending', async () => {
|
||||
const { queryFTS } = await import('../../src/core/lbug/lbug-adapter.js');
|
||||
vi.mocked(queryFTS)
|
||||
.mockResolvedValueOnce([]) // File
|
||||
.mockResolvedValueOnce([ // Function — hits across two files
|
||||
{ filePath: 'src/low.py', score: 2, nodeId: 'func:a', name: 'a' },
|
||||
{ filePath: 'src/high.py', score: 9, nodeId: 'func:b', name: 'b' },
|
||||
])
|
||||
.mockResolvedValueOnce([]) // Class
|
||||
.mockResolvedValueOnce([]) // Method
|
||||
.mockResolvedValueOnce([]); // Interface
|
||||
|
||||
const results = await searchFTSFromLbug('fn');
|
||||
|
||||
expect(results[0].filePath).toBe('src/high.py');
|
||||
expect(results[1].filePath).toBe('src/low.py');
|
||||
expect(results[0].rank).toBe(1);
|
||||
expect(results[1].rank).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue