Files
openmaic/OpenMAIC/tests/pbl/v2/quiz-snapshot.test.ts
2026-08-16 14:58:47 +08:00

257 lines
7.4 KiB
TypeScript

/**
* Tests for the pre-PBL quiz snapshot helpers.
*
* Covers both `buildQuizSnapshot` (client-side, reads RuntimeStore)
* and `applyQuizSignalsToProject` (server-side, mutates the project's
* proficiency assessment). The robustness matrix is the focus —
* non-quiz scenes, unsubmitted quizzes, all-short-answer quizzes,
* empty results, and missing assessment should all be safe.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
applyQuizSignalsToProject,
buildQuizSnapshot,
} from '@/lib/pbl/v2/operations/runtime/quiz-snapshot';
import { emptyAssessment } from '@/lib/pbl/v2/operations/kernel/proficiency';
import type { PBLProjectV2, PriorQuizResult } from '@/lib/pbl/v2/types';
import type { Scene } from '@/lib/types/stage';
vi.mock('@/lib/quiz/runtime', () => ({
loadQuizAttemptState: vi.fn(),
}));
import { loadQuizAttemptState } from '@/lib/quiz/runtime';
beforeEach(() => {
vi.mocked(loadQuizAttemptState).mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
function mkQuizScene(id: string, questions: number): Scene {
return {
id,
stageId: 'stg',
type: 'quiz',
title: `Q ${id}`,
order: 1,
content: {
type: 'quiz',
questions: Array.from({ length: questions }, (_, i) => ({
id: `q${i}`,
type: 'single' as const,
question: 'x',
options: [{ label: 'A', value: 'A' }],
answer: ['A'],
hasAnswer: true,
})),
},
actions: [],
} as unknown as Scene;
}
function mkSlideScene(id: string): Scene {
return {
id,
stageId: 'stg',
type: 'slide',
title: 's',
order: 1,
content: { type: 'slide', canvas: {} } as never,
actions: [],
} as unknown as Scene;
}
function mkProject(overrides: Partial<PBLProjectV2> = {}): PBLProjectV2 {
return {
uiPhase: 'workspace',
title: 't',
description: 'd',
proficiency: 'intermediate',
proficiencyAssessment: emptyAssessment(),
language: 'zh-CN',
tags: [],
status: 'active',
roles: [],
milestones: [],
submissions: [],
evaluations: [],
threads: [],
engagementEvents: [],
createdAt: '',
updatedAt: '',
...overrides,
};
}
describe('buildQuizSnapshot', () => {
it('returns [] when there are no quiz scenes', async () => {
await expect(buildQuizSnapshot([mkSlideScene('s1'), mkSlideScene('s2')])).resolves.toEqual([]);
});
it('skips legacy quiz scenes that do not have a stage id', async () => {
const { stageId: _stageId, ...legacy } = mkQuizScene('legacy', 1);
await expect(buildQuizSnapshot([legacy as Scene])).resolves.toEqual([]);
expect(loadQuizAttemptState).not.toHaveBeenCalled();
});
it('skips quizzes the learner has not been reviewed', async () => {
vi.mocked(loadQuizAttemptState).mockResolvedValue({
attemptId: 'attempt-1',
state: {
sessionId: 'attempt-1',
status: 'active',
phase: 'submitted',
answers: {},
},
});
await expect(buildQuizSnapshot([mkQuizScene('q1', 3)])).resolves.toEqual([]);
});
it('counts correct / incorrect / unscored properly', async () => {
vi.mocked(loadQuizAttemptState).mockResolvedValue({
attemptId: 'attempt-1',
state: {
sessionId: 'attempt-1',
status: 'completed',
phase: 'reviewed',
answers: {},
results: [
{ questionId: 'q0', correct: true, status: 'correct', earned: 1 },
{ questionId: 'q1', correct: false, status: 'incorrect', earned: 0 },
{ questionId: 'q2', correct: null, status: 'incorrect', earned: 0 },
],
},
});
const r = await buildQuizSnapshot([mkQuizScene('q1', 3)]);
expect(r).toHaveLength(1);
expect(r[0]).toMatchObject({
sceneId: 'q1',
correctCount: 1,
incorrectCount: 1,
unscoredCount: 1,
accuracy: 0.5, // 1/(1+1) scored
});
});
it('marks accuracy null when every question is unscored', async () => {
vi.mocked(loadQuizAttemptState).mockResolvedValue({
attemptId: 'attempt-1',
state: {
sessionId: 'attempt-1',
status: 'completed',
phase: 'reviewed',
answers: {},
results: [
{ questionId: 'q0', correct: null, status: 'incorrect', earned: 0 },
{ questionId: 'q1', correct: null, status: 'incorrect', earned: 0 },
],
},
});
const r = await buildQuizSnapshot([mkQuizScene('q1', 2)]);
expect(r[0].accuracy).toBeNull();
});
it('aggregates across multiple quiz scenes in scene order', async () => {
vi.mocked(loadQuizAttemptState).mockImplementation(async ({ sceneId }) => {
const correct = sceneId === 'q1';
return {
attemptId: `attempt-${sceneId}`,
state: {
sessionId: `attempt-${sceneId}`,
status: 'completed',
phase: 'reviewed',
answers: {},
results: [
{
questionId: 'q0',
correct,
status: correct ? 'correct' : 'incorrect',
earned: correct ? 1 : 0,
},
],
},
};
});
const r = await buildQuizSnapshot([mkQuizScene('q1', 1), mkQuizScene('q2', 1)]);
expect(r).toHaveLength(2);
expect(r[0].accuracy).toBe(1);
expect(r[1].accuracy).toBe(0);
});
it('skips one unavailable RuntimeStore snapshot without blocking launch', async () => {
vi.mocked(loadQuizAttemptState).mockRejectedValueOnce(new Error('indexedDB unavailable'));
await expect(buildQuizSnapshot([mkQuizScene('q1', 1)])).resolves.toEqual([]);
});
});
describe('applyQuizSignalsToProject', () => {
it('no-ops on empty results', () => {
const p = mkProject();
const before = p.proficiencyAssessment;
const r = applyQuizSignalsToProject(p, []);
expect(r.updated).toBe(false);
expect(p.proficiencyAssessment).toBe(before);
});
it('no-ops when every quiz is fully unscored', () => {
const p = mkProject();
const before = p.proficiencyAssessment;
const r = applyQuizSignalsToProject(p, [
{
sceneId: 'q',
sceneTitle: 'q',
totalQuestions: 3,
correctCount: 0,
incorrectCount: 0,
unscoredCount: 3,
accuracy: null,
},
]);
expect(r.updated).toBe(false);
expect(p.proficiencyAssessment).toBe(before);
});
it('updates assessment + flips tier on a strong quiz signal', () => {
const p = mkProject();
const results: PriorQuizResult[] = [
{
sceneId: 'q',
sceneTitle: 'q',
totalQuestions: 5,
correctCount: 5,
incorrectCount: 0,
unscoredCount: 0,
accuracy: 1,
},
];
const r = applyQuizSignalsToProject(p, results);
expect(r.updated).toBe(true);
expect(p.proficiencyAssessment!.source).toBe('pre-play');
// 100% accuracy with weight 0.6 → EWMA push 0.12, but a high
// direction signal layered on top of the existing path. The
// important assertion here is "engaged + recorded".
expect(p.proficiencyAssessment!.signals.some((s) => s.kind === 'quiz_accuracy')).toBe(true);
});
it('lazily creates an assessment when one is missing', () => {
const p = mkProject({ proficiencyAssessment: undefined });
applyQuizSignalsToProject(p, [
{
sceneId: 'q',
sceneTitle: 'q',
totalQuestions: 2,
correctCount: 1,
incorrectCount: 1,
unscoredCount: 0,
accuracy: 0.5,
},
]);
expect(p.proficiencyAssessment).toBeDefined();
});
});