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 }) => (
+ <>
+