From aacd7d7d65ff2fa648ff105e02b5c4e649247bed Mon Sep 17 00:00:00 2001 From: Nathan Arthur Date: Wed, 12 Feb 2025 16:30:41 -0500 Subject: [PATCH] improve test coverage --- src/lib/extractContext.spec.ts | 22 +++ src/lib/generateContextHash.spec.ts | 23 ++++ src/lib/validateQuestions.spec.ts | 67 +++++++++ src/services/events/events.spec.ts | 48 +++++++ src/services/openai/openai.spec.ts | 195 +++++++++++++++++++++++++++ src/services/storage/storage.spec.ts | 139 +++++++++++++++++++ test-coverage-plan.md | 53 ++++++++ 7 files changed, 547 insertions(+) create mode 100644 src/lib/extractContext.spec.ts create mode 100644 src/lib/generateContextHash.spec.ts create mode 100644 src/lib/validateQuestions.spec.ts create mode 100644 src/services/events/events.spec.ts create mode 100644 src/services/openai/openai.spec.ts create mode 100644 src/services/storage/storage.spec.ts create mode 100644 test-coverage-plan.md diff --git a/src/lib/extractContext.spec.ts b/src/lib/extractContext.spec.ts new file mode 100644 index 0000000..e4a3257 --- /dev/null +++ b/src/lib/extractContext.spec.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { extractContext } from "./extractContext"; + +describe("extractContext", () => { + it("should return full text if shorter than context size", () => { + const text = "Short text"; + const result = extractContext(text, "question", 500); + expect(result).toBe(text); + }); + + it("should extract context around middle of text", () => { + const longText = Array(1000).fill("word").join(" "); + const result = extractContext(longText, "question", 100); + const words = result.split(/\s+/); + expect(words.length).toBeLessThanOrEqual(100); + }); + + it("should handle empty text", () => { + const result = extractContext("", "question"); + expect(result).toBe(""); + }); +}); diff --git a/src/lib/generateContextHash.spec.ts b/src/lib/generateContextHash.spec.ts new file mode 100644 index 0000000..2417fc8 --- /dev/null +++ b/src/lib/generateContextHash.spec.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { generateContextHash } from "./generateContextHash"; + +describe("generateContextHash", () => { + it("should generate consistent hash for same content", async () => { + const content = "Test content"; + const hash1 = await generateContextHash(content); + const hash2 = await generateContextHash(content); + expect(hash1).toBe(hash2); + }); + + it("should generate different hashes for different content", async () => { + const hash1 = await generateContextHash("Content 1"); + const hash2 = await generateContextHash("Content 2"); + expect(hash1).not.toBe(hash2); + }); + + it("should handle empty content", async () => { + const hash = await generateContextHash(""); + expect(typeof hash).toBe("string"); + expect(hash.length).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/validateQuestions.spec.ts b/src/lib/validateQuestions.spec.ts new file mode 100644 index 0000000..1185e0f --- /dev/null +++ b/src/lib/validateQuestions.spec.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from "vitest"; +import { validateQuestions } from "./validateQuestions"; +import type { Quest } from "../services/storage"; +import { generateContextHash } from "./generateContextHash"; + +describe("validateQuestions", () => { + const mockPlugin: any = {}; + + it("should mark questions as obsolete when content hash changes", async () => { + const quests: Quest[] = [{ + id: "1", + question: "Test question", + isCompleted: false, + isDismissed: false, + createdAt: Date.now(), + documentId: "test.md", + documentPath: "test.md", + contextHash: "old-hash", + contextSnapshot: "old content", + }]; + + const newContent = "new content"; + const validatedQuests = await validateQuestions(mockPlugin, quests, newContent); + + expect(validatedQuests[0].isObsolete).toBe(true); + expect(validatedQuests[0].lastValidated).toBeDefined(); + expect(validatedQuests[0].obsoleteReason).toBeDefined(); + }); + + it("should not mark questions as obsolete when content hash matches", async () => { + const content = "test content"; + // First generate the actual hash + const hash = await generateContextHash(content); + + const quests: Quest[] = [{ + id: "1", + question: "Test question", + isCompleted: false, + isDismissed: false, + createdAt: Date.now(), + documentId: "test.md", + documentPath: "test.md", + contextHash: hash, + contextSnapshot: content, + }]; + + const validatedQuests = await validateQuestions(mockPlugin, quests, content); + + expect(validatedQuests[0].isObsolete).toBeUndefined(); + }); + + it("should skip validation for questions without context hash", async () => { + const quests: Quest[] = [{ + id: "1", + question: "Test question", + isCompleted: false, + isDismissed: false, + createdAt: Date.now(), + documentId: "test.md", + documentPath: "test.md", + }]; + + const validatedQuests = await validateQuestions(mockPlugin, quests, "content"); + + expect(validatedQuests[0]).toEqual(quests[0]); + }); +}); diff --git a/src/services/events/events.spec.ts b/src/services/events/events.spec.ts new file mode 100644 index 0000000..64164bc --- /dev/null +++ b/src/services/events/events.spec.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { EventEmitter } from "./index"; + +describe("EventEmitter", () => { + let events: EventEmitter; + + beforeEach(() => { + events = new EventEmitter(); + }); + + it("should register and trigger event handlers", () => { + const handler = vi.fn(); + events.on("test-event", handler); + events.emit("test-event", { data: "test" }); + expect(handler).toHaveBeenCalledWith({ data: "test" }); + }); + + it("should allow multiple handlers for same event", () => { + const handler1 = vi.fn(); + const handler2 = vi.fn(); + events.on("test-event", handler1); + events.on("test-event", handler2); + events.emit("test-event"); + expect(handler1).toHaveBeenCalled(); + expect(handler2).toHaveBeenCalled(); + }); + + it("should remove event handler", () => { + const handler = vi.fn(); + events.on("test-event", handler); + events.off("test-event", handler); + events.emit("test-event"); + expect(handler).not.toHaveBeenCalled(); + }); + + it("should handle events with no registered handlers", () => { + expect(() => { + events.emit("non-existent-event"); + }).not.toThrow(); + }); + + it("should handle removing non-existent handler", () => { + const handler = vi.fn(); + expect(() => { + events.off("test-event", handler); + }).not.toThrow(); + }); +}); diff --git a/src/services/openai/openai.spec.ts b/src/services/openai/openai.spec.ts new file mode 100644 index 0000000..18e6666 --- /dev/null +++ b/src/services/openai/openai.spec.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { OpenAIService } from "./index"; +import type OpenAI from "openai"; + +describe("OpenAIService", () => { + const mockSettings = { + generatePrompt: "Test generate prompt", + evaluatePrompt: "Test evaluate prompt", + breakdownPrompt: "Test breakdown prompt", + }; + + let service: OpenAIService; + let mockClient: any; + + beforeEach(() => { + mockClient = { + completions: { + create: vi.fn(), + }, + chat: { + completions: { + create: vi.fn(), + }, + }, + }; + + service = new OpenAIService("test-key", "gpt-4", mockSettings); + service.client = mockClient; + }); + + describe("generateQuestions", () => { + it("should generate questions using chat completion", async () => { + mockClient.chat.completions.create.mockResolvedValue({ + choices: [{ + message: { + tool_calls: [{ + function: { + arguments: JSON.stringify({ + questions: ["Question 1", "Question 2"] + }) + } + }] + }, + finish_reason: "tool_calls" + }] + }); + + const questions = await service.generateQuestions("Test content", 2); + + expect(questions).toEqual(["Question 1", "Question 2"]); + expect(mockClient.chat.completions.create).toHaveBeenCalledWith({ + model: "gpt-4", + messages: [ + { role: "system", content: mockSettings.generatePrompt }, + { role: "user", content: expect.stringContaining("Test content") } + ], + tools: expect.arrayContaining([ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ + name: "generate_questions" + }) + }) + ]), + temperature: 0.7 + }); + }); + + it("should throw error when no questions are generated", async () => { + mockClient.chat.completions.create.mockResolvedValue({ + choices: [{ + message: {}, + finish_reason: "stop" + }] + }); + + await expect(service.generateQuestions("Test content", 2)) + .rejects.toThrow("No questions generated"); + }); + }); + + describe("evaluateQuestions", () => { + it("should evaluate questions using chat completion", async () => { + const mockEvaluations = { + evaluations: [{ + questionId: "1", + isAnswered: true, + explanation: "Found answer" + }] + }; + + mockClient.chat.completions.create.mockResolvedValue({ + choices: [{ + message: { + tool_calls: [{ + function: { + arguments: JSON.stringify(mockEvaluations) + } + }] + }, + finish_reason: "tool_calls" + }] + }); + + const result = await service.evaluateQuestions("Test content", [ + { id: "1", question: "Test question" } + ]); + + expect(result).toEqual(mockEvaluations); + expect(mockClient.chat.completions.create).toHaveBeenCalledWith({ + model: "gpt-4", + messages: [ + { role: "system", content: mockSettings.evaluatePrompt }, + { role: "user", content: expect.stringContaining("Test content") } + ], + tools: expect.arrayContaining([ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ + name: "evaluate_questions" + }) + }) + ]), + temperature: 0.7 + }); + }); + + it("should throw error when no evaluations are generated", async () => { + mockClient.chat.completions.create.mockResolvedValue({ + choices: [{ + message: {}, + finish_reason: "stop" + }] + }); + + await expect(service.evaluateQuestions("Test content", [ + { id: "1", question: "Test question" } + ])).rejects.toThrow("No evaluations generated"); + }); + }); + + describe("breakdownQuestion", () => { + it("should break down question into sub-questions", async () => { + mockClient.chat.completions.create.mockResolvedValue({ + choices: [{ + message: { + tool_calls: [{ + function: { + arguments: JSON.stringify({ + questions: ["Sub-question 1", "Sub-question 2"] + }) + } + }] + }, + finish_reason: "tool_calls" + }] + }); + + const subQuestions = await service.breakdownQuestion( + "Test content", + "Main question" + ); + + expect(subQuestions).toEqual(["Sub-question 1", "Sub-question 2"]); + expect(mockClient.chat.completions.create).toHaveBeenCalledWith({ + model: "gpt-4", + messages: [ + { role: "system", content: mockSettings.breakdownPrompt }, + { role: "user", content: expect.stringContaining("Main question") } + ], + tools: expect.arrayContaining([ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ + name: "generate_sub_questions" + }) + }) + ]), + temperature: 0.7 + }); + }); + + it("should throw error when no sub-questions are generated", async () => { + mockClient.chat.completions.create.mockResolvedValue({ + choices: [{ + message: {}, + finish_reason: "stop" + }] + }); + + await expect(service.breakdownQuestion("Test content", "Main question")) + .rejects.toThrow("No questions generated"); + }); + }); +}); diff --git a/src/services/storage/storage.spec.ts b/src/services/storage/storage.spec.ts new file mode 100644 index 0000000..050af58 --- /dev/null +++ b/src/services/storage/storage.spec.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { StorageService } from "./index"; +import type { Quest } from "./index"; + +describe("StorageService", () => { + const mockPlugin: any = { + loadData: vi.fn(), + saveData: vi.fn(), + }; + + let storage: StorageService; + + beforeEach(() => { + vi.clearAllMocks(); + storage = new StorageService(mockPlugin); + }); + + describe("getQuests", () => { + it("should return empty array when no data exists", async () => { + mockPlugin.loadData.mockResolvedValue(null); + const quests = await storage.getQuests(); + expect(quests).toEqual([]); + }); + + it("should return quests from plugin data", async () => { + const mockQuests = [ + { + id: "1", + question: "Test question", + isCompleted: false, + isDismissed: false, + createdAt: Date.now(), + documentId: "test.md", + documentPath: "test.md", + }, + ]; + mockPlugin.loadData.mockResolvedValue({ quests: mockQuests }); + const quests = await storage.getQuests(); + expect(quests).toEqual(mockQuests); + }); + }); + + describe("getQuestsForDocument", () => { + it("should return only quests for specified document", async () => { + const mockQuests = [ + { + id: "1", + question: "Doc 1 question", + documentId: "doc1.md", + documentPath: "doc1.md", + isCompleted: false, + isDismissed: false, + createdAt: Date.now(), + }, + { + id: "2", + question: "Doc 2 question", + documentId: "doc2.md", + documentPath: "doc2.md", + isCompleted: false, + isDismissed: false, + createdAt: Date.now(), + }, + ]; + mockPlugin.loadData.mockResolvedValue({ quests: mockQuests }); + + const doc1Quests = await storage.getQuestsForDocument("doc1.md"); + expect(doc1Quests).toHaveLength(1); + expect(doc1Quests[0].documentId).toBe("doc1.md"); + }); + + it("should return empty array when no quests exist for document", async () => { + const mockQuests = [ + { + id: "1", + question: "Doc 1 question", + documentId: "doc1.md", + documentPath: "doc1.md", + isCompleted: false, + isDismissed: false, + createdAt: Date.now(), + }, + ]; + mockPlugin.loadData.mockResolvedValue({ quests: mockQuests }); + + const doc2Quests = await storage.getQuestsForDocument("doc2.md"); + expect(doc2Quests).toEqual([]); + }); + }); + + describe("saveQuests", () => { + it("should save quests to plugin data", async () => { + const questsToSave = [ + { + id: "1", + question: "Test question", + isCompleted: false, + isDismissed: false, + createdAt: Date.now(), + documentId: "test.md", + documentPath: "test.md", + }, + ]; + + await storage.saveQuests(questsToSave); + + expect(mockPlugin.saveData).toHaveBeenCalledWith({ + quests: questsToSave, + }); + }); + + it("should preserve existing data when saving quests", async () => { + const existingData = { + someOtherKey: "value", + quests: [], + }; + mockPlugin.loadData.mockResolvedValue(existingData); + + const questsToSave = [ + { + id: "1", + question: "Test question", + isCompleted: false, + isDismissed: false, + createdAt: Date.now(), + documentId: "test.md", + documentPath: "test.md", + }, + ]; + + await storage.saveQuests(questsToSave); + + expect(mockPlugin.saveData).toHaveBeenCalledWith({ + someOtherKey: "value", + quests: questsToSave, + }); + }); + }); +}); diff --git a/test-coverage-plan.md b/test-coverage-plan.md new file mode 100644 index 0000000..c12230f --- /dev/null +++ b/test-coverage-plan.md @@ -0,0 +1,53 @@ +# Test Coverage Plan + +## ✓ Completed + +1. Storage Service +- Quest saving/loading +- Document-specific quest filtering +- Data persistence + +2. OpenAI Service +- Question generation +- Question evaluation +- Question breakdown +- Error handling + +3. Context Management +- Context extraction +- Context hash generation +- Context validation +- Obsolescence detection + +4. Event System +- Event emission +- Event subscription +- Event cleanup + +## 🚧 In Progress + +5. Integration Tests +- Full workflow scenarios +- Plugin initialization/cleanup +- Settings management +- View interactions + +6. UI Components +- QuestList rendering +- Settings view +- Clear confirmation modal +- User interactions + +## Requirements + +- Focus on core functionality first +- Ensure critical paths are tested +- Add tests incrementally +- Use proper mocking and test isolation + +## Next Steps + +1. Set up Svelte component testing infrastructure +2. Add integration tests for full workflows +3. Add UI component tests +4. Add end-to-end tests for critical user journeys \ No newline at end of file