mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
chore(eslint): enable no-unsafe-assignment for tests (#2434)
Adds a test-only override enforcing @typescript-eslint/no-unsafe-assignment
on **/*.test.{ts,tsx} and fixes all 378 violations across 44 test files.
Production code (~499 violations) remains a follow-up PR — the rule stays
"off" globally, with the violation count comment updated to reflect that.
Common fix patterns applied:
- Typed accessor helpers (asInternal) for private-method access via
`as unknown as Shape`, replacing `(instance as any).method(...)`.
- Inline `as <Type>` on mock indexing (mock.calls[i][j]), JSON.parse,
and jest.requireMock results.
- mockTFile()/mockTFolder() from @/__tests__/mockObsidian replacing
`Object.create(TFile.prototype)` (also avoids obsidianmd/no-tfile-tfolder-cast).
- `as unknown as RealType` for mock-object casts where `as any` previously
masked the assignment.
This commit is contained in:
parent
500bc347a0
commit
280d95ebde
44 changed files with 891 additions and 569 deletions
|
|
@ -91,7 +91,7 @@ export default [
|
|||
// are exempted via per-file overrides below (see "no-unsafe-member-access
|
||||
// exemptions"). Remaining source files (≤5 violations each) were fixed in
|
||||
// this PR.
|
||||
"@typescript-eslint/no-unsafe-assignment": "off", // 879 violations
|
||||
"@typescript-eslint/no-unsafe-assignment": "off", // enabled for tests below; follow-up PR for production
|
||||
"@typescript-eslint/no-unsafe-call": "off", // 679 violations
|
||||
|
||||
// --- Medium: promise / method ergonomics ---
|
||||
|
|
@ -174,6 +174,15 @@ export default [
|
|||
},
|
||||
},
|
||||
|
||||
// Tests have been cleaned of unsafe `any` assignments. Production code
|
||||
// (~499 violations) is a follow-up; keep tests enforced.
|
||||
{
|
||||
files: ["**/*.test.{ts,tsx}"],
|
||||
rules: {
|
||||
"@typescript-eslint/no-unsafe-assignment": "error",
|
||||
},
|
||||
},
|
||||
|
||||
// Integration tests bootstrap jsdom fetch via `node-fetch` polyfill —
|
||||
// allow the otherwise-banned import here only.
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,59 @@
|
|||
import { BedrockChatModel } from "./BedrockChatModel";
|
||||
|
||||
type ProcessStreamResult = {
|
||||
deltaChunks: Array<{
|
||||
text?: string;
|
||||
message: {
|
||||
content: unknown;
|
||||
additional_kwargs?: { delta?: { reasoning?: string } };
|
||||
};
|
||||
}>;
|
||||
usage?: Record<string, unknown>;
|
||||
stopReason?: string;
|
||||
hasText: boolean;
|
||||
debugSummaries: string[];
|
||||
};
|
||||
|
||||
type ContentItem = { type: string; text?: string; thinking?: string };
|
||||
|
||||
type ImageContent = {
|
||||
type: "image";
|
||||
source: { type: "base64"; media_type: string; data: string };
|
||||
} | null;
|
||||
|
||||
type RequestBody = {
|
||||
thinking?: { type: string; budget_tokens: number };
|
||||
temperature?: number;
|
||||
anthropic_version?: string;
|
||||
messages: Array<{
|
||||
role: string;
|
||||
content: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
source?: { type: string; media_type: string; data: string };
|
||||
}>;
|
||||
}>;
|
||||
};
|
||||
|
||||
type BedrockInternal = {
|
||||
decodeChunkBytes: (encoded: string) => string[];
|
||||
processStreamEvent: (
|
||||
event: unknown,
|
||||
runManager: unknown,
|
||||
currentUsage: unknown,
|
||||
currentStopReason: unknown
|
||||
) => Promise<ProcessStreamResult>;
|
||||
buildContentItemsFromDelta: (event: unknown) => ContentItem[] | null;
|
||||
extractStreamText: (event: unknown) => string | null;
|
||||
buildRequestBody: (messages: unknown[], options?: unknown) => RequestBody;
|
||||
convertImageContent: (imageUrl: string) => ImageContent;
|
||||
normaliseMessageContent: (
|
||||
message: unknown
|
||||
) => string | Array<{ type: string; [key: string]: unknown }>;
|
||||
};
|
||||
|
||||
const asInternal = (m: BedrockChatModel): BedrockInternal => m as unknown as BedrockInternal;
|
||||
|
||||
/**
|
||||
* Builds a minimal Amazon EventStream message containing the provided UTF-8 payload.
|
||||
* This helper keeps CRC fields at zero because the decoder ignores them.
|
||||
|
|
@ -48,7 +102,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
const base64 = Buffer.from(payload, "utf-8").toString("base64");
|
||||
|
||||
const model = createModel();
|
||||
const decoded = (model as any).decodeChunkBytes(base64);
|
||||
const decoded = asInternal(model).decodeChunkBytes(base64);
|
||||
|
||||
expect(decoded).toEqual([payload]);
|
||||
});
|
||||
|
|
@ -64,7 +118,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
|
||||
const base64 = buildEventStreamChunk(payload);
|
||||
const model = createModel();
|
||||
const decoded = (model as any).decodeChunkBytes(base64);
|
||||
const decoded = asInternal(model).decodeChunkBytes(base64);
|
||||
|
||||
expect(decoded).toEqual([payload]);
|
||||
});
|
||||
|
|
@ -85,7 +139,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
};
|
||||
|
||||
const model = createModel();
|
||||
const processed = await (model as any).processStreamEvent(
|
||||
const processed = await asInternal(model).processStreamEvent(
|
||||
event,
|
||||
undefined,
|
||||
undefined,
|
||||
|
|
@ -111,10 +165,10 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
};
|
||||
|
||||
const model = createModel();
|
||||
const contentItems = (model as any).buildContentItemsFromDelta(event);
|
||||
const contentItems = asInternal(model).buildContentItemsFromDelta(event);
|
||||
|
||||
expect(contentItems).toHaveLength(1);
|
||||
expect(contentItems[0]).toEqual({
|
||||
expect(contentItems![0]).toEqual({
|
||||
type: "thinking",
|
||||
thinking: "Let me analyze this problem...",
|
||||
});
|
||||
|
|
@ -133,10 +187,10 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
};
|
||||
|
||||
const model = createModel();
|
||||
const contentItems = (model as any).buildContentItemsFromDelta(event);
|
||||
const contentItems = asInternal(model).buildContentItemsFromDelta(event);
|
||||
|
||||
expect(contentItems).toHaveLength(1);
|
||||
expect(contentItems[0]).toEqual({
|
||||
expect(contentItems![0]).toEqual({
|
||||
type: "text",
|
||||
text: "Based on my analysis, the answer is...",
|
||||
});
|
||||
|
|
@ -161,7 +215,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
};
|
||||
|
||||
const model = createModel();
|
||||
const processed = await (model as any).processStreamEvent(
|
||||
const processed = await asInternal(model).processStreamEvent(
|
||||
event,
|
||||
undefined,
|
||||
undefined,
|
||||
|
|
@ -208,7 +262,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
};
|
||||
|
||||
const model = createModel();
|
||||
const processed = await (model as any).processStreamEvent(
|
||||
const processed = await asInternal(model).processStreamEvent(
|
||||
event,
|
||||
undefined,
|
||||
undefined,
|
||||
|
|
@ -254,7 +308,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
chunk: { bytes: thinkingBase64 },
|
||||
};
|
||||
|
||||
const thinkingResult = await (model as any).processStreamEvent(
|
||||
const thinkingResult = await asInternal(model).processStreamEvent(
|
||||
thinkingEvent,
|
||||
undefined,
|
||||
undefined,
|
||||
|
|
@ -283,7 +337,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
chunk: { bytes: textBase64 },
|
||||
};
|
||||
|
||||
const textResult = await (model as any).processStreamEvent(
|
||||
const textResult = await asInternal(model).processStreamEvent(
|
||||
textEvent,
|
||||
undefined,
|
||||
undefined,
|
||||
|
|
@ -307,7 +361,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
};
|
||||
|
||||
const model = createModel();
|
||||
const extracted = (model as any).extractStreamText(event);
|
||||
const extracted = asInternal(model).extractStreamText(event);
|
||||
|
||||
expect(extracted).toBe("Fallback thinking extraction");
|
||||
});
|
||||
|
|
@ -324,10 +378,10 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
};
|
||||
|
||||
const model = createModel();
|
||||
const contentItems = (model as any).buildContentItemsFromDelta(event);
|
||||
const contentItems = asInternal(model).buildContentItemsFromDelta(event);
|
||||
|
||||
expect(contentItems).toHaveLength(1);
|
||||
expect(contentItems[0]).toEqual({
|
||||
expect(contentItems![0]).toEqual({
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
});
|
||||
|
|
@ -337,7 +391,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
describe("thinking mode enablement", () => {
|
||||
it("includes thinking parameter when enableThinking is true", () => {
|
||||
const model = createModel(true);
|
||||
const requestBody = (model as any).buildRequestBody([
|
||||
const requestBody = asInternal(model).buildRequestBody([
|
||||
{ role: "user", content: "test", getType: () => "human" },
|
||||
]);
|
||||
|
||||
|
|
@ -351,7 +405,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
|
||||
it("does not include thinking parameter when enableThinking is false", () => {
|
||||
const model = createModel(false);
|
||||
const requestBody = (model as any).buildRequestBody(
|
||||
const requestBody = asInternal(model).buildRequestBody(
|
||||
[{ role: "user", content: "test", getType: () => "human" }],
|
||||
{ temperature: 0.7 }
|
||||
);
|
||||
|
|
@ -364,7 +418,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
|
||||
it("respects user temperature when thinking is disabled", () => {
|
||||
const model = createModel(false);
|
||||
const requestBody = (model as any).buildRequestBody(
|
||||
const requestBody = asInternal(model).buildRequestBody(
|
||||
[{ role: "user", content: "test", getType: () => "human" }],
|
||||
{ temperature: 0.5 }
|
||||
);
|
||||
|
|
@ -375,7 +429,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
|
||||
it("forces temperature to 1 when thinking is enabled", () => {
|
||||
const model = createModel(true);
|
||||
const requestBody = (model as any).buildRequestBody(
|
||||
const requestBody = asInternal(model).buildRequestBody(
|
||||
[{ role: "user", content: "test", getType: () => "human" }],
|
||||
{ temperature: 0.5 } // User tries to set 0.5, should be overridden to 1
|
||||
);
|
||||
|
|
@ -390,7 +444,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
it("converts valid data URL to Claude image format", () => {
|
||||
const model = createModel();
|
||||
const dataUrl = "data:image/jpeg;base64,/9j/4AAQSkZJRg==";
|
||||
const result = (model as any).convertImageContent(dataUrl);
|
||||
const result = asInternal(model).convertImageContent(dataUrl);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "image",
|
||||
|
|
@ -405,7 +459,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
it("handles PNG images", () => {
|
||||
const model = createModel();
|
||||
const dataUrl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==";
|
||||
const result = (model as any).convertImageContent(dataUrl);
|
||||
const result = asInternal(model).convertImageContent(dataUrl);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "image",
|
||||
|
|
@ -420,7 +474,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
it("returns null for invalid data URL format", () => {
|
||||
const model = createModel();
|
||||
const invalidUrl = "not-a-data-url";
|
||||
const result = (model as any).convertImageContent(invalidUrl);
|
||||
const result = asInternal(model).convertImageContent(invalidUrl);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
|
@ -428,7 +482,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
it("returns null for non-image media type", () => {
|
||||
const model = createModel();
|
||||
const dataUrl = "data:text/plain;base64,SGVsbG8gV29ybGQ=";
|
||||
const result = (model as any).convertImageContent(dataUrl);
|
||||
const result = asInternal(model).convertImageContent(dataUrl);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
|
@ -448,7 +502,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
getType: () => "human",
|
||||
};
|
||||
|
||||
const result = (model as any).normaliseMessageContent(message);
|
||||
const result = asInternal(model).normaliseMessageContent(message);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(2);
|
||||
|
|
@ -469,7 +523,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
getType: () => "human",
|
||||
};
|
||||
|
||||
const result = (model as any).normaliseMessageContent(message);
|
||||
const result = asInternal(model).normaliseMessageContent(message);
|
||||
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result).toBe("Hello world!");
|
||||
|
|
@ -482,7 +536,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
getType: () => "human",
|
||||
};
|
||||
|
||||
const result = (model as any).normaliseMessageContent(message);
|
||||
const result = asInternal(model).normaliseMessageContent(message);
|
||||
|
||||
expect(result).toBe("Simple text message");
|
||||
});
|
||||
|
|
@ -504,7 +558,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
},
|
||||
];
|
||||
|
||||
const requestBody = (model as any).buildRequestBody(messages);
|
||||
const requestBody = asInternal(model).buildRequestBody(messages);
|
||||
|
||||
expect(requestBody.messages).toHaveLength(1);
|
||||
expect(requestBody.messages[0].content).toHaveLength(2);
|
||||
|
|
@ -545,14 +599,14 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
},
|
||||
];
|
||||
|
||||
const requestBody = (model as any).buildRequestBody(messages);
|
||||
const requestBody = asInternal(model).buildRequestBody(messages);
|
||||
|
||||
expect(requestBody.messages[0].content).toHaveLength(3);
|
||||
expect(requestBody.messages[0].content[0].type).toBe("text");
|
||||
expect(requestBody.messages[0].content[1].type).toBe("image");
|
||||
expect(requestBody.messages[0].content[1].source.media_type).toBe("image/jpeg");
|
||||
expect(requestBody.messages[0].content[1].source!.media_type).toBe("image/jpeg");
|
||||
expect(requestBody.messages[0].content[2].type).toBe("image");
|
||||
expect(requestBody.messages[0].content[2].source.media_type).toBe("image/png");
|
||||
expect(requestBody.messages[0].content[2].source!.media_type).toBe("image/png");
|
||||
});
|
||||
|
||||
it("handles text-only messages correctly", () => {
|
||||
|
|
@ -564,7 +618,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
},
|
||||
];
|
||||
|
||||
const requestBody = (model as any).buildRequestBody(messages);
|
||||
const requestBody = asInternal(model).buildRequestBody(messages);
|
||||
|
||||
expect(requestBody.messages).toHaveLength(1);
|
||||
expect(requestBody.messages[0].content).toHaveLength(1);
|
||||
|
|
@ -593,7 +647,7 @@ describe("BedrockChatModel streaming decode", () => {
|
|||
},
|
||||
];
|
||||
|
||||
const requestBody = (model as any).buildRequestBody(messages);
|
||||
const requestBody = asInternal(model).buildRequestBody(messages);
|
||||
|
||||
// Should have text + 1 valid image (invalid one skipped)
|
||||
expect(requestBody.messages[0].content).toHaveLength(2);
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ describe("Image extraction from content", () => {
|
|||
// Mock the global app object
|
||||
const mockApp = {
|
||||
metadataCache: {
|
||||
getFirstLinkpathDest: jest.fn(),
|
||||
getFirstLinkpathDest: jest.fn() as jest.Mock<{ path: string } | null, [string, string]>,
|
||||
},
|
||||
};
|
||||
|
||||
(window as any).app = mockApp;
|
||||
(window as unknown as { app: typeof mockApp }).app = mockApp;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
|
@ -33,7 +33,7 @@ describe("Image extraction from content", () => {
|
|||
|
||||
if (resolvedFile) {
|
||||
// Use the resolved path
|
||||
resolvedImages.push(resolvedFile.path as string);
|
||||
resolvedImages.push(resolvedFile.path);
|
||||
} else {
|
||||
// If file not found, still include the raw filename
|
||||
resolvedImages.push(imageName);
|
||||
|
|
@ -69,7 +69,7 @@ describe("Image extraction from content", () => {
|
|||
|
||||
if (resolvedFile) {
|
||||
// Use the resolved path
|
||||
resolvedImages.push(resolvedFile.path as string);
|
||||
resolvedImages.push(resolvedFile.path);
|
||||
} else {
|
||||
// If file not found, still include the raw path
|
||||
// Let ImageBatchProcessor handle validation
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { generatePromptDebugReportForAgent, resolveBasePrompt } from "./promptDebugService";
|
||||
import type ChainManager from "@/LLMProviders/chainManager";
|
||||
import { PromptSection } from "./modelAdapter";
|
||||
import { ModelAdapter, PromptSection } from "./modelAdapter";
|
||||
import { PromptDebugReport } from "./toolPromptDebugger";
|
||||
|
||||
const createAdapter = () => ({
|
||||
|
|
@ -25,7 +25,7 @@ const createAdapter = () => ({
|
|||
constructor: { name: "TestAdapter" },
|
||||
});
|
||||
|
||||
const createChainContext = (history: any[] = []): any => {
|
||||
const createChainContext = (history: unknown[] = []): ChainManager => {
|
||||
const memory = {
|
||||
loadMemoryVariables: jest.fn().mockResolvedValue({ history }),
|
||||
};
|
||||
|
|
@ -37,7 +37,7 @@ const createChainContext = (history: any[] = []): any => {
|
|||
userMemoryManager: {
|
||||
getUserMemoryPrompt: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
} as any;
|
||||
} as unknown as ChainManager;
|
||||
};
|
||||
|
||||
describe("promptDebugService", () => {
|
||||
|
|
@ -47,7 +47,7 @@ describe("promptDebugService", () => {
|
|||
|
||||
const report: PromptDebugReport = await generatePromptDebugReportForAgent({
|
||||
chainManager,
|
||||
adapter: adapter as any,
|
||||
adapter: adapter as unknown as ModelAdapter,
|
||||
basePrompt: "BasePrompt",
|
||||
toolDescriptions: "<tool></tool>",
|
||||
toolNames: ["localSearch"],
|
||||
|
|
@ -91,9 +91,9 @@ describe("promptDebugService", () => {
|
|||
userMemoryManager: {
|
||||
getUserMemoryPrompt: jest.fn().mockResolvedValue(memoryPrompt),
|
||||
},
|
||||
} as any;
|
||||
} as unknown as ChainManager;
|
||||
|
||||
const prompt = await resolveBasePrompt(chainManager as ChainManager);
|
||||
const prompt = await resolveBasePrompt(chainManager);
|
||||
expect(prompt).toContain(memoryPrompt);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ describe("promptPayloadRecorder", () => {
|
|||
});
|
||||
|
||||
it("serializes circular message graphs without throwing", async () => {
|
||||
const circular: any = { role: "system" };
|
||||
const circular: { role: string; self?: unknown } = { role: "system" };
|
||||
circular.self = circular;
|
||||
|
||||
expect(() => recordPromptPayload({ messages: [circular] })).not.toThrow();
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ jest.mock("@/logger", () => ({
|
|||
|
||||
// Mock the utility functions
|
||||
jest.mock("@/utils", () => {
|
||||
const actual = jest.requireActual("@/utils");
|
||||
const actual = jest.requireActual<{ stripFrontmatter: unknown }>("@/utils");
|
||||
return {
|
||||
extractTemplateNoteFiles: jest.fn().mockReturnValue([]),
|
||||
getFileContent: jest.fn(),
|
||||
|
|
@ -111,7 +111,7 @@ describe("processedPrompt()", () => {
|
|||
.mockResolvedValueOnce("here is the note content for note0")
|
||||
.mockResolvedValueOnce("note content for note1");
|
||||
|
||||
const { getFileName } = jest.requireMock("@/utils");
|
||||
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
|
||||
getFileName.mockReturnValueOnce("Variable1 Note").mockReturnValueOnce("Variable2 Note");
|
||||
|
||||
const mockNote1 = mockTFile({ path: "path/to/note1.md", basename: "Variable1 Note" });
|
||||
|
|
@ -175,7 +175,7 @@ describe("processedPrompt()", () => {
|
|||
const selectedText = "";
|
||||
|
||||
(getFileContent as jest.Mock).mockResolvedValue("Content of the active note");
|
||||
const { getFileName } = jest.requireMock("@/utils");
|
||||
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
|
||||
getFileName.mockReturnValue("Active Note");
|
||||
|
||||
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
|
||||
|
|
@ -238,7 +238,7 @@ describe("processedPrompt()", () => {
|
|||
(getNotesFromTags as jest.Mock).mockReturnValue([mockNoteForTag]);
|
||||
|
||||
// Mock getFileName to return the basename
|
||||
const { getFileName } = jest.requireMock("@/utils");
|
||||
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
|
||||
getFileName.mockReturnValue("Tagged Note");
|
||||
|
||||
// Mock getFileContent to return content for the note
|
||||
|
|
@ -270,7 +270,7 @@ describe("processedPrompt()", () => {
|
|||
(getNotesFromTags as jest.Mock).mockReturnValue([mockNoteForTag1, mockNoteForTag2]);
|
||||
|
||||
// Mock getFileName to return the basename
|
||||
const { getFileName } = jest.requireMock("@/utils");
|
||||
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
|
||||
getFileName.mockImplementation((file: TFile) => file.basename);
|
||||
|
||||
// Mock getFileContent to return content for each note
|
||||
|
|
@ -324,7 +324,7 @@ describe("processedPrompt()", () => {
|
|||
|
||||
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockNoteFile]);
|
||||
|
||||
const { getFileName } = jest.requireMock("@/utils");
|
||||
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
|
||||
getFileName.mockReturnValue("Test Note");
|
||||
|
||||
(getFileContent as jest.Mock).mockResolvedValue("Test note content");
|
||||
|
|
@ -362,7 +362,7 @@ describe("processedPrompt()", () => {
|
|||
// Only Note1 should be extracted since it's wrapped in {[[]]}
|
||||
(extractTemplateNoteFiles as jest.Mock).mockReturnValue([mockNote1]);
|
||||
|
||||
const { getFileName } = jest.requireMock("@/utils");
|
||||
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
|
||||
getFileName.mockImplementation((file: TFile) => file.basename);
|
||||
|
||||
(getFileContent as jest.Mock).mockImplementation((file: TFile) => {
|
||||
|
|
@ -459,7 +459,7 @@ describe("processedPrompt()", () => {
|
|||
const selectedText = "";
|
||||
|
||||
// Mock getFileName and getFileContent
|
||||
const { getFileName } = jest.requireMock("@/utils");
|
||||
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
|
||||
getFileName.mockReturnValue("Active Note");
|
||||
|
||||
(getFileContent as jest.Mock).mockResolvedValue("Content of the active note");
|
||||
|
|
@ -511,7 +511,7 @@ describe("processedPrompt()", () => {
|
|||
|
||||
// Mock getFileContent for the active note when processed via {}
|
||||
(getFileContent as jest.Mock).mockResolvedValue("Content of the active note");
|
||||
const { getFileName } = jest.requireMock("@/utils");
|
||||
const { getFileName } = jest.requireMock<{ getFileName: jest.Mock }>("@/utils");
|
||||
getFileName.mockReturnValue("Active Note");
|
||||
|
||||
const result = await processPrompt(doc.content, selectedText, mockVault, mockActiveNote);
|
||||
|
|
@ -663,14 +663,30 @@ describe("sortSlashCommands", () => {
|
|||
});
|
||||
|
||||
describe("parseCustomCommandFile", () => {
|
||||
let originalApp: any;
|
||||
let mockFile: any;
|
||||
let mockFrontmatter: any;
|
||||
let mockMetadata: any;
|
||||
interface MockFrontmatter {
|
||||
"copilot-command-context-menu-enabled"?: boolean;
|
||||
"copilot-command-slash-enabled"?: boolean;
|
||||
"copilot-command-context-menu-order"?: number;
|
||||
"copilot-command-model-key"?: string;
|
||||
"copilot-command-last-used"?: number;
|
||||
}
|
||||
interface MockMetadata {
|
||||
frontmatter: MockFrontmatter;
|
||||
}
|
||||
interface MockAppLike {
|
||||
vault: { read: jest.Mock };
|
||||
metadataCache: { getFileCache: jest.Mock };
|
||||
}
|
||||
type AppRef = { app: unknown };
|
||||
|
||||
let originalApp: unknown;
|
||||
let mockFile: TFile;
|
||||
let mockFrontmatter: MockFrontmatter;
|
||||
let mockMetadata: MockMetadata;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save and mock global app
|
||||
originalApp = window.app;
|
||||
originalApp = (window as unknown as AppRef).app;
|
||||
mockFrontmatter = {
|
||||
"copilot-command-context-menu-enabled": true,
|
||||
"copilot-command-slash-enabled": false,
|
||||
|
|
@ -679,34 +695,33 @@ describe("parseCustomCommandFile", () => {
|
|||
"copilot-command-last-used": 1234567890,
|
||||
};
|
||||
mockMetadata = { frontmatter: mockFrontmatter };
|
||||
window.app = {
|
||||
const mockedApp: MockAppLike = {
|
||||
vault: {
|
||||
read: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
"---\ncopilot-command-context-menu-enabled: true\ncopilot-command-slash-enabled: false\ncopilot-command-context-menu-order: 42\ncopilot-command-model-key: gpt-4\ncopilot-command-last-used: 1234567890\n---\nPrompt content here."
|
||||
) as any,
|
||||
} as any,
|
||||
),
|
||||
},
|
||||
metadataCache: {
|
||||
getFileCache: jest.fn().mockReturnValue(mockMetadata) as any,
|
||||
} as any,
|
||||
} as any;
|
||||
mockFile = {
|
||||
getFileCache: jest.fn().mockReturnValue(mockMetadata),
|
||||
},
|
||||
};
|
||||
(window as unknown as AppRef).app = mockedApp;
|
||||
mockFile = mockTFile({
|
||||
basename: "Test Command",
|
||||
extension: "md",
|
||||
path: "CustomCommands/Test Command.md",
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.app = originalApp;
|
||||
(window as unknown as AppRef).app = originalApp;
|
||||
});
|
||||
|
||||
it("parses a custom command file with frontmatter and content", async () => {
|
||||
const { parseCustomCommandFile } = await import("@/commands/customCommandUtils");
|
||||
const result = await parseCustomCommandFile(
|
||||
mockFile as Parameters<typeof parseCustomCommandFile>[0]
|
||||
);
|
||||
const result = await parseCustomCommandFile(mockFile);
|
||||
expect(result).toEqual({
|
||||
title: "Test Command",
|
||||
modelKey: "gpt-4",
|
||||
|
|
@ -724,9 +739,7 @@ describe("parseCustomCommandFile", () => {
|
|||
(window.app.vault as any).read.mockResolvedValue("Prompt content only, no frontmatter.");
|
||||
(window.app.metadataCache as any).getFileCache.mockReturnValue({});
|
||||
const { parseCustomCommandFile } = await import("@/commands/customCommandUtils");
|
||||
const result = await parseCustomCommandFile(
|
||||
mockFile as Parameters<typeof parseCustomCommandFile>[0]
|
||||
);
|
||||
const result = await parseCustomCommandFile(mockFile);
|
||||
expect(result).toEqual({
|
||||
title: "Test Command",
|
||||
modelKey: "",
|
||||
|
|
|
|||
|
|
@ -63,7 +63,9 @@ jest.mock("obsidian", () => {
|
|||
};
|
||||
});
|
||||
|
||||
const { __renderMarkdownMock: renderMarkdownMock } = jest.requireMock("obsidian");
|
||||
const { __renderMarkdownMock: renderMarkdownMock } = jest.requireMock<{
|
||||
__renderMarkdownMock: jest.Mock;
|
||||
}>("obsidian");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verifies that the HTML string passed to MarkdownRenderer.renderMarkdown
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { parseTextForPills } from "./lexicalTextUtils";
|
||||
import { TFile, TFolder } from "obsidian";
|
||||
import { TFile } from "obsidian";
|
||||
import { mockTFolder } from "@/__tests__/mockObsidian";
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("@/logger", () => ({
|
||||
|
|
@ -87,7 +88,7 @@ describe("parseTextForPills", () => {
|
|||
{
|
||||
type: "note-pill",
|
||||
content: "Valid Note",
|
||||
file: expect.any(TFile),
|
||||
file: expect.any(TFile) as unknown,
|
||||
isActive: false,
|
||||
},
|
||||
{
|
||||
|
|
@ -219,8 +220,8 @@ describe("parseTextForPills", () => {
|
|||
|
||||
describe("with folders only", () => {
|
||||
beforeEach(() => {
|
||||
// Create a mock folder using the TFolder constructor from the mock
|
||||
const mockFolder = new (TFolder as any)("Projects");
|
||||
// Create a mock folder using mockTFolder helper
|
||||
const mockFolder = mockTFolder({ path: "Projects", name: "Projects" });
|
||||
|
||||
// Mock folder resolution
|
||||
mockApp.vault.getAllLoadedFiles.mockReturnValue([mockFolder]);
|
||||
|
|
@ -238,7 +239,7 @@ describe("parseTextForPills", () => {
|
|||
{
|
||||
type: "folder-pill",
|
||||
content: "Projects",
|
||||
folder: expect.any(Object),
|
||||
folder: expect.any(Object) as unknown,
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
|
|
@ -287,7 +288,7 @@ describe("parseTextForPills", () => {
|
|||
frontmatter: { tags: ["test"] },
|
||||
});
|
||||
|
||||
const mockFolder = new (TFolder as any)("TestFolder");
|
||||
const mockFolder = mockTFolder({ path: "TestFolder", name: "TestFolder" });
|
||||
mockApp.vault.getAllLoadedFiles.mockReturnValue([mockFolder]);
|
||||
|
||||
mockApp.workspace.getActiveFile.mockReturnValue(null);
|
||||
|
|
@ -418,7 +419,7 @@ describe("parseTextForPills", () => {
|
|||
const text = "@tool-name #tag_with_underscores {folder with spaces}";
|
||||
|
||||
// Mock folder resolution for folder with spaces
|
||||
const mockFolder = new (TFolder as any)("folder with spaces");
|
||||
const mockFolder = mockTFolder({ path: "folder with spaces", name: "folder with spaces" });
|
||||
mockApp.vault.getAllLoadedFiles.mockReturnValue([mockFolder]);
|
||||
|
||||
const result = parseTextForPills(text, {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ window.app = {
|
|||
plugins: {
|
||||
plugins: {},
|
||||
},
|
||||
} as any;
|
||||
} as unknown as typeof window.app;
|
||||
|
||||
describe("ContextProcessor - Dataview Integration", () => {
|
||||
let contextProcessor: ContextProcessor;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { EMBEDDED_NOTE_TAG } from "@/constants";
|
|||
import { ChainType } from "@/chainType";
|
||||
import { TFile, Vault } from "obsidian";
|
||||
|
||||
type FileCacheMap = Record<string, Record<string, unknown>>;
|
||||
type FileCacheMap = Record<string, unknown>;
|
||||
type FileContentMap = Record<string, string>;
|
||||
|
||||
const createMockFile = (path: string): TFile =>
|
||||
|
|
@ -67,7 +67,7 @@ describe("ContextProcessor - Embedded Notes", () => {
|
|||
};
|
||||
});
|
||||
|
||||
const registerFile = (file: TFile, content: string, cache: any = {}): void => {
|
||||
const registerFile = (file: TFile, content: string, cache: unknown = {}): void => {
|
||||
fileIndex.set(file.path, file);
|
||||
fileContents[file.path] = content;
|
||||
fileCaches[file.path] = cache;
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ import type CopilotPlugin from "@/main";
|
|||
import { ChainType } from "@/chainType";
|
||||
import { getWebViewerService } from "@/services/webViewerService/webViewerServiceSingleton";
|
||||
import { ChatMessage, MessageContext } from "@/types/message";
|
||||
import { PromptContextEnvelope } from "@/context/PromptContextTypes";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
const USER_SENDER = "user";
|
||||
|
|
@ -113,7 +114,7 @@ describe("ChatManager", () => {
|
|||
editMessage: jest.fn(),
|
||||
getDebugInfo: jest.fn(),
|
||||
truncateAfter: jest.fn(),
|
||||
} as any;
|
||||
} as unknown as jest.Mocked<MessageRepository>;
|
||||
|
||||
mockChainManager = {
|
||||
memoryManager: {
|
||||
|
|
@ -140,7 +141,7 @@ describe("ChatManager", () => {
|
|||
mockContextManager = {
|
||||
processMessageContext: jest.fn(),
|
||||
reprocessMessageContext: jest.fn(),
|
||||
} as any;
|
||||
} as unknown as jest.Mocked<ContextManager>;
|
||||
|
||||
// Mock ContextManager.getInstance
|
||||
(ContextManager.getInstance as jest.Mock).mockReturnValue(mockContextManager);
|
||||
|
|
@ -335,7 +336,7 @@ describe("ChatManager", () => {
|
|||
const mockUserMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const mockLLMMessage = {
|
||||
...createMockMessage("msg-1", "Hello with context", USER_SENDER),
|
||||
contextEnvelope: { layers: [] } as any, // Has envelope, no lazy reprocessing needed
|
||||
contextEnvelope: { layers: [] } as unknown as PromptContextEnvelope, // Has envelope, no lazy reprocessing needed
|
||||
};
|
||||
|
||||
mockMessageRepo.getMessage.mockReturnValue(mockAiMessage);
|
||||
|
|
@ -376,7 +377,7 @@ describe("ChatManager", () => {
|
|||
// Second call: after reprocessing, has envelope
|
||||
const mockLLMMessageWithEnvelope = {
|
||||
...createMockMessage("msg-1", "Hello with context", USER_SENDER),
|
||||
contextEnvelope: { layers: [] } as any,
|
||||
contextEnvelope: { layers: [] } as unknown as PromptContextEnvelope,
|
||||
};
|
||||
|
||||
mockMessageRepo.getMessage.mockReturnValue(mockAiMessage);
|
||||
|
|
@ -677,7 +678,7 @@ describe("ChatManager", () => {
|
|||
const mockUserMessage = createMockMessage("msg-1", "Hello", USER_SENDER);
|
||||
const mockLLMMessage = {
|
||||
...createMockMessage("msg-1", "Hello with context", USER_SENDER),
|
||||
contextEnvelope: { layers: [] } as any,
|
||||
contextEnvelope: { layers: [] } as unknown as PromptContextEnvelope,
|
||||
};
|
||||
|
||||
mockMessageRepo.getMessage.mockReturnValue(mockAiMessage);
|
||||
|
|
@ -753,7 +754,7 @@ describe("ChatManager", () => {
|
|||
url: "https://active.example.com",
|
||||
isActive: true,
|
||||
}),
|
||||
]),
|
||||
]) as unknown,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
|
|
@ -797,7 +798,7 @@ describe("ChatManager", () => {
|
|||
url: "https://marker.example.com",
|
||||
isActive: true,
|
||||
}),
|
||||
]),
|
||||
]) as unknown,
|
||||
}),
|
||||
undefined
|
||||
);
|
||||
|
|
@ -1224,12 +1225,17 @@ describe("ChatManager", () => {
|
|||
|
||||
describe("System Prompt Template Processing", () => {
|
||||
// Import mocked modules for manipulation
|
||||
const { processPrompt } = jest.requireMock("@/commands/customCommandUtils");
|
||||
const { getSystemPrompt, getSystemPromptWithMemory, getEffectiveUserPrompt } = jest.requireMock(
|
||||
"@/system-prompts/systemPromptBuilder"
|
||||
const { processPrompt } = jest.requireMock<{ processPrompt: jest.Mock }>(
|
||||
"@/commands/customCommandUtils"
|
||||
);
|
||||
const { getSettings } = jest.requireMock("@/settings/model");
|
||||
const { getCurrentProject } = jest.requireMock("@/aiParams");
|
||||
const { getSystemPrompt, getSystemPromptWithMemory, getEffectiveUserPrompt } =
|
||||
jest.requireMock<{
|
||||
getSystemPrompt: jest.Mock;
|
||||
getSystemPromptWithMemory: jest.Mock;
|
||||
getEffectiveUserPrompt: jest.Mock;
|
||||
}>("@/system-prompts/systemPromptBuilder");
|
||||
const { getSettings } = jest.requireMock<{ getSettings: jest.Mock }>("@/settings/model");
|
||||
const { getCurrentProject } = jest.requireMock<{ getCurrentProject: jest.Mock }>("@/aiParams");
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset to defaults
|
||||
|
|
|
|||
|
|
@ -1,13 +1,20 @@
|
|||
import type ChainManager from "@/LLMProviders/chainManager";
|
||||
import { ChatMessage } from "@/types/message";
|
||||
import { App, Notice, TFile } from "obsidian";
|
||||
import type { MessageRepository } from "./MessageRepository";
|
||||
import type ChainManager from "@/LLMProviders/chainManager";
|
||||
import { ChatPersistenceManager } from "./ChatPersistenceManager";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
const USER_SENDER = "user";
|
||||
const AI_SENDER = "ai";
|
||||
|
||||
type PMInternal = {
|
||||
formatChatContent: (messages: ChatMessage[]) => string;
|
||||
parseChatContent: (content: string) => ChatMessage[];
|
||||
generateNoteContent: (chatContent: string, epoch: number, modelKey: string) => string;
|
||||
};
|
||||
const asInternal = (pm: ChatPersistenceManager): PMInternal => pm as unknown as PMInternal;
|
||||
|
||||
// Mock the imports
|
||||
jest.mock("obsidian", () => ({
|
||||
Notice: jest.fn(),
|
||||
|
|
@ -181,7 +188,7 @@ describe("ChatPersistenceManager", () => {
|
|||
},
|
||||
];
|
||||
|
||||
const result = (persistenceManager as any).formatChatContent(messages);
|
||||
const result = asInternal(persistenceManager).formatChatContent(messages);
|
||||
|
||||
const expected = `**user**: my name is logan, what's your name
|
||||
[Timestamp: 2024/09/23 22:18:00]
|
||||
|
|
@ -209,7 +216,7 @@ describe("ChatPersistenceManager", () => {
|
|||
},
|
||||
];
|
||||
|
||||
const result = (persistenceManager as any).formatChatContent(messages);
|
||||
const result = asInternal(persistenceManager).formatChatContent(messages);
|
||||
|
||||
expect(result).toBe(`**user**: Hello
|
||||
[Timestamp: Unknown time]`);
|
||||
|
|
@ -240,7 +247,7 @@ tags:
|
|||
**ai**: Your name is Logan.
|
||||
[Timestamp: 2024/09/23 22:56:20]`;
|
||||
|
||||
const result = (persistenceManager as any).parseChatContent(content);
|
||||
const result = asInternal(persistenceManager).parseChatContent(content);
|
||||
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[0]).toMatchObject({
|
||||
|
|
@ -292,7 +299,7 @@ Gentle breeze whispers secrets
|
|||
Nature's quiet song
|
||||
[Timestamp: 2024/09/23 22:18:01]`;
|
||||
|
||||
const result = (persistenceManager as any).parseChatContent(content);
|
||||
const result = asInternal(persistenceManager).parseChatContent(content);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].message).toBe(`Here's a haiku for you:
|
||||
|
|
@ -309,7 +316,7 @@ Nature's quiet song`);
|
|||
**ai**: Hi there!
|
||||
[Timestamp: Unknown time]`;
|
||||
|
||||
const result = (persistenceManager as any).parseChatContent(content);
|
||||
const result = asInternal(persistenceManager).parseChatContent(content);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].timestamp).toBeNull();
|
||||
|
|
@ -342,7 +349,7 @@ Nature's quiet song`);
|
|||
"test-folder/Hello@20240923_221800.md",
|
||||
expect.stringContaining("**user**: Hello")
|
||||
);
|
||||
const savedContent = mockApp.vault.create.mock.calls[0][1];
|
||||
const savedContent = mockApp.vault.create.mock.calls[0][1] as string;
|
||||
expect(savedContent).not.toContain("topic:");
|
||||
});
|
||||
|
||||
|
|
@ -379,16 +386,16 @@ Nature's quiet song`);
|
|||
],
|
||||
});
|
||||
|
||||
const chainManager = {
|
||||
const chainManager: ChainManager = {
|
||||
chatModelManager: {
|
||||
getChatModel: jest.fn().mockReturnValue({ invoke }),
|
||||
},
|
||||
} as any;
|
||||
} as unknown as ChainManager;
|
||||
|
||||
persistenceManager = new ChatPersistenceManager(
|
||||
mockApp as App,
|
||||
mockMessageRepo as MessageRepository,
|
||||
chainManager as ChainManager
|
||||
chainManager
|
||||
);
|
||||
const mockFile = mockTFile({
|
||||
path: "test-folder/Summarize_weather_data@20240923_221800.md",
|
||||
|
|
@ -410,7 +417,7 @@ Nature's quiet song`);
|
|||
"test-folder/Summarize_weather_data@20240923_221800.md",
|
||||
expect.stringContaining("Summarize weather data")
|
||||
);
|
||||
const savedContent = mockApp.vault.create.mock.calls[0][1];
|
||||
const savedContent = mockApp.vault.create.mock.calls[0][1] as string;
|
||||
expect(savedContent).not.toContain("topic:");
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
|
@ -640,7 +647,7 @@ Nature's quiet song`);
|
|||
];
|
||||
|
||||
// Mock getCurrentProject to return a project
|
||||
const getCurrentProject = jest.requireMock("@/aiParams").getCurrentProject;
|
||||
const getCurrentProject = jest.requireMock("@/aiParams").getCurrentProject as jest.Mock;
|
||||
getCurrentProject.mockReturnValue({ id: "project-123", name: "Test Project" });
|
||||
|
||||
mockMessageRepo.getDisplayMessages.mockReturnValue(messages);
|
||||
|
|
@ -749,7 +756,7 @@ Nature's quiet song`);
|
|||
];
|
||||
|
||||
// Mock getCurrentProject to return a project
|
||||
const getCurrentProject = jest.requireMock("@/aiParams").getCurrentProject;
|
||||
const getCurrentProject = jest.requireMock("@/aiParams").getCurrentProject as jest.Mock;
|
||||
getCurrentProject.mockReturnValue({ id: "songs-of-syx", name: "Songs of Syx Translation" });
|
||||
|
||||
mockMessageRepo.getDisplayMessages.mockReturnValue(messages);
|
||||
|
|
@ -804,8 +811,7 @@ Nature's quiet song`);
|
|||
},
|
||||
];
|
||||
|
||||
const existingFallbackFile: TFile = Object.create(TFile.prototype);
|
||||
Object.assign(existingFallbackFile, {
|
||||
const existingFallbackFile = mockTFile({
|
||||
path: "test-folder/chat-1729873880000.md",
|
||||
basename: "chat-1729873880000",
|
||||
});
|
||||
|
|
@ -898,8 +904,7 @@ Nature's quiet song`);
|
|||
},
|
||||
];
|
||||
|
||||
const existingFile: TFile = Object.create(TFile.prototype);
|
||||
Object.assign(existingFile, {
|
||||
const existingFile = mockTFile({
|
||||
path: "test-folder/Hello_again@20240923_221800.md",
|
||||
basename: "Hello_again@20240923_221800",
|
||||
});
|
||||
|
|
@ -951,8 +956,7 @@ Nature's quiet song`);
|
|||
},
|
||||
];
|
||||
|
||||
const existingFile: TFile = Object.create(TFile.prototype);
|
||||
Object.assign(existingFile, {
|
||||
const existingFile = mockTFile({
|
||||
path: "test-folder/Conflict_message@20240923_221800.md",
|
||||
basename: "Conflict_message@20240923_221800",
|
||||
});
|
||||
|
|
@ -1080,7 +1084,7 @@ tags:
|
|||
];
|
||||
|
||||
// Format the content
|
||||
const formattedContent = (persistenceManager as any).formatChatContent(originalMessages);
|
||||
const formattedContent = asInternal(persistenceManager).formatChatContent(originalMessages);
|
||||
|
||||
// Add frontmatter
|
||||
const fullContent = `---
|
||||
|
|
@ -1093,7 +1097,7 @@ tags:
|
|||
${formattedContent}`;
|
||||
|
||||
// Parse it back
|
||||
const parsedMessages = (persistenceManager as any).parseChatContent(fullContent);
|
||||
const parsedMessages = asInternal(persistenceManager).parseChatContent(fullContent);
|
||||
|
||||
// Verify the messages match
|
||||
expect(parsedMessages).toHaveLength(2);
|
||||
|
|
@ -1104,16 +1108,16 @@ ${formattedContent}`;
|
|||
});
|
||||
|
||||
it("should preserve context information through save and load cycle", async () => {
|
||||
// Create mock TFile for the note - must use Object.create to pass instanceof check
|
||||
const mockTFile: TFile = Object.create(TFile.prototype);
|
||||
mockTFile.basename = "typescript-guide.md";
|
||||
mockTFile.path = "docs/typescript-guide.md";
|
||||
mockTFile.extension = "md";
|
||||
mockTFile.name = "typescript-guide.md";
|
||||
const noteFile = mockTFile({
|
||||
basename: "typescript-guide.md",
|
||||
path: "docs/typescript-guide.md",
|
||||
extension: "md",
|
||||
name: "typescript-guide.md",
|
||||
});
|
||||
|
||||
// Mock vault to return the file when resolving by path
|
||||
mockApp.vault.getAbstractFileByPath = jest.fn().mockImplementation((path: string) => {
|
||||
return path === "docs/typescript-guide.md" ? mockTFile : null;
|
||||
return path === "docs/typescript-guide.md" ? noteFile : null;
|
||||
});
|
||||
|
||||
// Recreate persistence manager with the updated mock
|
||||
|
|
@ -1134,7 +1138,7 @@ ${formattedContent}`;
|
|||
},
|
||||
isVisible: true,
|
||||
context: {
|
||||
notes: [mockTFile],
|
||||
notes: [noteFile],
|
||||
urls: ["https://typescriptlang.org"],
|
||||
tags: ["programming", "typescript"],
|
||||
folders: ["docs/"],
|
||||
|
|
@ -1174,7 +1178,8 @@ ${formattedContent}`;
|
|||
];
|
||||
|
||||
// Format the content
|
||||
const formattedContent = (testPersistenceManager as any).formatChatContent(originalMessages);
|
||||
const formattedContent =
|
||||
asInternal(testPersistenceManager).formatChatContent(originalMessages);
|
||||
|
||||
// Verify the formatted content contains the full path (not just basename)
|
||||
expect(formattedContent).toContain("docs/typescript-guide.md");
|
||||
|
|
@ -1190,7 +1195,7 @@ tags:
|
|||
${formattedContent}`;
|
||||
|
||||
// Parse it back
|
||||
const parsedMessages = (testPersistenceManager as any).parseChatContent(fullContent);
|
||||
const parsedMessages = asInternal(testPersistenceManager).parseChatContent(fullContent);
|
||||
|
||||
// Verify the messages match
|
||||
expect(parsedMessages).toHaveLength(2);
|
||||
|
|
@ -1199,17 +1204,17 @@ ${formattedContent}`;
|
|||
|
||||
// Verify context is preserved (Tags, Folders, WebTabs are optional in type)
|
||||
expect(parsedMessages[0].context).toBeDefined();
|
||||
expect(parsedMessages[0].context.notes).toHaveLength(1);
|
||||
expect(parsedMessages[0].context.notes[0].basename).toBe("typescript-guide.md");
|
||||
expect(parsedMessages[0].context.notes[0].path).toBe("docs/typescript-guide.md");
|
||||
expect(parsedMessages[0].context.urls).toEqual(["https://typescriptlang.org"]);
|
||||
expect(parsedMessages[0].context.tags).toEqual(["programming", "typescript"]);
|
||||
expect(parsedMessages[0].context.folders).toHaveLength(1);
|
||||
expect(parsedMessages[0].context.folders[0]).toBe("docs/");
|
||||
expect(parsedMessages[0].context.webTabs).toHaveLength(3);
|
||||
expect(parsedMessages[0].context.webTabs[0].url).toBe("https://example.com/");
|
||||
expect(parsedMessages[0].context.webTabs[1].url).toBe("https://lucide.dev/");
|
||||
expect(parsedMessages[0].context.webTabs[2].url).toBe("https://obsidian.md/");
|
||||
expect(parsedMessages[0].context!.notes).toHaveLength(1);
|
||||
expect(parsedMessages[0].context!.notes[0].basename).toBe("typescript-guide.md");
|
||||
expect(parsedMessages[0].context!.notes[0].path).toBe("docs/typescript-guide.md");
|
||||
expect(parsedMessages[0].context!.urls).toEqual(["https://typescriptlang.org"]);
|
||||
expect(parsedMessages[0].context!.tags!).toEqual(["programming", "typescript"]);
|
||||
expect(parsedMessages[0].context!.folders!).toHaveLength(1);
|
||||
expect(parsedMessages[0].context!.folders![0]).toBe("docs/");
|
||||
expect(parsedMessages[0].context!.webTabs!).toHaveLength(3);
|
||||
expect(parsedMessages[0].context!.webTabs![0].url).toBe("https://example.com/");
|
||||
expect(parsedMessages[0].context!.webTabs![1].url).toBe("https://lucide.dev/");
|
||||
expect(parsedMessages[0].context!.webTabs![2].url).toBe("https://obsidian.md/");
|
||||
|
||||
// Verify the second message has no context
|
||||
expect(parsedMessages[1].context).toBeUndefined();
|
||||
|
|
@ -1243,13 +1248,13 @@ tags:
|
|||
**ai**: Here's what I found about TypeScript in your files...
|
||||
[Timestamp: 2024/09/23 22:18:01]`;
|
||||
|
||||
const parsedMessages = (persistenceManager as any).parseChatContent(content);
|
||||
const parsedMessages = asInternal(persistenceManager).parseChatContent(content);
|
||||
|
||||
expect(parsedMessages).toHaveLength(2);
|
||||
expect(parsedMessages[0].context).toBeDefined();
|
||||
expect(parsedMessages[0].context.notes).toHaveLength(1);
|
||||
expect(parsedMessages[0].context.notes[0].basename).toBe("typescript-guide.md");
|
||||
expect(parsedMessages[0].context.notes[0].path).toBe("docs/typescript-guide.md");
|
||||
expect(parsedMessages[0].context!.notes).toHaveLength(1);
|
||||
expect(parsedMessages[0].context!.notes[0].basename).toBe("typescript-guide.md");
|
||||
expect(parsedMessages[0].context!.notes[0].path).toBe("docs/typescript-guide.md");
|
||||
});
|
||||
|
||||
it("should handle ambiguous basename resolution gracefully", async () => {
|
||||
|
|
@ -1284,13 +1289,13 @@ tags:
|
|||
[Context: Notes: typescript-guide.md | URLs: https://typescriptlang.org]
|
||||
[Timestamp: 2024/09/23 22:18:00]`;
|
||||
|
||||
const parsedMessages = (persistenceManager as any).parseChatContent(content);
|
||||
const parsedMessages = asInternal(persistenceManager).parseChatContent(content);
|
||||
|
||||
// Should skip ambiguous note (logs warning) but preserve other context
|
||||
expect(parsedMessages).toHaveLength(1);
|
||||
expect(parsedMessages[0].context).toBeDefined();
|
||||
expect(parsedMessages[0].context.notes).toHaveLength(0); // Skipped due to ambiguity
|
||||
expect(parsedMessages[0].context.urls).toEqual(["https://typescriptlang.org"]); // Other context preserved
|
||||
expect(parsedMessages[0].context!.notes).toHaveLength(0); // Skipped due to ambiguity
|
||||
expect(parsedMessages[0].context!.urls).toEqual(["https://typescriptlang.org"]); // Other context preserved
|
||||
});
|
||||
|
||||
it("should handle deleted notes gracefully", async () => {
|
||||
|
|
@ -1310,13 +1315,13 @@ tags:
|
|||
[Context: Notes: docs/deleted-file.md | Tags: typescript, programming]
|
||||
[Timestamp: 2024/09/23 22:18:00]`;
|
||||
|
||||
const parsedMessages = (persistenceManager as any).parseChatContent(content);
|
||||
const parsedMessages = asInternal(persistenceManager).parseChatContent(content);
|
||||
|
||||
// Should skip missing note (logs warning) but preserve other context
|
||||
expect(parsedMessages).toHaveLength(1);
|
||||
expect(parsedMessages[0].context).toBeDefined();
|
||||
expect(parsedMessages[0].context.notes).toHaveLength(0); // Skipped - not found
|
||||
expect(parsedMessages[0].context.tags).toEqual(["typescript", "programming"]); // Other context preserved
|
||||
expect(parsedMessages[0].context!.notes).toHaveLength(0); // Skipped - not found
|
||||
expect(parsedMessages[0].context!.tags!).toEqual(["typescript", "programming"]); // Other context preserved
|
||||
});
|
||||
|
||||
it("should handle messages without context (backward compatibility)", async () => {
|
||||
|
|
@ -1333,7 +1338,7 @@ tags:
|
|||
**ai**: Hi there!
|
||||
[Timestamp: 2024/09/23 22:18:01]`;
|
||||
|
||||
const parsedMessages = (persistenceManager as any).parseChatContent(content);
|
||||
const parsedMessages = asInternal(persistenceManager).parseChatContent(content);
|
||||
|
||||
expect(parsedMessages).toHaveLength(2);
|
||||
expect(parsedMessages[0].context).toBeUndefined();
|
||||
|
|
@ -1363,7 +1368,7 @@ tags:
|
|||
// Test with user-defined display name containing special characters
|
||||
await persistenceManager.saveChat("[芥兰]Gemini-2.5-pro|3rd party");
|
||||
|
||||
const savedContent = mockApp.vault.create.mock.calls[0][1];
|
||||
const savedContent = mockApp.vault.create.mock.calls[0][1] as string;
|
||||
expect(savedContent).toContain('modelKey: "[芥兰]Gemini-2.5-pro|3rd party"');
|
||||
});
|
||||
|
||||
|
|
@ -1387,7 +1392,7 @@ tags:
|
|||
|
||||
await persistenceManager.saveChat("x-ai/grok-4-fast");
|
||||
|
||||
const savedContent = mockApp.vault.create.mock.calls[0][1];
|
||||
const savedContent = mockApp.vault.create.mock.calls[0][1] as string;
|
||||
expect(savedContent).toContain('modelKey: "x-ai/grok-4-fast"');
|
||||
});
|
||||
|
||||
|
|
@ -1411,7 +1416,7 @@ tags:
|
|||
|
||||
await persistenceManager.saveChat("copilot-plus-flash|copilot-plus");
|
||||
|
||||
const savedContent = mockApp.vault.create.mock.calls[0][1];
|
||||
const savedContent = mockApp.vault.create.mock.calls[0][1] as string;
|
||||
expect(savedContent).toContain('modelKey: "copilot-plus-flash|copilot-plus"');
|
||||
});
|
||||
|
||||
|
|
@ -1435,7 +1440,7 @@ tags:
|
|||
|
||||
await persistenceManager.saveChat('model"with"quotes|provider');
|
||||
|
||||
const savedContent = mockApp.vault.create.mock.calls[0][1];
|
||||
const savedContent = mockApp.vault.create.mock.calls[0][1] as string;
|
||||
expect(savedContent).toContain('modelKey: "model\\"with\\"quotes|provider"');
|
||||
});
|
||||
|
||||
|
|
@ -1459,7 +1464,7 @@ tags:
|
|||
|
||||
await persistenceManager.saveChat("model\\with\\backslash|provider");
|
||||
|
||||
const savedContent = mockApp.vault.create.mock.calls[0][1];
|
||||
const savedContent = mockApp.vault.create.mock.calls[0][1] as string;
|
||||
expect(savedContent).toContain('modelKey: "model\\\\with\\\\backslash|provider"');
|
||||
});
|
||||
|
||||
|
|
@ -1480,8 +1485,8 @@ tags:
|
|||
];
|
||||
|
||||
// Generate the note content
|
||||
const chatContent = (persistenceManager as any).formatChatContent(messages);
|
||||
const noteContent = (persistenceManager as any).generateNoteContent(
|
||||
const chatContent = asInternal(persistenceManager).formatChatContent(messages);
|
||||
const noteContent = asInternal(persistenceManager).generateNoteContent(
|
||||
chatContent,
|
||||
messages[0].timestamp!.epoch,
|
||||
testModelKey
|
||||
|
|
@ -1513,8 +1518,8 @@ tags:
|
|||
];
|
||||
|
||||
// Generate the note content
|
||||
const chatContent = (persistenceManager as any).formatChatContent(messages);
|
||||
const noteContent = (persistenceManager as any).generateNoteContent(
|
||||
const chatContent = asInternal(persistenceManager).formatChatContent(messages);
|
||||
const noteContent = asInternal(persistenceManager).generateNoteContent(
|
||||
chatContent,
|
||||
messages[0].timestamp!.epoch,
|
||||
testModelKey
|
||||
|
|
@ -1546,8 +1551,8 @@ tags:
|
|||
];
|
||||
|
||||
// Generate the note content
|
||||
const chatContent = (persistenceManager as any).formatChatContent(messages);
|
||||
const noteContent = (persistenceManager as any).generateNoteContent(
|
||||
const chatContent = asInternal(persistenceManager).formatChatContent(messages);
|
||||
const noteContent = asInternal(persistenceManager).generateNoteContent(
|
||||
chatContent,
|
||||
messages[0].timestamp!.epoch,
|
||||
testModelKey
|
||||
|
|
@ -1634,8 +1639,8 @@ tags:
|
|||
await persistenceManager.saveChat("gpt-4");
|
||||
|
||||
// Verify that create was called with content NOT containing lastAccessedAt
|
||||
const createCall = mockApp.vault.create.mock.calls[0];
|
||||
const content = createCall[1] as string;
|
||||
const createCall = mockApp.vault.create.mock.calls[0] as [string, string];
|
||||
const content = createCall[1];
|
||||
expect(content).not.toContain("lastAccessedAt:");
|
||||
});
|
||||
|
||||
|
|
@ -1654,8 +1659,7 @@ tags:
|
|||
},
|
||||
];
|
||||
|
||||
const existingFile: TFile = Object.create(TFile.prototype);
|
||||
Object.assign(existingFile, {
|
||||
const existingFile = mockTFile({
|
||||
path: "test-folder/Conflict_test@20240923_221800.md",
|
||||
basename: "Conflict_test@20240923_221800",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -56,6 +56,17 @@ jest.mock("@/commands/customCommandUtils", () => ({
|
|||
jest.mock("./ContextCompactor", () => ({}));
|
||||
|
||||
import { ContextManager } from "./ContextManager";
|
||||
import type { MessageRepository } from "./MessageRepository";
|
||||
|
||||
type ContextManagerInternal = {
|
||||
buildL2ContextFromPreviousTurns: (
|
||||
currentMessageId: string,
|
||||
messageRepo: MessageRepository
|
||||
) => { l2Context: string; l2Paths: Set<string> };
|
||||
};
|
||||
|
||||
const asInternal = (m: ContextManager): ContextManagerInternal =>
|
||||
m as unknown as ContextManagerInternal;
|
||||
|
||||
/**
|
||||
* Build a minimal PromptContextEnvelope with L3_TURN segments.
|
||||
|
|
@ -76,7 +87,7 @@ function buildEnvelopeWithL3Segments(segments: PromptLayerSegment[]): PromptCont
|
|||
},
|
||||
],
|
||||
serializedText: "",
|
||||
layerHashes: {} as any,
|
||||
layerHashes: {} as PromptContextEnvelope["layerHashes"],
|
||||
combinedHash: "test",
|
||||
};
|
||||
}
|
||||
|
|
@ -86,7 +97,7 @@ function buildEnvelopeWithL3Segments(segments: PromptLayerSegment[]): PromptCont
|
|||
*/
|
||||
function createMockMessageRepo(
|
||||
messages: Array<{ id: string; sender: string; contextEnvelope?: PromptContextEnvelope }>
|
||||
): any {
|
||||
): MessageRepository {
|
||||
return {
|
||||
getDisplayMessages: () =>
|
||||
messages.map((msg) => ({
|
||||
|
|
@ -96,11 +107,11 @@ function createMockMessageRepo(
|
|||
isVisible: true,
|
||||
contextEnvelope: msg.contextEnvelope,
|
||||
})),
|
||||
};
|
||||
} as unknown as MessageRepository;
|
||||
}
|
||||
|
||||
describe("ContextManager L2 promotion filtering", () => {
|
||||
let contextManager: any;
|
||||
let contextManager: ContextManager;
|
||||
|
||||
beforeEach(() => {
|
||||
contextManager = ContextManager.getInstance();
|
||||
|
|
@ -130,7 +141,10 @@ describe("ContextManager L2 promotion filtering", () => {
|
|||
{ id: "msg-2", sender: "user" }, // current message (no envelope yet)
|
||||
]);
|
||||
|
||||
const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo);
|
||||
const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns(
|
||||
"msg-2",
|
||||
mockRepo
|
||||
);
|
||||
|
||||
// note_context should be promoted to L2
|
||||
expect(l2Context).toContain("note_context");
|
||||
|
|
@ -158,7 +172,10 @@ describe("ContextManager L2 promotion filtering", () => {
|
|||
{ id: "msg-2", sender: "user" },
|
||||
]);
|
||||
|
||||
const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo);
|
||||
const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns(
|
||||
"msg-2",
|
||||
mockRepo
|
||||
);
|
||||
|
||||
expect(l2Context).not.toContain("web_selected_text");
|
||||
expect(l2Context).not.toContain("Web selection content");
|
||||
|
|
@ -188,7 +205,10 @@ describe("ContextManager L2 promotion filtering", () => {
|
|||
{ id: "msg-2", sender: "user" },
|
||||
]);
|
||||
|
||||
const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo);
|
||||
const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns(
|
||||
"msg-2",
|
||||
mockRepo
|
||||
);
|
||||
|
||||
expect(l2Context).toContain("Note content here");
|
||||
expect(l2Context).toContain("URL content here");
|
||||
|
|
@ -211,7 +231,10 @@ describe("ContextManager L2 promotion filtering", () => {
|
|||
{ id: "msg-2", sender: "user" },
|
||||
]);
|
||||
|
||||
const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo);
|
||||
const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns(
|
||||
"msg-2",
|
||||
mockRepo
|
||||
);
|
||||
|
||||
// prior_context is not in the registry, so it should pass through
|
||||
expect(l2Context).toContain("prior_context");
|
||||
|
|
@ -249,7 +272,10 @@ describe("ContextManager L2 promotion filtering", () => {
|
|||
{ id: "msg-2", sender: "user" },
|
||||
]);
|
||||
|
||||
const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo);
|
||||
const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns(
|
||||
"msg-2",
|
||||
mockRepo
|
||||
);
|
||||
|
||||
expect(l2Context).toContain("Should be in L2");
|
||||
expect(l2Context).toContain("Also in L2");
|
||||
|
|
@ -280,7 +306,10 @@ describe("ContextManager L2 promotion filtering", () => {
|
|||
{ id: "msg-2", sender: "user" },
|
||||
]);
|
||||
|
||||
const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo);
|
||||
const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns(
|
||||
"msg-2",
|
||||
mockRepo
|
||||
);
|
||||
|
||||
expect(l2Context).toContain("Note in compacted segment");
|
||||
expect(l2Context).toContain("URL in compacted segment");
|
||||
|
|
@ -311,7 +340,10 @@ describe("ContextManager L2 promotion filtering", () => {
|
|||
{ id: "msg-2", sender: "user" },
|
||||
]);
|
||||
|
||||
const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo);
|
||||
const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns(
|
||||
"msg-2",
|
||||
mockRepo
|
||||
);
|
||||
|
||||
expect(l2Context).toContain("Recoverable note");
|
||||
expect(l2Context).not.toContain("<web_selected_text>");
|
||||
|
|
@ -341,7 +373,10 @@ describe("ContextManager L2 promotion filtering", () => {
|
|||
{ id: "msg-2", sender: "user" },
|
||||
]);
|
||||
|
||||
const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo);
|
||||
const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns(
|
||||
"msg-2",
|
||||
mockRepo
|
||||
);
|
||||
|
||||
expect(l2Context).toContain("Some compacted note");
|
||||
// prior_context_note should appear exactly once (appended by the caller),
|
||||
|
|
@ -371,7 +406,10 @@ describe("ContextManager L2 promotion filtering", () => {
|
|||
{ id: "msg-2", sender: "user" },
|
||||
]);
|
||||
|
||||
const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo);
|
||||
const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns(
|
||||
"msg-2",
|
||||
mockRepo
|
||||
);
|
||||
|
||||
// No prior_context blocks exist, so no re-fetch instruction should be appended
|
||||
expect(l2Context).toContain("Regular note content");
|
||||
|
|
@ -400,7 +438,10 @@ describe("ContextManager L2 promotion filtering", () => {
|
|||
{ id: "msg-2", sender: "user" },
|
||||
]);
|
||||
|
||||
const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo);
|
||||
const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns(
|
||||
"msg-2",
|
||||
mockRepo
|
||||
);
|
||||
|
||||
expect(l2Context).toContain("Known block");
|
||||
// Unknown tag must NOT be dropped
|
||||
|
|
@ -428,7 +469,10 @@ describe("ContextManager L2 promotion filtering", () => {
|
|||
{ id: "msg-2", sender: "user" },
|
||||
]);
|
||||
|
||||
const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo);
|
||||
const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns(
|
||||
"msg-2",
|
||||
mockRepo
|
||||
);
|
||||
|
||||
expect(l2Context).toContain("XML Docs");
|
||||
// No real prior_context blocks, so no refetch instruction
|
||||
|
|
@ -455,7 +499,10 @@ describe("ContextManager L2 promotion filtering", () => {
|
|||
{ id: "msg-2", sender: "user" },
|
||||
]);
|
||||
|
||||
const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo);
|
||||
const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns(
|
||||
"msg-2",
|
||||
mockRepo
|
||||
);
|
||||
|
||||
expect(l2Context).toContain("XML Guide");
|
||||
// The literal <selected_text> inside note content must survive
|
||||
|
|
@ -484,7 +531,10 @@ describe("ContextManager L2 promotion filtering", () => {
|
|||
{ id: "msg-2", sender: "user" },
|
||||
]);
|
||||
|
||||
const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo);
|
||||
const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns(
|
||||
"msg-2",
|
||||
mockRepo
|
||||
);
|
||||
|
||||
// Both notes must be present (compacted or verbatim)
|
||||
expect(l2Context).toContain("Note A");
|
||||
|
|
@ -510,7 +560,10 @@ describe("ContextManager L2 promotion filtering", () => {
|
|||
{ id: "msg-2", sender: "user" },
|
||||
]);
|
||||
|
||||
const { l2Context } = contextManager.buildL2ContextFromPreviousTurns("msg-2", mockRepo);
|
||||
const { l2Context } = asInternal(contextManager).buildL2ContextFromPreviousTurns(
|
||||
"msg-2",
|
||||
mockRepo
|
||||
);
|
||||
|
||||
expect(l2Context).toContain("Compacted note summary");
|
||||
// Real prior_context block exists, so re-fetch instruction should be present
|
||||
|
|
|
|||
|
|
@ -139,13 +139,13 @@ The team appears to be taking a pragmatic approach with a focused MVP scope and
|
|||
context: {
|
||||
notes: expect.arrayContaining([
|
||||
expect.objectContaining({ basename: "meeting-notes-2024-01-15" }),
|
||||
]),
|
||||
]) as unknown,
|
||||
},
|
||||
});
|
||||
|
||||
// AI response
|
||||
expect(finalDisplayMessages[1]).toMatchObject({
|
||||
message: expect.stringContaining("Based on the meeting notes"),
|
||||
message: expect.stringContaining("Based on the meeting notes") as unknown,
|
||||
sender: AI_SENDER,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { MessageRepository } from "./MessageRepository";
|
||||
import { ChatMessage, MessageContext, StoredMessage } from "@/types/message";
|
||||
import { formatDateTime } from "@/utils";
|
||||
import { formatDateTime, FormattedDateTime } from "@/utils";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
// Mock dependencies
|
||||
|
|
@ -14,11 +14,12 @@ jest.mock("@/logger", () => ({
|
|||
|
||||
describe("MessageRepository", () => {
|
||||
let messageRepo: MessageRepository;
|
||||
let mockFormattedDateTime: any;
|
||||
let mockFormattedDateTime: FormattedDateTime;
|
||||
|
||||
beforeEach(() => {
|
||||
messageRepo = new MessageRepository();
|
||||
mockFormattedDateTime = {
|
||||
fileName: "20231201_103000",
|
||||
display: "2023-12-01 10:30:00",
|
||||
epoch: 1701423000000,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -376,7 +376,9 @@ describe("createMapPosReplaceGuard", () => {
|
|||
});
|
||||
|
||||
describe("createHighlightReplaceGuard", () => {
|
||||
const { SelectionHighlight } = jest.requireMock("./selectionHighlight");
|
||||
const { SelectionHighlight } = jest.requireMock<{
|
||||
SelectionHighlight: { getRange: jest.Mock };
|
||||
}>("./selectionHighlight");
|
||||
|
||||
const createMockEditorView = (docContent: string): EditorView => {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ const mockElectron = {
|
|||
jest.mock("electron", () => mockElectron);
|
||||
|
||||
window.TextEncoder = TextEncoder;
|
||||
window.TextDecoder = TextDecoder as any;
|
||||
window.TextDecoder = TextDecoder as unknown as typeof window.TextDecoder;
|
||||
|
||||
// Now we can import our modules
|
||||
import { encryptAllKeys, getDecryptedKey, getEncryptedKey } from "@/encryptionService";
|
||||
|
|
|
|||
|
|
@ -104,7 +104,10 @@ describe.skip("Composer Instructions - Integration Tests", () => {
|
|||
// For canvas files, validate JSON structure
|
||||
if (path.endsWith(".canvas")) {
|
||||
try {
|
||||
const canvasJson = JSON.parse(contentStr);
|
||||
const canvasJson = JSON.parse(contentStr) as {
|
||||
nodes: unknown[];
|
||||
edges?: unknown[];
|
||||
};
|
||||
expect(canvasJson).toHaveProperty("nodes");
|
||||
expect(Array.isArray(canvasJson.nodes)).toBe(true);
|
||||
if (canvasJson.edges) {
|
||||
|
|
|
|||
|
|
@ -21,15 +21,26 @@ import { getSettings } from "@/settings/model";
|
|||
import { ensureFolderExists } from "@/utils";
|
||||
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
|
||||
import { AIMessageChunk } from "@langchain/core/messages";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
// Typed accessor for private UserMemoryManager methods used in tests.
|
||||
type UserMemoryManagerInternal = {
|
||||
updateMemory: (messages: ChatMessage[], chatModel?: BaseChatModel) => Promise<void>;
|
||||
extractJsonFromResponse: (content: string) => string;
|
||||
parseExistingConversations: (content: string) => string[];
|
||||
};
|
||||
const asInternal = (m: UserMemoryManager): UserMemoryManagerInternal =>
|
||||
m as unknown as UserMemoryManagerInternal;
|
||||
|
||||
// Helper to create TFile mock instances
|
||||
const createMockTFile = (path: string): TFile => {
|
||||
const file: TFile = Object.create(TFile.prototype);
|
||||
file.path = path;
|
||||
file.name = path.split("/").pop() || "";
|
||||
file.basename = file.name.replace(/\.[^/.]+$/, "");
|
||||
file.extension = path.split(".").pop() || "";
|
||||
return file;
|
||||
const name = path.split("/").pop() || "";
|
||||
return mockTFile({
|
||||
path,
|
||||
name,
|
||||
basename: name.replace(/\.[^/.]+$/, ""),
|
||||
extension: path.split(".").pop() || "",
|
||||
});
|
||||
};
|
||||
|
||||
describe("UserMemoryManager", () => {
|
||||
|
|
@ -58,12 +69,12 @@ describe("UserMemoryManager", () => {
|
|||
modify: jest.fn(),
|
||||
create: jest.fn(),
|
||||
createFolder: jest.fn(),
|
||||
} as any;
|
||||
} as unknown as jest.Mocked<Vault>;
|
||||
|
||||
// Mock app
|
||||
mockApp = {
|
||||
vault: mockVault,
|
||||
} as any;
|
||||
} as unknown as jest.Mocked<App>;
|
||||
|
||||
// Reset ensureFolderExists mock
|
||||
(ensureFolderExists as jest.Mock).mockClear();
|
||||
|
|
@ -71,7 +82,7 @@ describe("UserMemoryManager", () => {
|
|||
// Mock chat model
|
||||
mockChatModel = {
|
||||
invoke: jest.fn(),
|
||||
} as any;
|
||||
} as unknown as jest.Mocked<BaseChatModel>;
|
||||
|
||||
userMemoryManager = new UserMemoryManager(mockApp);
|
||||
});
|
||||
|
|
@ -159,7 +170,7 @@ describe("UserMemoryManager", () => {
|
|||
mockChatModel.invoke.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
// Execute the updateMemory function directly to ensure proper awaiting
|
||||
await (userMemoryManager as any).updateMemory(messages, mockChatModel);
|
||||
await asInternal(userMemoryManager).updateMemory(messages, mockChatModel);
|
||||
|
||||
// Verify the end result: file was modified with new conversation
|
||||
const modifyCall = mockVault.modify.mock.calls[0];
|
||||
|
|
@ -183,7 +194,7 @@ describe("UserMemoryManager", () => {
|
|||
expect(mockChatModel.invoke).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
content: expect.stringContaining("generate both a title and a summary"),
|
||||
content: expect.stringContaining("generate both a title and a summary") as unknown,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
|
@ -201,7 +212,7 @@ describe("UserMemoryManager", () => {
|
|||
const mockResponse = new AIMessageChunk({ content: "Invalid JSON response" });
|
||||
mockChatModel.invoke.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
await (userMemoryManager as any).updateMemory(messages, mockChatModel);
|
||||
await asInternal(userMemoryManager).updateMemory(messages, mockChatModel);
|
||||
|
||||
// Should still create a conversation entry with fallback values
|
||||
const modifyCall = mockVault.modify.mock.calls[0];
|
||||
|
|
@ -229,7 +240,7 @@ describe("UserMemoryManager", () => {
|
|||
|
||||
That's the JSON data.`;
|
||||
|
||||
const result = (userMemoryManager as any).extractJsonFromResponse(content);
|
||||
const result = asInternal(userMemoryManager).extractJsonFromResponse(content);
|
||||
expect(result).toBe('{\n "title": "Test Title",\n "summary": "Test Summary"\n}');
|
||||
});
|
||||
|
||||
|
|
@ -241,7 +252,7 @@ That's the JSON data.`;
|
|||
}
|
||||
\`\`\``;
|
||||
|
||||
const result = (userMemoryManager as any).extractJsonFromResponse(content);
|
||||
const result = asInternal(userMemoryManager).extractJsonFromResponse(content);
|
||||
expect(result).toBe(
|
||||
'{\n "title": "Unmarked Block",\n "summary": "No language specified"\n}'
|
||||
);
|
||||
|
|
@ -250,14 +261,14 @@ That's the JSON data.`;
|
|||
it("should extract JSON object when no code blocks present", () => {
|
||||
const content = `Some text before {"title": "Inline JSON", "summary": "Direct JSON"} and after`;
|
||||
|
||||
const result = (userMemoryManager as any).extractJsonFromResponse(content);
|
||||
const result = asInternal(userMemoryManager).extractJsonFromResponse(content);
|
||||
expect(result).toBe('{"title": "Inline JSON", "summary": "Direct JSON"}');
|
||||
});
|
||||
|
||||
it("should return original content when no JSON patterns found", () => {
|
||||
const content = "No JSON here, just plain text";
|
||||
|
||||
const result = (userMemoryManager as any).extractJsonFromResponse(content);
|
||||
const result = asInternal(userMemoryManager).extractJsonFromResponse(content);
|
||||
expect(result).toBe(content);
|
||||
});
|
||||
|
||||
|
|
@ -269,7 +280,7 @@ That's the JSON data.`;
|
|||
}
|
||||
\`\`\``;
|
||||
|
||||
const result = (userMemoryManager as any).extractJsonFromResponse(content);
|
||||
const result = asInternal(userMemoryManager).extractJsonFromResponse(content);
|
||||
expect(result).toContain('"title": "Multi-line Test"');
|
||||
expect(result).toContain("special characters: äöü");
|
||||
});
|
||||
|
|
@ -277,7 +288,7 @@ That's the JSON data.`;
|
|||
|
||||
describe("parseExistingConversations", () => {
|
||||
it("should return empty array for empty string", () => {
|
||||
const result = (userMemoryManager as any).parseExistingConversations("");
|
||||
const result = asInternal(userMemoryManager).parseExistingConversations("");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
|
|
@ -287,7 +298,7 @@ It has multiple lines but no conversations.
|
|||
# This is H1, not H2
|
||||
### This is H3, not H2`;
|
||||
|
||||
const result = (userMemoryManager as any).parseExistingConversations(content);
|
||||
const result = asInternal(userMemoryManager).parseExistingConversations(content);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
|
|
@ -296,7 +307,7 @@ It has multiple lines but no conversations.
|
|||
**Time:** 2024-01-01 10:00
|
||||
**Summary:** User asked about creating daily note templates with automatic date formatting.`;
|
||||
|
||||
const result = (userMemoryManager as any).parseExistingConversations(content);
|
||||
const result = asInternal(userMemoryManager).parseExistingConversations(content);
|
||||
expect(result).toEqual([
|
||||
`## Daily Note Template Setup
|
||||
**Time:** 2024-01-01 10:00
|
||||
|
|
@ -317,7 +328,7 @@ It has multiple lines but no conversations.
|
|||
**Time:** 2024-01-01 11:00
|
||||
**Summary:** User learned about backlinks.`;
|
||||
|
||||
const result = (userMemoryManager as any).parseExistingConversations(content);
|
||||
const result = asInternal(userMemoryManager).parseExistingConversations(content);
|
||||
expect(result).toEqual([
|
||||
`## First Conversation
|
||||
**Time:** 2024-01-01 09:00
|
||||
|
|
@ -343,7 +354,7 @@ It might contain important information, but it's before the first conversation.
|
|||
**Time:** 2024-01-01 10:00
|
||||
**Summary:** This conversation should also be included.`;
|
||||
|
||||
const result = (userMemoryManager as any).parseExistingConversations(content);
|
||||
const result = asInternal(userMemoryManager).parseExistingConversations(content);
|
||||
expect(result).toEqual([
|
||||
`## First Conversation
|
||||
**Time:** 2024-01-01 09:00
|
||||
|
|
@ -363,7 +374,7 @@ It might contain important information, but it's before the first conversation.
|
|||
**Time:** 2024-01-01 10:00
|
||||
**Summary:** User inquired about linking notes. `;
|
||||
|
||||
const result = (userMemoryManager as any).parseExistingConversations(content);
|
||||
const result = asInternal(userMemoryManager).parseExistingConversations(content);
|
||||
expect(result).toEqual([
|
||||
`## First Conversation
|
||||
**Time:** 2024-01-01 09:00
|
||||
|
|
@ -388,7 +399,7 @@ The conversation covered advanced features and included code examples.
|
|||
**Time:** 2024-01-01 10:00
|
||||
**Summary:** Short summary.`;
|
||||
|
||||
const result = (userMemoryManager as any).parseExistingConversations(content);
|
||||
const result = asInternal(userMemoryManager).parseExistingConversations(content);
|
||||
expect(result).toEqual([
|
||||
`## Complex Conversation
|
||||
**Time:** 2024-01-01 09:00
|
||||
|
|
@ -409,7 +420,7 @@ The conversation covered advanced features and included code examples.`,
|
|||
**Time:** 2024-01-01 09:00
|
||||
**Summary:** This is the only conversation and it's at the end.`;
|
||||
|
||||
const result = (userMemoryManager as any).parseExistingConversations(content);
|
||||
const result = asInternal(userMemoryManager).parseExistingConversations(content);
|
||||
expect(result).toEqual([
|
||||
`## Only Conversation
|
||||
**Time:** 2024-01-01 09:00
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ jest.mock("@/plusUtils", () => ({
|
|||
isSelfHostAccessValid: jest.fn(),
|
||||
}));
|
||||
|
||||
import { getMiyoFilePath, getMiyoFolderName, getVaultRelativeMiyoPath } from "@/miyo/miyoUtils";
|
||||
import type { App } from "obsidian";
|
||||
import { getMiyoFilePath, getMiyoFolderName, getVaultRelativeMiyoPath } from "@/miyo/miyoUtils";
|
||||
|
||||
describe("getMiyoFolderName", () => {
|
||||
it("uses the vault folder name even when an adapter exposes an absolute path", () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { App, TFile, Vault } from "obsidian";
|
||||
import { App, Vault } from "obsidian";
|
||||
import { ProjectConfig } from "@/aiParams";
|
||||
import { ProjectFileManager } from "@/projects/ProjectFileManager";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Module mocks
|
||||
|
|
@ -87,11 +88,7 @@ function makeConfig(
|
|||
/** Build a minimal Vault mock. */
|
||||
function makeMockVault(): jest.Mocked<Vault> {
|
||||
return {
|
||||
create: jest.fn(async (path: string) => {
|
||||
const file: TFile = Object.create(TFile.prototype);
|
||||
Object.assign(file, { path });
|
||||
return file;
|
||||
}),
|
||||
create: jest.fn(async (path: string) => mockTFile({ path })),
|
||||
// Reason: null = file does not exist yet, avoids collision error in createProject
|
||||
getAbstractFileByPath: jest.fn(() => null),
|
||||
adapter: { exists: jest.fn(async () => false) },
|
||||
|
|
|
|||
|
|
@ -40,11 +40,7 @@ function setupAppMock(rawContent: string, frontmatter: Record<string, unknown> |
|
|||
read: jest.fn().mockResolvedValue(rawContent),
|
||||
// Reason: parseProjectConfigFile uses `cachedFile instanceof TFile` to detect synthetic TFiles.
|
||||
// Return an object with TFile prototype so tests exercise the vault.read() path by default.
|
||||
getAbstractFileByPath: jest.fn((path: string): TFile => {
|
||||
const file: TFile = Object.create(TFile.prototype);
|
||||
Object.assign(file, { path });
|
||||
return file;
|
||||
}),
|
||||
getAbstractFileByPath: jest.fn((path: string): TFile => mockTFile({ path })),
|
||||
adapter: { read: jest.fn().mockResolvedValue(rawContent) },
|
||||
},
|
||||
metadataCache: {
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ jest.mock("obsidian", () => ({
|
|||
path: string;
|
||||
},
|
||||
Modal: class Modal {
|
||||
app: any;
|
||||
constructor(app: any) {
|
||||
app: unknown;
|
||||
constructor(app: unknown) {
|
||||
this.app = app;
|
||||
}
|
||||
open() {}
|
||||
|
|
@ -48,7 +48,7 @@ const mockApp = {
|
|||
vault: {
|
||||
getAbstractFileByPath: mockGetAbstractFileByPath,
|
||||
},
|
||||
} as any;
|
||||
} as unknown as typeof window.app;
|
||||
|
||||
// Mock getTagsFromNote utility function
|
||||
jest.mock(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,19 @@
|
|||
import { App, TFile } from "obsidian";
|
||||
import { FilterRetriever } from "./FilterRetriever";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
// Helper: create a TFile-typed mock from a path. Uses mockTFile so `instanceof TFile`
|
||||
// works for downstream code paths.
|
||||
const createTFile = (path: string, stat: { mtime: number; ctime: number }): TFile => {
|
||||
const basename = path.split("/").pop()?.replace(/\.md$/, "") ?? "";
|
||||
return mockTFile({ path, basename, stat: { ...stat, size: 0 } });
|
||||
};
|
||||
|
||||
// Typed accessors for mocked module exports used in tests.
|
||||
const getObsidianMock = (): { getAllTags: jest.Mock } => jest.requireMock("obsidian");
|
||||
const getUtilsMock = (): { extractNoteFiles: jest.Mock } => jest.requireMock("@/utils");
|
||||
const getSearchUtilsMock = (): { shouldIndexFile: jest.Mock } =>
|
||||
jest.requireMock("@/search/searchUtils");
|
||||
|
||||
// Mock modules
|
||||
jest.mock("obsidian");
|
||||
|
|
@ -17,7 +31,7 @@ describe("FilterRetriever", () => {
|
|||
let mockApp: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const obsidianMock = jest.requireMock("obsidian");
|
||||
const obsidianMock = getObsidianMock();
|
||||
obsidianMock.getAllTags = jest.fn().mockReturnValue([]);
|
||||
|
||||
mockApp = {
|
||||
|
|
@ -34,12 +48,8 @@ describe("FilterRetriever", () => {
|
|||
|
||||
describe("title matches", () => {
|
||||
it("should return title-matched notes with includeInContext true", async () => {
|
||||
const { extractNoteFiles } = jest.requireMock("@/utils");
|
||||
const mockFile = new (TFile as any)("mentioned.md");
|
||||
Object.setPrototypeOf(mockFile, TFile.prototype);
|
||||
mockFile.path = "mentioned.md";
|
||||
mockFile.basename = "mentioned";
|
||||
mockFile.stat = { mtime: 1000, ctime: 500 };
|
||||
const extractNoteFiles = jest.requireMock("@/utils").extractNoteFiles as jest.Mock;
|
||||
const mockFile = createTFile("mentioned.md", { mtime: 1000, ctime: 500 });
|
||||
|
||||
extractNoteFiles.mockReturnValueOnce([mockFile]);
|
||||
mockApp.vault.cachedRead.mockResolvedValue("Note content here");
|
||||
|
|
@ -61,7 +71,7 @@ describe("FilterRetriever", () => {
|
|||
});
|
||||
|
||||
it("should return empty when no [[note]] mentions in query", async () => {
|
||||
const { extractNoteFiles } = jest.requireMock("@/utils");
|
||||
const extractNoteFiles = jest.requireMock("@/utils").extractNoteFiles as jest.Mock;
|
||||
extractNoteFiles.mockReturnValueOnce([]);
|
||||
|
||||
const retriever = new FilterRetriever(mockApp as App, {
|
||||
|
|
@ -76,22 +86,18 @@ describe("FilterRetriever", () => {
|
|||
|
||||
describe("tag matches", () => {
|
||||
it("should return tag-matched notes with includeInContext true", async () => {
|
||||
const obsidianMock = jest.requireMock("obsidian");
|
||||
const obsidianMock = getObsidianMock();
|
||||
|
||||
const taggedFile = new (TFile as any)("projects/alpha.md");
|
||||
Object.setPrototypeOf(taggedFile, TFile.prototype);
|
||||
taggedFile.path = "projects/alpha.md";
|
||||
taggedFile.basename = "alpha";
|
||||
taggedFile.stat = { mtime: 2000, ctime: 1000 };
|
||||
const taggedFile = createTFile("projects/alpha.md", { mtime: 2000, ctime: 1000 });
|
||||
|
||||
mockApp.vault.getMarkdownFiles.mockReturnValue([taggedFile]);
|
||||
mockApp.metadataCache.getFileCache.mockImplementation((file: any) => {
|
||||
mockApp.metadataCache.getFileCache.mockImplementation((file: TFile) => {
|
||||
if (file.path === "projects/alpha.md") return { tags: [{ tag: "#project" }] };
|
||||
return null;
|
||||
});
|
||||
obsidianMock.getAllTags.mockImplementation((cache: { tags?: { tag: string }[] }) => {
|
||||
if (!cache?.tags) return [];
|
||||
return cache.tags.map((t: { tag: string }) => t.tag);
|
||||
return cache.tags.map((t) => t.tag);
|
||||
});
|
||||
mockApp.vault.cachedRead.mockResolvedValue("Alpha content");
|
||||
|
||||
|
|
@ -110,22 +116,18 @@ describe("FilterRetriever", () => {
|
|||
});
|
||||
|
||||
it("should support hierarchical prefix matching (#project matches #project/beta)", async () => {
|
||||
const obsidianMock = jest.requireMock("obsidian");
|
||||
const obsidianMock = getObsidianMock();
|
||||
|
||||
const betaFile = new (TFile as any)("projects/beta.md");
|
||||
Object.setPrototypeOf(betaFile, TFile.prototype);
|
||||
betaFile.path = "projects/beta.md";
|
||||
betaFile.basename = "beta";
|
||||
betaFile.stat = { mtime: 3000, ctime: 1000 };
|
||||
const betaFile = createTFile("projects/beta.md", { mtime: 3000, ctime: 1000 });
|
||||
|
||||
mockApp.vault.getMarkdownFiles.mockReturnValue([betaFile]);
|
||||
mockApp.metadataCache.getFileCache.mockImplementation((file: any) => {
|
||||
mockApp.metadataCache.getFileCache.mockImplementation((file: TFile) => {
|
||||
if (file.path === "projects/beta.md") return { tags: [{ tag: "#project/beta" }] };
|
||||
return null;
|
||||
});
|
||||
obsidianMock.getAllTags.mockImplementation((cache: { tags?: { tag: string }[] }) => {
|
||||
if (!cache?.tags) return [];
|
||||
return cache.tags.map((t: { tag: string }) => t.tag);
|
||||
return cache.tags.map((t) => t.tag);
|
||||
});
|
||||
mockApp.vault.cachedRead.mockResolvedValue("Beta content");
|
||||
|
||||
|
|
@ -151,23 +153,18 @@ describe("FilterRetriever", () => {
|
|||
});
|
||||
|
||||
it("should cap tag matches at maxK when returnAll is false", async () => {
|
||||
const obsidianMock = jest.requireMock("obsidian");
|
||||
const obsidianMock = getObsidianMock();
|
||||
|
||||
// Create 10 files all matching the tag, but set maxK to 3
|
||||
const files: TFile[] = Array.from({ length: 10 }, (_, i): TFile => {
|
||||
const f: TFile = new (TFile as any)(`note${i}.md`);
|
||||
Object.setPrototypeOf(f, TFile.prototype);
|
||||
f.path = `note${i}.md`;
|
||||
f.basename = `note${i}`;
|
||||
f.stat = { mtime: 1000 + i, ctime: 500 } as TFile["stat"];
|
||||
return f;
|
||||
});
|
||||
const files = Array.from({ length: 10 }, (_, i) =>
|
||||
createTFile(`note${i}.md`, { mtime: 1000 + i, ctime: 500 })
|
||||
);
|
||||
|
||||
mockApp.vault.getMarkdownFiles.mockReturnValue(files);
|
||||
mockApp.metadataCache.getFileCache.mockReturnValue({ tags: [{ tag: "#daily" }] });
|
||||
obsidianMock.getAllTags.mockImplementation((cache: { tags?: { tag: string }[] }) => {
|
||||
if (!cache?.tags) return [];
|
||||
return cache.tags.map((t: { tag: string }) => t.tag);
|
||||
return cache.tags.map((t) => t.tag);
|
||||
});
|
||||
mockApp.vault.cachedRead.mockResolvedValue("Content");
|
||||
|
||||
|
|
@ -181,23 +178,18 @@ describe("FilterRetriever", () => {
|
|||
});
|
||||
|
||||
it("should use RETURN_ALL_LIMIT when returnAll is true (tag queries need expanded limits)", async () => {
|
||||
const obsidianMock = jest.requireMock("obsidian");
|
||||
const obsidianMock = getObsidianMock();
|
||||
|
||||
// Create 40 files all matching the tag, set maxK to 3 but returnAll to true
|
||||
const files: TFile[] = Array.from({ length: 40 }, (_, i): TFile => {
|
||||
const f: TFile = new (TFile as any)(`note${i}.md`);
|
||||
Object.setPrototypeOf(f, TFile.prototype);
|
||||
f.path = `note${i}.md`;
|
||||
f.basename = `note${i}`;
|
||||
f.stat = { mtime: 1000 + i, ctime: 500 } as TFile["stat"];
|
||||
return f;
|
||||
});
|
||||
const files = Array.from({ length: 40 }, (_, i) =>
|
||||
createTFile(`note${i}.md`, { mtime: 1000 + i, ctime: 500 })
|
||||
);
|
||||
|
||||
mockApp.vault.getMarkdownFiles.mockReturnValue(files);
|
||||
mockApp.metadataCache.getFileCache.mockReturnValue({ tags: [{ tag: "#daily" }] });
|
||||
obsidianMock.getAllTags.mockImplementation((cache: { tags?: { tag: string }[] }) => {
|
||||
if (!cache?.tags) return [];
|
||||
return cache.tags.map((t: { tag: string }) => t.tag);
|
||||
return cache.tags.map((t) => t.tag);
|
||||
});
|
||||
mockApp.vault.cachedRead.mockResolvedValue("Content");
|
||||
|
||||
|
|
@ -217,14 +209,10 @@ describe("FilterRetriever", () => {
|
|||
|
||||
describe("deduplication", () => {
|
||||
it("should deduplicate title and tag matches by path (title wins)", async () => {
|
||||
const obsidianMock = jest.requireMock("obsidian");
|
||||
const { extractNoteFiles } = jest.requireMock("@/utils");
|
||||
const obsidianMock = getObsidianMock();
|
||||
const { extractNoteFiles } = getUtilsMock();
|
||||
|
||||
const sharedFile = new (TFile as any)("shared.md");
|
||||
Object.setPrototypeOf(sharedFile, TFile.prototype);
|
||||
sharedFile.path = "shared.md";
|
||||
sharedFile.basename = "shared";
|
||||
sharedFile.stat = { mtime: 1000, ctime: 500 };
|
||||
const sharedFile = createTFile("shared.md", { mtime: 1000, ctime: 500 });
|
||||
|
||||
// File appears in both title matches and tag matches
|
||||
extractNoteFiles.mockReturnValueOnce([sharedFile]);
|
||||
|
|
@ -232,7 +220,7 @@ describe("FilterRetriever", () => {
|
|||
mockApp.metadataCache.getFileCache.mockReturnValue({ tags: [{ tag: "#tag1" }] });
|
||||
obsidianMock.getAllTags.mockImplementation((cache: { tags?: { tag: string }[] }) => {
|
||||
if (!cache?.tags) return [];
|
||||
return cache.tags.map((t: { tag: string }) => t.tag);
|
||||
return cache.tags.map((t) => t.tag);
|
||||
});
|
||||
mockApp.vault.cachedRead.mockResolvedValue("Shared content");
|
||||
|
||||
|
|
@ -251,16 +239,14 @@ describe("FilterRetriever", () => {
|
|||
|
||||
describe("time range", () => {
|
||||
it("should return time-filtered documents when timeRange is set", async () => {
|
||||
const { extractNoteFiles } = jest.requireMock("@/utils");
|
||||
const { extractNoteFiles } = getUtilsMock();
|
||||
extractNoteFiles.mockReturnValue([]);
|
||||
|
||||
const now = Date.now();
|
||||
const recentFile = {
|
||||
path: "notes/recent.md",
|
||||
basename: "recent",
|
||||
stat: { mtime: now - 1000, ctime: now - 2000 },
|
||||
};
|
||||
Object.setPrototypeOf(recentFile, TFile.prototype);
|
||||
const recentFile = createTFile("notes/recent.md", {
|
||||
mtime: now - 1000,
|
||||
ctime: now - 2000,
|
||||
});
|
||||
|
||||
mockApp.vault.getMarkdownFiles.mockReturnValue([recentFile]);
|
||||
mockApp.vault.cachedRead.mockResolvedValue("Recent content");
|
||||
|
|
@ -284,26 +270,21 @@ describe("FilterRetriever", () => {
|
|||
});
|
||||
|
||||
it("should exclude files matching QA exclusion patterns in time-range searches", async () => {
|
||||
const { shouldIndexFile } = jest.requireMock("@/search/searchUtils");
|
||||
const { extractNoteFiles } = jest.requireMock("@/utils");
|
||||
const { shouldIndexFile } = getSearchUtilsMock();
|
||||
const { extractNoteFiles } = getUtilsMock();
|
||||
extractNoteFiles.mockReturnValue([]);
|
||||
|
||||
const now = Date.now();
|
||||
const validFile = {
|
||||
path: "notes/valid.md",
|
||||
basename: "valid",
|
||||
stat: { mtime: now - 1000, ctime: now - 2000 },
|
||||
};
|
||||
const excludedFile = {
|
||||
path: "copilot/excluded.md",
|
||||
basename: "excluded",
|
||||
stat: { mtime: now - 1000, ctime: now - 2000 },
|
||||
};
|
||||
[validFile, excludedFile].forEach((f) => {
|
||||
Object.setPrototypeOf(f, TFile.prototype);
|
||||
const validFile = createTFile("notes/valid.md", {
|
||||
mtime: now - 1000,
|
||||
ctime: now - 2000,
|
||||
});
|
||||
const excludedFile = createTFile("copilot/excluded.md", {
|
||||
mtime: now - 1000,
|
||||
ctime: now - 2000,
|
||||
});
|
||||
|
||||
shouldIndexFile.mockImplementation((file: any) => !file.path.startsWith("copilot/"));
|
||||
shouldIndexFile.mockImplementation((file: TFile) => !file.path.startsWith("copilot/"));
|
||||
mockApp.vault.getMarkdownFiles.mockReturnValue([validFile, excludedFile]);
|
||||
mockApp.vault.cachedRead.mockResolvedValue("Content");
|
||||
mockApp.metadataCache.getFileCache.mockReturnValue({ tags: [] });
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { TieredLexicalRetriever } from "./TieredLexicalRetriever";
|
|||
import { HybridRetriever } from "@/search/hybridRetriever";
|
||||
|
||||
const optionsSpy: {
|
||||
lexicalOptions?: Record<string, any>;
|
||||
semanticOptions?: Record<string, any>;
|
||||
lexicalOptions?: Record<string, unknown>;
|
||||
semanticOptions?: Record<string, unknown>;
|
||||
} = {};
|
||||
|
||||
let lexicalResults: Document[] = [];
|
||||
|
|
@ -14,18 +14,20 @@ let semanticResults: Document[] = [];
|
|||
|
||||
jest.mock("@/search/v3/TieredLexicalRetriever", () => {
|
||||
return {
|
||||
TieredLexicalRetriever: jest.fn().mockImplementation((_app: any, options: any) => {
|
||||
optionsSpy.lexicalOptions = options;
|
||||
return {
|
||||
getRelevantDocuments: jest.fn(async () => lexicalResults),
|
||||
};
|
||||
}),
|
||||
TieredLexicalRetriever: jest
|
||||
.fn()
|
||||
.mockImplementation((_app: unknown, options: Record<string, unknown>) => {
|
||||
optionsSpy.lexicalOptions = options;
|
||||
return {
|
||||
getRelevantDocuments: jest.fn(async () => lexicalResults),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("@/search/hybridRetriever", () => {
|
||||
return {
|
||||
HybridRetriever: jest.fn().mockImplementation((options: any) => {
|
||||
HybridRetriever: jest.fn().mockImplementation((options: Record<string, unknown>) => {
|
||||
optionsSpy.semanticOptions = options;
|
||||
return {
|
||||
getRelevantDocuments: jest.fn(async () => semanticResults),
|
||||
|
|
@ -35,7 +37,7 @@ jest.mock("@/search/hybridRetriever", () => {
|
|||
});
|
||||
|
||||
describe("MergedSemanticRetriever", () => {
|
||||
const mockApp = {} as any;
|
||||
const mockApp = {} as unknown as App;
|
||||
|
||||
beforeEach(() => {
|
||||
lexicalResults = [];
|
||||
|
|
@ -68,7 +70,7 @@ describe("MergedSemanticRetriever", () => {
|
|||
lexicalResults = [lexicalInput];
|
||||
semanticResults = [semanticInput];
|
||||
|
||||
const retriever = new MergedSemanticRetriever(mockApp as App, {
|
||||
const retriever = new MergedSemanticRetriever(mockApp, {
|
||||
maxK: 5,
|
||||
salientTerms: [],
|
||||
});
|
||||
|
|
@ -102,7 +104,7 @@ describe("MergedSemanticRetriever", () => {
|
|||
lexicalResults = [lexicalInput];
|
||||
semanticResults = [semanticInput];
|
||||
|
||||
const retriever = new MergedSemanticRetriever(mockApp as App, {
|
||||
const retriever = new MergedSemanticRetriever(mockApp, {
|
||||
maxK: 5,
|
||||
salientTerms: [],
|
||||
});
|
||||
|
|
@ -142,7 +144,7 @@ describe("MergedSemanticRetriever", () => {
|
|||
];
|
||||
semanticResults = [];
|
||||
|
||||
const retriever = new MergedSemanticRetriever(mockApp as App, {
|
||||
const retriever = new MergedSemanticRetriever(mockApp, {
|
||||
maxK: 5,
|
||||
salientTerms: [],
|
||||
});
|
||||
|
|
@ -175,7 +177,7 @@ describe("MergedSemanticRetriever", () => {
|
|||
}),
|
||||
];
|
||||
|
||||
const retriever = new MergedSemanticRetriever(mockApp as App, {
|
||||
const retriever = new MergedSemanticRetriever(mockApp, {
|
||||
maxK: 5,
|
||||
salientTerms: [],
|
||||
});
|
||||
|
|
@ -209,7 +211,7 @@ describe("MergedSemanticRetriever", () => {
|
|||
}),
|
||||
];
|
||||
|
||||
const retriever = new MergedSemanticRetriever(mockApp as App, {
|
||||
const retriever = new MergedSemanticRetriever(mockApp, {
|
||||
maxK: 1,
|
||||
salientTerms: [],
|
||||
returnAll: true,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,22 @@
|
|||
import { App, TFile } from "obsidian";
|
||||
import { App } from "obsidian";
|
||||
import { TieredLexicalRetriever } from "./TieredLexicalRetriever";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
type MockApp = {
|
||||
vault: {
|
||||
getAbstractFileByPath: jest.Mock;
|
||||
cachedRead: jest.Mock;
|
||||
getMarkdownFiles: jest.Mock;
|
||||
};
|
||||
metadataCache: {
|
||||
getFileCache: jest.Mock;
|
||||
};
|
||||
};
|
||||
|
||||
type MockChunkManager = {
|
||||
getChunkTextSync: jest.Mock;
|
||||
getChunkText: jest.Mock;
|
||||
};
|
||||
|
||||
const retrieveMock = jest.fn();
|
||||
|
||||
|
|
@ -32,12 +49,14 @@ jest.mock("./chunks", () => {
|
|||
});
|
||||
|
||||
describe("TieredLexicalRetriever", () => {
|
||||
let mockApp: any;
|
||||
let mockChunkManager: any;
|
||||
let mockApp: MockApp;
|
||||
let mockChunkManager: MockChunkManager;
|
||||
|
||||
beforeEach(() => {
|
||||
// Get reference to the mocked chunk manager
|
||||
const chunksModule = jest.requireMock("./chunks");
|
||||
const chunksModule = jest.requireMock<{ getSharedChunkManager: () => MockChunkManager }>(
|
||||
"./chunks"
|
||||
);
|
||||
mockChunkManager = chunksModule.getSharedChunkManager();
|
||||
|
||||
retrieveMock.mockReset();
|
||||
|
|
@ -88,9 +107,7 @@ describe("TieredLexicalRetriever", () => {
|
|||
// Mock file system
|
||||
mockApp.vault.getAbstractFileByPath.mockImplementation((path: string) => {
|
||||
if (path === "note1.md" || path === "note2.md") {
|
||||
const file: TFile = new (TFile as any)(path);
|
||||
Object.setPrototypeOf(file, TFile.prototype);
|
||||
file.stat = { mtime: 1000, ctime: 1000 } as TFile["stat"];
|
||||
const file = mockTFile({ path, stat: { mtime: 1000, ctime: 1000, size: 0 } });
|
||||
return file;
|
||||
}
|
||||
return null;
|
||||
|
|
@ -116,7 +133,7 @@ describe("TieredLexicalRetriever", () => {
|
|||
},
|
||||
});
|
||||
|
||||
const chunkRetriever = new TieredLexicalRetriever(mockApp as App, {
|
||||
const chunkRetriever = new TieredLexicalRetriever(mockApp as unknown as App, {
|
||||
minSimilarityScore: 0.1,
|
||||
maxK: 30,
|
||||
salientTerms: [],
|
||||
|
|
@ -143,7 +160,7 @@ describe("TieredLexicalRetriever", () => {
|
|||
expandedQueries: [],
|
||||
},
|
||||
});
|
||||
const emptyRetriever = new TieredLexicalRetriever(mockApp as App, {
|
||||
const emptyRetriever = new TieredLexicalRetriever(mockApp as unknown as App, {
|
||||
minSimilarityScore: 0.1,
|
||||
maxK: 30,
|
||||
salientTerms: [],
|
||||
|
|
@ -166,7 +183,7 @@ describe("TieredLexicalRetriever", () => {
|
|||
},
|
||||
});
|
||||
|
||||
const sortRetriever = new TieredLexicalRetriever(mockApp as App, {
|
||||
const sortRetriever = new TieredLexicalRetriever(mockApp as unknown as App, {
|
||||
minSimilarityScore: 0.1,
|
||||
maxK: 30,
|
||||
salientTerms: [],
|
||||
|
|
@ -207,15 +224,13 @@ describe("TieredLexicalRetriever", () => {
|
|||
|
||||
mockApp.vault.getAbstractFileByPath.mockImplementation((path: string) => {
|
||||
if (path === "test.md") {
|
||||
const file: TFile = new (TFile as any)(path);
|
||||
Object.setPrototypeOf(file, TFile.prototype);
|
||||
file.stat = { mtime: 1000, ctime: 1000 } as TFile["stat"];
|
||||
const file = mockTFile({ path, stat: { mtime: 1000, ctime: 1000, size: 0 } });
|
||||
return file;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const chunkRetriever = new TieredLexicalRetriever(mockApp as App, {
|
||||
const chunkRetriever = new TieredLexicalRetriever(mockApp as unknown as App, {
|
||||
minSimilarityScore: 0.1,
|
||||
maxK: 30,
|
||||
salientTerms: [],
|
||||
|
|
@ -243,7 +258,7 @@ describe("TieredLexicalRetriever", () => {
|
|||
},
|
||||
});
|
||||
|
||||
const emptyChunkRetriever = new TieredLexicalRetriever(mockApp as App, {
|
||||
const emptyChunkRetriever = new TieredLexicalRetriever(mockApp as unknown as App, {
|
||||
minSimilarityScore: 0.1,
|
||||
maxK: 30,
|
||||
salientTerms: [],
|
||||
|
|
@ -258,9 +273,7 @@ describe("TieredLexicalRetriever", () => {
|
|||
it("should handle multiple chunks from same note correctly", async () => {
|
||||
mockApp.vault.getAbstractFileByPath.mockImplementation((path: string) => {
|
||||
if (path === "large.md" || path === "other.md") {
|
||||
const file: TFile = new (TFile as any)(path);
|
||||
Object.setPrototypeOf(file, TFile.prototype);
|
||||
file.stat = { mtime: 1000, ctime: 1000 } as TFile["stat"];
|
||||
const file = mockTFile({ path, stat: { mtime: 1000, ctime: 1000, size: 0 } });
|
||||
return file;
|
||||
}
|
||||
return null;
|
||||
|
|
@ -291,7 +304,7 @@ describe("TieredLexicalRetriever", () => {
|
|||
Promise.resolve(getMultiChunkContent(id))
|
||||
);
|
||||
|
||||
const multiChunkRetriever = new TieredLexicalRetriever(mockApp as App, {
|
||||
const multiChunkRetriever = new TieredLexicalRetriever(mockApp as unknown as App, {
|
||||
minSimilarityScore: 0.1,
|
||||
maxK: 30,
|
||||
salientTerms: [],
|
||||
|
|
|
|||
|
|
@ -32,6 +32,16 @@ jest.mock("@/logger", () => ({
|
|||
|
||||
import { App, TFile } from "obsidian";
|
||||
import { ChunkManager, ChunkOptions } from "./chunks";
|
||||
import { mockTFile } from "@/__tests__/mockObsidian";
|
||||
|
||||
type ChunkManagerInternal = {
|
||||
cache: Map<string, unknown>;
|
||||
maxCacheBytes: number;
|
||||
generateChunkId: (path: string, index: number) => string;
|
||||
};
|
||||
|
||||
const asChunkInternal = (m: ChunkManager): ChunkManagerInternal =>
|
||||
m as unknown as ChunkManagerInternal;
|
||||
|
||||
describe("ChunkManager", () => {
|
||||
let chunkManager: ChunkManager;
|
||||
|
|
@ -49,9 +59,11 @@ describe("ChunkManager", () => {
|
|||
if (mockFileCache.has(path)) {
|
||||
return mockFileCache.get(path);
|
||||
}
|
||||
const file: TFile = new (TFile as any)(path);
|
||||
// Ensure the file passes instanceof TFile checks
|
||||
Object.setPrototypeOf(file, TFile.prototype);
|
||||
const file = mockTFile({
|
||||
path,
|
||||
basename: path.replace(".md", ""),
|
||||
stat: { mtime: Date.now(), ctime: 0, size: 0 },
|
||||
});
|
||||
mockFileCache.set(path, file);
|
||||
return file;
|
||||
}),
|
||||
|
|
@ -487,7 +499,7 @@ describe("ChunkManager", () => {
|
|||
it("should evict cache when memory limit is exceeded", async () => {
|
||||
// Mock a manager with very small cache limit
|
||||
const smallCacheManager = new ChunkManager(mockApp as App);
|
||||
(smallCacheManager as any).maxCacheBytes = 1000; // 1KB limit
|
||||
asChunkInternal(smallCacheManager).maxCacheBytes = 1000; // 1KB limit
|
||||
|
||||
// Add multiple large documents
|
||||
await smallCacheManager.getChunks(["long.md"], {
|
||||
|
|
@ -503,7 +515,7 @@ describe("ChunkManager", () => {
|
|||
});
|
||||
|
||||
// Cache should not grow indefinitely - check cache size doesn't exceed notes added
|
||||
const cacheSize = (smallCacheManager as any).cache.size;
|
||||
const cacheSize = asChunkInternal(smallCacheManager).cache.size;
|
||||
expect(cacheSize).toBeLessThanOrEqual(3); // Should not cache more than a few notes due to memory limits
|
||||
});
|
||||
|
||||
|
|
@ -713,7 +725,7 @@ describe("ChunkManager", () => {
|
|||
|
||||
for (const testCase of testCases) {
|
||||
const manager = new ChunkManager(mockApp as App);
|
||||
const generatedId = (manager as any).generateChunkId("test.md", testCase.index);
|
||||
const generatedId = asChunkInternal(manager).generateChunkId("test.md", testCase.index);
|
||||
expect(generatedId).toBe(`test.md#${testCase.expected}`);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -222,6 +222,16 @@ jest.mock("../chunks", () => {
|
|||
import { App, TFile } from "obsidian";
|
||||
import { FullTextEngine } from "./FullTextEngine";
|
||||
|
||||
// Typed accessor for private FullTextEngine fields/methods used in tests.
|
||||
type FullTextEngineInternal = {
|
||||
tokenizeMixed: (str: string) => string[];
|
||||
getFieldWeight: (field: string) => number;
|
||||
index: ({ destroy?: () => void; clear?: () => void } & Record<string, unknown>) | null;
|
||||
indexedChunks: Set<string>;
|
||||
};
|
||||
const asInternal = (e: FullTextEngine): FullTextEngineInternal =>
|
||||
e as unknown as FullTextEngineInternal;
|
||||
|
||||
describe("FullTextEngine", () => {
|
||||
let engine: FullTextEngine;
|
||||
let mockApp: any;
|
||||
|
|
@ -304,7 +314,7 @@ describe("FullTextEngine", () => {
|
|||
vault: {
|
||||
getAbstractFileByPath: jest.fn((path: string) => {
|
||||
if (!path || path === "missing.md") return null;
|
||||
const file: TFile = new (TFile as any)(path);
|
||||
const file: TFile = new (TFile as unknown as new (path: string) => TFile)(path);
|
||||
// Make it pass instanceof TFile check
|
||||
Object.setPrototypeOf(file, TFile.prototype);
|
||||
return file;
|
||||
|
|
@ -345,7 +355,7 @@ describe("FullTextEngine", () => {
|
|||
|
||||
describe("tokenizeMixed", () => {
|
||||
it("should tokenize ASCII words", () => {
|
||||
const tokens = (engine as any).tokenizeMixed("Hello World TypeScript");
|
||||
const tokens = asInternal(engine).tokenizeMixed("Hello World TypeScript");
|
||||
|
||||
expect(tokens).toContain("hello");
|
||||
expect(tokens).toContain("world");
|
||||
|
|
@ -353,14 +363,14 @@ describe("FullTextEngine", () => {
|
|||
});
|
||||
|
||||
it("should tokenize alphanumeric and underscores", () => {
|
||||
const tokens = (engine as any).tokenizeMixed("test_123 var_name");
|
||||
const tokens = asInternal(engine).tokenizeMixed("test_123 var_name");
|
||||
|
||||
expect(tokens).toContain("test_123");
|
||||
expect(tokens).toContain("var_name");
|
||||
});
|
||||
|
||||
it("should generate CJK bigrams", () => {
|
||||
const tokens = (engine as any).tokenizeMixed("中文编程");
|
||||
const tokens = asInternal(engine).tokenizeMixed("中文编程");
|
||||
|
||||
expect(tokens).toContain("中文");
|
||||
expect(tokens).toContain("文编");
|
||||
|
|
@ -368,7 +378,7 @@ describe("FullTextEngine", () => {
|
|||
});
|
||||
|
||||
it("should handle mixed content", () => {
|
||||
const tokens = (engine as any).tokenizeMixed("TypeScript 和 JavaScript 编程");
|
||||
const tokens = asInternal(engine).tokenizeMixed("TypeScript 和 JavaScript 编程");
|
||||
|
||||
expect(tokens).toContain("typescript");
|
||||
expect(tokens).toContain("javascript");
|
||||
|
|
@ -376,14 +386,14 @@ describe("FullTextEngine", () => {
|
|||
});
|
||||
|
||||
it("should handle single CJK characters", () => {
|
||||
const tokens = (engine as any).tokenizeMixed("中 文");
|
||||
const tokens = asInternal(engine).tokenizeMixed("中 文");
|
||||
|
||||
expect(tokens).toContain("中");
|
||||
expect(tokens).toContain("文");
|
||||
});
|
||||
|
||||
it("should tokenize hash tags and their hierarchy variants", () => {
|
||||
const tokens = (engine as any).tokenizeMixed("Working on #Project/Alpha and #deepWork");
|
||||
const tokens = asInternal(engine).tokenizeMixed("Working on #Project/Alpha and #deepWork");
|
||||
|
||||
expect(tokens).toEqual(
|
||||
expect.arrayContaining([
|
||||
|
|
@ -399,7 +409,7 @@ describe("FullTextEngine", () => {
|
|||
});
|
||||
|
||||
it("should not split hyphenated tags into partial word tokens", () => {
|
||||
const tokens = (engine as any).tokenizeMixed("#copilot-conversation updates");
|
||||
const tokens = asInternal(engine).tokenizeMixed("#copilot-conversation updates");
|
||||
|
||||
expect(tokens).toContain("#copilot-conversation");
|
||||
expect(tokens).toContain("copilot-conversation");
|
||||
|
|
@ -408,7 +418,7 @@ describe("FullTextEngine", () => {
|
|||
});
|
||||
|
||||
it("should return empty array for empty input", () => {
|
||||
const tokens = (engine as any).tokenizeMixed("");
|
||||
const tokens = asInternal(engine).tokenizeMixed("");
|
||||
expect(tokens).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -429,7 +439,7 @@ describe("FullTextEngine", () => {
|
|||
const candidates = Array.from({ length: 100 }, (_, i) => `note${i}.md`);
|
||||
|
||||
// Mock vault to return files for all candidates
|
||||
mockApp.vault.getAbstractFileByPath = jest.fn((path) => ({
|
||||
mockApp.vault.getAbstractFileByPath = jest.fn((path: string) => ({
|
||||
path,
|
||||
basename: path.replace(".md", ""),
|
||||
stat: { mtime: Date.now() },
|
||||
|
|
@ -448,7 +458,7 @@ describe("FullTextEngine", () => {
|
|||
it("should handle missing files gracefully", async () => {
|
||||
mockApp.vault.getAbstractFileByPath = jest.fn((path: string) => {
|
||||
if (path === "missing.md") return null;
|
||||
const file: TFile = new (TFile as any)(path);
|
||||
const file: TFile = new (TFile as unknown as new (path: string) => TFile)(path);
|
||||
Object.setPrototypeOf(file, TFile.prototype);
|
||||
return file;
|
||||
});
|
||||
|
|
@ -477,7 +487,7 @@ describe("FullTextEngine", () => {
|
|||
await engine.buildFromCandidates(["note1.md"]);
|
||||
|
||||
// Mock the index to throw an error on destroy/clear
|
||||
const mockIndex = (engine as any).index;
|
||||
const mockIndex = asInternal(engine).index;
|
||||
if (mockIndex) {
|
||||
mockIndex.destroy = jest.fn(() => {
|
||||
throw new Error("Mock cleanup error");
|
||||
|
|
@ -491,15 +501,15 @@ describe("FullTextEngine", () => {
|
|||
expect(() => engine.clear()).not.toThrow();
|
||||
|
||||
// Should still reset state
|
||||
expect((engine as any).index).toBeNull();
|
||||
expect((engine as any).indexedChunks.size).toBe(0);
|
||||
expect(asInternal(engine).index).toBeNull();
|
||||
expect(asInternal(engine).indexedChunks.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should skip unsafe vault paths", async () => {
|
||||
// Mock vault to return files for any safe path
|
||||
mockApp.vault.getAbstractFileByPath = jest.fn((path: string) => {
|
||||
if (path === "note1.md") {
|
||||
const file: TFile = new (TFile as any)(path);
|
||||
const file: TFile = new (TFile as unknown as new (path: string) => TFile)(path);
|
||||
Object.setPrototypeOf(file, TFile.prototype);
|
||||
return file;
|
||||
}
|
||||
|
|
@ -755,7 +765,9 @@ describe("FullTextEngine", () => {
|
|||
|
||||
describe("getFieldWeight", () => {
|
||||
it("should return correct weights for known fields", () => {
|
||||
const getFieldWeight = (engine as any).getFieldWeight.bind(engine);
|
||||
const getFieldWeight = asInternal(engine).getFieldWeight.bind(engine) as (
|
||||
f: string
|
||||
) => number;
|
||||
|
||||
expect(getFieldWeight("title")).toBe(5);
|
||||
expect(getFieldWeight("heading")).toBe(2.5);
|
||||
|
|
@ -768,7 +780,9 @@ describe("FullTextEngine", () => {
|
|||
});
|
||||
|
||||
it("should return default weight for unknown fields", () => {
|
||||
const getFieldWeight = (engine as any).getFieldWeight.bind(engine);
|
||||
const getFieldWeight = asInternal(engine).getFieldWeight.bind(engine) as (
|
||||
f: string
|
||||
) => number;
|
||||
expect(getFieldWeight("unknown")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -927,12 +941,12 @@ describe("FullTextEngine", () => {
|
|||
});
|
||||
|
||||
it("should handle circular frontmatter safely", async () => {
|
||||
const a: any = { name: "A" };
|
||||
const b: any = { name: "B" };
|
||||
const a: Record<string, unknown> = { name: "A" };
|
||||
const b: Record<string, unknown> = { name: "B" };
|
||||
a.ref = b;
|
||||
b.ref = a; // circular
|
||||
|
||||
const propsCache: Record<string, unknown> = {
|
||||
const propsCache: Record<string, { headings: unknown[]; frontmatter: unknown }> = {
|
||||
"circular.md": {
|
||||
headings: [],
|
||||
frontmatter: a,
|
||||
|
|
@ -951,13 +965,13 @@ describe("FullTextEngine", () => {
|
|||
describe("clear method", () => {
|
||||
it("should safely clear when index is null (not yet created)", () => {
|
||||
// Engine starts with null index
|
||||
expect((engine as any).index).toBeNull();
|
||||
expect(asInternal(engine).index).toBeNull();
|
||||
|
||||
// Should not throw error when clearing uninitialized index
|
||||
expect(() => engine.clear()).not.toThrow();
|
||||
|
||||
// Should still reset collections
|
||||
expect((engine as any).indexedChunks.size).toBe(0);
|
||||
expect(asInternal(engine).indexedChunks.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should properly clear after index has been created", async () => {
|
||||
|
|
@ -965,15 +979,15 @@ describe("FullTextEngine", () => {
|
|||
await engine.buildFromCandidates(["note1.md", "note2.md"]);
|
||||
|
||||
// Verify index was created and chunks indexed
|
||||
expect((engine as any).index).not.toBeNull();
|
||||
expect((engine as any).indexedChunks.size).toBeGreaterThan(0);
|
||||
expect(asInternal(engine).index).not.toBeNull();
|
||||
expect(asInternal(engine).indexedChunks.size).toBeGreaterThan(0);
|
||||
|
||||
// Clear should not throw
|
||||
expect(() => engine.clear()).not.toThrow();
|
||||
|
||||
// Should reset state
|
||||
expect((engine as any).index).toBeNull();
|
||||
expect((engine as any).indexedChunks.size).toBe(0);
|
||||
expect(asInternal(engine).index).toBeNull();
|
||||
expect(asInternal(engine).indexedChunks.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should allow multiple clear calls safely", async () => {
|
||||
|
|
@ -986,20 +1000,20 @@ describe("FullTextEngine", () => {
|
|||
expect(() => engine.clear()).not.toThrow();
|
||||
|
||||
// State should remain clean
|
||||
expect((engine as any).index).toBeNull();
|
||||
expect((engine as any).indexedChunks.size).toBe(0);
|
||||
expect(asInternal(engine).index).toBeNull();
|
||||
expect(asInternal(engine).indexedChunks.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should nullify MiniSearch index on clear", async () => {
|
||||
await engine.buildFromCandidates(["note1.md"]);
|
||||
|
||||
// Verify index exists
|
||||
expect((engine as any).index).not.toBeNull();
|
||||
expect(asInternal(engine).index).not.toBeNull();
|
||||
|
||||
engine.clear();
|
||||
|
||||
// MiniSearch cleanup is just nullifying the reference
|
||||
expect((engine as any).index).toBeNull();
|
||||
expect(asInternal(engine).index).toBeNull();
|
||||
});
|
||||
|
||||
it("should allow new index to be created after clear", async () => {
|
||||
|
|
@ -1008,28 +1022,28 @@ describe("FullTextEngine", () => {
|
|||
|
||||
// Should be able to create a new index after clearing
|
||||
await engine.buildFromCandidates(["note2.md"]);
|
||||
expect((engine as any).index).not.toBeNull();
|
||||
expect((engine as any).indexedChunks.size).toBeGreaterThan(0);
|
||||
expect(asInternal(engine).index).not.toBeNull();
|
||||
expect(asInternal(engine).indexedChunks.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should handle index without destroy or clear methods", async () => {
|
||||
await engine.buildFromCandidates(["note1.md"]);
|
||||
|
||||
// Mock index without cleanup methods
|
||||
(engine as any).index = {
|
||||
asInternal(engine).index = {
|
||||
someOtherMethod: jest.fn(),
|
||||
};
|
||||
|
||||
// Should not throw and still reset
|
||||
expect(() => engine.clear()).not.toThrow();
|
||||
expect((engine as any).index).toBeNull();
|
||||
expect(asInternal(engine).index).toBeNull();
|
||||
});
|
||||
|
||||
it("should continue cleanup even if index cleanup throws", async () => {
|
||||
await engine.buildFromCandidates(["note1.md"]);
|
||||
|
||||
// Mock index that throws on cleanup
|
||||
(engine as any).index = {
|
||||
asInternal(engine).index = {
|
||||
destroy: jest.fn(() => {
|
||||
throw new Error("Mock cleanup failure");
|
||||
}),
|
||||
|
|
@ -1039,8 +1053,8 @@ describe("FullTextEngine", () => {
|
|||
expect(() => engine.clear()).not.toThrow();
|
||||
|
||||
// Should still reset state
|
||||
expect((engine as any).index).toBeNull();
|
||||
expect((engine as any).indexedChunks.size).toBe(0);
|
||||
expect(asInternal(engine).index).toBeNull();
|
||||
expect(asInternal(engine).indexedChunks.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should reset memory manager during clear", async () => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
import type { App } from "obsidian";
|
||||
import { App } from "obsidian";
|
||||
import { GrepScanner } from "./GrepScanner";
|
||||
|
||||
interface MockFile {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
describe("GrepScanner", () => {
|
||||
let scanner: GrepScanner;
|
||||
let mockApp: any;
|
||||
let mockFiles: any[];
|
||||
let mockApp: App;
|
||||
let mockFiles: MockFile[];
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock file data
|
||||
|
|
@ -20,14 +25,14 @@ describe("GrepScanner", () => {
|
|||
mockApp = {
|
||||
vault: {
|
||||
getMarkdownFiles: jest.fn(() => mockFiles.map((f) => ({ path: f.path }))),
|
||||
cachedRead: jest.fn((file) => {
|
||||
cachedRead: jest.fn((file: { path: string }) => {
|
||||
const mockFile = mockFiles.find((f) => f.path === file.path);
|
||||
return Promise.resolve(mockFile?.content || "");
|
||||
}),
|
||||
},
|
||||
};
|
||||
} as unknown as App;
|
||||
|
||||
scanner = new GrepScanner(mockApp as App);
|
||||
scanner = new GrepScanner(mockApp);
|
||||
});
|
||||
|
||||
describe("batchCachedReadGrep", () => {
|
||||
|
|
@ -82,7 +87,7 @@ describe("GrepScanner", () => {
|
|||
});
|
||||
|
||||
it("should skip files that can't be read", async () => {
|
||||
mockApp.vault.cachedRead = jest.fn((file) => {
|
||||
mockApp.vault.cachedRead = jest.fn((file: { path: string }) => {
|
||||
if (file.path === "note2.md") {
|
||||
return Promise.reject(new Error("File read error"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
DEFAULT_SETTINGS,
|
||||
SEND_SHORTCUT,
|
||||
} from "@/constants";
|
||||
import { sanitizeQaExclusions, sanitizeSettings } from "@/settings/model";
|
||||
import { sanitizeQaExclusions, sanitizeSettings, CopilotSettings } from "@/settings/model";
|
||||
import { getEffectiveUserPrompt, getSystemPrompt } from "@/system-prompts/systemPromptBuilder";
|
||||
import * as systemPromptsState from "@/system-prompts/state";
|
||||
import * as settingsModel from "@/settings/model";
|
||||
|
|
@ -18,9 +18,9 @@ jest.mock("@/system-prompts/state", () => ({
|
|||
|
||||
// Mock settings/model getSettings for legacy fallback tests
|
||||
jest.mock("@/settings/model", () => {
|
||||
const actual = jest.requireActual("@/settings/model");
|
||||
const actual = jest.requireActual<object>("@/settings/model");
|
||||
return {
|
||||
...(actual as object),
|
||||
...actual,
|
||||
getSettings: jest.fn(() => ({ userSystemPrompt: "" })),
|
||||
};
|
||||
});
|
||||
|
|
@ -57,8 +57,8 @@ describe("sanitizeSettings - defaultSendShortcut migration", () => {
|
|||
it("should use default when defaultSendShortcut is missing", () => {
|
||||
const settingsWithoutShortcut = {
|
||||
...DEFAULT_SETTINGS,
|
||||
defaultSendShortcut: undefined as any,
|
||||
};
|
||||
defaultSendShortcut: undefined,
|
||||
} as unknown as CopilotSettings;
|
||||
|
||||
const sanitized = sanitizeSettings(settingsWithoutShortcut);
|
||||
|
||||
|
|
@ -68,8 +68,8 @@ describe("sanitizeSettings - defaultSendShortcut migration", () => {
|
|||
it("should use default when defaultSendShortcut is invalid", () => {
|
||||
const settingsWithInvalidShortcut = {
|
||||
...DEFAULT_SETTINGS,
|
||||
defaultSendShortcut: "invalid-shortcut" as any,
|
||||
};
|
||||
defaultSendShortcut: "invalid-shortcut",
|
||||
} as unknown as CopilotSettings;
|
||||
|
||||
const sanitized = sanitizeSettings(settingsWithInvalidShortcut);
|
||||
|
||||
|
|
@ -103,9 +103,9 @@ describe("sanitizeSettings - autoAddActiveContentToContext migration", () => {
|
|||
it("should migrate from old includeActiveNoteAsContext=true", () => {
|
||||
const oldSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
autoAddActiveContentToContext: undefined as any,
|
||||
autoAddActiveContentToContext: undefined,
|
||||
includeActiveNoteAsContext: true,
|
||||
};
|
||||
} as unknown as CopilotSettings;
|
||||
|
||||
const sanitized = sanitizeSettings(oldSettings);
|
||||
|
||||
|
|
@ -115,9 +115,9 @@ describe("sanitizeSettings - autoAddActiveContentToContext migration", () => {
|
|||
it("should migrate from old includeActiveNoteAsContext=false", () => {
|
||||
const oldSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
autoAddActiveContentToContext: undefined as any,
|
||||
autoAddActiveContentToContext: undefined,
|
||||
includeActiveNoteAsContext: false,
|
||||
};
|
||||
} as unknown as CopilotSettings;
|
||||
|
||||
const sanitized = sanitizeSettings(oldSettings);
|
||||
|
||||
|
|
@ -127,8 +127,8 @@ describe("sanitizeSettings - autoAddActiveContentToContext migration", () => {
|
|||
it("should use default when no old setting exists", () => {
|
||||
const newSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
autoAddActiveContentToContext: undefined as any,
|
||||
};
|
||||
autoAddActiveContentToContext: undefined,
|
||||
} as unknown as CopilotSettings;
|
||||
|
||||
const sanitized = sanitizeSettings(newSettings);
|
||||
|
||||
|
|
@ -142,9 +142,9 @@ describe("sanitizeSettings - autoAddSelectionToContext migration", () => {
|
|||
it("should migrate from old autoIncludeTextSelection=true", () => {
|
||||
const oldSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
autoAddSelectionToContext: undefined as any,
|
||||
autoAddSelectionToContext: undefined,
|
||||
autoIncludeTextSelection: true,
|
||||
};
|
||||
} as unknown as CopilotSettings;
|
||||
|
||||
const sanitized = sanitizeSettings(oldSettings);
|
||||
|
||||
|
|
@ -154,9 +154,9 @@ describe("sanitizeSettings - autoAddSelectionToContext migration", () => {
|
|||
it("should migrate from old autoIncludeTextSelection=false", () => {
|
||||
const oldSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
autoAddSelectionToContext: undefined as any,
|
||||
autoAddSelectionToContext: undefined,
|
||||
autoIncludeTextSelection: false,
|
||||
};
|
||||
} as unknown as CopilotSettings;
|
||||
|
||||
const sanitized = sanitizeSettings(oldSettings);
|
||||
|
||||
|
|
@ -166,8 +166,8 @@ describe("sanitizeSettings - autoAddSelectionToContext migration", () => {
|
|||
it("should use default when no old setting exists", () => {
|
||||
const newSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
autoAddSelectionToContext: undefined as any,
|
||||
};
|
||||
autoAddSelectionToContext: undefined,
|
||||
} as unknown as CopilotSettings;
|
||||
|
||||
const sanitized = sanitizeSettings(newSettings);
|
||||
|
||||
|
|
@ -179,11 +179,11 @@ describe("sanitizeSettings - legacy Miyo settings cleanup", () => {
|
|||
it("migrates legacy Miyo settings and strips obsolete remote vault path state", () => {
|
||||
const legacySettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
enableMiyo: undefined as any,
|
||||
enableMiyo: undefined,
|
||||
enableMiyoSearch: true,
|
||||
miyoServerUrl: "http://127.0.0.1:8742",
|
||||
miyoRemoteVaultPath: "\\\\Mac\\Home\\Downloads\\graham-essays-main",
|
||||
};
|
||||
} as unknown as CopilotSettings;
|
||||
|
||||
const sanitized = sanitizeSettings(legacySettings);
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ jest.mock("@/system-prompts/systemPromptUtils", () => ({
|
|||
|
||||
// Mock utils
|
||||
jest.mock("@/utils", () => {
|
||||
const actual = jest.requireActual("@/utils");
|
||||
const actual = jest.requireActual<{ stripFrontmatter: unknown }>("@/utils");
|
||||
return {
|
||||
ensureFolderExists: jest.fn(),
|
||||
stripFrontmatter: actual.stripFrontmatter,
|
||||
|
|
@ -52,7 +52,7 @@ jest.mock("@/components/modals/ConfirmModal", () => ({
|
|||
|
||||
describe("migrateSystemPromptsFromSettings", () => {
|
||||
let mockVault: Vault;
|
||||
let originalApp: any;
|
||||
let originalApp: typeof window.app;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
|
@ -78,7 +78,7 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
originalApp = window.app;
|
||||
window.app = {
|
||||
vault: mockVault,
|
||||
} as any;
|
||||
} as unknown as typeof window.app;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -423,7 +423,10 @@ describe("migrateSystemPromptsFromSettings", () => {
|
|||
})
|
||||
);
|
||||
|
||||
const callArgs = (systemPromptUtils.ensurePromptFrontmatter as jest.Mock).mock.calls[0][1];
|
||||
const callArgs = (systemPromptUtils.ensurePromptFrontmatter as jest.Mock).mock.calls[0][1] as {
|
||||
createdMs: number;
|
||||
modifiedMs: number;
|
||||
};
|
||||
expect(callArgs.createdMs).toBeGreaterThanOrEqual(beforeTime);
|
||||
expect(callArgs.createdMs).toBeLessThanOrEqual(afterTime);
|
||||
expect(callArgs.modifiedMs).toBeGreaterThanOrEqual(beforeTime);
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ jest.mock("@/system-prompts/state", () => ({
|
|||
describe("SystemPromptManager", () => {
|
||||
let manager: SystemPromptManager;
|
||||
let mockVault: Vault;
|
||||
let originalApp: any;
|
||||
let originalApp: typeof window.app;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset the singleton instance before each test
|
||||
|
|
@ -74,7 +74,7 @@ describe("SystemPromptManager", () => {
|
|||
renameFile: jest.fn(),
|
||||
trashFile: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
} as any;
|
||||
} as unknown as typeof window.app;
|
||||
|
||||
// Initialize manager
|
||||
manager = SystemPromptManager.getInstance(mockVault);
|
||||
|
|
|
|||
|
|
@ -239,11 +239,16 @@ describe("SystemPromptRegister", () => {
|
|||
jest.useFakeTimers();
|
||||
|
||||
// Capture the settings change handler
|
||||
const { subscribeToSettingsChange } = jest.requireMock("@/settings/model");
|
||||
settingsChangeHandler = subscribeToSettingsChange.mock.calls[0]?.[0];
|
||||
const { subscribeToSettingsChange } = jest.requireMock<{
|
||||
subscribeToSettingsChange: jest.Mock;
|
||||
}>("@/settings/model");
|
||||
settingsChangeHandler = subscribeToSettingsChange.mock
|
||||
.calls[0]?.[0] as typeof settingsChangeHandler;
|
||||
|
||||
// Get reference to mock manager
|
||||
const { SystemPromptManager } = jest.requireMock("@/system-prompts/systemPromptManager");
|
||||
const { SystemPromptManager } = jest.requireMock<{
|
||||
SystemPromptManager: { getInstance: () => { fetchPrompts: jest.Mock } };
|
||||
}>("@/system-prompts/systemPromptManager");
|
||||
mockManager = SystemPromptManager.getInstance();
|
||||
});
|
||||
|
||||
|
|
@ -280,10 +285,13 @@ describe("SystemPromptRegister", () => {
|
|||
});
|
||||
|
||||
it("clears defaultSystemPromptTitle when prompt not found in new folder", async () => {
|
||||
const { getSettings, updateSetting } = jest.requireMock("@/settings/model");
|
||||
const { getSettings, updateSetting } = jest.requireMock<{
|
||||
getSettings: jest.Mock;
|
||||
updateSetting: jest.Mock;
|
||||
}>("@/settings/model");
|
||||
|
||||
// Set up: default prompt points to a prompt that won't exist in new folder
|
||||
(getSettings as jest.Mock).mockReturnValue({
|
||||
getSettings.mockReturnValue({
|
||||
defaultSystemPromptTitle: "OldDefault",
|
||||
userSystemPromptsFolder: "NewFolder",
|
||||
});
|
||||
|
|
@ -313,10 +321,13 @@ describe("SystemPromptRegister", () => {
|
|||
});
|
||||
|
||||
it("does not clear prompts when they exist in new folder", async () => {
|
||||
const { getSettings, updateSetting } = jest.requireMock("@/settings/model");
|
||||
const { getSettings, updateSetting } = jest.requireMock<{
|
||||
getSettings: jest.Mock;
|
||||
updateSetting: jest.Mock;
|
||||
}>("@/settings/model");
|
||||
|
||||
// Set up: prompts exist in new folder
|
||||
(getSettings as jest.Mock).mockReturnValue({
|
||||
getSettings.mockReturnValue({
|
||||
defaultSystemPromptTitle: "ExistingPrompt",
|
||||
userSystemPromptsFolder: "NewFolder",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ describe("isSystemPromptFile", () => {
|
|||
});
|
||||
|
||||
describe("parseSystemPromptFile", () => {
|
||||
let originalApp: any;
|
||||
let originalApp: typeof window.app;
|
||||
let mockFile: TFile;
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -265,7 +265,7 @@ describe("parseSystemPromptFile", () => {
|
|||
metadataCache: {
|
||||
getFileCache: jest.fn(),
|
||||
},
|
||||
} as any;
|
||||
} as unknown as typeof window.app;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
|
|||
|
|
@ -162,7 +162,17 @@ describe("Context Manage Modal Functions", () => {
|
|||
(shouldIndexFile as jest.Mock).mockReturnValue(true);
|
||||
(getFilePattern as jest.Mock).mockImplementation((file: TFile) => `[[${file.basename}]]`);
|
||||
(createPatternSettingsValue as jest.Mock).mockImplementation(
|
||||
({ tagPatterns = [], folderPatterns = [], extensionPatterns = [], notePatterns = [] }) => {
|
||||
({
|
||||
tagPatterns = [],
|
||||
folderPatterns = [],
|
||||
extensionPatterns = [],
|
||||
notePatterns = [],
|
||||
}: {
|
||||
tagPatterns?: string[];
|
||||
folderPatterns?: string[];
|
||||
extensionPatterns?: string[];
|
||||
notePatterns?: string[];
|
||||
}) => {
|
||||
const patterns = [...tagPatterns, ...folderPatterns, ...extensionPatterns, ...notePatterns];
|
||||
return patterns.join(",");
|
||||
}
|
||||
|
|
@ -421,9 +431,9 @@ describe("Context Manage Modal Functions", () => {
|
|||
convertGroupListToInclusions(groupList, []);
|
||||
|
||||
expect(createPatternSettingsValue).toHaveBeenCalledWith({
|
||||
tagPatterns: expect.arrayContaining([`#tag0`, `#tag99`]),
|
||||
folderPatterns: expect.arrayContaining([`folder0`, `folder99`]),
|
||||
extensionPatterns: expect.arrayContaining([`*.ext0`, `*.ext99`]),
|
||||
tagPatterns: expect.arrayContaining([`#tag0`, `#tag99`]) as string[],
|
||||
folderPatterns: expect.arrayContaining([`folder0`, `folder99`]) as string[],
|
||||
extensionPatterns: expect.arrayContaining([`*.ext0`, `*.ext99`]) as string[],
|
||||
notePatterns: [],
|
||||
});
|
||||
});
|
||||
|
|
@ -464,7 +474,7 @@ describe("Context Manage Modal Functions", () => {
|
|||
|
||||
expect(getFilePattern).toHaveBeenCalledTimes(1000);
|
||||
expect(createPatternSettingsValue).toHaveBeenCalledWith({
|
||||
notePatterns: expect.arrayContaining(["[[file0]]", "[[file999]]"]),
|
||||
notePatterns: expect.arrayContaining(["[[file0]]", "[[file999]]"]) as string[],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -526,7 +536,7 @@ describe("Context Manage Modal Functions", () => {
|
|||
tagPatterns: [],
|
||||
folderPatterns: ["docs", "src"],
|
||||
extensionPatterns: ["*.md", "*.ts"],
|
||||
notePatterns: expect.any(Array),
|
||||
notePatterns: expect.any(Array) as string[],
|
||||
});
|
||||
|
||||
expect(createPatternSettingsValue).toHaveBeenCalledWith({
|
||||
|
|
|
|||
|
|
@ -3,14 +3,19 @@ import { ContextCache, ProjectContextCache } from "@/cache/projectContextCache";
|
|||
import type { TFile } from "obsidian";
|
||||
|
||||
// Mock dependencies
|
||||
interface MockFileStat {
|
||||
mtime: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
jest.mock("obsidian", () => ({
|
||||
TFile: class MockTFile {
|
||||
path: string;
|
||||
extension: string;
|
||||
basename: string;
|
||||
stat: any;
|
||||
stat: MockFileStat;
|
||||
|
||||
constructor(path: string, extension: string, basename: string, stat: any) {
|
||||
constructor(path: string, extension: string, basename: string, stat: MockFileStat) {
|
||||
this.path = path;
|
||||
this.extension = extension;
|
||||
this.basename = basename;
|
||||
|
|
@ -112,13 +117,26 @@ describe("ProjectContextCache", () => {
|
|||
let mockProject: ProjectConfig;
|
||||
|
||||
// Mock files
|
||||
const { TFile: MockedTFile } = jest.requireMock("obsidian");
|
||||
const mockMarkdownFile = new MockedTFile("test/file.md", "md", "file", {
|
||||
interface MockedTFileType {
|
||||
path: string;
|
||||
extension: string;
|
||||
basename: string;
|
||||
stat: MockFileStat;
|
||||
}
|
||||
const { TFile: MockedTFile } = jest.requireMock<{
|
||||
TFile: new (
|
||||
path: string,
|
||||
extension: string,
|
||||
basename: string,
|
||||
stat: MockFileStat
|
||||
) => MockedTFileType;
|
||||
}>("obsidian");
|
||||
const mockMarkdownFile: MockedTFileType = new MockedTFile("test/file.md", "md", "file", {
|
||||
mtime: Date.now(),
|
||||
size: 100,
|
||||
});
|
||||
|
||||
const mockPdfFile = new MockedTFile("test/document.pdf", "pdf", "document", {
|
||||
const mockPdfFile: MockedTFileType = new MockedTFile("test/document.pdf", "pdf", "document", {
|
||||
mtime: Date.now(),
|
||||
size: 200,
|
||||
});
|
||||
|
|
@ -128,7 +146,7 @@ describe("ProjectContextCache", () => {
|
|||
jest.clearAllMocks();
|
||||
|
||||
// Mock globals
|
||||
window.app = mockApp as any;
|
||||
(window as unknown as { app: unknown }).app = mockApp;
|
||||
|
||||
// Set up mock vault file retrieval
|
||||
mockApp.vault.getFiles.mockReturnValue([mockMarkdownFile, mockPdfFile]);
|
||||
|
|
@ -154,7 +172,7 @@ describe("ProjectContextCache", () => {
|
|||
inclusions: "**/*.md, **/*.pdf",
|
||||
exclusions: "",
|
||||
},
|
||||
} as ProjectConfig;
|
||||
} as unknown as ProjectConfig;
|
||||
});
|
||||
|
||||
test("should store and retrieve file content", async () => {
|
||||
|
|
@ -203,11 +221,7 @@ describe("ProjectContextCache", () => {
|
|||
await projectContextCache.getOrInitializeCache(mockProject);
|
||||
|
||||
// First add some context
|
||||
await projectContextCache.setFileContext(
|
||||
mockProject,
|
||||
mockPdfFile.path as string,
|
||||
"PDF content"
|
||||
);
|
||||
await projectContextCache.setFileContext(mockProject, mockPdfFile.path, "PDF content");
|
||||
|
||||
// Then clean it up
|
||||
await projectContextCache.cleanupProjectFileReferences(mockProject);
|
||||
|
|
@ -408,8 +422,8 @@ describe("ProjectContextCache", () => {
|
|||
expect(mockApp.vault.adapter.write).toHaveBeenCalled();
|
||||
|
||||
// Check that written content contains updated values
|
||||
const writeCall = mockApp.vault.adapter.write.mock.calls[0];
|
||||
const writtenContent = JSON.parse(writeCall[1] as string);
|
||||
const writeCall = mockApp.vault.adapter.write.mock.calls[0] as unknown[];
|
||||
const writtenContent = JSON.parse(writeCall[1] as string) as ContextCache;
|
||||
expect(writtenContent.markdownContext).toBe("Updated markdown content");
|
||||
expect(writtenContent.markdownNeedsReload).toBe(true);
|
||||
});
|
||||
|
|
@ -445,8 +459,8 @@ describe("ProjectContextCache", () => {
|
|||
expect(mockApp.vault.adapter.write).toHaveBeenCalled();
|
||||
|
||||
// Check that written content contains updated values
|
||||
const writeCall = mockApp.vault.adapter.write.mock.calls[0];
|
||||
const writtenContent = JSON.parse(writeCall[1] as string);
|
||||
const writeCall = mockApp.vault.adapter.write.mock.calls[0] as unknown[];
|
||||
const writtenContent = JSON.parse(writeCall[1] as string) as ContextCache;
|
||||
expect(writtenContent.markdownContext).toBe("Async updated content");
|
||||
expect(writtenContent.webContexts["https://example.com"]).toBe("Async web content");
|
||||
});
|
||||
|
|
@ -460,7 +474,7 @@ describe("ProjectContextCache", () => {
|
|||
inclusions: "**/*.md, **/*.pdf",
|
||||
exclusions: "",
|
||||
},
|
||||
} as ProjectConfig;
|
||||
} as unknown as ProjectConfig;
|
||||
|
||||
// Mock cache non-existence for this isolated project
|
||||
mockApp.vault.adapter.exists.mockImplementation((path) => {
|
||||
|
|
@ -506,7 +520,7 @@ describe("ProjectContextCache", () => {
|
|||
return Promise.resolve(JSON.stringify(currentCache));
|
||||
});
|
||||
mockApp.vault.adapter.write.mockImplementation((path, content) => {
|
||||
currentCache = JSON.parse(content as string);
|
||||
currentCache = JSON.parse(content as string) as ContextCache;
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
|
|
@ -580,7 +594,7 @@ describe("ProjectContextCache", () => {
|
|||
const startTime = Date.now();
|
||||
|
||||
mockApp.vault.adapter.write.mockImplementation((path, content) => {
|
||||
const parsed = JSON.parse(content as string);
|
||||
const parsed = JSON.parse(content as string) as ContextCache;
|
||||
currentCache = parsed;
|
||||
|
||||
// Track what was written and when
|
||||
|
|
|
|||
|
|
@ -130,12 +130,11 @@ describe("FileTreeTools", () => {
|
|||
|
||||
// Also test the tool to ensure it uses buildFileTree correctly
|
||||
const tool = createGetFileTreeTool(root);
|
||||
const result = await ToolManager.callTool(tool, {});
|
||||
const result = (await ToolManager.callTool(tool, {})) as string;
|
||||
|
||||
// Extract JSON part after the prompt
|
||||
const resultStr = result as string;
|
||||
const jsonPart = resultStr.substring(resultStr.indexOf("{"));
|
||||
const treeFromTool = JSON.parse(jsonPart);
|
||||
const jsonPart = result.substring(result.indexOf("{"));
|
||||
const treeFromTool = JSON.parse(jsonPart) as typeof expectedTree;
|
||||
|
||||
expect(treeFromTool).toEqual(expectedTree);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import {
|
|||
runRandomReadCommand,
|
||||
} from "@/services/obsidianCli/ObsidianCliClient";
|
||||
|
||||
type InvokableTool = { invoke: (args: Record<string, unknown>) => Promise<string> };
|
||||
const asInvokable = (t: unknown): InvokableTool => t as InvokableTool;
|
||||
|
||||
jest.mock("@/services/obsidianCli/ObsidianCliClient", () => ({
|
||||
runDailyReadCommand: jest.fn(),
|
||||
runRandomReadCommand: jest.fn(),
|
||||
|
|
@ -91,8 +94,13 @@ describe("ObsidianCliDailyTools", () => {
|
|||
buildSuccessResult("daily:read", "Today I worked on CLI integration.")
|
||||
);
|
||||
|
||||
const response = await (obsidianDailyReadTool as any).invoke({ vault: "Work" });
|
||||
const parsed = JSON.parse(response as string);
|
||||
const response = await asInvokable(obsidianDailyReadTool).invoke({ vault: "Work" });
|
||||
const parsed = JSON.parse(response) as {
|
||||
type: string;
|
||||
command: string;
|
||||
vault: string | null;
|
||||
content: string;
|
||||
};
|
||||
|
||||
expect(parsed.type).toBe("obsidian_cli_daily_read");
|
||||
expect(parsed.command).toBe("daily:read");
|
||||
|
|
@ -106,8 +114,13 @@ describe("ObsidianCliDailyTools", () => {
|
|||
buildSuccessResult("random:read", "Random note body")
|
||||
);
|
||||
|
||||
const response = await (obsidianRandomReadTool as any).invoke({});
|
||||
const parsed = JSON.parse(response as string);
|
||||
const response = await asInvokable(obsidianRandomReadTool).invoke({});
|
||||
const parsed = JSON.parse(response) as {
|
||||
type: string;
|
||||
command: string;
|
||||
vault: string | null;
|
||||
content: string;
|
||||
};
|
||||
|
||||
expect(parsed.type).toBe("obsidian_cli_random_read");
|
||||
expect(parsed.command).toBe("random:read");
|
||||
|
|
@ -121,7 +134,7 @@ describe("ObsidianCliDailyTools", () => {
|
|||
buildFailedResult("daily:read", "EFAIL", "daily note unavailable")
|
||||
);
|
||||
|
||||
await expect((obsidianDailyReadTool as any).invoke({})).rejects.toThrow(
|
||||
await expect(asInvokable(obsidianDailyReadTool).invoke({})).rejects.toThrow(
|
||||
"daily note unavailable"
|
||||
);
|
||||
});
|
||||
|
|
@ -129,7 +142,7 @@ describe("ObsidianCliDailyTools", () => {
|
|||
test("obsidianRandomReadTool surfaces actionable ENOENT failure details", async () => {
|
||||
mockedRunRandomReadCommand.mockResolvedValue(buildFailedResult("random:read", "ENOENT", ""));
|
||||
|
||||
await expect((obsidianRandomReadTool as any).invoke({})).rejects.toThrow(
|
||||
await expect(asInvokable(obsidianRandomReadTool).invoke({})).rejects.toThrow(
|
||||
"CLI binary not found"
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,6 +14,20 @@ jest.mock("@/services/obsidianCli/ObsidianCliClient", () => ({
|
|||
|
||||
const mockedRunCommand = runObsidianCliCommand as jest.MockedFunction<typeof runObsidianCliCommand>;
|
||||
|
||||
/**
|
||||
* Minimal interface for invoking a LangChain tool in tests.
|
||||
* The actual tool's `invoke` is generic; we only need the string-result form here.
|
||||
*/
|
||||
type InvokableTool = { invoke: (args: Record<string, unknown>) => Promise<string> };
|
||||
const asInvokable = (t: unknown): InvokableTool => t as InvokableTool;
|
||||
|
||||
type ParsedToolResponse = {
|
||||
type?: string;
|
||||
command?: string;
|
||||
vault?: string | null;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type CliResult = {
|
||||
command: string;
|
||||
args: string[];
|
||||
|
|
@ -81,8 +95,8 @@ describe("obsidianDailyNoteTool", () => {
|
|||
test("daily creates today's daily note", async () => {
|
||||
mockedRunCommand.mockResolvedValue(buildSuccessResult("daily", ""));
|
||||
|
||||
const response = await (obsidianDailyNoteTool as any).invoke({ command: "daily" });
|
||||
const parsed = JSON.parse(response as string);
|
||||
const response = await asInvokable(obsidianDailyNoteTool).invoke({ command: "daily" });
|
||||
const parsed = JSON.parse(response) as ParsedToolResponse;
|
||||
|
||||
expect(parsed.type).toBe("obsidian_cli_daily_note");
|
||||
expect(parsed.command).toBe("daily");
|
||||
|
|
@ -98,8 +112,8 @@ describe("obsidianDailyNoteTool", () => {
|
|||
buildSuccessResult("daily:read", "# 2026-03-03\n\nToday's tasks...")
|
||||
);
|
||||
|
||||
const response = await (obsidianDailyNoteTool as any).invoke({ command: "daily:read" });
|
||||
const parsed = JSON.parse(response as string);
|
||||
const response = await asInvokable(obsidianDailyNoteTool).invoke({ command: "daily:read" });
|
||||
const parsed = JSON.parse(response) as ParsedToolResponse;
|
||||
|
||||
expect(parsed.type).toBe("obsidian_cli_daily_note");
|
||||
expect(parsed.command).toBe("daily:read");
|
||||
|
|
@ -115,11 +129,11 @@ describe("obsidianDailyNoteTool", () => {
|
|||
test("daily:path returns path payload", async () => {
|
||||
mockedRunCommand.mockResolvedValue(buildSuccessResult("daily:path", "Daily/2026-03-03.md"));
|
||||
|
||||
const response = await (obsidianDailyNoteTool as any).invoke({
|
||||
const response = await asInvokable(obsidianDailyNoteTool).invoke({
|
||||
command: "daily:path",
|
||||
vault: "Work",
|
||||
});
|
||||
const parsed = JSON.parse(response as string);
|
||||
const parsed = JSON.parse(response) as ParsedToolResponse;
|
||||
|
||||
expect(parsed.type).toBe("obsidian_cli_daily_note");
|
||||
expect(parsed.command).toBe("daily:path");
|
||||
|
|
@ -132,17 +146,17 @@ describe("obsidianDailyNoteTool", () => {
|
|||
buildFailedResult("daily:read", "EFAIL", "Daily note plugin not enabled", 1)
|
||||
);
|
||||
|
||||
await expect((obsidianDailyNoteTool as any).invoke({ command: "daily:read" })).rejects.toThrow(
|
||||
"Daily note plugin not enabled"
|
||||
);
|
||||
await expect(
|
||||
asInvokable(obsidianDailyNoteTool).invoke({ command: "daily:read" })
|
||||
).rejects.toThrow("Daily note plugin not enabled");
|
||||
});
|
||||
|
||||
test("throws ENOENT failure with actionable message", async () => {
|
||||
mockedRunCommand.mockResolvedValue(buildFailedResult("daily:read", "ENOENT", ""));
|
||||
|
||||
await expect((obsidianDailyNoteTool as any).invoke({ command: "daily:read" })).rejects.toThrow(
|
||||
"CLI binary not found"
|
||||
);
|
||||
await expect(
|
||||
asInvokable(obsidianDailyNoteTool).invoke({ command: "daily:read" })
|
||||
).rejects.toThrow("CLI binary not found");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -156,8 +170,8 @@ describe("obsidianPropertiesTool", () => {
|
|||
buildSuccessResult("properties", "aliases\nauthor\ndate\ntags")
|
||||
);
|
||||
|
||||
const response = await (obsidianPropertiesTool as any).invoke({ command: "properties" });
|
||||
const parsed = JSON.parse(response as string);
|
||||
const response = await asInvokable(obsidianPropertiesTool).invoke({ command: "properties" });
|
||||
const parsed = JSON.parse(response) as ParsedToolResponse;
|
||||
|
||||
expect(parsed.type).toBe("obsidian_cli_properties");
|
||||
expect(parsed.command).toBe("properties");
|
||||
|
|
@ -174,7 +188,7 @@ describe("obsidianPropertiesTool", () => {
|
|||
buildSuccessResult("properties", "tags: false\ntitle: My Note")
|
||||
);
|
||||
|
||||
await (obsidianPropertiesTool as any).invoke({
|
||||
await asInvokable(obsidianPropertiesTool).invoke({
|
||||
command: "properties",
|
||||
file: "My Note",
|
||||
counts: true,
|
||||
|
|
@ -190,12 +204,12 @@ describe("obsidianPropertiesTool", () => {
|
|||
test("property:read returns single property value", async () => {
|
||||
mockedRunCommand.mockResolvedValue(buildSuccessResult("property:read", "project, review"));
|
||||
|
||||
const response = await (obsidianPropertiesTool as any).invoke({
|
||||
const response = await asInvokable(obsidianPropertiesTool).invoke({
|
||||
command: "property:read",
|
||||
name: "tags",
|
||||
file: "My Note",
|
||||
});
|
||||
const parsed = JSON.parse(response as string);
|
||||
const parsed = JSON.parse(response) as ParsedToolResponse;
|
||||
|
||||
expect(parsed.content).toBe("project, review");
|
||||
expect(mockedRunCommand).toHaveBeenCalledWith({
|
||||
|
|
@ -207,7 +221,7 @@ describe("obsidianPropertiesTool", () => {
|
|||
|
||||
test("property:read throws when name is missing", async () => {
|
||||
await expect(
|
||||
(obsidianPropertiesTool as any).invoke({ command: "property:read" })
|
||||
asInvokable(obsidianPropertiesTool).invoke({ command: "property:read" })
|
||||
).rejects.toThrow("name is required for property:read");
|
||||
});
|
||||
|
||||
|
|
@ -215,9 +229,9 @@ describe("obsidianPropertiesTool", () => {
|
|||
// When error code is present, it takes precedence over exit code in error message
|
||||
mockedRunCommand.mockResolvedValue(buildFailedResult("properties", "EFAIL", "", 1));
|
||||
|
||||
await expect((obsidianPropertiesTool as any).invoke({ command: "properties" })).rejects.toThrow(
|
||||
"error code EFAIL"
|
||||
);
|
||||
await expect(
|
||||
asInvokable(obsidianPropertiesTool).invoke({ command: "properties" })
|
||||
).rejects.toThrow("error code EFAIL");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -231,8 +245,8 @@ describe("obsidianTasksTool", () => {
|
|||
buildSuccessResult("tasks", "- [ ] Review PR #2181\n- [x] Write tests")
|
||||
);
|
||||
|
||||
const response = await (obsidianTasksTool as any).invoke({ command: "tasks" });
|
||||
const parsed = JSON.parse(response as string);
|
||||
const response = await asInvokable(obsidianTasksTool).invoke({ command: "tasks" });
|
||||
const parsed = JSON.parse(response) as ParsedToolResponse;
|
||||
|
||||
expect(parsed.type).toBe("obsidian_cli_tasks");
|
||||
expect(parsed.command).toBe("tasks");
|
||||
|
|
@ -247,7 +261,7 @@ describe("obsidianTasksTool", () => {
|
|||
test("tasks passes all filter params", async () => {
|
||||
mockedRunCommand.mockResolvedValue(buildSuccessResult("tasks", "- [ ] Task A"));
|
||||
|
||||
await (obsidianTasksTool as any).invoke({
|
||||
await asInvokable(obsidianTasksTool).invoke({
|
||||
command: "tasks",
|
||||
file: "Project Plan",
|
||||
todo: true,
|
||||
|
|
@ -265,7 +279,7 @@ describe("obsidianTasksTool", () => {
|
|||
test("tasks with daily flag", async () => {
|
||||
mockedRunCommand.mockResolvedValue(buildSuccessResult("tasks", "- [ ] Daily task"));
|
||||
|
||||
await (obsidianTasksTool as any).invoke({ command: "tasks", daily: true });
|
||||
await asInvokable(obsidianTasksTool).invoke({ command: "tasks", daily: true });
|
||||
|
||||
expect(mockedRunCommand).toHaveBeenCalledWith({
|
||||
command: "tasks",
|
||||
|
|
@ -277,7 +291,7 @@ describe("obsidianTasksTool", () => {
|
|||
test("throws on CLI failure with stderr", async () => {
|
||||
mockedRunCommand.mockResolvedValue(buildFailedResult("tasks", "ENOENT", ""));
|
||||
|
||||
await expect((obsidianTasksTool as any).invoke({ command: "tasks" })).rejects.toThrow(
|
||||
await expect(asInvokable(obsidianTasksTool).invoke({ command: "tasks" })).rejects.toThrow(
|
||||
"CLI binary not found"
|
||||
);
|
||||
});
|
||||
|
|
@ -293,11 +307,11 @@ describe("obsidianLinksTool", () => {
|
|||
buildSuccessResult("backlinks", "Projects/roadmap.md\nDaily/2026-03-01.md")
|
||||
);
|
||||
|
||||
const response = await (obsidianLinksTool as any).invoke({
|
||||
const response = await asInvokable(obsidianLinksTool).invoke({
|
||||
command: "backlinks",
|
||||
file: "My Note",
|
||||
});
|
||||
const parsed = JSON.parse(response as string);
|
||||
const parsed = JSON.parse(response) as ParsedToolResponse;
|
||||
|
||||
expect(parsed.type).toBe("obsidian_cli_links");
|
||||
expect(parsed.command).toBe("backlinks");
|
||||
|
|
@ -314,7 +328,7 @@ describe("obsidianLinksTool", () => {
|
|||
buildSuccessResult("links", "Ideas/brainstorm.md\nProjects/roadmap.md")
|
||||
);
|
||||
|
||||
await (obsidianLinksTool as any).invoke({ command: "links", path: "Notes/note.md" });
|
||||
await asInvokable(obsidianLinksTool).invoke({ command: "links", path: "Notes/note.md" });
|
||||
|
||||
expect(mockedRunCommand).toHaveBeenCalledWith({
|
||||
command: "links",
|
||||
|
|
@ -328,8 +342,8 @@ describe("obsidianLinksTool", () => {
|
|||
buildSuccessResult("orphans", "Inbox/draft.md\nAttic/old.md")
|
||||
);
|
||||
|
||||
const response = await (obsidianLinksTool as any).invoke({ command: "orphans" });
|
||||
const parsed = JSON.parse(response as string);
|
||||
const response = await asInvokable(obsidianLinksTool).invoke({ command: "orphans" });
|
||||
const parsed = JSON.parse(response) as ParsedToolResponse;
|
||||
|
||||
expect(parsed.content).toBe("Inbox/draft.md\nAttic/old.md");
|
||||
});
|
||||
|
|
@ -339,7 +353,7 @@ describe("obsidianLinksTool", () => {
|
|||
buildSuccessResult("unresolved", "Missing Note\t5\nOld Reference\t2")
|
||||
);
|
||||
|
||||
await (obsidianLinksTool as any).invoke({
|
||||
await asInvokable(obsidianLinksTool).invoke({
|
||||
command: "unresolved",
|
||||
counts: true,
|
||||
verbose: false,
|
||||
|
|
@ -355,7 +369,11 @@ describe("obsidianLinksTool", () => {
|
|||
test("backlinks with total flag", async () => {
|
||||
mockedRunCommand.mockResolvedValue(buildSuccessResult("backlinks", "4"));
|
||||
|
||||
await (obsidianLinksTool as any).invoke({ command: "backlinks", file: "Note", total: true });
|
||||
await asInvokable(obsidianLinksTool).invoke({
|
||||
command: "backlinks",
|
||||
file: "Note",
|
||||
total: true,
|
||||
});
|
||||
|
||||
expect(mockedRunCommand).toHaveBeenCalledWith({
|
||||
command: "backlinks",
|
||||
|
|
@ -370,7 +388,7 @@ describe("obsidianLinksTool", () => {
|
|||
);
|
||||
|
||||
await expect(
|
||||
(obsidianLinksTool as any).invoke({ command: "backlinks", file: "note" })
|
||||
asInvokable(obsidianLinksTool).invoke({ command: "backlinks", file: "note" })
|
||||
).rejects.toThrow('File "note.md" not found.');
|
||||
});
|
||||
});
|
||||
|
|
@ -385,8 +403,8 @@ describe("obsidianTemplatesTool", () => {
|
|||
buildSuccessResult("templates", "Daily Note\nMeeting Notes\nProject Plan")
|
||||
);
|
||||
|
||||
const response = await (obsidianTemplatesTool as any).invoke({ command: "templates" });
|
||||
const parsed = JSON.parse(response as string);
|
||||
const response = await asInvokable(obsidianTemplatesTool).invoke({ command: "templates" });
|
||||
const parsed = JSON.parse(response) as ParsedToolResponse;
|
||||
|
||||
expect(parsed.type).toBe("obsidian_cli_templates");
|
||||
expect(parsed.command).toBe("templates");
|
||||
|
|
@ -402,7 +420,7 @@ describe("obsidianTemplatesTool", () => {
|
|||
test("templates passes vault param", async () => {
|
||||
mockedRunCommand.mockResolvedValue(buildSuccessResult("templates", "Daily Note"));
|
||||
|
||||
await (obsidianTemplatesTool as any).invoke({ command: "templates", vault: "Work" });
|
||||
await asInvokable(obsidianTemplatesTool).invoke({ command: "templates", vault: "Work" });
|
||||
|
||||
expect(mockedRunCommand).toHaveBeenCalledWith({
|
||||
command: "templates",
|
||||
|
|
@ -416,11 +434,11 @@ describe("obsidianTemplatesTool", () => {
|
|||
buildSuccessResult("template:read", "# {{date}}\n\n## Tasks\n- [ ] ")
|
||||
);
|
||||
|
||||
const response = await (obsidianTemplatesTool as any).invoke({
|
||||
const response = await asInvokable(obsidianTemplatesTool).invoke({
|
||||
command: "template:read",
|
||||
name: "Daily Note",
|
||||
});
|
||||
const parsed = JSON.parse(response as string);
|
||||
const parsed = JSON.parse(response) as ParsedToolResponse;
|
||||
|
||||
expect(parsed.type).toBe("obsidian_cli_templates");
|
||||
expect(parsed.command).toBe("template:read");
|
||||
|
|
@ -434,7 +452,7 @@ describe("obsidianTemplatesTool", () => {
|
|||
|
||||
test("template:read throws when name is missing", async () => {
|
||||
await expect(
|
||||
(obsidianTemplatesTool as any).invoke({ command: "template:read" })
|
||||
asInvokable(obsidianTemplatesTool).invoke({ command: "template:read" })
|
||||
).rejects.toThrow("name is required for template:read");
|
||||
});
|
||||
|
||||
|
|
@ -443,17 +461,17 @@ describe("obsidianTemplatesTool", () => {
|
|||
buildFailedResult("templates", "EFAIL", "Templates plugin not enabled", 1)
|
||||
);
|
||||
|
||||
await expect((obsidianTemplatesTool as any).invoke({ command: "templates" })).rejects.toThrow(
|
||||
"Templates plugin not enabled"
|
||||
);
|
||||
await expect(
|
||||
asInvokable(obsidianTemplatesTool).invoke({ command: "templates" })
|
||||
).rejects.toThrow("Templates plugin not enabled");
|
||||
});
|
||||
|
||||
test("throws ENOENT failure with actionable message", async () => {
|
||||
mockedRunCommand.mockResolvedValue(buildFailedResult("templates", "ENOENT", ""));
|
||||
|
||||
await expect((obsidianTemplatesTool as any).invoke({ command: "templates" })).rejects.toThrow(
|
||||
"CLI binary not found"
|
||||
);
|
||||
await expect(
|
||||
asInvokable(obsidianTemplatesTool).invoke({ command: "templates" })
|
||||
).rejects.toThrow("CLI binary not found");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -467,8 +485,8 @@ describe("obsidianBasesTool", () => {
|
|||
buildSuccessResult("bases", "Contacts.base\nProjects.base\nTasks.base")
|
||||
);
|
||||
|
||||
const response = await (obsidianBasesTool as any).invoke({ command: "bases" });
|
||||
const parsed = JSON.parse(response as string);
|
||||
const response = await asInvokable(obsidianBasesTool).invoke({ command: "bases" });
|
||||
const parsed = JSON.parse(response) as ParsedToolResponse;
|
||||
|
||||
expect(parsed.type).toBe("obsidian_cli_bases");
|
||||
expect(parsed.command).toBe("bases");
|
||||
|
|
@ -486,11 +504,11 @@ describe("obsidianBasesTool", () => {
|
|||
buildSuccessResult("base:views", "All Items\nBy Status\nKanban")
|
||||
);
|
||||
|
||||
const response = await (obsidianBasesTool as any).invoke({
|
||||
const response = await asInvokable(obsidianBasesTool).invoke({
|
||||
command: "base:views",
|
||||
file: "Projects",
|
||||
});
|
||||
const parsed = JSON.parse(response as string);
|
||||
const parsed = JSON.parse(response) as ParsedToolResponse;
|
||||
|
||||
expect(parsed.type).toBe("obsidian_cli_bases");
|
||||
expect(parsed.content).toBe("All Items\nBy Status\nKanban");
|
||||
|
|
@ -504,7 +522,7 @@ describe("obsidianBasesTool", () => {
|
|||
test("base:views passes path param", async () => {
|
||||
mockedRunCommand.mockResolvedValue(buildSuccessResult("base:views", "Default View"));
|
||||
|
||||
await (obsidianBasesTool as any).invoke({
|
||||
await asInvokable(obsidianBasesTool).invoke({
|
||||
command: "base:views",
|
||||
path: "Databases/Projects.base",
|
||||
});
|
||||
|
|
@ -521,13 +539,13 @@ describe("obsidianBasesTool", () => {
|
|||
buildSuccessResult("base:query", "Name,Status\nAlpha,Active\nBeta,Done")
|
||||
);
|
||||
|
||||
const response = await (obsidianBasesTool as any).invoke({
|
||||
const response = await asInvokable(obsidianBasesTool).invoke({
|
||||
command: "base:query",
|
||||
file: "Projects",
|
||||
view: "All Items",
|
||||
format: "csv",
|
||||
});
|
||||
const parsed = JSON.parse(response as string);
|
||||
const parsed = JSON.parse(response) as ParsedToolResponse;
|
||||
|
||||
expect(parsed.content).toBe("Name,Status\nAlpha,Active\nBeta,Done");
|
||||
expect(mockedRunCommand).toHaveBeenCalledWith({
|
||||
|
|
@ -540,7 +558,7 @@ describe("obsidianBasesTool", () => {
|
|||
test("base:query with total flag", async () => {
|
||||
mockedRunCommand.mockResolvedValue(buildSuccessResult("base:query", "42"));
|
||||
|
||||
await (obsidianBasesTool as any).invoke({
|
||||
await asInvokable(obsidianBasesTool).invoke({
|
||||
command: "base:query",
|
||||
file: "Contacts",
|
||||
total: true,
|
||||
|
|
@ -558,14 +576,14 @@ describe("obsidianBasesTool", () => {
|
|||
buildSuccessResult("base:create", "Created: Library/Dune Messiah.md")
|
||||
);
|
||||
|
||||
const response = await (obsidianBasesTool as any).invoke({
|
||||
const response = await asInvokable(obsidianBasesTool).invoke({
|
||||
command: "base:create",
|
||||
file: "Library",
|
||||
view: "To Read",
|
||||
name: "Dune Messiah",
|
||||
content: "A book by Frank Herbert",
|
||||
});
|
||||
const parsed = JSON.parse(response as string);
|
||||
const parsed = JSON.parse(response) as ParsedToolResponse;
|
||||
|
||||
expect(parsed.type).toBe("obsidian_cli_bases");
|
||||
expect(parsed.command).toBe("base:create");
|
||||
|
|
@ -585,7 +603,7 @@ describe("obsidianBasesTool", () => {
|
|||
test("base:create with path and vault params", async () => {
|
||||
mockedRunCommand.mockResolvedValue(buildSuccessResult("base:create", "Created: item.md"));
|
||||
|
||||
await (obsidianBasesTool as any).invoke({
|
||||
await asInvokable(obsidianBasesTool).invoke({
|
||||
command: "base:create",
|
||||
path: "Databases/Library.base",
|
||||
name: "New Item",
|
||||
|
|
@ -602,7 +620,7 @@ describe("obsidianBasesTool", () => {
|
|||
test("base:create with minimal params (file only)", async () => {
|
||||
mockedRunCommand.mockResolvedValue(buildSuccessResult("base:create", "Created: Untitled.md"));
|
||||
|
||||
await (obsidianBasesTool as any).invoke({
|
||||
await asInvokable(obsidianBasesTool).invoke({
|
||||
command: "base:create",
|
||||
file: "Projects",
|
||||
});
|
||||
|
|
@ -615,19 +633,19 @@ describe("obsidianBasesTool", () => {
|
|||
});
|
||||
|
||||
test("base:create throws when file and path are both missing", async () => {
|
||||
await expect((obsidianBasesTool as any).invoke({ command: "base:create" })).rejects.toThrow(
|
||||
await expect(asInvokable(obsidianBasesTool).invoke({ command: "base:create" })).rejects.toThrow(
|
||||
"file or path is required for base:create"
|
||||
);
|
||||
});
|
||||
|
||||
test("base:views throws when file and path are both missing", async () => {
|
||||
await expect((obsidianBasesTool as any).invoke({ command: "base:views" })).rejects.toThrow(
|
||||
await expect(asInvokable(obsidianBasesTool).invoke({ command: "base:views" })).rejects.toThrow(
|
||||
"file or path is required for base:views"
|
||||
);
|
||||
});
|
||||
|
||||
test("base:query throws when file and path are both missing", async () => {
|
||||
await expect((obsidianBasesTool as any).invoke({ command: "base:query" })).rejects.toThrow(
|
||||
await expect(asInvokable(obsidianBasesTool).invoke({ command: "base:query" })).rejects.toThrow(
|
||||
"file or path is required for base:query"
|
||||
);
|
||||
});
|
||||
|
|
@ -637,7 +655,7 @@ describe("obsidianBasesTool", () => {
|
|||
buildFailedResult("bases", "EFAIL", "Bases plugin not enabled", 1)
|
||||
);
|
||||
|
||||
await expect((obsidianBasesTool as any).invoke({ command: "bases" })).rejects.toThrow(
|
||||
await expect(asInvokable(obsidianBasesTool).invoke({ command: "bases" })).rejects.toThrow(
|
||||
"Bases plugin not enabled"
|
||||
);
|
||||
});
|
||||
|
|
@ -645,7 +663,7 @@ describe("obsidianBasesTool", () => {
|
|||
test("throws ENOENT failure with actionable message", async () => {
|
||||
mockedRunCommand.mockResolvedValue(buildFailedResult("bases", "ENOENT", ""));
|
||||
|
||||
await expect((obsidianBasesTool as any).invoke({ command: "bases" })).rejects.toThrow(
|
||||
await expect(asInvokable(obsidianBasesTool).invoke({ command: "bases" })).rejects.toThrow(
|
||||
"CLI binary not found"
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,10 +2,35 @@ import { StructuredTool } from "@langchain/core/tools";
|
|||
|
||||
const mockRead = jest.fn();
|
||||
|
||||
type ReadNoteResult = {
|
||||
notePath: string;
|
||||
status?: string;
|
||||
message?: string;
|
||||
noteTitle?: string;
|
||||
heading?: string;
|
||||
chunkId?: string;
|
||||
chunkIndex?: number;
|
||||
totalChunks?: number;
|
||||
hasMore?: boolean;
|
||||
nextChunkIndex?: number | null;
|
||||
content?: string;
|
||||
mtime?: number;
|
||||
linkedNotes?: Array<{
|
||||
linkText: string;
|
||||
displayText: string;
|
||||
section: string | undefined;
|
||||
candidates: Array<{ path: string; title: string }>;
|
||||
}>;
|
||||
candidates?: Array<{ path: string; title: string }>;
|
||||
};
|
||||
|
||||
// Helper to invoke tool and parse JSON result
|
||||
const invokeReadNoteTool = async (tool: StructuredTool, args: any): Promise<any> => {
|
||||
const result = await tool.invoke(args);
|
||||
return typeof result === "string" ? JSON.parse(result) : result;
|
||||
const invokeReadNoteTool = async (
|
||||
tool: StructuredTool,
|
||||
args: { notePath: string; chunkIndex?: number | string }
|
||||
): Promise<ReadNoteResult> => {
|
||||
const result: unknown = await tool.invoke(args);
|
||||
return (typeof result === "string" ? JSON.parse(result) : result) as ReadNoteResult;
|
||||
};
|
||||
|
||||
class MockTFile {
|
||||
|
|
@ -27,7 +52,7 @@ jest.mock("obsidian", () => ({
|
|||
|
||||
describe("readNoteTool", () => {
|
||||
let readNoteTool: StructuredTool;
|
||||
let originalApp: any;
|
||||
let originalApp: typeof window.app;
|
||||
let getAbstractFileByPathMock: jest.Mock;
|
||||
let getMarkdownFilesMock: jest.Mock;
|
||||
let getFirstLinkpathDestMock: jest.Mock;
|
||||
|
|
@ -56,7 +81,7 @@ describe("readNoteTool", () => {
|
|||
getLeavesOfType: jest.fn().mockReturnValue([]),
|
||||
getActiveFile: jest.fn().mockReturnValue(null),
|
||||
},
|
||||
} as any;
|
||||
} as unknown as typeof window.app;
|
||||
|
||||
({ readNoteTool } = await import("./NoteTools"));
|
||||
});
|
||||
|
|
@ -101,7 +126,7 @@ describe("readNoteTool", () => {
|
|||
const lines = Array.from({ length: 205 }, (_, i) => `Line ${i + 1}`);
|
||||
mockRead.mockResolvedValue(lines.join("\n"));
|
||||
|
||||
const result = await invokeReadNoteTool(readNoteTool, { notePath, chunkIndex: "1" as any });
|
||||
const result = await invokeReadNoteTool(readNoteTool, { notePath, chunkIndex: "1" });
|
||||
|
||||
expect(result.chunkIndex).toBe(1);
|
||||
expect(result.content).toBe(lines.slice(200).join("\n"));
|
||||
|
|
|
|||
|
|
@ -1,10 +1,38 @@
|
|||
import { ToolManager } from "@/tools/toolManager";
|
||||
import { createGetTagListTool, enforceSizeLimit } from "./TagTools";
|
||||
|
||||
describe("TagTools", () => {
|
||||
const originalApp = (window as any).app;
|
||||
interface TagEntry {
|
||||
tag: string;
|
||||
occurrences: number;
|
||||
frontmatterOccurrences: number;
|
||||
inlineOccurrences: number;
|
||||
}
|
||||
|
||||
const parsePayload = (result: string): any => {
|
||||
interface TagPayload {
|
||||
totalUniqueTags: number;
|
||||
returnedTagCount: number;
|
||||
totalOccurrences: number;
|
||||
truncated: boolean;
|
||||
includedSources: Array<"frontmatter" | "inline">;
|
||||
tags: TagEntry[];
|
||||
}
|
||||
|
||||
interface MockApp {
|
||||
metadataCache: {
|
||||
getTags: jest.Mock;
|
||||
getFrontmatterTags: jest.Mock;
|
||||
};
|
||||
}
|
||||
|
||||
const getMockApp = (): MockApp => (window as unknown as { app: MockApp }).app;
|
||||
const setMockApp = (value: MockApp | undefined): void => {
|
||||
(window as unknown as { app: MockApp | undefined }).app = value;
|
||||
};
|
||||
|
||||
describe("TagTools", () => {
|
||||
const originalApp = getMockApp();
|
||||
|
||||
const parsePayload = (result: string): TagPayload => {
|
||||
const startIndex = result.indexOf('{"');
|
||||
const endIndex = result.lastIndexOf("}");
|
||||
|
||||
|
|
@ -13,7 +41,7 @@ describe("TagTools", () => {
|
|||
|
||||
const jsonSegment = result.slice(startIndex, endIndex + 1);
|
||||
try {
|
||||
return JSON.parse(jsonSegment);
|
||||
return JSON.parse(jsonSegment) as TagPayload;
|
||||
} catch (error) {
|
||||
console.error("Failed to parse tag tool payload:", jsonSegment);
|
||||
throw error;
|
||||
|
|
@ -21,7 +49,7 @@ describe("TagTools", () => {
|
|||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(window as any).app = {
|
||||
setMockApp({
|
||||
metadataCache: {
|
||||
getTags: jest.fn().mockReturnValue({
|
||||
"#project": 5,
|
||||
|
|
@ -33,22 +61,22 @@ describe("TagTools", () => {
|
|||
"#daily": 2,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(window as any).app = originalApp;
|
||||
setMockApp(originalApp);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns combined tag statistics by default", async () => {
|
||||
const tool = createGetTagListTool();
|
||||
const result = await ToolManager.callTool(tool, {});
|
||||
const result = (await ToolManager.callTool(tool, {})) as string;
|
||||
|
||||
const payload = parsePayload(result as string);
|
||||
const payload = parsePayload(result);
|
||||
|
||||
expect((window as any).app.metadataCache.getTags).toHaveBeenCalled();
|
||||
expect((window as any).app.metadataCache.getFrontmatterTags).toHaveBeenCalled();
|
||||
expect(getMockApp().metadataCache.getTags).toHaveBeenCalled();
|
||||
expect(getMockApp().metadataCache.getFrontmatterTags).toHaveBeenCalled();
|
||||
expect(payload.totalUniqueTags).toBe(3);
|
||||
expect(payload.returnedTagCount).toBe(3);
|
||||
expect(payload.totalOccurrences).toBe(10);
|
||||
|
|
@ -78,12 +106,12 @@ describe("TagTools", () => {
|
|||
|
||||
it("supports frontmatter-only mode", async () => {
|
||||
const tool = createGetTagListTool();
|
||||
const result = await ToolManager.callTool(tool, { includeInline: false });
|
||||
const result = (await ToolManager.callTool(tool, { includeInline: false })) as string;
|
||||
|
||||
const payload = parsePayload(result as string);
|
||||
const payload = parsePayload(result);
|
||||
|
||||
expect((window as any).app.metadataCache.getTags).not.toHaveBeenCalled();
|
||||
expect((window as any).app.metadataCache.getFrontmatterTags).toHaveBeenCalled();
|
||||
expect(getMockApp().metadataCache.getTags).not.toHaveBeenCalled();
|
||||
expect(getMockApp().metadataCache.getFrontmatterTags).toHaveBeenCalled();
|
||||
expect(payload.totalUniqueTags).toBe(2);
|
||||
expect(payload.includedSources).toEqual(["frontmatter"]);
|
||||
expect(payload.tags).toEqual([
|
||||
|
|
@ -104,9 +132,9 @@ describe("TagTools", () => {
|
|||
|
||||
it("limits entries when maxEntries is provided", async () => {
|
||||
const tool = createGetTagListTool();
|
||||
const result = await ToolManager.callTool(tool, { maxEntries: 2 });
|
||||
const result = (await ToolManager.callTool(tool, { maxEntries: 2 })) as string;
|
||||
|
||||
const payload = parsePayload(result as string);
|
||||
const payload = parsePayload(result);
|
||||
|
||||
expect(payload.totalUniqueTags).toBe(3);
|
||||
expect(payload.returnedTagCount).toBe(2);
|
||||
|
|
@ -117,13 +145,13 @@ describe("TagTools", () => {
|
|||
});
|
||||
|
||||
it("handles empty vault gracefully", async () => {
|
||||
(window as any).app.metadataCache.getTags.mockReturnValue({});
|
||||
(window as any).app.metadataCache.getFrontmatterTags.mockReturnValue({});
|
||||
getMockApp().metadataCache.getTags.mockReturnValue({});
|
||||
getMockApp().metadataCache.getFrontmatterTags.mockReturnValue({});
|
||||
|
||||
const tool = createGetTagListTool();
|
||||
const result = await ToolManager.callTool(tool, {});
|
||||
const result = (await ToolManager.callTool(tool, {})) as string;
|
||||
|
||||
const payload = parsePayload(result as string);
|
||||
const payload = parsePayload(result);
|
||||
|
||||
expect(payload.totalUniqueTags).toBe(0);
|
||||
expect(payload.tags).toEqual([]);
|
||||
|
|
@ -131,19 +159,19 @@ describe("TagTools", () => {
|
|||
});
|
||||
|
||||
it("normalizes malformed tags correctly", async () => {
|
||||
(window as any).app.metadataCache.getTags.mockReturnValue({
|
||||
getMockApp().metadataCache.getTags.mockReturnValue({
|
||||
project: 5,
|
||||
"##Weird/Tag": 4,
|
||||
});
|
||||
(window as any).app.metadataCache.getFrontmatterTags.mockReturnValue({
|
||||
getMockApp().metadataCache.getFrontmatterTags.mockReturnValue({
|
||||
" #Project ": 3,
|
||||
"##Weird/Tag": 1,
|
||||
});
|
||||
|
||||
const tool = createGetTagListTool();
|
||||
const result = await ToolManager.callTool(tool, {});
|
||||
const result = (await ToolManager.callTool(tool, {})) as string;
|
||||
|
||||
const payload = parsePayload(result as string);
|
||||
const payload = parsePayload(result);
|
||||
|
||||
expect(payload.tags).toEqual([
|
||||
{
|
||||
|
|
@ -162,17 +190,17 @@ describe("TagTools", () => {
|
|||
});
|
||||
|
||||
it("falls back when inline counts omit frontmatter occurrences", async () => {
|
||||
(window as any).app.metadataCache.getTags.mockReturnValue({
|
||||
getMockApp().metadataCache.getTags.mockReturnValue({
|
||||
"#project": 1,
|
||||
});
|
||||
(window as any).app.metadataCache.getFrontmatterTags.mockReturnValue({
|
||||
getMockApp().metadataCache.getFrontmatterTags.mockReturnValue({
|
||||
"#project": 3,
|
||||
});
|
||||
|
||||
const tool = createGetTagListTool();
|
||||
const result = await ToolManager.callTool(tool, {});
|
||||
const result = (await ToolManager.callTool(tool, {})) as string;
|
||||
|
||||
const payload = parsePayload(result as string);
|
||||
const payload = parsePayload(result);
|
||||
|
||||
expect(payload.tags).toEqual([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import { DateTime } from "luxon";
|
||||
import { getTimeRangeMsTool } from "./TimeTools";
|
||||
|
||||
type InvokableTool = { invoke: (args: Record<string, unknown>) => Promise<string> };
|
||||
type TimeRangeResult = { startTime: number; endTime: number; error?: string };
|
||||
|
||||
// Helper function to call the tool and parse result
|
||||
const getTimeRangeMs = async (timeExpression: string): Promise<any> => {
|
||||
const result = await (getTimeRangeMsTool as any).invoke({ timeExpression });
|
||||
const getTimeRangeMs = async (timeExpression: string): Promise<TimeRangeResult | undefined> => {
|
||||
const result = await (getTimeRangeMsTool as unknown as InvokableTool).invoke({
|
||||
timeExpression,
|
||||
});
|
||||
// The tool returns JSON string, parse it
|
||||
const parsed = typeof result === "string" ? JSON.parse(result) : result;
|
||||
const parsed: TimeRangeResult =
|
||||
typeof result === "string" ? (JSON.parse(result) as TimeRangeResult) : result;
|
||||
// Return undefined if it's an error response
|
||||
if (parsed.error) return undefined;
|
||||
return parsed;
|
||||
|
|
@ -21,8 +27,8 @@ const verifyDateRange = async (expression: string, expected: DateRange) => {
|
|||
const result = await getTimeRangeMs(expression);
|
||||
expect(result).toBeDefined();
|
||||
// getTimeRangeMs now returns epoch values directly: {startTime: number, endTime: number}
|
||||
const startDate = DateTime.fromMillis(result!.startTime as number);
|
||||
const endDate = DateTime.fromMillis(result!.endTime as number);
|
||||
const startDate = DateTime.fromMillis(result!.startTime);
|
||||
const endDate = DateTime.fromMillis(result!.endTime);
|
||||
|
||||
expect(startDate.toISODate()).toBe(expected.startDate);
|
||||
expect(endDate.toISODate()).toBe(expected.endDate);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,34 @@
|
|||
import { DateTime } from "luxon";
|
||||
import { getCurrentTimeTool, convertTimeBetweenTimezonesTool } from "./TimeTools";
|
||||
|
||||
interface TimeResult {
|
||||
epoch: number;
|
||||
isoString: string;
|
||||
userLocaleString: string;
|
||||
localDateString: string;
|
||||
timezoneOffset: number;
|
||||
timezone: string;
|
||||
originalTime?: string;
|
||||
convertedTime?: string;
|
||||
}
|
||||
|
||||
interface InvokableTool {
|
||||
invoke: (args: unknown) => Promise<unknown>;
|
||||
}
|
||||
|
||||
// Helper to invoke tool and parse result
|
||||
const invokeGetCurrentTime = async (args: { timezoneOffset?: string }): Promise<any> => {
|
||||
const result = await (getCurrentTimeTool as any).invoke(args);
|
||||
return typeof result === "string" ? JSON.parse(result) : result;
|
||||
const invokeGetCurrentTime = async (args: { timezoneOffset?: string }): Promise<TimeResult> => {
|
||||
const result = await (getCurrentTimeTool as unknown as InvokableTool).invoke(args);
|
||||
return (typeof result === "string" ? JSON.parse(result) : result) as TimeResult;
|
||||
};
|
||||
|
||||
const invokeConvertTime = async (args: {
|
||||
time: string;
|
||||
fromOffset: string;
|
||||
toOffset: string;
|
||||
}): Promise<any> => {
|
||||
const result = await (convertTimeBetweenTimezonesTool as any).invoke(args);
|
||||
return typeof result === "string" ? JSON.parse(result) : result;
|
||||
}): Promise<TimeResult> => {
|
||||
const result = await (convertTimeBetweenTimezonesTool as unknown as InvokableTool).invoke(args);
|
||||
return (typeof result === "string" ? JSON.parse(result) : result) as TimeResult;
|
||||
};
|
||||
|
||||
describe("TimeTools Timezone Tests", () => {
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ const mockFileMetadata = {
|
|||
const mockApp = {
|
||||
vault: new Obsidian.Vault(),
|
||||
metadataCache: mockMetadataCache,
|
||||
} as any;
|
||||
} as unknown as typeof window.app;
|
||||
|
||||
describe("isFolderMatch", () => {
|
||||
it("should return file from the folder name 1", async () => {
|
||||
|
|
@ -321,9 +321,10 @@ describe("getNotesFromTags", () => {
|
|||
it("should handle both path and tags, returning files under the specified path with the specified tags", async () => {
|
||||
const mockVault = new Obsidian.Vault();
|
||||
const tags = ["#tag1"];
|
||||
type TFileCtor = new (path: string) => TFile;
|
||||
const noteFiles: TFile[] = [
|
||||
new (TFile as any)("test/test2/note1.md"),
|
||||
new (TFile as any)("test/note2.md"),
|
||||
new (TFile as unknown as TFileCtor)("test/test2/note1.md"),
|
||||
new (TFile as unknown as TFileCtor)("test/note2.md"),
|
||||
];
|
||||
const expectedPaths = ["test/test2/note1.md"];
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue