122 lines
3.8 KiB
TypeScript
122 lines
3.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('@/lib/quiz/runtime', () => ({
|
|
loadQuizAttemptState: vi.fn(),
|
|
}));
|
|
|
|
import {
|
|
buildQuizResultsForStoreState,
|
|
didActiveSceneRemainUnchanged,
|
|
} from '@/lib/chat/quiz-results-for-store-state';
|
|
import { loadQuizAttemptState } from '@/lib/quiz/runtime';
|
|
|
|
describe('quiz results for chat store state', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(loadQuizAttemptState).mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('omits an explicitly reviewed quiz when the grader returned no results', async () => {
|
|
vi.mocked(loadQuizAttemptState).mockResolvedValue({
|
|
attemptId: 'attempt-1',
|
|
state: {
|
|
sessionId: 'attempt-1',
|
|
status: 'completed',
|
|
phase: 'reviewed',
|
|
answers: { q1: 'A' },
|
|
results: [],
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
buildQuizResultsForStoreState([{ id: 'quiz-1', type: 'quiz', stageId: 'stage-1' }], 'quiz-1'),
|
|
).resolves.toBeUndefined();
|
|
expect(loadQuizAttemptState).toHaveBeenCalledWith({
|
|
stageId: 'stage-1',
|
|
sceneId: 'quiz-1',
|
|
});
|
|
});
|
|
|
|
it('includes non-empty reviewed results for the active quiz', async () => {
|
|
const results = [{ questionId: 'q1', correct: true, status: 'correct' as const, earned: 1 }];
|
|
vi.mocked(loadQuizAttemptState).mockResolvedValue({
|
|
attemptId: 'attempt-1',
|
|
state: {
|
|
sessionId: 'attempt-1',
|
|
status: 'completed',
|
|
phase: 'reviewed',
|
|
answers: { q1: 'A' },
|
|
results,
|
|
},
|
|
});
|
|
|
|
await expect(
|
|
buildQuizResultsForStoreState([{ id: 'quiz-1', type: 'quiz', stageId: 'stage-1' }], 'quiz-1'),
|
|
).resolves.toEqual({ sceneId: 'quiz-1', answers: { q1: 'A' }, results });
|
|
});
|
|
|
|
it('omits quiz context when RuntimeStore is unavailable', async () => {
|
|
vi.mocked(loadQuizAttemptState).mockRejectedValue(new Error('indexedDB unavailable'));
|
|
|
|
await expect(
|
|
buildQuizResultsForStoreState([{ id: 'quiz-1', type: 'quiz', stageId: 'stage-1' }], 'quiz-1'),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('times out a stalled RuntimeStore read instead of blocking chat', async () => {
|
|
vi.useFakeTimers();
|
|
vi.mocked(loadQuizAttemptState).mockReturnValue(new Promise(() => {}));
|
|
|
|
const reading = buildQuizResultsForStoreState(
|
|
[{ id: 'quiz-1', type: 'quiz', stageId: 'stage-1' }],
|
|
'quiz-1',
|
|
);
|
|
await vi.runAllTimersAsync();
|
|
|
|
await expect(reading).resolves.toBeUndefined();
|
|
});
|
|
|
|
it('retains quiz results when the active scene object was reallocated in place', () => {
|
|
// A store update may replace the scene object (same id) during the async
|
|
// quiz read; the learner never left the scene, so their graded answers
|
|
// must still reach the outgoing request. The scene ID is the boundary.
|
|
const before = {
|
|
id: 'quiz-1',
|
|
type: 'quiz',
|
|
stageId: 'stage-1',
|
|
content: { questions: ['old'] },
|
|
};
|
|
const after = { ...before, content: { questions: ['new'] } };
|
|
|
|
expect(didActiveSceneRemainUnchanged([before], 'quiz-1', [after], 'quiz-1')).toBe(true);
|
|
});
|
|
|
|
it('rejects quiz results when the active scene left the deck during the read', () => {
|
|
const before = { id: 'quiz-1', type: 'quiz', stageId: 'stage-1', content: {} };
|
|
|
|
expect(didActiveSceneRemainUnchanged([before], 'quiz-1', [], 'quiz-1')).toBe(false);
|
|
});
|
|
|
|
it('retains quiz results when unrelated scenes changed during the read', () => {
|
|
const quiz = {
|
|
id: 'quiz-1',
|
|
type: 'quiz',
|
|
stageId: 'stage-1',
|
|
content: { questions: ['same'] },
|
|
};
|
|
const editedSlide = { id: 'slide-1', title: 'edited' };
|
|
|
|
expect(
|
|
didActiveSceneRemainUnchanged(
|
|
[quiz, { id: 'slide-1' }],
|
|
'quiz-1',
|
|
[quiz, editedSlide],
|
|
'quiz-1',
|
|
),
|
|
).toBe(true);
|
|
});
|
|
});
|