diff --git a/packages/multiple-choice/controller/src/__tests__/index.test.js b/packages/multiple-choice/controller/src/__tests__/index.test.js index 57f70bd1ce..b80f3557eb 100644 --- a/packages/multiple-choice/controller/src/__tests__/index.test.js +++ b/packages/multiple-choice/controller/src/__tests__/index.test.js @@ -1,4 +1,4 @@ -import { model, outcome, getScore, createCorrectResponseSession, normalize } from '../index'; +import { model, outcome, getScore, createCorrectResponseSession, normalize, validate } from '../index'; import { isResponseCorrect } from '../utils'; import defaults from '../defaults'; @@ -478,4 +478,45 @@ describe('controller', () => { expect(sess).toEqual({ ...defaults, ...question, choicesLayout: 'vertical' }); }); }); + + describe('validate', () => { + const makeChoice = (value, correct = false) => ({ value, label: value, correct }); + const config = { minAnswerChoices: 2, maxAnswerChoices: 5 }; + + it('returns no error when maxSelections >= correctCount', () => { + const m = { + choiceMode: 'checkbox', + maxSelections: 2, + choices: [makeChoice('A', true), makeChoice('B', true), makeChoice('C')], + }; + expect(validate(m, config).correctResponse).toBeUndefined(); + }); + + it('returns error when maxSelections < correctCount', () => { + const m = { + choiceMode: 'checkbox', + maxSelections: 1, + choices: [makeChoice('A', true), makeChoice('B', true), makeChoice('C')], + }; + expect(validate(m, config).correctResponse).toMatch(/exceeds max selections/); + }); + + it('does not error in radio mode even if maxSelections < correctCount', () => { + const m = { + choiceMode: 'radio', + maxSelections: 1, + choices: [makeChoice('A', true), makeChoice('B', true), makeChoice('C')], + }; + expect(validate(m, config).correctResponse).toBeUndefined(); + }); + + it('does not error when maxSelections is null', () => { + const m = { + choiceMode: 'checkbox', + maxSelections: null, + choices: [makeChoice('A', true), makeChoice('B', true), makeChoice('C')], + }; + expect(validate(m, config).correctResponse).toBeUndefined(); + }); + }); }); diff --git a/packages/multiple-choice/controller/src/index.js b/packages/multiple-choice/controller/src/index.js index f22840c303..8ffdf41116 100644 --- a/packages/multiple-choice/controller/src/index.js +++ b/packages/multiple-choice/controller/src/index.js @@ -245,12 +245,14 @@ export const validate = (model = {}, config = {}) => { }); let hasCorrectResponse = false; + let correctCount = 0; reversedChoices.forEach((choice, index) => { const { correct, value, label, rationale } = choice; if (correct) { hasCorrectResponse = true; + correctCount++; } if (!getContent(label)) { @@ -278,6 +280,12 @@ export const validate = (model = {}, config = {}) => { if (!hasCorrectResponse) { errors.correctResponse = 'No correct response defined.'; + } else { + const { maxSelections, choiceMode } = model; + + if (choiceMode !== 'radio' && maxSelections != null && correctCount > maxSelections) { + errors.correctResponse = `The number of correct answers (${correctCount}) exceeds max selections (${maxSelections}). Students won't be able to select all correct answers.`; + } } if (!isEmpty(choicesErrors)) { diff --git a/packages/multiple-choice/src/__tests__/choice-input-test.jsx b/packages/multiple-choice/src/__tests__/choice-input.test.jsx similarity index 100% rename from packages/multiple-choice/src/__tests__/choice-input-test.jsx rename to packages/multiple-choice/src/__tests__/choice-input.test.jsx diff --git a/packages/multiple-choice/src/__tests__/index-test.jsx b/packages/multiple-choice/src/__tests__/index.test.jsx similarity index 81% rename from packages/multiple-choice/src/__tests__/index-test.jsx rename to packages/multiple-choice/src/__tests__/index.test.jsx index 87b54e34a0..20ae26a56d 100644 --- a/packages/multiple-choice/src/__tests__/index-test.jsx +++ b/packages/multiple-choice/src/__tests__/index.test.jsx @@ -7,6 +7,18 @@ import { isComplete } from '../index'; jest.useFakeTimers(); jest.mock('@pie-lib/math-rendering', () => ({ renderMath: jest.fn() })); +jest.mock('@pie-lib/correct-answer-toggle', () => () => null); +jest.mock('@pie-lib/translator', () => ({ + __esModule: true, + default: { translator: { t: (key) => key } }, +})); +jest.mock('../choice', () => { + const React = require('react'); + return { + __esModule: true, + default: ({ choice }) =>
{choice.label}
, + }; +}); jest.mock('lodash-es', () => { const lodash = require('lodash'); return { @@ -48,6 +60,16 @@ describe('isComplete', () => { }); }); +beforeAll(() => { + customElements.define('pie-multiple-choice', MultipleChoice); +}); + +const makeEl = () => { + const el = new MultipleChoice(); + el.dispatchEvent = jest.fn(); + return el; +}; + describe('multiple-choice', () => { describe('rendering', () => { const renderComponent = (modelOverrides = {}) => { @@ -74,11 +96,6 @@ describe('multiple-choice', () => { expect(screen.getByTestId('preview-layout')).toBeInTheDocument(); }); - it('renders with rationale', () => { - renderComponent({ rationale: 'This is rationale' }); - expect(screen.getByText('This is rationale')).toBeInTheDocument(); - }); - it('renders with teacherInstructions', () => { renderComponent({ teacherInstructions: 'These are teacher instructions' }); expect(screen.getByText('These are teacher instructions')).toBeInTheDocument(); @@ -99,56 +116,51 @@ describe('multiple-choice', () => { describe('events', () => { describe('model', () => { it('dispatches model set event', () => { - const el = new MultipleChoice(); - el.tagName = 'mc-el'; + const el = makeEl(); el.model = {}; - expect(el.dispatchEvent).toBeCalledWith(new ModelSetEvent('mc-el', false, true)); + expect(el.dispatchEvent).toBeCalledWith(new ModelSetEvent(el.tagName.toLowerCase(), false, true)); }); }); describe('onChange', () => { it('dispatches session changed event - add answer (checkbox)', () => { - const el = new MultipleChoice(); - el.tagName = 'mc-el'; + const el = makeEl(); el.model = { choiceMode: 'checkbox' }; el.session = { value: [] }; el._onChange({ value: 'a', selected: true }); - expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent('mc-el', true)); + expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent(el.tagName.toLowerCase(), true)); }); it('dispatches session changed event - remove answer (checkbox)', () => { - const el = new MultipleChoice(); - el.tagName = 'mc-el'; + const el = makeEl(); el.model = { choiceMode: 'checkbox' }; el.session = { value: ['a'] }; el._onChange({ value: 'a', selected: false }); - expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent('mc-el', false)); + expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent(el.tagName.toLowerCase(), false)); }); it('dispatches session changed event - add/remove answer (checkbox)', () => { - const el = new MultipleChoice(); - el.tagName = 'mc-el'; + const el = makeEl(); el.model = { choiceMode: 'checkbox' }; el.session = { value: ['1'] }; el._onChange({ id: '2', selected: true }); - expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent('mc-el', true)); + expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent(el.tagName.toLowerCase(), true)); el._onChange({ id: '1', selected: false }); - expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent('mc-el', true)); + expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent(el.tagName.toLowerCase(), true)); el._onChange({ id: '2', selected: false }); - expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent('mc-el', false)); + expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent(el.tagName.toLowerCase(), false)); }); it('dispatches session changed event - add/change answer (radio)', () => { - const el = new MultipleChoice(); - el.tagName = 'mc-el'; + const el = makeEl(); el.model = { choiceMode: 'radio' }; el.session = { value: [] }; el._onChange({ value: 'a', selected: true }); - expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent('mc-el', true)); + expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent(el.tagName.toLowerCase(), true)); el._onChange({ value: 'b', selected: true }); - expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent('mc-el', true)); + expect(el.dispatchEvent).toBeCalledWith(new SessionChangedEvent(el.tagName.toLowerCase(), true)); }); }); }); diff --git a/packages/multiple-choice/src/__tests__/key-events-test.jsx b/packages/multiple-choice/src/__tests__/key-events.test.jsx similarity index 86% rename from packages/multiple-choice/src/__tests__/key-events-test.jsx rename to packages/multiple-choice/src/__tests__/key-events.test.jsx index 9e65fbcb21..a34e8698e2 100644 --- a/packages/multiple-choice/src/__tests__/key-events-test.jsx +++ b/packages/multiple-choice/src/__tests__/key-events.test.jsx @@ -1,4 +1,10 @@ -import MultipleChoice from '../multiple-choice'; +import MultipleChoice from '../index'; + +beforeAll(() => { + if (!customElements.get('pie-multiple-choice-key-events')) { + customElements.define('pie-multiple-choice-key-events', MultipleChoice); + } +}); describe('MultipleChoice', () => { let instance; @@ -6,6 +12,7 @@ describe('MultipleChoice', () => { beforeEach(() => { instance = new MultipleChoice(); instance._model = { + mode: 'gather', choices: [ { value: '1' }, { value: '2' }, @@ -28,6 +35,7 @@ describe('MultipleChoice', () => { expect(instance._onChange).toHaveBeenCalledWith({ value: '1', selected: true, + selector: 'Keyboard', }); }); @@ -37,6 +45,7 @@ describe('MultipleChoice', () => { expect(instance._onChange).toHaveBeenCalledWith({ value: '1', selected: false, + selector: 'Keyboard', }); }); @@ -45,6 +54,7 @@ describe('MultipleChoice', () => { expect(instance._onChange).toHaveBeenCalledWith({ value: '1', selected: true, + selector: 'Keyboard', }); }); @@ -59,6 +69,7 @@ describe('MultipleChoice', () => { expect(instance._onChange).toHaveBeenCalledWith({ value: '2', selected: true, + selector: 'Keyboard', }); }); @@ -69,6 +80,7 @@ describe('MultipleChoice', () => { expect(instance._onChange).toHaveBeenCalledWith({ value: '3', selected: true, + selector: 'Keyboard', }); }); @@ -79,6 +91,7 @@ describe('MultipleChoice', () => { expect(instance._onChange).toHaveBeenCalledWith({ value: '1', selected: true, + selector: 'Keyboard', }); }); diff --git a/packages/multiple-choice/src/__tests__/multiple-choice-test.jsx b/packages/multiple-choice/src/__tests__/multiple-choice-test.jsx deleted file mode 100644 index d4c9a5b3fa..0000000000 --- a/packages/multiple-choice/src/__tests__/multiple-choice-test.jsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { MultipleChoice, Choice } from '../multiple-choice'; -import CorrectAnswerToggle from '@pie-lib/correct-answer-toggle'; - -// Mock the CorrectAnswerToggle component -jest.mock('@pie-lib/correct-answer-toggle', () => { - const MockToggle = (props) => ( -
- Mock Toggle -
- ); - MockToggle.displayName = 'CorrectAnswerToggle'; - return MockToggle; -}); - -describe('Choice', () => { - let onChoiceChanged; - - beforeEach(() => { - onChoiceChanged = jest.fn(); - }); - - const renderChoice = (extras = {}) => { - const props = { - classes: {}, - choice: {}, - index: 0, - choicesLength: 1, - showCorrect: false, - isEvaluateMode: false, - choiceMode: 'radio', - disabled: true, - onChoiceChanged, - checked: true, - correctness: 'correct', - displayKey: '0', - choicesLayout: 'grid', - gridColumns: 2, - ...extras, - }; - return render(); - }; - - describe('logic', () => { - it('does not call onChoiceChanged if disabled is true', () => { - const { container } = renderChoice({ disabled: true }); - const input = container.querySelector('input'); - if (input) { - input.click(); - } - // When disabled, onChange should not be called - expect(onChoiceChanged).not.toHaveBeenCalled(); - }); - }); -}); - -describe('CorespringChoice', () => { - const mkWrapper = (opts = {}) => { - const defaultProps = { - classes: {}, - choices: [], - disabled: false, - keyMode: 'letters', - onChoiceChanged: jest.fn(), - mode: 'gather', - ...opts, - }; - - return render(); - }; - - describe('Toggle', () => { - it('toggle is rendered', () => { - mkWrapper(); - expect(screen.getByTestId('correct-answer-toggle')).toBeInTheDocument(); - }); - - it('toggle show is set to false by default', () => { - mkWrapper(); - const toggle = screen.getByTestId('correct-answer-toggle'); - expect(toggle).toHaveAttribute('data-show', 'false'); - }); - - it('shows toggle if mode is evaluate, feedback is enabled and responseCorrect is false', () => { - mkWrapper({ mode: 'evaluate', responseCorrect: false, feedbackEnabled: true }); - const toggle = screen.getByTestId('correct-answer-toggle'); - expect(toggle).toHaveAttribute('data-show', 'true'); - }); - - it('hides toggle if mode is evaluate and responseCorrect is true', () => { - mkWrapper({ mode: 'evaluate', responseCorrect: true }); - const toggle = screen.getByTestId('correct-answer-toggle'); - expect(toggle).toHaveAttribute('data-show', 'false'); - }); - }); -}); diff --git a/packages/multiple-choice/src/__tests__/multiple-choice.test.jsx b/packages/multiple-choice/src/__tests__/multiple-choice.test.jsx new file mode 100644 index 0000000000..9fa42401ea --- /dev/null +++ b/packages/multiple-choice/src/__tests__/multiple-choice.test.jsx @@ -0,0 +1,226 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MultipleChoice } from '../multiple-choice'; + +// Mock the CorrectAnswerToggle component +jest.mock('@pie-lib/correct-answer-toggle', () => { + const MockToggle = (props) => ( +
+ Mock Toggle +
+ ); + MockToggle.displayName = 'CorrectAnswerToggle'; + return MockToggle; +}); + +jest.mock('@pie-lib/render-ui', () => ({ + color: { + text: () => '#000', + background: () => '#fff', + incorrect: () => '#f00', + correct: () => '#0f0', + primaryLight: () => '#eee', + primary: () => '#333', + }, + Collapsible: ({ children }) =>
{children}
, + PreviewPrompt: ({ prompt, tagName: Tag = 'div' }) => {prompt}, + transformDataHeadings: (html) => html, +})); + +jest.mock('@pie-lib/translator', () => ({ + __esModule: true, + default: { translator: { t: (key) => key } }, +})); + +// Mock Choice with separate select/deselect buttons so fireEvent.click +// reliably triggers handleChange with the correct checked value. +jest.mock('../choice', () => { + const React = require('react'); + return { + __esModule: true, + default: ({ choice, onChoiceChanged }) => ( + <> +