diff --git a/client/app/bundles/course/assessment/submission/reducers/grading/__test__/index.test.js b/client/app/bundles/course/assessment/submission/reducers/grading/__test__/index.test.js new file mode 100644 index 00000000000..d6841842557 --- /dev/null +++ b/client/app/bundles/course/assessment/submission/reducers/grading/__test__/index.test.js @@ -0,0 +1,239 @@ +import { dispatch, store } from 'store'; + +import actions, { questionTypes } from '../../../constants'; + +const answerId = 3; + +const buildPayload = ({ questionType, grade, correct, testCases } = {}) => ({ + submission: { + pointsAwarded: null, + basePoints: 1000, + submittedAt: '2017-05-11T17:02:17.000+08:00', + bonusEndAt: '2017-05-11T17:02:17.000+08:00', + bonusPoints: 0, + }, + assessment: {}, + annotations: [], + posts: [], + topics: [], + questions: [{ id: 1, type: questionType, maximumGrade: 10 }], + answers: [ + { + id: answerId, + fields: { + id: answerId, + questionId: 1, + }, + questionId: 1, + grading: { + grade, + id: answerId, + }, + explanation: correct !== undefined ? { correct } : undefined, + testCases, + }, + ], +}); + +const dispatchFetchSuccess = (payload) => + dispatch({ type: actions.FETCH_SUBMISSION_SUCCESS, payload }); + +const getGrade = () => + store.getState().assessments.submission.grading.questions[1].grade; + +describe('getPrefilledGrade via FETCH_SUBMISSION_SUCCESS', () => { + describe('Question Types with ALWAYS_PREFILL_POLICY', () => { + it('prefills maximum grade for an ungraded correct answer', () => { + dispatchFetchSuccess( + buildPayload({ + questionType: questionTypes.MultipleChoice, + grade: null, + correct: true, + }), + ); + + expect(getGrade()).toBe(10); + }); + + it('prefills 0 for an ungraded incorrect answer', () => { + dispatchFetchSuccess( + buildPayload({ + questionType: questionTypes.MultipleChoice, + grade: null, + correct: false, + }), + ); + + expect(getGrade()).toBe(0); + }); + + it('leaves grade as null when there is no explanation', () => { + dispatchFetchSuccess( + buildPayload({ + questionType: questionTypes.MultipleChoice, + grade: null, + correct: undefined, + }), + ); + + expect(getGrade()).toBeNull(); + }); + + it('preserves existing grades even if answer is correct', () => { + dispatchFetchSuccess( + buildPayload({ + questionType: questionTypes.MultipleChoice, + grade: 5, + correct: true, + }), + ); + + expect(getGrade()).toBe(5); + }); + + it('preserves existing grade even if answer is incorrect', () => { + dispatchFetchSuccess( + buildPayload({ + questionType: questionTypes.MultipleChoice, + grade: 8, + correct: false, + }), + ); + + expect(getGrade()).toBe(8); + }); + }); + + describe('Question Types with NEVER_PREFILL_POLICY', () => { + it('does not prefill for an ungraded correct answer', () => { + dispatchFetchSuccess( + buildPayload({ + questionType: questionTypes.VoiceResponse, + grade: null, + correct: true, + }), + ); + + expect(getGrade()).toBeNull(); + }); + + it('does not prefill for an ungraded incorrect answer', () => { + dispatchFetchSuccess( + buildPayload({ + questionType: questionTypes.VoiceResponse, + grade: null, + correct: false, + }), + ); + + expect(getGrade()).toBeNull(); + }); + + it('preserves existing grades', () => { + dispatchFetchSuccess( + buildPayload({ + questionType: questionTypes.VoiceResponse, + grade: 5, + correct: true, + }), + ); + + expect(getGrade()).toBe(5); + }); + }); + + describe('Question Types with ONLY_PREFILL_FULL_POLICY', () => { + it('prefills maximum grade for an ungraded correct answer', () => { + dispatchFetchSuccess( + buildPayload({ + questionType: questionTypes.TextResponse, + grade: null, + correct: true, + }), + ); + + expect(getGrade()).toBe(10); + }); + + it('does not prefill 0 for an ungraded incorrect answer', () => { + dispatchFetchSuccess( + buildPayload({ + questionType: questionTypes.TextResponse, + grade: null, + correct: false, + }), + ); + + expect(getGrade()).toBeNull(); + }); + + it('leaves grade as null when there is no explanation', () => { + dispatchFetchSuccess( + buildPayload({ + questionType: questionTypes.TextResponse, + grade: null, + correct: undefined, + }), + ); + + expect(getGrade()).toBeNull(); + }); + + it('preserves existing grades', () => { + dispatchFetchSuccess( + buildPayload({ + questionType: questionTypes.TextResponse, + grade: 5, + correct: true, + }), + ); + + expect(getGrade()).toBe(5); + }); + }); + + describe('Programming', () => { + const withTestCases = { + public_test: [{ identifier: 'test1' }], + }; + + it('prefills maximum grade when test cases exist and answer is correct', () => { + dispatchFetchSuccess( + buildPayload({ + questionType: questionTypes.Programming, + grade: null, + correct: true, + testCases: withTestCases, + }), + ); + + expect(getGrade()).toBe(10); + }); + + it('does not prefill 0 when test cases exist and answer is incorrect', () => { + dispatchFetchSuccess( + buildPayload({ + questionType: questionTypes.Programming, + grade: null, + correct: false, + testCases: withTestCases, + }), + ); + + expect(getGrade()).toBeNull(); + }); + + it('leaves grade as null when no test cases exist, even if answer is correct', () => { + dispatchFetchSuccess( + buildPayload({ + questionType: questionTypes.Programming, + grade: null, + correct: true, + testCases: null, + }), + ); + + expect(getGrade()).toBeNull(); + }); + }); +}); diff --git a/client/app/bundles/course/assessment/submission/reducers/grading/index.js b/client/app/bundles/course/assessment/submission/reducers/grading/index.js index e18d1ee292a..eb97b3f000b 100644 --- a/client/app/bundles/course/assessment/submission/reducers/grading/index.js +++ b/client/app/bundles/course/assessment/submission/reducers/grading/index.js @@ -39,58 +39,89 @@ const extractGrades = (answers) => return draft; }, {}); -const isSpecificAnswerGradePrefillableMap = { - [questionTypes.MultipleChoice]: () => true, - [questionTypes.MultipleResponse]: () => true, - [questionTypes.Programming]: (answer) => { - const { testCases } = answer; - const isPublicTestCasesExist = testCases?.public_test?.length > 0; - const isPrivateTestCasesExist = testCases?.private_test?.length > 0; - const isEvaluationTestCasesExist = testCases?.evaluation_test?.length > 0; - return ( - isPublicTestCasesExist || - isPrivateTestCasesExist || - isEvaluationTestCasesExist - ); - }, - [questionTypes.RubricBasedResponse]: () => false, - [questionTypes.TextResponse]: () => false, - [questionTypes.Comprehension]: () => false, - [questionTypes.FileUpload]: () => false, - [questionTypes.Scribing]: () => false, - [questionTypes.VoiceResponse]: () => false, - [questionTypes.ForumPostResponse]: () => false, +const NEVER_PREFILL_POLICY = { + canPrefillFullCredit: () => false, + canPrefillZeroCredit: false, +}; + +const ALWAYS_PREFILL_POLICY = { + canPrefillFullCredit: () => true, + canPrefillZeroCredit: true, +}; + +const ONLY_PREFILL_FULL_POLICY = { + canPrefillFullCredit: () => true, + canPrefillZeroCredit: false, }; -const isAnswerGradePrefillable = (answer, questionType) => { - const isAnswerPrefillable = - answer.grading.grade === null && answer.explanation?.correct; - const isSpecificAnswerPrefillable = - isSpecificAnswerGradePrefillableMap[questionType](answer); - return isAnswerPrefillable && isSpecificAnswerPrefillable; +const PROGRAMMING_PREFILL_POLICY = { + canPrefillFullCredit: ({ testCases }) => + (testCases?.public_test?.length ?? 0) > 0 || + (testCases?.private_test?.length ?? 0) > 0 || + (testCases?.evaluation_test?.length ?? 0) > 0, + + // Partial grading is possible + canPrefillZeroCredit: false, +}; + +const prefillPolicies = { + [questionTypes.MultipleChoice]: ALWAYS_PREFILL_POLICY, + [questionTypes.MultipleResponse]: ALWAYS_PREFILL_POLICY, + + [questionTypes.Programming]: PROGRAMMING_PREFILL_POLICY, + + [questionTypes.TextResponse]: ONLY_PREFILL_FULL_POLICY, + [questionTypes.Comprehension]: ONLY_PREFILL_FULL_POLICY, + + [questionTypes.RubricBasedResponse]: NEVER_PREFILL_POLICY, + [questionTypes.FileUpload]: NEVER_PREFILL_POLICY, + [questionTypes.Scribing]: NEVER_PREFILL_POLICY, + [questionTypes.VoiceResponse]: NEVER_PREFILL_POLICY, + [questionTypes.ForumPostResponse]: NEVER_PREFILL_POLICY, +}; + +const getPrefilledGrade = (answer, questionType, maxGrade) => { + const existingGrade = answer?.grading?.grade; + if (existingGrade != null) return existingGrade; + + const policy = prefillPolicies[questionType]; + + if ( + answer?.explanation?.correct === true && + policy?.canPrefillFullCredit(answer) + ) { + return maxGrade; + } + + if (answer?.explanation?.correct === false && policy?.canPrefillZeroCredit) { + return 0; + } + + return null; }; /** - * Extracts grades from `payload.answer`, and pre-fills the maximum grade for correct - * answers that have not been graded. "Correct" follows the definition of - * `explanation.correct` from the server. + * Extracts grades from `payload.answer` and pre-fills: + * - maximum grade for correct answers + * - 0 for incorrect answers + * when they have not already been graded. + * "Correct" and "incorrect" follows the definition of `explanation.correct` from the server. */ const extractPrefillableGrades = (payload) => { const mapQuestionIdToQuestion = arrayToObjectWithKey(payload.questions, 'id'); return payload.answers.reduce((draft, answer) => { const { questionId, grading } = answer; - const prefillable = isAnswerGradePrefillable( + const prefilledGrade = getPrefilledGrade( answer, mapQuestionIdToQuestion[questionId].type, + mapQuestionIdToQuestion[questionId].maximumGrade, ); draft[questionId] = { ...grading, originalGrade: grading.grade, - grade: prefillable - ? mapQuestionIdToQuestion[questionId].maximumGrade - : grading.grade, - prefilled: prefillable, + grade: prefilledGrade, + prefilled: grading.grade == null && prefilledGrade !== null, }; return draft; diff --git a/spec/features/course/assessment/submission/password_protected_and_delayed_publishing_spec.rb b/spec/features/course/assessment/submission/password_protected_and_delayed_publishing_spec.rb index f1fdab4383e..f69f201a20a 100644 --- a/spec/features/course/assessment/submission/password_protected_and_delayed_publishing_spec.rb +++ b/spec/features/course/assessment/submission/password_protected_and_delayed_publishing_spec.rb @@ -81,15 +81,13 @@ visit current_path - # Grade the submission with empty answer grade - expect(page).to have_button('Submit for Publishing', disabled: true) - find_field(class: 'grade').set(0) - wait_for_autosave + # Wrong MRQ answers are prefilled to 0 automatically, so the button is + # already enabled without staff needing to manually enter a grade. + expect(page).to have_button('Submit for Publishing', disabled: false) find_field(class: 'exp').set(50) click_button('Save') - expect(page).to have_button('Submit for Publishing', disabled: false) click_button('Submit for Publishing') expect(current_path).