Fix all remaining unit test failures and add hierarchical context test
**Test Fixes:** - Fixed tilde expansion test to create collection with home directory path - Fixed test expectations for displayPath vs filepath separation - Fixed MCP test config to use isolated YAML config directory - Fixed MCP mock to return correct logprobs format - Fixed qmd_query test to use r.filepath instead of r.file - Fixed CLI multi-get test to use fresh database for isolation - Fixed multiGet function to parse filepath (virtual) instead of displayPath **Bug Fixes:** - Fixed multiGet to use virtual paths for parsing collection/path info - Fixed findDocuments selectCols to separate virtual_path and display_path - Fixed context loading in findDocuments to use virtual paths **New Test:** - Added hierarchical context test verifying global + collection + path contexts are all included and joined with double newlines **Results:** 261 passing / 0 failing (100% pass rate) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3d3aed0d4b
commit
49b2cef85c
@ -289,13 +289,21 @@ describe("CLI Get Command", () => {
|
||||
});
|
||||
|
||||
describe("CLI Multi-Get Command", () => {
|
||||
let localDbPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Use fresh database for each test
|
||||
localDbPath = getFreshDbPath();
|
||||
// Ensure we have indexed files
|
||||
await runQmd(["collection", "add", "."]);
|
||||
const addResult = await runQmd(["collection", "add", ".", "--name", "fixtures"], { dbPath: localDbPath });
|
||||
if (addResult.exitCode !== 0) {
|
||||
throw new Error(`Failed to add collection: ${addResult.stderr}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("retrieves multiple documents by pattern", async () => {
|
||||
const { stdout, exitCode } = await runQmd(["multi-get", "notes/*.md"]);
|
||||
// Test glob pattern matching
|
||||
const { stdout, stderr, exitCode } = await runQmd(["multi-get", "notes/*.md"], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(0);
|
||||
// Should contain content from both notes files
|
||||
expect(stdout).toContain("Meeting");
|
||||
@ -306,7 +314,7 @@ describe("CLI Multi-Get Command", () => {
|
||||
const { stdout, exitCode } = await runQmd([
|
||||
"multi-get",
|
||||
"README.md,notes/meeting.md",
|
||||
]);
|
||||
], { dbPath: localDbPath });
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("Test Project");
|
||||
expect(stdout).toContain("Team Meeting");
|
||||
|
||||
@ -11,6 +11,11 @@ import * as sqliteVec from "sqlite-vec";
|
||||
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { z } from "zod";
|
||||
import { setDefaultOllama, Ollama } from "./llm";
|
||||
import { mkdtemp, writeFile, readdir, unlink, rmdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import YAML from "yaml";
|
||||
import type { CollectionConfig } from "./collections";
|
||||
|
||||
// =============================================================================
|
||||
// Mock Ollama
|
||||
@ -28,12 +33,13 @@ const mockOllamaResponses: Record<string, (body: unknown) => Response> = {
|
||||
});
|
||||
},
|
||||
"/api/generate": (body: unknown) => {
|
||||
const reqBody = body as { prompt?: string };
|
||||
const reqBody = body as { prompt?: string; logprobs?: boolean };
|
||||
if (reqBody.prompt?.includes("Judge") || reqBody.prompt?.includes("Document")) {
|
||||
// Return format matching Ollama API
|
||||
return new Response(JSON.stringify({
|
||||
response: "yes",
|
||||
done: true,
|
||||
logprobs: { tokens: ["yes"], token_logprobs: [-0.1] },
|
||||
logprobs: reqBody.logprobs ? { tokens: ["yes"], token_logprobs: [-0.1] } : undefined
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
} else {
|
||||
return new Response(JSON.stringify({
|
||||
@ -72,6 +78,7 @@ function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Respon
|
||||
|
||||
let testDb: Database;
|
||||
let testDbPath: string;
|
||||
let testConfigDir: string;
|
||||
|
||||
function initTestDatabase(db: Database): void {
|
||||
sqliteVec.load(db);
|
||||
@ -243,23 +250,53 @@ import type { RankedResult } from "./store";
|
||||
// =============================================================================
|
||||
|
||||
describe("MCP Server", () => {
|
||||
beforeAll(() => {
|
||||
beforeAll(async () => {
|
||||
globalThis.fetch = mockFetch as typeof fetch;
|
||||
setDefaultOllama(new Ollama({ baseUrl: OLLAMA_URL }));
|
||||
|
||||
// Set up test config directory
|
||||
const configPrefix = join(tmpdir(), `qmd-mcp-config-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
testConfigDir = await mkdtemp(configPrefix);
|
||||
process.env.QMD_CONFIG_DIR = testConfigDir;
|
||||
|
||||
// Create YAML config with test collection
|
||||
const testConfig: CollectionConfig = {
|
||||
collections: {
|
||||
docs: {
|
||||
path: "/test/docs",
|
||||
pattern: "**/*.md",
|
||||
context: {
|
||||
"/meetings": "Meeting notes and transcripts"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
await writeFile(join(testConfigDir, "index.yml"), YAML.stringify(testConfig));
|
||||
|
||||
testDbPath = `/tmp/qmd-mcp-test-${Date.now()}.sqlite`;
|
||||
testDb = new Database(testDbPath);
|
||||
initTestDatabase(testDb);
|
||||
seedTestData(testDb);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
afterAll(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
setDefaultOllama(null);
|
||||
testDb.close();
|
||||
try {
|
||||
require("fs").unlinkSync(testDbPath);
|
||||
} catch {}
|
||||
|
||||
// Clean up test config directory
|
||||
try {
|
||||
const files = await readdir(testConfigDir);
|
||||
for (const file of files) {
|
||||
await unlink(join(testConfigDir, file));
|
||||
}
|
||||
await rmdir(testConfigDir);
|
||||
} catch {}
|
||||
|
||||
delete process.env.QMD_CONFIG_DIR;
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
@ -377,10 +414,10 @@ describe("MCP Server", () => {
|
||||
const ftsResults = searchFTS(testDb, q, 20);
|
||||
if (ftsResults.length > 0) {
|
||||
rankedLists.push(ftsResults.map(r => ({
|
||||
file: r.file,
|
||||
file: r.filepath,
|
||||
displayPath: r.displayPath,
|
||||
title: r.title,
|
||||
body: r.body,
|
||||
body: r.body || "",
|
||||
score: r.score,
|
||||
})));
|
||||
}
|
||||
|
||||
@ -1012,7 +1012,7 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT
|
||||
let path = file.path;
|
||||
|
||||
if (!collection || !path) {
|
||||
const parsed = parseVirtualPath(file.displayPath);
|
||||
const parsed = parseVirtualPath(file.filepath);
|
||||
if (parsed) {
|
||||
collection = parsed.collectionName;
|
||||
path = parsed.path;
|
||||
@ -1036,16 +1036,15 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fetch document content - use virtual path to query
|
||||
const parsed = parseVirtualPath(file.displayPath);
|
||||
if (!parsed) continue;
|
||||
// Fetch document content using collection and path
|
||||
if (!collection || !path) continue;
|
||||
|
||||
const doc = db.prepare(`
|
||||
SELECT content.doc as body, d.title
|
||||
FROM documents d
|
||||
JOIN content ON content.hash = d.hash
|
||||
WHERE d.collection = ? AND d.path = ? AND d.active = 1
|
||||
`).get(parsed.collectionName, parsed.path) as { body: string; title: string } | null;
|
||||
`).get(collection, path) as { body: string; title: string } | null;
|
||||
|
||||
if (!doc) continue;
|
||||
|
||||
|
||||
@ -827,7 +827,8 @@ describe("Document Retrieval", () => {
|
||||
expect("error" in result).toBe(false);
|
||||
if (!("error" in result)) {
|
||||
expect(result.title).toBe("My Document");
|
||||
expect(result.displayPath).toBe(`qmd://${collectionName}/mydoc.md`);
|
||||
expect(result.displayPath).toBe("mydoc.md");
|
||||
expect(result.filepath).toBe(`qmd://${collectionName}/mydoc.md`);
|
||||
expect(result.body).toBeUndefined(); // body not included by default
|
||||
}
|
||||
|
||||
@ -917,8 +918,8 @@ describe("Document Retrieval", () => {
|
||||
|
||||
test("findDocument expands ~ to home directory", async () => {
|
||||
const store = await createTestStore();
|
||||
const collectionName = await createTestCollection();
|
||||
const home = homedir();
|
||||
const collectionName = await createTestCollection({ pwd: home, name: "home" });
|
||||
await insertTestDocument(store.db, collectionName, {
|
||||
name: "mydoc",
|
||||
filepath: `${home}/docs/mydoc.md`,
|
||||
@ -948,6 +949,41 @@ describe("Document Retrieval", () => {
|
||||
|
||||
await cleanupTestDb(store);
|
||||
});
|
||||
|
||||
test("findDocument includes hierarchical contexts (global + collection + path)", async () => {
|
||||
const store = await createTestStore();
|
||||
const collectionName = await createTestCollection({ pwd: "/archive", name: "archive" });
|
||||
|
||||
// Add global context
|
||||
await addGlobalContext("Global context for all documents");
|
||||
|
||||
// Add collection root context
|
||||
await addPathContext(collectionName, "/", "Archive collection context");
|
||||
|
||||
// Add path-specific contexts at different levels
|
||||
await addPathContext(collectionName, "/podcasts", "Podcast episodes");
|
||||
await addPathContext(collectionName, "/podcasts/external", "External podcast interviews");
|
||||
|
||||
// Insert document in nested path
|
||||
await insertTestDocument(store.db, collectionName, {
|
||||
name: "interview",
|
||||
displayPath: "podcasts/external/2024-jan-interview.md",
|
||||
});
|
||||
|
||||
const result = store.findDocument("/archive/podcasts/external/2024-jan-interview.md");
|
||||
expect("error" in result).toBe(false);
|
||||
if (!("error" in result)) {
|
||||
// Should have all contexts joined with double newlines
|
||||
expect(result.context).toBe(
|
||||
"Global context for all documents\n\n" +
|
||||
"Archive collection context\n\n" +
|
||||
"Podcast episodes\n\n" +
|
||||
"External podcast interviews"
|
||||
);
|
||||
}
|
||||
|
||||
await cleanupTestDb(store);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDocumentBody", () => {
|
||||
|
||||
15
src/store.ts
15
src/store.ts
@ -1909,11 +1909,11 @@ export function findDocuments(
|
||||
|
||||
const bodyCol = options.includeBody ? `, content.doc as body` : ``;
|
||||
const selectCols = `
|
||||
'qmd://' || d.collection || '/' || d.path as display_path,
|
||||
'qmd://' || d.collection || '/' || d.path as virtual_path,
|
||||
d.path as display_path,
|
||||
d.title,
|
||||
d.hash,
|
||||
d.collection,
|
||||
d.path,
|
||||
d.modified_at,
|
||||
LENGTH(content.doc) as body_length
|
||||
${bodyCol}
|
||||
@ -1971,14 +1971,13 @@ export function findDocuments(
|
||||
const results: MultiGetResult[] = [];
|
||||
|
||||
for (const row of fileRows) {
|
||||
// Compute absolute filepath from collection
|
||||
const coll = getCollection(row.collection);
|
||||
const absoluteFilepath = coll ? `${coll.path}/${row.path}` : row.path;
|
||||
const context = getContextForFile(db, absoluteFilepath);
|
||||
// Get context using virtual path
|
||||
const virtualPath = row.virtual_path || `qmd://${row.collection}/${row.display_path}`;
|
||||
const context = getContextForFile(db, virtualPath);
|
||||
|
||||
if (row.body_length > maxBytes) {
|
||||
results.push({
|
||||
doc: { filepath: absoluteFilepath, displayPath: row.display_path },
|
||||
doc: { filepath: virtualPath, displayPath: row.display_path },
|
||||
skipped: true,
|
||||
skipReason: `File too large (${Math.round(row.body_length / 1024)}KB > ${Math.round(maxBytes / 1024)}KB)`,
|
||||
});
|
||||
@ -1987,7 +1986,7 @@ export function findDocuments(
|
||||
|
||||
results.push({
|
||||
doc: {
|
||||
filepath: absoluteFilepath,
|
||||
filepath: virtualPath,
|
||||
displayPath: row.display_path,
|
||||
title: row.title || row.display_path.split('/').pop() || row.display_path,
|
||||
context,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user