@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
810 lines (708 loc) • 25.7 kB
JavaScript
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()
})
})
})