diff --git a/src/features/canvas/services/canvasQuizService.test.ts b/src/features/canvas/services/canvasQuizService.test.ts new file mode 100644 index 0000000..a11e2bd --- /dev/null +++ b/src/features/canvas/services/canvasQuizService.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { canvasQuizService } from "./canvasQuizService"; +import { CanvasQuizQuestion } from "@/features/canvas/models/quizzes/canvasQuizQuestionModel"; +import { LocalQuiz } from "@/features/local/quizzes/models/localQuiz"; +import { QuestionType } from "@/features/local/quizzes/models/localQuizQuestion"; + +// Mock the dependencies +vi.mock("@/services/axiosUtils", () => ({ + axiosClient: { + get: vi.fn(), + post: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock("./canvasServiceUtils", () => ({ + canvasApi: "https://test.instructure.com/api/v1", + paginatedRequest: vi.fn(), +})); + +vi.mock("./canvasAssignmentService", () => ({ + canvasAssignmentService: { + getAll: vi.fn(() => Promise.resolve([])), + delete: vi.fn(() => Promise.resolve()), + }, +})); + +vi.mock("@/services/htmlMarkdownUtils", () => ({ + markdownToHTMLSafe: vi.fn(({ markdownString }) => `

${markdownString}

`), +})); + +vi.mock("@/features/local/utils/timeUtils", () => ({ + getDateFromStringOrThrow: vi.fn((dateString) => new Date(dateString)), +})); + +vi.mock("@/services/utils/questionHtmlUtils", () => ({ + escapeMatchingText: vi.fn((text) => text), +})); + +describe("canvasQuizService", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("getQuizQuestions", () => { + it("should fetch and sort quiz questions by position", async () => { + const mockQuestions: CanvasQuizQuestion[] = [ + { + id: 3, + quiz_id: 1, + position: 3, + question_name: "Question 3", + question_type: "multiple_choice_question", + question_text: "What is 2+2?", + correct_comments: "", + incorrect_comments: "", + neutral_comments: "", + }, + { + id: 1, + quiz_id: 1, + position: 1, + question_name: "Question 1", + question_type: "multiple_choice_question", + question_text: "What is your name?", + correct_comments: "", + incorrect_comments: "", + neutral_comments: "", + }, + { + id: 2, + quiz_id: 1, + position: 2, + question_name: "Question 2", + question_type: "essay_question", + question_text: "Describe yourself", + correct_comments: "", + incorrect_comments: "", + neutral_comments: "", + }, + ]; + + const { paginatedRequest } = await import("./canvasServiceUtils"); + vi.mocked(paginatedRequest).mockResolvedValue(mockQuestions); + + const result = await canvasQuizService.getQuizQuestions(1, 1); + + expect(result).toHaveLength(3); + expect(result[0].position).toBe(1); + expect(result[1].position).toBe(2); + expect(result[2].position).toBe(3); + expect(result[0].question_text).toBe("What is your name?"); + expect(result[1].question_text).toBe("Describe yourself"); + expect(result[2].question_text).toBe("What is 2+2?"); + }); + + it("should handle questions without position", async () => { + const mockQuestions: CanvasQuizQuestion[] = [ + { + id: 1, + quiz_id: 1, + question_name: "Question 1", + question_type: "multiple_choice_question", + question_text: "What is your name?", + correct_comments: "", + incorrect_comments: "", + neutral_comments: "", + }, + { + id: 2, + quiz_id: 1, + question_name: "Question 2", + question_type: "essay_question", + question_text: "Describe yourself", + correct_comments: "", + incorrect_comments: "", + neutral_comments: "", + }, + ]; + + const { paginatedRequest } = await import("./canvasServiceUtils"); + vi.mocked(paginatedRequest).mockResolvedValue(mockQuestions); + + const result = await canvasQuizService.getQuizQuestions(1, 1); + + expect(result).toHaveLength(2); + // Should maintain original order when no position is specified + }); + }); + + describe("Question order verification (integration test concept)", () => { + it("should detect correct question order", async () => { + // This is a conceptual test showing what the verification should validate + const localQuiz: LocalQuiz = { + name: "Test Quiz", + description: "A test quiz", + dueAt: "2023-12-01T23:59:00Z", + shuffleAnswers: false, + showCorrectAnswers: true, + oneQuestionAtATime: false, + allowedAttempts: 1, + questions: [ + { + text: "What is your name?", + questionType: QuestionType.SHORT_ANSWER, + points: 5, + answers: [], + matchDistractors: [], + }, + { + text: "Describe yourself", + questionType: QuestionType.ESSAY, + points: 10, + answers: [], + matchDistractors: [], + }, + { + text: "What is 2+2?", + questionType: QuestionType.MULTIPLE_CHOICE, + points: 5, + answers: [ + { text: "3", correct: false }, + { text: "4", correct: true }, + { text: "5", correct: false }, + ], + matchDistractors: [], + }, + ], + }; + + const canvasQuestions: CanvasQuizQuestion[] = [ + { + id: 1, + quiz_id: 1, + position: 1, + question_name: "Question 1", + question_type: "short_answer_question", + question_text: "

What is your name?

", + correct_comments: "", + incorrect_comments: "", + neutral_comments: "", + }, + { + id: 2, + quiz_id: 1, + position: 2, + question_name: "Question 2", + question_type: "essay_question", + question_text: "

Describe yourself

", + correct_comments: "", + incorrect_comments: "", + neutral_comments: "", + }, + { + id: 3, + quiz_id: 1, + position: 3, + question_name: "Question 3", + question_type: "multiple_choice_question", + question_text: "

What is 2+2?

", + correct_comments: "", + incorrect_comments: "", + neutral_comments: "", + }, + ]; + + // Mock the getQuizQuestions to return our test data + const { paginatedRequest } = await import("./canvasServiceUtils"); + vi.mocked(paginatedRequest).mockResolvedValue(canvasQuestions); + + const result = await canvasQuizService.getQuizQuestions(1, 1); + + // Verify the questions are in the expected order + expect(result).toHaveLength(3); + expect(result[0].question_text).toContain("What is your name?"); + expect(result[1].question_text).toContain("Describe yourself"); + expect(result[2].question_text).toContain("What is 2+2?"); + + // Verify positions are sequential + expect(result[0].position).toBe(1); + expect(result[1].position).toBe(2); + expect(result[2].position).toBe(3); + }); + }); +}); \ No newline at end of file diff --git a/src/features/canvas/services/canvasQuizService.ts b/src/features/canvas/services/canvasQuizService.ts index adbca00..a04db64 100644 --- a/src/features/canvas/services/canvasQuizService.ts +++ b/src/features/canvas/services/canvasQuizService.ts @@ -89,6 +89,68 @@ const hackFixQuestionOrdering = async ( await axiosClient.post(url, { order }); }; +const verifyQuestionOrder = async ( + canvasCourseId: number, + canvasQuizId: number, + localQuiz: LocalQuiz +): Promise => { + console.log("Verifying question order in Canvas quiz"); + + try { + const canvasQuestions = await canvasQuizService.getQuizQuestions( + canvasCourseId, + canvasQuizId + ); + + // Check if the number of questions matches + if (canvasQuestions.length !== localQuiz.questions.length) { + console.error( + `Question count mismatch: Canvas has ${canvasQuestions.length}, local quiz has ${localQuiz.questions.length}` + ); + return false; + } + + // Verify that questions are in the correct order by comparing text content + // We'll use a simple approach: strip HTML tags and compare the core text content + const stripHtml = (html: string): string => { + return html.replace(/<[^>]*>/g, '').trim(); + }; + + for (let i = 0; i < localQuiz.questions.length; i++) { + const localQuestion = localQuiz.questions[i]; + const canvasQuestion = canvasQuestions[i]; + + const localQuestionText = localQuestion.text.trim(); + const canvasQuestionText = stripHtml(canvasQuestion.question_text).trim(); + + // Check if the question text content matches (allowing for HTML conversion differences) + if (!canvasQuestionText.includes(localQuestionText) && + !localQuestionText.includes(canvasQuestionText)) { + console.error( + `Question order mismatch at position ${i}:`, + `Local: "${localQuestionText}"`, + `Canvas: "${canvasQuestionText}"` + ); + return false; + } + + // Verify position is correct + if (canvasQuestion.position !== undefined && canvasQuestion.position !== i + 1) { + console.error( + `Question position mismatch at index ${i}: Canvas position is ${canvasQuestion.position}, expected ${i + 1}` + ); + return false; + } + } + + console.log("Question order verification successful"); + return true; + } catch (error) { + console.error("Error during question order verification:", error); + return false; + } +}; + const hackFixRedundantAssignments = async (canvasCourseId: number) => { console.log("hack fixing redundant quiz assignments that are auto-created"); const assignments = await canvasAssignmentService.getAll(canvasCourseId); @@ -137,6 +199,19 @@ const createQuizQuestions = async ( questionAndPositions ); await hackFixRedundantAssignments(canvasCourseId); + + // Verify that the question order in Canvas matches the local quiz order + const orderVerified = await verifyQuestionOrder( + canvasCourseId, + canvasQuizId, + localQuiz + ); + + if (!orderVerified) { + console.warn( + "Question order verification failed! The quiz was created but the question order may not match the intended order." + ); + } }; export const canvasQuizService = { @@ -165,6 +240,21 @@ export const canvasQuizService = { } }, + async getQuizQuestions( + canvasCourseId: number, + canvasQuizId: number + ): Promise { + try { + const url = `${canvasApi}/courses/${canvasCourseId}/quizzes/${canvasQuizId}/questions`; + const questions = await paginatedRequest({ url }); + // Sort by position to ensure correct order + return questions.sort((a, b) => (a.position || 0) - (b.position || 0)); + } catch (error) { + console.error("Error fetching quiz questions from Canvas:", error); + throw error; + } + }, + async create( canvasCourseId: number, localQuiz: LocalQuiz,