From 6fba02cbe99d1898cb82d6e0d56dbd2125dc84fb Mon Sep 17 00:00:00 2001 From: Markus Graf Date: Sun, 29 Mar 2026 12:05:00 +0200 Subject: [PATCH] test(02-04): add failing tests for companion text module - Tests for getGreeting, getLetterIntro, getForestComment - Covers API success, API failure, no config, rate-limited paths - 12 test cases total with mocked dependencies --- src/companion/companion.test.ts | 227 ++++++++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 src/companion/companion.test.ts diff --git a/src/companion/companion.test.ts b/src/companion/companion.test.ts new file mode 100644 index 0000000..9b90e5e --- /dev/null +++ b/src/companion/companion.test.ts @@ -0,0 +1,227 @@ +import "fake-indexeddb/auto"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { openDB } from "../storage/db"; +import type { GeminiConfig } from "../api/config"; + +// Mock modules before importing the module under test +vi.mock("../api/config", () => ({ + loadConfig: vi.fn(), + resetConfigCache: vi.fn(), +})); + +vi.mock("../api/gemini", () => ({ + generateText: vi.fn(), + checkRateLimit: vi.fn(), + incrementApiCall: vi.fn(), +})); + +import { loadConfig } from "../api/config"; +import { + checkRateLimit, + generateText, + incrementApiCall, +} from "../api/gemini"; +import { getGreeting, getLetterIntro, getForestComment } from "./companion"; + +const TEST_CONFIG: GeminiConfig = { + geminiApiKey: "test-key", + geminiModel: "gemini-2.5-flash", + imageModel: "gemini-3.1-flash-preview-image", +}; + +let testCounter = 0; +let db: IDBDatabase; + +beforeEach(async () => { + vi.clearAllMocks(); + testCounter++; + db = await openDB(`companion-test-${testCounter}`); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("getGreeting", () => { + it("returns AI text when API succeeds and rate limit not exceeded", async () => { + vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG); + vi.mocked(checkRateLimit).mockResolvedValue(true); + vi.mocked(generateText).mockResolvedValue( + "Guten Morgen, kleiner Waldfreund!", + ); + + const result = await getGreeting("fee", db); + + expect(result).toBe("Guten Morgen, kleiner Waldfreund!"); + expect(generateText).toHaveBeenCalledTimes(1); + expect(incrementApiCall).toHaveBeenCalledWith(db, "text"); + }); + + it("returns fallback text when loadConfig returns null (no API key)", async () => { + vi.mocked(loadConfig).mockResolvedValue(null); + + const result = await getGreeting("fee", db); + + expect(result).toBeTruthy(); + expect(typeof result).toBe("string"); + expect(generateText).not.toHaveBeenCalled(); + }); + + it("returns fallback text when generateText returns null (API error)", async () => { + vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG); + vi.mocked(checkRateLimit).mockResolvedValue(true); + vi.mocked(generateText).mockResolvedValue(null); + + const result = await getGreeting("fee", db); + + expect(result).toBeTruthy(); + expect(typeof result).toBe("string"); + expect(incrementApiCall).not.toHaveBeenCalled(); + }); + + it("returns fallback text when rate limit exceeded", async () => { + vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG); + vi.mocked(checkRateLimit).mockResolvedValue(false); + + const result = await getGreeting("fee", db); + + expect(result).toBeTruthy(); + expect(generateText).not.toHaveBeenCalled(); + }); + + it("passes correct system prompt with companion personality and time of day", async () => { + vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG); + vi.mocked(checkRateLimit).mockResolvedValue(true); + vi.mocked(generateText).mockResolvedValue("Hallo!"); + + await getGreeting("fee", db); + + const systemPrompt = vi.mocked(generateText).mock.calls[0]![1]; + expect(systemPrompt).toContain("Lila"); + expect(systemPrompt).toContain( + "Sanft, ermutigend, ein bisschen vertraeumt", + ); + expect(systemPrompt).toMatch(/morgen|mittag|abend/); + expect(systemPrompt).toContain("Max. 2 kurze Saetze"); + }); + + it("calls incrementApiCall after successful API call", async () => { + vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG); + vi.mocked(checkRateLimit).mockResolvedValue(true); + vi.mocked(generateText).mockResolvedValue("Hallo Waldkind!"); + + await getGreeting("einhorn", db); + + expect(incrementApiCall).toHaveBeenCalledWith(db, "text"); + }); +}); + +describe("getLetterIntro", () => { + it("returns AI text when API succeeds", async () => { + vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG); + vi.mocked(checkRateLimit).mockResolvedValue(true); + vi.mocked(generateText).mockResolvedValue( + "Das F ist wie ein Farn im Wald!", + ); + + const result = await getLetterIntro( + "fee", + "f", + "linker Zeigefinger", + db, + ); + + expect(result).toBe("Das F ist wie ein Farn im Wald!"); + expect(incrementApiCall).toHaveBeenCalledWith(db, "text"); + }); + + it("returns fallback LetterIntro text when API unavailable", async () => { + vi.mocked(loadConfig).mockResolvedValue(null); + + const result = await getLetterIntro( + "fee", + "f", + "linker Zeigefinger", + db, + ); + + expect(result).toBeTruthy(); + expect(result).toContain("Zeigefinger"); + expect(generateText).not.toHaveBeenCalled(); + }); + + it("system prompt includes letter, finger description, and companion personality", async () => { + vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG); + vi.mocked(checkRateLimit).mockResolvedValue(true); + vi.mocked(generateText).mockResolvedValue("Das D wohnt hier!"); + + await getLetterIntro("fuchs", "d", "linker Mittelfinger", db); + + const systemPrompt = vi.mocked(generateText).mock.calls[0]![1]; + expect(systemPrompt).toContain("Finn"); + expect(systemPrompt).toContain("Ruhig, weise, humorvoll"); + expect(systemPrompt).toContain("d"); + expect(systemPrompt).toContain("linker Mittelfinger"); + }); + + it("returns default text when fallback not found for unknown letter", async () => { + vi.mocked(loadConfig).mockResolvedValue(null); + + const result = await getLetterIntro( + "fee", + "z", + "linker kleiner Finger", + db, + ); + + expect(result).toBe("Druecke die leuchtende Taste!"); + }); +}); + +describe("getForestComment", () => { + it("returns AI text when API succeeds", async () => { + vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG); + vi.mocked(checkRateLimit).mockResolvedValue(true); + vi.mocked(generateText).mockResolvedValue( + "Oh, ein Schmetterling ist erschienen!", + ); + + const result = await getForestComment( + "eule", + "ein blauer Schmetterling", + db, + ); + + expect(result).toBe("Oh, ein Schmetterling ist erschienen!"); + expect(incrementApiCall).toHaveBeenCalledWith(db, "text"); + }); + + it("returns fallback text when API unavailable", async () => { + vi.mocked(loadConfig).mockResolvedValue(null); + + const result = await getForestComment( + "eule", + "ein blauer Schmetterling", + db, + ); + + expect(result).toBeTruthy(); + expect(typeof result).toBe("string"); + expect(generateText).not.toHaveBeenCalled(); + }); + + it("system prompt includes element description and companion personality", async () => { + vi.mocked(loadConfig).mockResolvedValue(TEST_CONFIG); + vi.mocked(checkRateLimit).mockResolvedValue(true); + vi.mocked(generateText).mockResolvedValue("Wie wunderschoen!"); + + await getForestComment("einhorn", "ein leuchtender Pilz", db); + + const systemPrompt = vi.mocked(generateText).mock.calls[0]![1]; + expect(systemPrompt).toContain("Stella"); + expect(systemPrompt).toContain( + "Froehlich, enthusiastisch, feiert jeden kleinen Erfolg", + ); + expect(systemPrompt).toContain("ein leuchtender Pilz"); + }); +});