mirror of
https://github.com/giancarloerra/socraticode.git
synced 2026-07-03 14:05:21 +02:00
f745d59ddd
- Scope dedupe key to label::relativePath in mergeMultiCollectionResults so same file in different projects is not collapsed - Compute embedding once in searchMultipleCollections via internal searchChunksWithVector helper (avoids N redundant API calls) - Compare base project hashes (not branch-suffixed names) when skipping duplicate linked projects in resolveLinkedCollections - Use path.resolve() for platform-neutral assertion in query-tools tests - Update multi-collection-search tests for new cross-project dedup semantics
272 lines
8.4 KiB
TypeScript
272 lines
8.4 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-only
|
|
// Copyright (C) 2026 Giancarlo Erra - Altaire Limited
|
|
|
|
/**
|
|
* Unit tests for mergeMultiCollectionResults — client-side RRF fusion
|
|
* and deduplication across multiple collection result sets.
|
|
* Tests the pure function directly (no mocks needed).
|
|
*/
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { mergeMultiCollectionResults } from "../../src/services/qdrant.js";
|
|
import type { SearchResult } from "../../src/types.js";
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
|
|
function makeResult(relativePath: string, score: number, overrides?: Partial<SearchResult>): SearchResult {
|
|
return {
|
|
filePath: `/project/${relativePath}`,
|
|
relativePath,
|
|
content: `content of ${relativePath}`,
|
|
startLine: 1,
|
|
endLine: 10,
|
|
language: "typescript",
|
|
score,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────
|
|
|
|
describe("mergeMultiCollectionResults", () => {
|
|
it("returns empty array for empty input", () => {
|
|
const results = mergeMultiCollectionResults([], 10);
|
|
expect(results).toEqual([]);
|
|
});
|
|
|
|
it("returns results from a single collection with project label", () => {
|
|
const results = mergeMultiCollectionResults(
|
|
[
|
|
{
|
|
label: "my-project",
|
|
results: [
|
|
makeResult("src/index.ts", 0.85),
|
|
makeResult("src/utils.ts", 0.72),
|
|
],
|
|
},
|
|
],
|
|
10,
|
|
);
|
|
|
|
expect(results).toHaveLength(2);
|
|
expect(results[0].project).toBe("my-project");
|
|
expect(results[1].project).toBe("my-project");
|
|
expect(results[0].relativePath).toBe("src/index.ts");
|
|
expect(results[1].relativePath).toBe("src/utils.ts");
|
|
});
|
|
|
|
it("merges results from two collections — all unique files present", () => {
|
|
const results = mergeMultiCollectionResults(
|
|
[
|
|
{
|
|
label: "project-a",
|
|
results: [
|
|
makeResult("src/file1.ts", 0.90),
|
|
makeResult("src/file2.ts", 0.80),
|
|
],
|
|
},
|
|
{
|
|
label: "project-b",
|
|
results: [
|
|
makeResult("src/file3.ts", 0.95),
|
|
makeResult("src/file4.ts", 0.70),
|
|
],
|
|
},
|
|
],
|
|
10,
|
|
);
|
|
|
|
expect(results).toHaveLength(4);
|
|
const paths = results.map((r) => r.relativePath);
|
|
expect(paths).toContain("src/file1.ts");
|
|
expect(paths).toContain("src/file2.ts");
|
|
expect(paths).toContain("src/file3.ts");
|
|
expect(paths).toContain("src/file4.ts");
|
|
|
|
// Correct project labels
|
|
expect(results.find((r) => r.relativePath === "src/file1.ts")?.project).toBe("project-a");
|
|
expect(results.find((r) => r.relativePath === "src/file3.ts")?.project).toBe("project-b");
|
|
});
|
|
|
|
it("keeps same relativePath from different projects as separate entries", () => {
|
|
const results = mergeMultiCollectionResults(
|
|
[
|
|
{
|
|
label: "project-a",
|
|
results: [makeResult("src/shared.ts", 0.90, { content: "content from A" })],
|
|
},
|
|
{
|
|
label: "project-b",
|
|
results: [makeResult("src/shared.ts", 0.85, { content: "content from B" })],
|
|
},
|
|
],
|
|
10,
|
|
);
|
|
|
|
// Dedupe key is label::relativePath, so same file in different projects stays separate
|
|
const sharedResults = results.filter((r) => r.relativePath === "src/shared.ts");
|
|
expect(sharedResults).toHaveLength(2);
|
|
expect(sharedResults.map((r) => r.project)).toEqual(
|
|
expect.arrayContaining(["project-a", "project-b"]),
|
|
);
|
|
});
|
|
|
|
it("deduplicates same relativePath within the same project", () => {
|
|
const results = mergeMultiCollectionResults(
|
|
[
|
|
{
|
|
label: "project-a",
|
|
results: [
|
|
makeResult("src/shared.ts", 0.90, { content: "first chunk" }),
|
|
makeResult("src/shared.ts", 0.80, { content: "second chunk" }),
|
|
],
|
|
},
|
|
],
|
|
10,
|
|
);
|
|
|
|
// Same label + same relativePath → deduplicated, first (higher-ranked) wins
|
|
const sharedResults = results.filter((r) => r.relativePath === "src/shared.ts");
|
|
expect(sharedResults).toHaveLength(1);
|
|
expect(sharedResults[0].content).toBe("first chunk");
|
|
});
|
|
|
|
it("same relativePath from different projects has independent RRF scores", () => {
|
|
const results = mergeMultiCollectionResults(
|
|
[
|
|
{
|
|
label: "project-a",
|
|
results: [
|
|
makeResult("src/popular.ts", 0.90),
|
|
makeResult("src/only-a.ts", 0.80),
|
|
],
|
|
},
|
|
{
|
|
label: "project-b",
|
|
results: [
|
|
makeResult("src/popular.ts", 0.85),
|
|
makeResult("src/only-b.ts", 0.75),
|
|
],
|
|
},
|
|
],
|
|
10,
|
|
);
|
|
|
|
// With label-scoped keys, src/popular.ts from A and B are separate entries
|
|
const popularResults = results.filter((r) => r.relativePath === "src/popular.ts");
|
|
expect(popularResults).toHaveLength(2);
|
|
|
|
// Each has a single-collection RRF score: 1/(60+rank)
|
|
// project-a's popular.ts is rank 1 → 1/61
|
|
// project-b's popular.ts is rank 1 → 1/61
|
|
const expectedSingle = 1 / 61;
|
|
for (const r of popularResults) {
|
|
expect(r.score).toBeCloseTo(expectedSingle, 6);
|
|
}
|
|
});
|
|
|
|
it("respects the limit parameter", () => {
|
|
const results = mergeMultiCollectionResults(
|
|
[
|
|
{
|
|
label: "a",
|
|
results: [
|
|
makeResult("src/a1.ts", 0.9),
|
|
makeResult("src/a2.ts", 0.8),
|
|
makeResult("src/a3.ts", 0.7),
|
|
],
|
|
},
|
|
{
|
|
label: "b",
|
|
results: [
|
|
makeResult("src/b1.ts", 0.95),
|
|
makeResult("src/b2.ts", 0.85),
|
|
makeResult("src/b3.ts", 0.75),
|
|
],
|
|
},
|
|
],
|
|
3,
|
|
);
|
|
|
|
expect(results).toHaveLength(3);
|
|
});
|
|
|
|
it("results are sorted by fused RRF score descending", () => {
|
|
const results = mergeMultiCollectionResults(
|
|
[
|
|
{
|
|
label: "a",
|
|
results: [
|
|
makeResult("src/first.ts", 0.90),
|
|
makeResult("src/second.ts", 0.80),
|
|
],
|
|
},
|
|
{
|
|
label: "b",
|
|
results: [
|
|
makeResult("src/third.ts", 0.95),
|
|
],
|
|
},
|
|
],
|
|
10,
|
|
);
|
|
|
|
for (let i = 1; i < results.length; i++) {
|
|
expect(results[i - 1].score).toBeGreaterThanOrEqual(results[i].score);
|
|
}
|
|
});
|
|
|
|
it("handles three collections", () => {
|
|
const results = mergeMultiCollectionResults(
|
|
[
|
|
{ label: "a", results: [makeResult("src/a.ts", 0.9)] },
|
|
{ label: "b", results: [makeResult("src/b.ts", 0.8)] },
|
|
{ label: "c", results: [makeResult("src/c.ts", 0.7)] },
|
|
],
|
|
10,
|
|
);
|
|
|
|
expect(results).toHaveLength(3);
|
|
expect(results.map((r) => r.project)).toEqual(
|
|
expect.arrayContaining(["a", "b", "c"]),
|
|
);
|
|
});
|
|
|
|
it("handles empty results from some collections", () => {
|
|
const results = mergeMultiCollectionResults(
|
|
[
|
|
{ label: "a", results: [makeResult("src/a.ts", 0.9)] },
|
|
{ label: "b", results: [] },
|
|
{ label: "c", results: [makeResult("src/c.ts", 0.7)] },
|
|
],
|
|
10,
|
|
);
|
|
|
|
expect(results).toHaveLength(2);
|
|
expect(results.map((r) => r.project)).toEqual(
|
|
expect.arrayContaining(["a", "c"]),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ── searchMultipleCollections tests ─────────────────────────────────────
|
|
// searchMultipleCollections calls searchChunks internally (same module),
|
|
// so vi.mock cannot intercept those calls. The pure merge logic is
|
|
// thoroughly tested above (9 tests). The orchestration layer (includeLinked
|
|
// → resolveLinkedCollections → searchMultipleCollections) is tested via
|
|
// query-tools.test.ts which mocks at the module boundary.
|
|
// Here we only test the empty-input short-circuit which needs no Qdrant.
|
|
|
|
vi.mock("../../src/services/logger.js", () => ({
|
|
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
}));
|
|
|
|
const { searchMultipleCollections } = await import("../../src/services/qdrant.js");
|
|
|
|
describe("searchMultipleCollections", () => {
|
|
it("returns empty array when collections array is empty", async () => {
|
|
const results = await searchMultipleCollections([], "test", 10);
|
|
expect(results).toEqual([]);
|
|
});
|
|
});
|