diff --git a/packages/explicit-constructed-response/controller/src/__tests__/index.test.js b/packages/explicit-constructed-response/controller/src/__tests__/index.test.js index cdff72e5ec..3f7856dcc1 100644 --- a/packages/explicit-constructed-response/controller/src/__tests__/index.test.js +++ b/packages/explicit-constructed-response/controller/src/__tests__/index.test.js @@ -475,7 +475,7 @@ describe('controller', () => { sessionValue, ); - expect(result).toEqual(expected); + expect(result).toEqual(expect.objectContaining(expected)); }); }; @@ -509,7 +509,7 @@ describe('controller', () => { it(`empty: true when session is ${JSON.stringify(session)}`, async () => { const m = await outcome(question, session); - expect(m).toEqual(expect.objectContaining({ score: 0, empty: true })); + expect(m).toEqual({ score: 0, empty: true, traceLog: ['Student did not fill any response areas. Score: 0.'] }); }); }; diff --git a/packages/explicit-constructed-response/controller/src/index.js b/packages/explicit-constructed-response/controller/src/index.js index 69db6ca247..273d4061e7 100644 --- a/packages/explicit-constructed-response/controller/src/index.js +++ b/packages/explicit-constructed-response/controller/src/index.js @@ -217,6 +217,77 @@ export const getScore = (config, session) => { return parseFloat(str); }; + /** + * Generates detailed trace log for scoring evaluation + * @param {Object} model - the question model + * @param {Object} session - the student session + * @param {Object} env - the environment + * @returns {Array} traceLog - array of trace messages + */ +export const getLogTrace = (model, session, env) => { + const traceLog = []; + const { value } = session || {}; + const { choices, markup } = model || {}; + + const responseAreas = markup ? markup.match(/\{\{(.+?)\}\}/g) : []; + const totalAreas = responseAreas ? responseAreas.length : 0; + + traceLog.push(`${totalAreas} response area(s) defined in this question.`); + + if (value && Object.keys(value).length > 0) { + const filledAreas = Object.entries(value).filter(([key, val]) => val && val.trim()).length; + traceLog.push(`Student filled ${filledAreas} out of ${totalAreas} response area(s).`); + + Object.keys(choices || {}).forEach((areaKey) => { + const studentAnswer = (value && value[areaKey]) || ''; + const correctOptions = choices[areaKey] || []; + const isCorrect = !isEmpty(studentAnswer.trim()) && + correctOptions.some(option => prepareVal(option.label) === prepareVal(studentAnswer)); + + if (studentAnswer.trim()) { + traceLog.push(`Response area ${parseInt(areaKey) + 1}: ${isCorrect ? 'CORRECT' : 'INCORRECT'}.`); + } else { + traceLog.push(`Response area ${parseInt(areaKey) + 1}: left empty.`); + } + }); + } else { + traceLog.push('Student did not fill any response areas.'); + } + + const hasAlternates = Object.values(choices || {}).some(optionArray => optionArray.length > 1); + if (hasAlternates) { + traceLog.push(`Alternate answers are accepted for some response areas.`); + } + + const partialScoringEnabled = partialScoring.enabled(model, env); + + if (partialScoringEnabled) { + traceLog.push(`Score calculated using partial scoring.`); + traceLog.push(`Student receives credit for each correctly filled response area.`); + + if (value && Object.keys(value).length > 0) { + let correctCount = 0; + Object.keys(choices || {}).forEach((areaKey) => { + const studentAnswer = (value && value[areaKey]) || ''; + const correctOptions = choices[areaKey] || []; + const isCorrect = !isEmpty(studentAnswer.trim()) && + correctOptions.some(option => prepareVal(option.label) === prepareVal(studentAnswer)); + if (isCorrect) correctCount++; + }); + + traceLog.push(`Partial scoring: ${correctCount} correct out of ${totalAreas} response areas.`); + } + } else { + traceLog.push(`Score calculated using all-or-nothing scoring.`); + traceLog.push(`Student must fill all response areas correctly to receive full credit.`); + } + + const score = getScore(model, session); + traceLog.push(`Score: ${score}.`); + + return traceLog; +}; + /** * The score is partial by default for checkbox mode, allOrNothing for radio mode. * To disable partial scoring for checkbox mode you either set model.partialScoring = false or env.partialScoring = @@ -230,10 +301,23 @@ export const getScore = (config, session) => { */ export function outcome(model, session, env = {}) { return new Promise((resolve) => { - const partialScoringEnabled = partialScoring.enabled(model, env); - const score = getScore(model, session); - - resolve({ score: partialScoringEnabled ? score : score === 1 ? 1 : 0, empty: isEmpty(session) }); + if (!session || isEmpty(session)) { + resolve({ + score: 0, + empty: true, + traceLog: ['Student did not fill any response areas. Score: 0.'] + }); + } else { + const traceLog = getLogTrace(model, session, env); + const partialScoringEnabled = partialScoring.enabled(model, env); + const score = getScore(model, session); + + resolve({ + score: partialScoringEnabled ? score : score === 1 ? 1 : 0, + empty: false, + traceLog + }); + } }); } diff --git a/packages/explicit-constructed-response/src/__tests__/__snapshots__/main.test.jsx.snap b/packages/explicit-constructed-response/src/__tests__/__snapshots__/main.test.jsx.snap index c9d76facae..898450748d 100644 --- a/packages/explicit-constructed-response/src/__tests__/__snapshots__/main.test.jsx.snap +++ b/packages/explicit-constructed-response/src/__tests__/__snapshots__/main.test.jsx.snap @@ -142,11 +142,6 @@ exports[`Main render should render in gather mode 1`] = ` - - - - - } - {!alwaysShowCorrect && ( + {!alwaysShowCorrect && mode === 'evaluate' && (