diff --git a/packages/app/obojobo-document-engine/src/scripts/oboeditor/documents/oboeditor-tutorial.json b/packages/app/obojobo-document-engine/src/scripts/oboeditor/documents/oboeditor-tutorial.json index 3aa5bbfbda..b8a85d10f7 100644 --- a/packages/app/obojobo-document-engine/src/scripts/oboeditor/documents/oboeditor-tutorial.json +++ b/packages/app/obojobo-document-engine/src/scripts/oboeditor/documents/oboeditor-tutorial.json @@ -7209,13 +7209,13 @@ { "data": {}, "text": { - "value": "Pick all correct answers questions allow a student to select as many answers as they want. A student must select all the correct choices to get the points for that question.", + "value": "Pick all correct answers questions allow a student to select as many answers as they want. By default, a student must select all the correct choices to get the points for that question.", "styleList": [ { - "end": 105, + "end": 117, "data": {}, "type": "b", - "start": 100 + "start": 112 } ] } @@ -7224,6 +7224,29 @@ }, "children": [] }, + { + "id": "e081e958-6130-40b6-b4c4-30b956a61143", + "type": "ObojoboDraft.Chunks.Text", + "content": { + "textGroup": [ + { + "data": {}, + "text": { + "value": "Alternatively, Partial Scoring can be turned on which allows students to receive partial credit for the question based on how many correct and incorrect choices they select.", + "styleList": null + } + }, + { + "data": {}, + "text": { + "value": "In the question below, selecting only one correct answer would result in a 50%, and selecting one correct answer along with the incorrect answer would result in a 0%. Selecting all three answer choices would result in a 50% as well, as the incorrect choice cancels out one of the correct choices.", + "styleList": null + } + } + ] + }, + "children": [] + }, { "id": "a687a99d-c517-4dc9-9bdd-4c3352d3618f", "type": "ObojoboDraft.Chunks.Question", @@ -7249,6 +7272,15 @@ } ] } + }, + { + "data": { + "indent": 0 + }, + "text": { + "value": "If Partial Scoring is enabled, then the student can receive partial credit even if they didn't get it completely correct.", + "styleList": null + } } ] }, diff --git a/packages/app/obojobo-repository/shared/components/stats/assessment-stats-controls.scss b/packages/app/obojobo-repository/shared/components/stats/assessment-stats-controls.scss index 07858890f7..11300063e0 100644 --- a/packages/app/obojobo-repository/shared/components/stats/assessment-stats-controls.scss +++ b/packages/app/obojobo-repository/shared/components/stats/assessment-stats-controls.scss @@ -5,6 +5,31 @@ margin-bottom: 1em; } + .filter-controls { + display: flex; + flex-direction: column; + width: 100%; + + hr { + margin-top: 1em; + } + + input { + margin-right: 0.5em; + margin-left: 0; + } + + label { + cursor: pointer; + display: flex; + align-items: center; + + span { + white-space: nowrap; + } + } + } + .search-controls { select { @include select-input(); @@ -90,29 +115,4 @@ font-family: $font-monospace; } } - - .filter-controls { - display: flex; - flex-direction: column; - width: 100%; - - hr { - margin-top: 1em; - } - - input { - margin-right: 0.5em; - margin-left: 0; - } - - label { - cursor: pointer; - display: flex; - align-items: center; - - span { - white-space: nowrap; - } - } - } } diff --git a/packages/obonode/obojobo-chunks-multiple-choice-assessment/__snapshots__/adapter.test.js.snap b/packages/obonode/obojobo-chunks-multiple-choice-assessment/__snapshots__/adapter.test.js.snap index 90146ee0ec..a5430cc36c 100644 --- a/packages/obonode/obojobo-chunks-multiple-choice-assessment/__snapshots__/adapter.test.js.snap +++ b/packages/obonode/obojobo-chunks-multiple-choice-assessment/__snapshots__/adapter.test.js.snap @@ -3,6 +3,7 @@ exports[`MCAssessment adapter can convert to JSON 1`] = ` Object { "content": Object { + "partialScoring": false, "responseType": "pick-one", "shuffle": true, }, @@ -12,6 +13,7 @@ Object { exports[`MCAssessment adapter can convert to JSON WITH attributes 1`] = ` Object { "content": Object { + "partialScoring": false, "responseType": "pick-one", "shuffle": true, }, @@ -21,6 +23,7 @@ Object { exports[`MCAssessment adapter construct builds with attributes 1`] = ` Object { "modelState": Object { + "partialScoring": false, "responseType": "pick-one", "shuffle": false, }, @@ -30,6 +33,7 @@ Object { exports[`MCAssessment adapter construct builds with responseType 1`] = ` Object { "modelState": Object { + "partialScoring": false, "responseType": "pick-one", "shuffle": true, }, @@ -39,6 +43,7 @@ Object { exports[`MCAssessment adapter construct builds with shuffle 1`] = ` Object { "modelState": Object { + "partialScoring": false, "responseType": "pick-one", "shuffle": true, }, @@ -48,6 +53,7 @@ Object { exports[`MCAssessment adapter construct builds without attributes 1`] = ` Object { "modelState": Object { + "partialScoring": false, "responseType": "pick-one", "shuffle": true, }, diff --git a/packages/obonode/obojobo-chunks-multiple-choice-assessment/adapter.js b/packages/obonode/obojobo-chunks-multiple-choice-assessment/adapter.js index 05cc36fafb..5a0bfc46a6 100644 --- a/packages/obonode/obojobo-chunks-multiple-choice-assessment/adapter.js +++ b/packages/obonode/obojobo-chunks-multiple-choice-assessment/adapter.js @@ -4,16 +4,19 @@ const Adapter = { model.modelState.responseType = content.responseType || 'pick-one' model.modelState.shuffle = content.shuffle !== false + model.modelState.partialScoring = content.partialScoring === true }, clone(model, clone) { clone.modelState.responseType = model.modelState.responseType clone.modelState.shuffle = model.modelState.shuffle + clone.modelState.partialScoring = model.modelState.partialScoring }, toJSON(model, json) { json.content.responseType = model.modelState.responseType json.content.shuffle = model.modelState.shuffle + json.content.partialScoring = model.modelState.partialScoring } } diff --git a/packages/obonode/obojobo-chunks-multiple-choice-assessment/adapter.test.js b/packages/obonode/obojobo-chunks-multiple-choice-assessment/adapter.test.js index f73058af9a..3549d6dc8e 100644 --- a/packages/obonode/obojobo-chunks-multiple-choice-assessment/adapter.test.js +++ b/packages/obonode/obojobo-chunks-multiple-choice-assessment/adapter.test.js @@ -12,7 +12,8 @@ describe('MCAssessment adapter', () => { const attrs = { content: { responseType: 'pick-one', - shuffle: false + shuffle: false, + partialScoring: false } } @@ -62,19 +63,22 @@ describe('MCAssessment adapter', () => { const a = { modelState: { responseType: 'pick-one', - shuffle: true + shuffle: true, + partialScoring: false } } const attrs = { content: { responseType: 'pick-one', - shuffle: true + shuffle: true, + partialScoring: false } } const b = { modelState: { responseType: null, - shuffle: false + shuffle: false, + partialScoring: false } } @@ -101,7 +105,8 @@ describe('MCAssessment adapter', () => { const attrs = { content: { responseType: 'pick-one', - shuffle: true + shuffle: true, + partialScoring: false } } const json = { content: {} } diff --git a/packages/obonode/obojobo-chunks-multiple-choice-assessment/converter.js b/packages/obonode/obojobo-chunks-multiple-choice-assessment/converter.js index b379712ceb..569c659fb5 100644 --- a/packages/obonode/obojobo-chunks-multiple-choice-assessment/converter.js +++ b/packages/obonode/obojobo-chunks-multiple-choice-assessment/converter.js @@ -38,7 +38,8 @@ const slateToObo = node => { triggers: node.content.triggers, objectives: node.content.objectives, responseType, - shuffle: node.content.shuffle + shuffle: node.content.shuffle, + partialScoring: node.content.partialScoring }) } } diff --git a/packages/obonode/obojobo-chunks-multiple-choice-assessment/empty-node.json b/packages/obonode/obojobo-chunks-multiple-choice-assessment/empty-node.json index 292182baeb..fb43eec519 100644 --- a/packages/obonode/obojobo-chunks-multiple-choice-assessment/empty-node.json +++ b/packages/obonode/obojobo-chunks-multiple-choice-assessment/empty-node.json @@ -2,7 +2,8 @@ "type": "ObojoboDraft.Chunks.MCAssessment", "content": { "responseType": "pick-one", - "shuffle": true + "shuffle": true, + "partialScoring": false }, "children": [ { diff --git a/packages/obonode/obojobo-chunks-multiple-choice-assessment/server/mcassessment.js b/packages/obonode/obojobo-chunks-multiple-choice-assessment/server/mcassessment.js index 5ff85e4ae2..1891a8b5b5 100644 --- a/packages/obonode/obojobo-chunks-multiple-choice-assessment/server/mcassessment.js +++ b/packages/obonode/obojobo-chunks-multiple-choice-assessment/server/mcassessment.js @@ -22,14 +22,22 @@ class MCAssessment extends DraftNode { }) ) + const partialScoring = this.node.content.partialScoring || false const responseIds = new Set(responseRecord.response.ids) - if (correctIds.size !== responseIds.size) return setScore(0) + let score, + numCorrect = 0 - let score = 100 - correctIds.forEach(id => { - if (!responseIds.has(id)) score = 0 + responseIds.forEach(id => { + if (correctIds.has(id)) numCorrect++ + else numCorrect-- }) + + if (!partialScoring && numCorrect !== correctIds.size) return setScore(0) + + if (numCorrect <= 0) score = 0 + else score = (100 * numCorrect) / correctIds.size + setScore(score) break } diff --git a/packages/obonode/obojobo-chunks-multiple-choice-assessment/server/mcassessment.test.js b/packages/obonode/obojobo-chunks-multiple-choice-assessment/server/mcassessment.test.js index b1261b7847..0aeb9fff57 100644 --- a/packages/obonode/obojobo-chunks-multiple-choice-assessment/server/mcassessment.test.js +++ b/packages/obonode/obojobo-chunks-multiple-choice-assessment/server/mcassessment.test.js @@ -66,10 +66,20 @@ describe('MCAssessment', () => { expect(setScore).toHaveBeenCalledWith(0) }) - test('onCalculateScore sets score to 0 if number of chosen !== number of correct answers (pick-all)', () => { + test('onCalculateScore determines partial score if number of chosen !== number of correct answers (pick-all)', () => { const question = { contains: () => true } const responseRecord = { response: { ids: ['test'] } } - mcAssessment.node.content = { responseType: 'pick-all' } + mcAssessment.node.content = { responseType: 'pick-all', partialScoring: true } + + expect(setScore).not.toHaveBeenCalled() + mcAssessment.onCalculateScore({}, question, responseRecord, setScore) + expect(setScore).toHaveBeenCalledWith(50) + }) + + test('onCalculateScore does not give negative score with partial scoring (pick-all)', () => { + const question = { contains: () => true } + const responseRecord = { response: { ids: ['test2', 'test3'] } } + mcAssessment.node.content = { responseType: 'pick-all', partialScoring: true } expect(setScore).not.toHaveBeenCalled() mcAssessment.onCalculateScore({}, question, responseRecord, setScore) diff --git a/packages/obonode/obojobo-chunks-multiple-choice-assessment/viewer-component.js b/packages/obonode/obojobo-chunks-multiple-choice-assessment/viewer-component.js index 8e18d7da8b..a6ce61ceeb 100644 --- a/packages/obonode/obojobo-chunks-multiple-choice-assessment/viewer-component.js +++ b/packages/obonode/obojobo-chunks-multiple-choice-assessment/viewer-component.js @@ -119,17 +119,23 @@ export default class MCAssessment extends OboQuestionAssessmentComponent { switch (this.props.model.modelState.responseType) { case 'pick-all': { - if (correct.size !== responses.size) { - return { score: 0, details: null } - } - - let score = 100 - correct.forEach(function(id) { - if (!responses.has(id)) { - score = 0 + let score, + numCorrect = 0 + + responses.forEach(function(id) { + if (correct.has(id)) { + numCorrect++ + } else { + numCorrect-- } }) + if (numCorrect <= 0) { + score = 0 + } else { + score = (100 * numCorrect) / correct.size + } + return { score, details: null } } diff --git a/packages/obonode/obojobo-chunks-multiple-choice-assessment/viewer-component.test.js b/packages/obonode/obojobo-chunks-multiple-choice-assessment/viewer-component.test.js index 0f4e1da696..74a9276465 100644 --- a/packages/obonode/obojobo-chunks-multiple-choice-assessment/viewer-component.test.js +++ b/packages/obonode/obojobo-chunks-multiple-choice-assessment/viewer-component.test.js @@ -238,8 +238,8 @@ describe('MCAssessmentViewerComponent', () => { ${'pick-one-multiple-correct'} | ${['b', 'c', 'd']} | ${100} ${'pick-one-multiple-correct'} | ${['a', 'b', 'c', 'd']} | ${100} ${'pick-all'} | ${[]} | ${0} - ${'pick-all'} | ${['a']} | ${0} - ${'pick-all'} | ${['b']} | ${0} + ${'pick-all'} | ${['a']} | ${50} + ${'pick-all'} | ${['b']} | ${50} ${'pick-all'} | ${['c']} | ${0} ${'pick-all'} | ${['d']} | ${0} ${'pick-all'} | ${['a', 'b']} | ${100} @@ -248,8 +248,8 @@ describe('MCAssessmentViewerComponent', () => { ${'pick-all'} | ${['b', 'c']} | ${0} ${'pick-all'} | ${['b', 'd']} | ${0} ${'pick-all'} | ${['c', 'd']} | ${0} - ${'pick-all'} | ${['a', 'b', 'c']} | ${0} - ${'pick-all'} | ${['a', 'b', 'd']} | ${0} + ${'pick-all'} | ${['a', 'b', 'c']} | ${50} + ${'pick-all'} | ${['a', 'b', 'd']} | ${50} ${'pick-all'} | ${['a', 'c', 'd']} | ${0} ${'pick-all'} | ${['b', 'c', 'd']} | ${0} ${'pick-all'} | ${['a', 'b', 'c', 'd']} | ${0} diff --git a/packages/obonode/obojobo-chunks-question/editor-component.js b/packages/obonode/obojobo-chunks-question/editor-component.js index 8f45b50905..03e4feebc9 100644 --- a/packages/obonode/obojobo-chunks-question/editor-component.js +++ b/packages/obonode/obojobo-chunks-question/editor-component.js @@ -8,7 +8,7 @@ import withSlateWrapper from 'obojobo-document-engine/src/scripts/oboeditor/comp import React from 'react' import Node from 'obojobo-document-engine/src/scripts/oboeditor/components/node/editor-component' -const { Button } = Common.components +const { Button, MoreInfoButton } = Common.components const QUESTION_NODE = 'ObojoboDraft.Chunks.Question' const SOLUTION_NODE = 'ObojoboDraft.Chunks.Question.Solution' const MCASSESSMENT_NODE = 'ObojoboDraft.Chunks.MCAssessment' @@ -30,7 +30,7 @@ const Question = props => { }) } - const toggleCollapsed = () => { + function toggleCollapsed() { const path = ReactEditor.findPath(props.editor, props.element) const collapsed = !props.element.content.collapsed @@ -60,6 +60,24 @@ const Question = props => { ) } + function onSetScoring(event) { + const hasSolution = getHasSolution() + let assessmentNode + + if (hasSolution) { + assessmentNode = props.element.children[props.element.children.length - 2] + } else { + assessmentNode = props.element.children[props.element.children.length - 1] + } + + const path = ReactEditor.findPath(props.editor, assessmentNode) + return Transforms.setNodes( + props.editor, + { content: { ...assessmentNode.content, partialScoring: event.target.checked } }, + { at: path } + ) + } + function getHasSolution() { return props.element.children[props.element.children.length - 1].subtype === SOLUTION_NODE } @@ -165,15 +183,14 @@ const Question = props => { const hasSolution = getHasSolution() const isInAssessment = getIsInAssessment() - let questionType - - // The question type is determined by the MCAssessment or the NumericAssessement - // This is either the last node or the second to last node - if (hasSolution) { - questionType = element.children[element.children.length - 2].type - } else { - questionType = element.children[element.children.length - 1].type - } + + // The question type is determined by the MCAssessment or the NumericAssessment + // This is either the last node or the second to last node depending on whether the + // 'explanation' area is visible + const questionElement = element.children[element.children.length - (hasSolution ? 2 : 1)] + const questionType = questionElement.type + + const partialScoring = questionElement.content?.partialScoring || false const className = 'component obojobo-draft--chunks--question is-viewed pad' + @@ -204,6 +221,25 @@ const Question = props => { + {questionType === MCASSESSMENT_NODE && + questionElement.content?.responseType === 'pick-all' ? ( + + + +
+

+ Students will earn partial credit based on how many of the correct + answers they select. +

+
+
+
+ +
+ ) : null}