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-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-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-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-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) |
|
| ✅ **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 {
|
import {
|
||||||
getContextMessage,
|
getContextMessage,
|
||||||
getAllContextMessages,
|
getAllContextMessages,
|
||||||
getWarningMessage,
|
getWarningMessage,
|
||||||
hasContextMatch,
|
hasContextMatch,
|
||||||
getContextRules,
|
getContextRules,
|
||||||
|
fetchEnrichedMessage,
|
||||||
} from './context-messages';
|
} from './context-messages';
|
||||||
|
|
||||||
describe('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