UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

810 lines (708 loc) • 25.7 kB
import {vi} from 'vitest' import React from 'react' import {v4 as uuid} from 'uuid' import runAxe from '@instructure/ui-axe-check' import {render, screen, fireEvent} from '@testing-library/react' import userEvent from '@testing-library/user-event' import MultipleChoiceEdit from '../index' describe('Multiple Choice Edit', () => { const firstChoiceID = uuid() const secondChoiceID = uuid() const props = { answerFeedback: {[firstChoiceID]: 'answer feedback', [secondChoiceID]: 'more feedback'}, itemBody: 'STEM', itemId: 'test', interactionData: { choices: [ {id: firstChoiceID, itemBody: 'item1', position: 1}, {id: secondChoiceID, itemBody: '', position: 2}, ], }, properties: { varyPointsByAnswer: false, shuffleRules: { choices: { shuffled: false, toLock: [], }, }, }, openImportModal: () => {}, scoringData: { value: firstChoiceID, }, key: 1, changeItemState: Function.prototype, setOneQuestionAtATime: Function.prototype, answerFeedbackEnabled: true, } describe('answer feedback rich content editors', () => { it('does not show any answer feedback RCEs by default', () => { render(<MultipleChoiceEdit {...props} />) expect(screen.queryByText('Answer Feedback')).toBeNull() }) it('shows an answer feedback RCE when the answer feedback button is clicked', () => { render(<MultipleChoiceEdit {...props} />) const editButton = screen.getAllByRole('button', {name: /Edit Answer Feedback/})[0] fireEvent.click(editButton) expect(screen.queryByText('Answer Feedback')).not.toBeNull() }) }) it('renders a rich content editor by default', () => { const {container} = render(<MultipleChoiceEdit {...props} />) const rces = container.querySelectorAll('[data-automation="sdk-rce"]') expect(rces.length).toBeGreaterThan(0) }) it('does not render a rich content editor when it is turned off', () => { const {container} = render(<MultipleChoiceEdit {...props} enableRichContentEditor={false} />) const rces = container.querySelectorAll('[data-automation="sdk-rce"]') expect(rces).toHaveLength(0) }) it('includes validation errors in changeItemState', () => { const changeItemState = vi.fn() const EditComponent = MultipleChoiceEdit.WrappedComponent || MultipleChoiceEdit const interactionType = new EditComponent.interactionType() const defaultInteractionData = interactionType.getDefaultInteractionData() const interactionData = {...defaultInteractionData, ...props.interactionData} const scoringData = { ...interactionType.getDefaultScoringData(interactionData), ...props.scoringData, } render( <MultipleChoiceEdit {...props} interactionData={interactionData} scoringData={scoringData} changeItemState={changeItemState} />, ) expect(changeItemState.mock.lastCall[0]).toMatchObject({errors: {}}) }) it('should show Errors if errorsAreShowing is true and choices have errors', () => { const errorMsg = 'something is wrong!' const errors = { interactionData: { choices: { $errors: [errorMsg], }, }, } render(<MultipleChoiceEdit {...props} errorsAreShowing errors={errors} />) expect(screen.queryByText(errorMsg)).not.toBeNull() }) it('renders add choice button', () => { render(<MultipleChoiceEdit {...props} />) expect(screen.queryByRole('button', {name: /Add Answer/})).not.toBeNull() }) it('renders remove choice button', () => { render(<MultipleChoiceEdit {...props} />) expect(screen.queryAllByRole('button', {name: /Remove Answer Value/}).length).toBeGreaterThan(0) }) describe('overrideEditableForItem', () => { it('does not render add choice button', () => { render(<MultipleChoiceEdit {...props} overrideEditableForItem />) expect(screen.queryByRole('button', {name: /Add Answer/})).toBeNull() }) it('does not render remove choice button', () => { render(<MultipleChoiceEdit {...props} overrideEditableForItem />) expect(screen.queryByRole('button', {name: /Remove Answer Value/})).toBeNull() }) it('disables question stem input', () => { render( <MultipleChoiceEdit {...props} // No obvious way to test the rich content editor with RTL enableRichContentEditor={false} overrideEditableForItem />, ) const stemInput = screen.getByLabelText(/Question Stem/) expect(stemInput).toBeDisabled() }) it('does not disable lockChoiceButton', () => { const properties = { shuffleRules: { choices: { shuffled: true, toLock: [], }, }, } render(<MultipleChoiceEdit {...props} overrideEditableForItem properties={properties} />) const lockButtons = screen.getAllByRole('button', {name: /Lock Distractor Position/}) expect(lockButtons[0]).not.toBeDisabled() }) }) describe('overrideEditableForRegrading', () => { it('does not render add choice button', () => { render(<MultipleChoiceEdit {...props} overrideEditableForRegrading />) expect(screen.queryByRole('button', {name: /Add Answer/})).toBeNull() }) it('does not render remove choice button', () => { render(<MultipleChoiceEdit {...props} overrideEditableForRegrading />) expect(screen.queryByRole('button', {name: /Remove Answer Value/})).toBeNull() }) it('disables question stem input', () => { render( <MultipleChoiceEdit {...props} enableRichContentEditor={false} overrideEditableForRegrading />, ) const stemInput = screen.getByLabelText(/Question Stem/) expect(stemInput).toBeDisabled() }) it('disables lockChoiceButton', () => { const properties = { shuffleRules: { choices: { shuffled: true, toLock: [], }, }, } render(<MultipleChoiceEdit {...props} overrideEditableForRegrading properties={properties} />) const lockButtons = screen.getAllByRole('button', {name: /Lock Distractor Position/}) expect(lockButtons[0]).toBeDisabled() }) it('disables the calculator option', () => { render(<MultipleChoiceEdit {...props} overrideEditableForRegrading />) const calculatorCheckbox = screen.getByRole('checkbox', { name: /Show on-screen calculator/i, }) expect(calculatorCheckbox).toBeDisabled() }) }) describe('selecting the correct answer', () => { it('calls changeItemState with the correct arguments', () => { const changeItemStateStub = vi.fn() render(<MultipleChoiceEdit {...props} changeItemState={changeItemStateStub} />) const radioButton = screen.getByLabelText('Radio button for blank answer') fireEvent.click(radioButton) expect(changeItemStateStub.mock.lastCall[0]).toMatchObject({ scoringData: { value: secondChoiceID, }, }) }) }) describe('changing an answer description', () => { it('calls changeItemState with the correct arguments', () => { const changeItemStateStub = vi.fn() render( <MultipleChoiceEdit {...props} changeItemState={changeItemStateStub} enableRichContentEditor={false} />, ) const inputs = screen.getAllByRole('textbox', {name: /Answer/i}) const lastInput = inputs[inputs.length - 1] fireEvent.change(lastInput, {target: {value: 'new text'}}) expect(changeItemStateStub).toHaveBeenCalled() const newChoices = changeItemStateStub.mock.lastCall[0].interactionData.choices const newItemBody = newChoices.find(c => { return c.id === secondChoiceID }).itemBody expect(newItemBody).toBe('new text') }) }) describe('ScreenReaderContent for checkbox choices', () => { it('has the label text', () => { render(<MultipleChoiceEdit {...props} />) const itemBody = props.interactionData.choices[0].itemBody const screenReaderText = `Radio button for answer ${itemBody}` expect(screen.queryByText(screenReaderText)).not.toBeNull() }) it('has Checkbox for blank answer for empty choices', () => { render(<MultipleChoiceEdit {...props} />) const screenReaderText = 'Radio button for blank answer' expect(screen.queryByText(screenReaderText)).not.toBeNull() }) }) describe('creating a new choice', () => { it('calls changeItemState with an additional choice', () => { const changeItemStateStub = vi.fn() render(<MultipleChoiceEdit {...props} changeItemState={changeItemStateStub} />) const addButton = screen.getByRole('button', {name: /Add Answer/}) fireEvent.click(addButton) expect(changeItemStateStub).toHaveBeenCalled() const choicesInNewState = changeItemStateStub.mock.lastCall[0].interactionData.choices expect(choicesInNewState.length).toBe(3) expect(choicesInNewState.map(c => c.position)).toEqual([1, 2, 3]) }) }) describe('removing a choice', () => { it('moves toLock along with the answers', () => { const changeItemStateStub = vi.fn() const properties = { shuffleRules: { choices: { shuffled: true, toLock: [1], }, }, varyPointsByAnswer: false, } render( <MultipleChoiceEdit {...props} changeItemState={changeItemStateStub} properties={properties} />, ) const removeButton = screen.getAllByRole('button', {name: /Remove Answer Value/})[0] fireEvent.click(removeButton) // Check that one call set scoringData to null expect( changeItemStateStub.mock.calls.some(call => call[0]?.scoringData?.value === null), ).toBe(true) // Check that one call has the right interactionData and properties const dataCall = changeItemStateStub.mock.calls.find( call => call[0]?.interactionData?.choices?.length === 1, ) expect(dataCall).toBeDefined() expect(dataCall[0]).toMatchObject({ interactionData: { choices: [{id: secondChoiceID, itemBody: '', position: 2}], }, properties: { varyPointsByAnswer: false, shuffleRules: { choices: { shuffled: true, toLock: [0], }, }, }, }) }) it('calls changeItemState with the correct arguments', () => { const changeItemStateStub = vi.fn() render(<MultipleChoiceEdit {...props} changeItemState={changeItemStateStub} />) const removeButton = screen.getAllByRole('button', {name: /Remove Answer Value/})[0] fireEvent.click(removeButton) // Check that one call set scoringData to null expect( changeItemStateStub.mock.calls.some(call => call[0]?.scoringData?.value === null), ).toBe(true) // Check that one call has the right interactionData and properties const dataCall = changeItemStateStub.mock.calls.find( call => call[0]?.interactionData?.choices?.length === 1, ) expect(dataCall).toBeDefined() expect(dataCall[0]).toMatchObject({ interactionData: { choices: [{id: secondChoiceID, itemBody: '', position: 2}], }, properties: props.properties, answerFeedback: {[secondChoiceID]: 'more feedback'}, }) }) }) describe('changing points per answer', () => { const properties = { varyPointsByAnswer: true, } it('calls changeItemState with the correct arguments', () => { const changeItemStateStub = vi.fn() const pointsChangeStub = vi.fn() const scoringData = { values: [ { value: firstChoiceID, points: -5, }, { value: secondChoiceID, points: -3, }, ], } render( <MultipleChoiceEdit {...props} changeItemState={changeItemStateStub} pointsChange={pointsChangeStub} properties={properties} scoringData={scoringData} />, ) const pointsInputs = screen.getAllByLabelText('Points Possible') fireEvent.change(pointsInputs[0], {target: {value: '1'}}) expect(changeItemStateStub).toHaveBeenCalled() const item = changeItemStateStub.mock.lastCall[0] const newVal = item.scoringData.values.find(v => v.value === firstChoiceID) expect(newVal.points).toBe(1) }) it('calls pointsChange with the correct pointsPossible', () => { const changeItemStateStub = vi.fn() const pointsChangeStub = vi.fn() const scoringData = { values: [ { value: firstChoiceID, points: -1, }, { value: secondChoiceID, points: 5, }, ], } render( <MultipleChoiceEdit {...props} changeItemState={changeItemStateStub} pointsChange={pointsChangeStub} properties={properties} scoringData={scoringData} />, ) const pointsInputs = screen.getAllByLabelText('Points Possible') fireEvent.change(pointsInputs[0], {target: {value: '1'}}) expect(changeItemStateStub).toHaveBeenCalled() const maxPoints = pointsChangeStub.mock.lastCall[0] expect(maxPoints).toBe(5) }) it('calls pointsChange with the correct pointsPossible with negative answer points', () => { const changeItemStateStub = vi.fn() const pointsChangeStub = vi.fn() const scoringData = { values: [ { value: firstChoiceID, points: -5, }, { value: secondChoiceID, points: -3, }, ], } render( <MultipleChoiceEdit {...props} changeItemState={changeItemStateStub} pointsChange={pointsChangeStub} properties={properties} scoringData={scoringData} />, ) const pointsInputs = screen.getAllByLabelText('Points Possible') fireEvent.change(pointsInputs[0], {target: {value: '-1'}}) expect(changeItemStateStub).toHaveBeenCalled() const maxPoints = pointsChangeStub.mock.lastCall[0] expect(maxPoints).toBe(-1) }) }) describe('locking a choice', () => { it('calls changeItemState with the correct arguments', () => { const changeItemStateStub = vi.fn() const properties = { shuffleRules: { choices: { shuffled: true, toLock: [], }, }, } render( <MultipleChoiceEdit {...props} changeItemState={changeItemStateStub} properties={properties} />, ) const lockButton = screen.getAllByRole('button', {name: /Lock Distractor Position/})[0] fireEvent.click(lockButton) expect(changeItemStateStub).toHaveBeenCalled() expect(changeItemStateStub.mock.lastCall[0]).toMatchObject({ properties: { shuffleRules: { choices: { shuffled: true, toLock: [0], }, }, }, }) }) it('calls notifyScreenreader with Choice locked', () => { const notifyScreenReaderStub = vi.fn() const properties = { shuffleRules: { choices: { shuffled: true, toLock: [], }, }, } render( <MultipleChoiceEdit {...props} notifyScreenreader={notifyScreenReaderStub} properties={properties} />, ) const lockButton = screen.getAllByRole('button', {name: /Lock Distractor Position/})[0] fireEvent.click(lockButton) expect(notifyScreenReaderStub).toHaveBeenCalled() expect(notifyScreenReaderStub.mock.lastCall[0]).toBe('Choice locked') }) it('calls notifyScreenreader with Choice unlocked', () => { const notifyScreenReaderStub = vi.fn() const properties = { shuffleRules: { choices: { shuffled: true, toLock: [0], }, }, } render( <MultipleChoiceEdit {...props} notifyScreenreader={notifyScreenReaderStub} properties={properties} />, ) const lockButton = screen.getByRole('button', {name: /Unlock Distractor Position/}) fireEvent.click(lockButton) expect(notifyScreenReaderStub).toHaveBeenCalled() expect(notifyScreenReaderStub.mock.lastCall[0]).toBe('Choice unlocked') }) }) describe('toggling shuffle choices', () => { it('calls changeItemState with the correct arguments', () => { const changeItemStateStub = vi.fn() const properties = { shuffleRules: { choices: {shuffled: false}, }, } render( <MultipleChoiceEdit {...props} changeItemState={changeItemStateStub} properties={properties} />, ) const checkbox = screen.getByRole('checkbox', {name: /shuffle choices/i}) userEvent.click(checkbox) expect(changeItemStateStub).toHaveBeenCalled() expect(changeItemStateStub.mock.lastCall[0]).toMatchObject({ properties: { shuffleRules: { choices: {shuffled: true}, }, }, }) expect(changeItemStateStub.mock.lastCall[0].properties).toEqual( changeItemStateStub.mock.lastCall[1].properties, ) }) it('calls notifyScreenreader with Shuffling turned on', () => { const notifyScreenReaderStub = vi.fn() const properties = { shuffleRules: { choices: {shuffled: false}, }, } render( <MultipleChoiceEdit {...props} notifyScreenreader={notifyScreenReaderStub} properties={properties} />, ) const checkbox = screen.getByRole('checkbox', {name: /shuffle choices/i}) userEvent.click(checkbox) expect(notifyScreenReaderStub).toHaveBeenCalled() expect(notifyScreenReaderStub.mock.lastCall[0]).toBe( 'Shuffling turned on. Navigate to a choice to lock it in place.', ) }) it('calls notifyScreenreader with Shuffling turned off', () => { const notifyScreenReaderStub = vi.fn() const properties = { shuffleRules: { choices: {shuffled: true}, }, } render( <MultipleChoiceEdit {...props} notifyScreenreader={notifyScreenReaderStub} properties={properties} />, ) const checkbox = screen.getByRole('checkbox', {name: /shuffle choices/i}) userEvent.click(checkbox) expect(notifyScreenReaderStub).toHaveBeenCalled() expect(notifyScreenReaderStub.mock.lastCall[0]).toBe('Shuffling turned off.') }) }) describe('toggling vary points by answer', () => { it('toggles varyPointsByAnswer property without clobbering other options', () => { const changeItemStateStub = vi.fn() render(<MultipleChoiceEdit {...props} changeItemState={changeItemStateStub} />) const checkbox = screen.getByRole('checkbox', {name: /vary points by answer/i}) fireEvent.click(checkbox) expect(changeItemStateStub).toHaveBeenCalled() const [newItem, changes] = changeItemStateStub.mock.lastCall expect(newItem).toMatchObject({ properties: { ...props.properties, varyPointsByAnswer: true, }, }) expect(changes).toMatchObject({properties: {varyPointsByAnswer: true}}) }) it('sets the scoring algorithm to VaryPointsByAnswer if the option is selected', () => { const changeItemStateStub = vi.fn() render(<MultipleChoiceEdit {...props} changeItemState={changeItemStateStub} />) const checkbox = screen.getByRole('checkbox', {name: /vary points by answer/i}) fireEvent.click(checkbox) expect(changeItemStateStub.mock.lastCall[0]).toMatchObject({ scoringAlgorithm: 'VaryPointsByAnswer', }) }) it('sets the scoring algorithm to Equivalence if the option is unselected', () => { const changeItemStateStub = vi.fn() const properties = {varyPointsByAnswer: true} render( <MultipleChoiceEdit {...props} changeItemState={changeItemStateStub} properties={properties} />, ) const checkbox = screen.getByRole('checkbox', {name: /vary points by answer/i}) fireEvent.click(checkbox) expect(changeItemStateStub.mock.lastCall[0]).toMatchObject({ scoringAlgorithm: 'Equivalence', }) }) }) describe('additional options', () => { it('passes additional options to QuestionSettingsContainer', () => { const additionalOptions = [ { key: 'foo', title: 'Additional option', component: <div className="additional" />, }, ] render(<MultipleChoiceEdit {...props} additionalOptions={additionalOptions} />) expect(screen.queryByText('Additional option')).not.toBeNull() }) }) describe('radioboxes name generation', () => { it('generates different name for every radio', () => { const interactionDataMock = { choices: [ { id: '1', itemBody: 'foo', position: 1, }, { id: '2', itemBody: 'bar', position: 2, }, { id: '3', itemBody: 'foobar', position: 3, }, { id: '4', itemBody: 'barfoo', position: 4, }, ], } const itemId = 'test' const {container} = render( <MultipleChoiceEdit {...props} interactionData={interactionDataMock} itemId={itemId} />, ) const radios = container.querySelectorAll('[id^="RadioInput"]') expect(radios[0].getAttribute('name')).toBe('edit_interaction_test_1') expect(radios[1].getAttribute('name')).toBe('edit_interaction_test_2') expect(radios[2].getAttribute('name')).toBe('edit_interaction_test_3') expect(radios[3].getAttribute('name')).toBe('edit_interaction_test_4') }) }) describe('calculator per question option', () => { it('does not render the calculator checkbox if showCalculatorOption is false', () => { render(<MultipleChoiceEdit {...props} showCalculatorOption={false} />) expect(screen.queryByRole('checkbox', {name: /show on-screen calculator/i})).toBeNull() }) it('renders the calculator per question option', () => { render(<MultipleChoiceEdit {...props} />) expect(screen.queryByRole('checkbox', {name: /show on-screen calculator/i})).not.toBeNull() }) it('calls the correct callback function with the correct data when the calculator type is changed', () => { const changeItemStateStub = vi.fn() render( <MultipleChoiceEdit {...props} calculatorType="basic" changeItemState={changeItemStateStub} />, ) const radioButton = screen.getByRole('radio', {name: /scientific calculator/i}) userEvent.click(radioButton) expect(changeItemStateStub.mock.lastCall[0]).toMatchObject({ calculatorType: 'scientific', }) }) it('calls the correct callback function when OQAAT is changed', () => { const setOneQuestionAtATimeStub = vi.fn() render( <MultipleChoiceEdit {...props} calculatorType="basic" setOneQuestionAtATime={setOneQuestionAtATimeStub} />, ) const oqaatToggle = screen.getByRole('checkbox', {name: /Enable One Question at a Time/i}) fireEvent.click(oqaatToggle) expect(setOneQuestionAtATimeStub).toHaveBeenCalled() }) }) it('should meet a11y standards', async () => { const {container} = render(<MultipleChoiceEdit {...props} />) // frame-title-unique is because each RCE iframe has the same default title // (ok for now, would be nice to fix later) expect( await runAxe(container, { ignores: ['radiogroup', 'frame-title-unique'], }), ).toBe(true) }) describe('Survey', () => { it('hides the "Vary points by answer" checkbox when isSurvey is true', () => { render(<MultipleChoiceEdit {...props} isSurvey={true} />) const checkbox = screen.queryByRole('checkbox', {name: /vary points by answer/i}) expect(checkbox).toBeNull() }) it('shows the "Vary points by answer" checkbox when isSurvey is false', () => { render(<MultipleChoiceEdit {...props} isSurvey={false} />) const checkbox = screen.queryByRole('checkbox', {name: /vary points by answer/i}) expect(checkbox).not.toBeNull() }) it('still shows other option when isSurvey is true', () => { render(<MultipleChoiceEdit {...props} isSurvey={true} />) const shuffleCheckbox = screen.queryByRole('checkbox', {name: /shuffle choices/i}) expect(shuffleCheckbox).not.toBeNull() const calculatorCheckbox = screen.queryByRole('checkbox', { name: /show on-screen calculator/i, }) expect(calculatorCheckbox).not.toBeNull() }) }) })