Files
openmaic/OpenMAIC/tests/edit/surfaces/quiz/quiz-edit-ops.test.ts
2026-08-16 14:58:47 +08:00

223 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, it, expect } from 'vitest';
import type { QuizContent, QuizQuestion } from '@/lib/types/stage';
import {
addOption,
addQuestion,
commitQuizContent,
createBlankQuestion,
createQuizEditHistory,
deleteOption,
deleteQuestion,
MAX_OPTIONS,
redoQuiz,
reorderOptions,
reorderQuestions,
setQuestionType,
toggleCorrect,
undoQuiz,
updateOptionLabel,
updateQuestion,
} from '@/components/edit/surfaces/quiz/quiz-edit-ops';
function choiceQuestion(over: Partial<QuizQuestion> = {}): QuizQuestion {
return {
id: 'q1',
type: 'single',
question: 'Capital of France?',
options: [
{ label: 'Paris', value: 'A' },
{ label: 'Lyon', value: 'B' },
{ label: 'Nice', value: 'C' },
],
answer: ['A'],
points: 1,
...over,
};
}
function content(...questions: QuizQuestion[]): QuizContent {
return { type: 'quiz', questions };
}
describe('quiz history primitive', () => {
it('commit pushes a fresh undo step and clears redo', () => {
const a = content(choiceQuestion());
const b = content(choiceQuestion({ question: 'changed' }));
let h = createQuizEditHistory(a);
h = commitQuizContent(h, b);
expect(h.present).toBe(b);
expect(h.past).toEqual([a]);
expect(h.future).toEqual([]);
});
it('committing the same reference is a no-op (no empty undo step)', () => {
const a = content(choiceQuestion());
const h = createQuizEditHistory(a);
expect(commitQuizContent(h, a)).toBe(h);
});
it('undo / redo walk the timeline', () => {
const a = content(choiceQuestion({ question: 'a' }));
const b = content(choiceQuestion({ question: 'b' }));
let h = commitQuizContent(createQuizEditHistory(a), b);
h = undoQuiz(h);
expect(h.present).toBe(a);
expect(h.future).toEqual([b]);
h = redoQuiz(h);
expect(h.present).toBe(b);
});
it('undo / redo at the ends are no-ops', () => {
const h = createQuizEditHistory(content());
expect(undoQuiz(h)).toBe(h);
expect(redoQuiz(h)).toBe(h);
});
});
describe('question factories + list ops', () => {
it('createBlankQuestion seeds two options for choice, none for short_answer', () => {
const single = createBlankQuestion('single', 'x');
expect(single).toMatchObject({ id: 'x', type: 'single', answer: [], points: 1 });
expect(single.options).toHaveLength(2);
const short = createBlankQuestion('short_answer', 'y');
expect(short).toMatchObject({ id: 'y', type: 'short_answer', hasAnswer: false });
expect(short.options).toBeUndefined();
});
it('addQuestion appends; deleteQuestion removes by id; both return new content', () => {
const c0 = content(choiceQuestion());
const c1 = addQuestion(c0, 'multiple', 'q2');
expect(c1).not.toBe(c0);
expect(c1.questions.map((q) => q.id)).toEqual(['q1', 'q2']);
const c2 = deleteQuestion(c1, 'q1');
expect(c2.questions.map((q) => q.id)).toEqual(['q2']);
});
it('reorderQuestions reorders by id and keeps unnamed questions', () => {
const c0 = content(
choiceQuestion({ id: 'q1' }),
choiceQuestion({ id: 'q2' }),
choiceQuestion({ id: 'q3' }),
);
const c1 = reorderQuestions(c0, ['q3', 'q1']);
expect(c1.questions.map((q) => q.id)).toEqual(['q3', 'q1', 'q2']);
});
it('updateQuestion patches scalar fields only', () => {
const c0 = content(choiceQuestion());
const c1 = updateQuestion(c0, 'q1', { points: 5, analysis: 'because' });
expect(c1.questions[0]).toMatchObject({ points: 5, analysis: 'because' });
});
});
describe('setQuestionType transitions', () => {
it('choice → short_answer drops options/answer and seeds grading fields', () => {
const c0 = content(choiceQuestion({ type: 'multiple', answer: ['A', 'C'] }));
const q = setQuestionType(c0, 'q1', 'short_answer').questions[0];
expect(q.type).toBe('short_answer');
expect(q.options).toBeUndefined();
expect(q.answer).toBeUndefined();
expect(q.hasAnswer).toBe(false);
});
it('short_answer → choice seeds two blank options + empty answer', () => {
const c0 = content(createBlankQuestion('short_answer', 'q1'));
const q = setQuestionType(c0, 'q1', 'single').questions[0];
expect(q.type).toBe('single');
expect(q.options).toHaveLength(2);
expect(q.answer).toEqual([]);
expect(q.hasAnswer).toBeUndefined();
});
it('multiple → single keeps options but collapses to a single correct answer', () => {
const c0 = content(choiceQuestion({ type: 'multiple', answer: ['A', 'C'] }));
const q = setQuestionType(c0, 'q1', 'single').questions[0];
expect(q.type).toBe('single');
expect(q.options).toHaveLength(3);
expect(q.answer).toEqual(['A']);
});
it('same-type is a no-op (identity question object)', () => {
const c0 = content(choiceQuestion({ type: 'single' }));
expect(setQuestionType(c0, 'q1', 'single').questions[0]).toBe(c0.questions[0]);
});
});
describe('option mutations keep value=letter and answer correct', () => {
it('addOption appends a blank C-row; updateOptionLabel sets its text', () => {
let c = content(choiceQuestion());
c = addOption(c, 'q1');
const q = c.questions[0];
expect(q.options?.map((o) => o.value)).toEqual(['A', 'B', 'C', 'D']);
expect(q.options?.[3]).toEqual({ label: '', value: 'D' });
c = updateOptionLabel(c, 'q1', 3, 'Marseille');
expect(c.questions[0].options?.[3].label).toBe('Marseille');
});
it('deleteOption removes a row, re-letters the rest, and drops it from answer', () => {
// answer = A (Paris). Delete A → remaining re-lettered, answer recomputed.
const c0 = content(choiceQuestion({ answer: ['A'] }));
const q = deleteOption(c0, 'q1', 0).questions[0];
expect(q.options).toEqual([
{ label: 'Lyon', value: 'A' },
{ label: 'Nice', value: 'B' },
]);
expect(q.answer).toEqual([]); // the correct option was deleted
});
it('deleteOption keeps answer pointing at the surviving correct option', () => {
const c0 = content(choiceQuestion({ answer: ['C'] })); // Nice correct
const q = deleteOption(c0, 'q1', 0).questions[0]; // delete Paris (A)
// Nice slides from C→B; answer must follow.
expect(q.options?.find((o) => o.label === 'Nice')?.value).toBe('B');
expect(q.answer).toEqual(['B']);
});
it('reorderOptions re-letters by new position and answer follows the correct option', () => {
const c0 = content(choiceQuestion({ answer: ['A'] })); // Paris correct at A
// Move Paris (index 0) to the end (index 2).
const q = reorderOptions(c0, 'q1', 0, 2).questions[0];
expect(q.options).toEqual([
{ label: 'Lyon', value: 'A' },
{ label: 'Nice', value: 'B' },
{ label: 'Paris', value: 'C' },
]);
expect(q.answer).toEqual(['C']); // answer tracked Paris to its new letter
});
it('toggleCorrect on single is radio (one correct); on multiple is checkbox', () => {
const single = toggleCorrect(
content(choiceQuestion({ type: 'single', answer: ['A'] })),
'q1',
2,
);
expect(single.questions[0].answer).toEqual(['C']);
let multi = content(choiceQuestion({ type: 'multiple', answer: ['A'] }));
multi = toggleCorrect(multi, 'q1', 2); // add C
expect(multi.questions[0].answer).toEqual(['A', 'C']);
multi = toggleCorrect(multi, 'q1', 0); // remove A
expect(multi.questions[0].answer).toEqual(['C']);
});
it('option mutations on a short_answer question are no-ops', () => {
const c0 = content(createBlankQuestion('short_answer', 'q1'));
expect(addOption(c0, 'q1').questions[0].options).toBeUndefined();
expect(toggleCorrect(c0, 'q1', 0).questions[0]).toEqual(c0.questions[0]);
});
it('addOption caps at MAX_OPTIONS (A–Z)', () => {
let c = content(choiceQuestion({ options: [], answer: [] }));
for (let i = 0; i < MAX_OPTIONS + 5; i++) c = addOption(c, 'q1');
expect(c.questions[0].options).toHaveLength(MAX_OPTIONS);
expect(c.questions[0].options?.at(-1)?.value).toBe('Z');
});
it('reorderOptions with out-of-range or equal indices is a no-op', () => {
const c0 = content(choiceQuestion());
expect(reorderOptions(c0, 'q1', 1, 1).questions[0].options).toEqual(c0.questions[0].options);
expect(reorderOptions(c0, 'q1', -1, 0).questions[0].options).toEqual(c0.questions[0].options);
expect(reorderOptions(c0, 'q1', 0, 99).questions[0].options).toEqual(c0.questions[0].options);
});
});