test(ai-context): TODO-009 unit tests for LLM context message generators
Adds 18 new tests covering: Backend (13 tests in ai-context.test.ts): - keyword fallback — meeting, doctor, flight, case-insensitive - generic fallback — no match, minute/hour formatting - LLM path — flag-gated (off = no fetch), extraction-service success, Ollama cascade when extraction returns null, error → keyword fallback, short Ollama response rejected, non-200 fallthrough - prompt construction — includes category, urgency, timeOfDay, recentTimerLabels Web (5 new tests in context-messages.test.ts): - LLM success path - keyword fallback when backend returns 500 - keyword fallback when backend throws - generic fallback when backend fails and no keyword matches - payload shape — POST /api/context-message with all params Test counts: backend 240 (was ~227), web 399 (was ~394), all green.
This commit is contained in:
parent
6064d7d227
commit
8ca9e27532
199
backend/src/lib/ai-context.test.ts
Normal file
199
backend/src/lib/ai-context.test.ts
Normal file
@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Tests for backend/src/lib/ai-context.ts — TODO-009.
|
||||
*
|
||||
* Covers:
|
||||
* - keyword fallback matches known keywords
|
||||
* - generic fallback when no keyword matches
|
||||
* - LLM path gated by feature flag (off → always falls back)
|
||||
* - extraction-service success path
|
||||
* - Ollama success path
|
||||
* - extraction-service → Ollama cascade when first fails
|
||||
* - LLM errors fall through to keyword fallback
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const { mockConfig, mockIsFeatureEnabled } = vi.hoisted(() => ({
|
||||
mockConfig: {
|
||||
EXTRACTION_SERVICE_URL: undefined as string | undefined,
|
||||
OLLAMA_URL: undefined as string | undefined,
|
||||
OLLAMA_MODEL: 'gemma4',
|
||||
} as Record<string, unknown>,
|
||||
mockIsFeatureEnabled: vi.fn((_flag: string) => false),
|
||||
}));
|
||||
|
||||
vi.mock('./config.js', () => ({
|
||||
config: new Proxy({} as Record<string, unknown>, {
|
||||
get: (_t, prop: string) => mockConfig[prop],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('./feature-flags.js', () => ({
|
||||
isFeatureEnabled: mockIsFeatureEnabled,
|
||||
}));
|
||||
|
||||
// Must import AFTER mocks
|
||||
const { generateContextMessage } = await import('./ai-context.js');
|
||||
|
||||
describe('generateContextMessage — keyword fallback', () => {
|
||||
beforeEach(() => {
|
||||
mockIsFeatureEnabled.mockReturnValue(false);
|
||||
mockConfig.EXTRACTION_SERVICE_URL = undefined;
|
||||
mockConfig.OLLAMA_URL = undefined;
|
||||
});
|
||||
|
||||
it('matches "meeting" keyword', async () => {
|
||||
const r = await generateContextMessage({ timerLabel: 'Team meeting', minutesBefore: 10 });
|
||||
expect(r.source).toBe('keyword');
|
||||
expect(r.message).toBe('Review your agenda');
|
||||
});
|
||||
|
||||
it('matches "doctor appointment" keyword', async () => {
|
||||
const r = await generateContextMessage({ timerLabel: 'Doctor appointment', minutesBefore: 30 });
|
||||
expect(r.source).toBe('keyword');
|
||||
expect(r.message).toContain('insurance');
|
||||
});
|
||||
|
||||
it('keyword match is case-insensitive', async () => {
|
||||
const r = await generateContextMessage({ timerLabel: 'FLIGHT to NYC', minutesBefore: 120 });
|
||||
expect(r.source).toBe('keyword');
|
||||
expect(r.message).toContain('Check in');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateContextMessage — generic fallback', () => {
|
||||
beforeEach(() => {
|
||||
mockIsFeatureEnabled.mockReturnValue(false);
|
||||
mockConfig.EXTRACTION_SERVICE_URL = undefined;
|
||||
mockConfig.OLLAMA_URL = undefined;
|
||||
});
|
||||
|
||||
it('falls back to generic when no keyword matches', async () => {
|
||||
const r = await generateContextMessage({ timerLabel: 'xyzzy random task', minutesBefore: 5 });
|
||||
expect(r.source).toBe('generic');
|
||||
expect(r.message).toContain('xyzzy random task');
|
||||
expect(r.message).toContain('get ready');
|
||||
});
|
||||
|
||||
it('formats minutes below 60 as "Nm"', async () => {
|
||||
const r = await generateContextMessage({ timerLabel: 'random', minutesBefore: 15 });
|
||||
expect(r.message).toContain('15m');
|
||||
});
|
||||
|
||||
it('formats minutes >= 60 as "Nh Mm"', async () => {
|
||||
const r = await generateContextMessage({ timerLabel: 'random', minutesBefore: 90 });
|
||||
expect(r.message).toContain('1h 30m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateContextMessage — LLM path', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockIsFeatureEnabled.mockReturnValue(true);
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
mockConfig.EXTRACTION_SERVICE_URL = undefined;
|
||||
mockConfig.OLLAMA_URL = undefined;
|
||||
});
|
||||
|
||||
it('does NOT call LLM when flag is off', async () => {
|
||||
mockIsFeatureEnabled.mockReturnValue(false);
|
||||
mockConfig.EXTRACTION_SERVICE_URL = 'http://fake';
|
||||
mockConfig.OLLAMA_URL = 'http://fake';
|
||||
|
||||
const r = await generateContextMessage({ timerLabel: 'meeting', minutesBefore: 10 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(r.source).toBe('keyword');
|
||||
});
|
||||
|
||||
it('uses extraction-service when available', async () => {
|
||||
mockConfig.EXTRACTION_SERVICE_URL = 'http://extract';
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ result: 'AI-generated prep message' }), { status: 200 }),
|
||||
);
|
||||
|
||||
const r = await generateContextMessage({ timerLabel: 'standup', minutesBefore: 10 });
|
||||
expect(r.source).toBe('llm');
|
||||
expect(r.message).toBe('AI-generated prep message');
|
||||
});
|
||||
|
||||
it('falls back to Ollama when extraction-service returns null', async () => {
|
||||
mockConfig.EXTRACTION_SERVICE_URL = 'http://extract';
|
||||
mockConfig.OLLAMA_URL = 'http://ollama';
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 })) // extraction returns null
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ response: 'Ollama enriched text' }), { status: 200 }));
|
||||
|
||||
const r = await generateContextMessage({ timerLabel: 'random', minutesBefore: 10 });
|
||||
expect(r.source).toBe('llm');
|
||||
expect(r.message).toBe('Ollama enriched text');
|
||||
});
|
||||
|
||||
it('falls through to keyword when LLM errors', async () => {
|
||||
mockConfig.EXTRACTION_SERVICE_URL = 'http://extract';
|
||||
mockConfig.OLLAMA_URL = 'http://ollama';
|
||||
fetchSpy.mockRejectedValue(new Error('network'));
|
||||
|
||||
const r = await generateContextMessage({ timerLabel: 'presentation', minutesBefore: 30 });
|
||||
expect(r.source).toBe('keyword');
|
||||
expect(r.message).toContain('slides');
|
||||
});
|
||||
|
||||
it('rejects very short Ollama responses', async () => {
|
||||
mockConfig.OLLAMA_URL = 'http://ollama';
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ response: 'hi' }), { status: 200 }),
|
||||
);
|
||||
|
||||
const r = await generateContextMessage({ timerLabel: 'random', minutesBefore: 5 });
|
||||
expect(r.source).toBe('generic');
|
||||
});
|
||||
|
||||
it('falls through when extraction-service returns non-200', async () => {
|
||||
mockConfig.EXTRACTION_SERVICE_URL = 'http://extract';
|
||||
fetchSpy.mockResolvedValueOnce(new Response('', { status: 500 }));
|
||||
|
||||
const r = await generateContextMessage({ timerLabel: 'focus time', minutesBefore: 25 });
|
||||
expect(r.source).toBe('keyword');
|
||||
});
|
||||
});
|
||||
|
||||
describe('prompt construction', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockIsFeatureEnabled.mockReturnValue(true);
|
||||
mockConfig.EXTRACTION_SERVICE_URL = 'http://extract';
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('includes all optional context fields in the prompt', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ result: 'ok' }), { status: 200 }),
|
||||
);
|
||||
await generateContextMessage({
|
||||
timerLabel: 'team sync',
|
||||
category: 'work',
|
||||
urgency: 'high',
|
||||
minutesBefore: 10,
|
||||
timeOfDay: 'morning',
|
||||
recentTimerLabels: ['standup', 'review'],
|
||||
});
|
||||
|
||||
const call = fetchSpy.mock.calls[0];
|
||||
const body = JSON.parse((call[1] as RequestInit).body as string);
|
||||
expect(body.input).toContain('team sync');
|
||||
expect(body.input).toContain('work');
|
||||
expect(body.input).toContain('high');
|
||||
expect(body.input).toContain('morning');
|
||||
expect(body.input).toContain('standup');
|
||||
});
|
||||
});
|
||||
@ -24,7 +24,7 @@
|
||||
| ✅ **TODO-006** | low | A.4 | `web/src/lib/context-messages.ts` | ~~Centralize backend URL~~ — uses `getBackendBaseURL()` from `product-config.ts` (5dafcc2) |
|
||||
| **TODO-007** | medium | A.3 | `learning_ai_common_plat/services/mcp-server/` | Add integration tests for 5 new ChronoMind MCP tools (reschedule, availability, routine start, agent-actions list, agent-actions approve) |
|
||||
| **TODO-008** | medium | B | `backend/src/lib/telemetry-events.ts`, `web/src/lib/telemetry-events.ts` | Wire `trackEvent()` / `bufferEvent()` calls into routes and components using the defined telemetry constants. See in-code comments for specific call sites |
|
||||
| **TODO-009** | medium | A.4 | `backend/src/lib/ai-context.ts`, `web/src/lib/context-messages.ts` | Add unit tests for `generateContextMessage()` (backend) and `fetchEnrichedMessage()` (web). Test keyword fallback, LLM enrichment, generic fallback, and error paths |
|
||||
| ✅ **TODO-009** | medium | A.4 | `backend/src/lib/ai-context.test.ts`, `web/src/lib/context-messages.test.ts` | Unit tests for `generateContextMessage()` (13 tests: keyword, generic, LLM flag-off, extraction-service, Ollama cascade, error paths, prompt construction) + `fetchEnrichedMessage()` (5 tests: LLM success, keyword fallback on 500/throw, generic fallback, payload shape) |
|
||||
| ✅ **TODO-010** | low | cleanup | `backend/src/modules/timers/routes.ts`, `routines/routes.ts`, `households/routes.ts`, `webhooks/routes.ts`, `shared-timers/routes.ts` | ~~Replace hardcoded PRODUCT_ID~~ — imports from `product-config.js` in all 5 files (d74c80a) |
|
||||
| ✅ **TODO-011** | low | cleanup | `web/src/app/error.tsx` | ~~Wire error boundary to telemetry~~ — `trackEvent('error', 'app', 'error_boundary', ...)` (9e63418) |
|
||||
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
getContextMessage,
|
||||
getAllContextMessages,
|
||||
getWarningMessage,
|
||||
hasContextMatch,
|
||||
getContextRules,
|
||||
fetchEnrichedMessage,
|
||||
} from './context-messages';
|
||||
|
||||
describe('context-messages', () => {
|
||||
@ -125,3 +126,76 @@ describe('context-messages', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchEnrichedMessage', () => {
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns LLM-sourced result when backend responds 200', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ message: 'AI-generated prep', source: 'llm' }), { status: 200 }),
|
||||
);
|
||||
|
||||
const r = await fetchEnrichedMessage({ timerLabel: 'meeting', minutesBefore: 10 });
|
||||
expect(r.source).toBe('llm');
|
||||
expect(r.message).toBe('AI-generated prep');
|
||||
});
|
||||
|
||||
it('falls back to keyword rule when backend returns non-200', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(new Response('', { status: 500 }));
|
||||
|
||||
const r = await fetchEnrichedMessage({ timerLabel: 'Team meeting', minutesBefore: 10 });
|
||||
expect(r.source).toBe('keyword');
|
||||
expect(r.message).toBe('Review your agenda');
|
||||
});
|
||||
|
||||
it('falls back to keyword rule when backend throws', async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new Error('network fail'));
|
||||
|
||||
const r = await fetchEnrichedMessage({ timerLabel: 'Flight to LAX', minutesBefore: 120 });
|
||||
expect(r.source).toBe('keyword');
|
||||
expect(r.message).toBe('Check in online');
|
||||
});
|
||||
|
||||
it('falls back to generic when backend fails and no keyword matches', async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new Error('timeout'));
|
||||
|
||||
const r = await fetchEnrichedMessage({ timerLabel: 'xyzzy plinko', minutesBefore: 5 });
|
||||
expect(r.source).toBe('generic');
|
||||
expect(r.message).toContain('xyzzy plinko');
|
||||
expect(r.message).toContain('get ready');
|
||||
});
|
||||
|
||||
it('POSTs params to /api/context-message', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ message: 'ok', source: 'llm' }), { status: 200 }),
|
||||
);
|
||||
|
||||
await fetchEnrichedMessage({
|
||||
timerLabel: 'team sync',
|
||||
category: 'work',
|
||||
urgency: 'high',
|
||||
minutesBefore: 10,
|
||||
timeOfDay: 'morning',
|
||||
recentTimerLabels: ['standup'],
|
||||
});
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain('/api/context-message');
|
||||
expect(init.method).toBe('POST');
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.timerLabel).toBe('team sync');
|
||||
expect(body.category).toBe('work');
|
||||
expect(body.urgency).toBe('high');
|
||||
expect(body.timeOfDay).toBe('morning');
|
||||
expect(body.recentTimerLabels).toEqual(['standup']);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user