feat: implement micro-learning generation service and UI components for interactive topic-based learning
All checks were successful
On Push to Main / test (push) Successful in 32s
On Push to Main / publish (push) Successful in 59s
On Push to Main / deploy-dev (push) Successful in 1m33s

This commit is contained in:
RaymondVerhoef
2026-05-25 22:17:26 +02:00
parent f16438c1bc
commit a653812cd8
10 changed files with 227 additions and 263 deletions

View File

@@ -24,7 +24,8 @@ vi.mock('../llm', () => ({
cachedSystem: (text) => [{ type: 'text', text }],
}));
vi.mock('../curriculumService', () => ({
getCurrentWeekContent: vi.fn(async () => null),
getCurriculumTopic: vi.fn(async () => ({ topic: null })),
getQuarterForWeek: vi.fn(() => 1),
}));
import { forceGenerateTopicQuestions } from '../testService';
@@ -53,8 +54,8 @@ describe('forceGenerateTopicQuestions', () => {
beforeEach(() => {
bankStore.clear();
callLLMMock.mockReset();
debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => { });
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
});
afterEach(() => { debugSpy.mockRestore(); warnSpy.mockRestore(); });
@@ -66,6 +67,17 @@ describe('forceGenerateTopicQuestions', () => {
expect(bankStore.get('onboarding')).toHaveLength(5);
});
it('re-rolls when one correctIndex dominates the batch, then accepts on the third try', async () => {
const allZero = [0, 1, 2, 3, 4].map((i) => makeQuestion(i, { correctIndex: 0 }));
llmEmits(allZero);
llmEmits(allZero);
llmEmits(allZero);
const out = await forceGenerateTopicQuestions(topic, 5);
expect(callLLMMock).toHaveBeenCalledTimes(3);
expect(out).toHaveLength(5);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('correctIndex dominated'));
});
it('rejects a batch containing a banned "all of the above" option', async () => {
const bad = [0, 1, 2, 3, 4].map((i) =>
makeQuestion(i, { options: ['A) x', 'B) y', 'C) z', 'D) All of the above'] }),
@@ -73,7 +85,7 @@ describe('forceGenerateTopicQuestions', () => {
llmEmits(bad);
llmEmits(bad);
llmEmits(bad);
await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/banned filler|failed/i);
await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/banned filler|rejected/i);
});
it('rejects a batch where an explanation is too short', async () => {
@@ -81,7 +93,7 @@ describe('forceGenerateTopicQuestions', () => {
llmEmits(bad);
llmEmits(bad);
llmEmits(bad);
await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/too short|failed/i);
await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/too short|rejected/i);
});
it('drops duplicates whose normalized text matches an existing bank entry', async () => {
@@ -100,19 +112,4 @@ describe('forceGenerateTopicQuestions', () => {
expect(out.find((q) => q.question.toLowerCase().includes('buddy'))).toBeUndefined();
expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('dropped duplicate'), expect.any(String));
});
it('retries on LLM failure and succeeds on a later attempt', async () => {
callLLMMock.mockRejectedValueOnce(new Error('API timeout'));
llmEmits([0, 1, 2, 3, 4].map((i) => makeQuestion(i)));
const out = await forceGenerateTopicQuestions(topic, 5);
expect(callLLMMock).toHaveBeenCalledTimes(2);
expect(out).toHaveLength(5);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('LLM call failed'), expect.any(String));
});
it('throws after 3 consecutive LLM failures', async () => {
callLLMMock.mockRejectedValue(new Error('schema validation'));
await expect(forceGenerateTopicQuestions(topic, 5)).rejects.toThrow(/failed.*schema validation/i);
expect(callLLMMock).toHaveBeenCalledTimes(3);
});
});