@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
553 lines (474 loc) • 19 kB
JavaScript
import React from 'react'
import {vi} from 'vitest'
import {v4 as uuid} from 'uuid'
import {RadioInputGroup} from '@instructure/ui-radio-input'
import {Checkbox} from '@instructure/ui-checkbox'
import {ScreenReaderContent} from '@instructure/ui-a11y-content'
import {RichContentInput} from '@instructure/quiz-rce/components/RichContentInput/index'
import mathRenderer from '@instructure/quiz-rce/components/RichContentRenderer/mathRenderer'
import RemoveChoiceButton from '../../../common/edit/components/RemoveChoiceButton'
import QuestionSettingsContainer from '../../../common/edit/components/QuestionSettingsContainer'
import QuestionContainer from '../../../common/edit/components/QuestionContainer'
import Footer from '../../../common/edit/components/Footer'
import ChoiceInput from '../../../common/edit/components/ChoiceInput'
import enableRichContentEditorChecks from '../../../../../tests/util/enableRichContentEditorChecks'
import shouldValidateItemChanges from '../../../../../tests/util/shouldValidateItemChanges'
import MultipleAnswerEdit from '../index'
import CalculatorOptionWithOqaatAlert from '../../../common/edit/components/CalculatorOptionWithOqaatAlert'
import {ReactTestbed} from '../../../../../tests/util/react-testbed'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import runAxeCheck from '@instructure/ui-axe-check'
describe('Multiple Answer Edit', () => {
const firstChoiceID = uuid()
const secondChoiceID = uuid()
const changeItemStateStub = vi.fn()
const setOneQuestionAtATimeStub = vi.fn()
const props = {
itemId: '1',
changeItemState: changeItemStateStub,
setOneQuestionAtATime: setOneQuestionAtATimeStub,
itemBody: 'STEM',
interactionData: {
choices: [
{id: firstChoiceID, itemBody: 'item1', position: 1},
{id: secondChoiceID, itemBody: '', position: 2},
],
},
openImportModal: () => {},
scoringData: {
value: [firstChoiceID],
},
properties: {
shuffleRules: {
choices: {
shuffled: false,
toLock: [],
},
},
},
}
const testbed = new ReactTestbed(MultipleAnswerEdit, props)
afterEach(() => {
changeItemStateStub.mockClear()
// Cancel any pending debounced math rendering to prevent errors after environment teardown
mathRenderer.debouncedDocumentRendering.cancel()
})
shouldValidateItemChanges(testbed)
enableRichContentEditorChecks(testbed)
describe('answer feedback rich content editors', () => {
it('shows answer feedback toggle buttons for each choice when feature is enabled', () => {
render(
<MultipleAnswerEdit
{...props}
multipleAnswerFeedbackEnabled={true}
answerFeedback={{[firstChoiceID]: 'test'}}
/>,
)
// The ChoiceInput component should render feedback toggle buttons
const feedbackButtons = screen.queryAllByRole('button', {name: /feedback/i})
expect(feedbackButtons.length).toBeGreaterThan(0)
})
it('does not show feedback toggle when feature is disabled', () => {
render(
<MultipleAnswerEdit
{...props}
multipleAnswerFeedbackEnabled={false}
answerFeedback={{[firstChoiceID]: 'test'}}
/>,
)
// Should not find feedback buttons when feature is disabled
const feedbackButtons = screen.queryAllByRole('button', {name: /answer feedback/i})
expect(feedbackButtons).toHaveLength(0)
})
it('does not show feedback toggle when item is not editable', () => {
render(
<MultipleAnswerEdit
{...props}
multipleAnswerFeedbackEnabled={true}
overrideEditableForRegrading={true}
/>,
)
// Should not find feedback buttons when overrideEditable is true
const feedbackButtons = screen.queryAllByRole('button', {name: /answer feedback/i})
expect(feedbackButtons).toHaveLength(0)
})
})
it('should show Errors if errorsAreShowing is true and choices have errors', () => {
const errorMsg = 'something is wrong!'
render(
<MultipleAnswerEdit
{...props}
errorsAreShowing
errors={{
interactionData: {
choices: {
$errors: [errorMsg],
},
},
}}
/>,
)
expect(screen.queryByText(/something is wrong!/)).not.toBeNull()
})
describe('overrideEditableForItem', () => {
it('does not render add choice button', () => {
testbed.render({overrideEditableForItem: true})
expect(testbed.findChildrenByType(Footer).length).toBe(0)
})
it('does not render remove choice button', () => {
testbed.render({overrideEditableForItem: true})
expect(testbed.findChildrenByType(RemoveChoiceButton).length).toBe(0)
})
it('disables question stem input', () => {
testbed.render({overrideEditableForItem: true})
expect(testbed.findChildByType(QuestionContainer).props.disabled).toBe(true)
})
it('does not disable lock choice button', () => {
testbed.render({overrideEditableForItem: true})
const choiceInput = testbed.findChildrenByType(ChoiceInput)
choiceInput.forEach(choice => {
expect(choice.props.disabledFields.includes('lockChoiceButton')).toBe(false)
})
})
})
describe('overrideEditableForRegrading', () => {
it('does not render add choice button', () => {
testbed.render({overrideEditableForRegrading: true})
expect(testbed.findChildrenByType(Footer).length).toBe(0)
})
it('does not render remove choice button', () => {
testbed.render({overrideEditableForRegrading: true})
expect(testbed.findChildrenByType(RemoveChoiceButton).length).toBe(0)
})
it('disables question stem input', () => {
testbed.render({overrideEditableForRegrading: true})
expect(testbed.findChildByType(QuestionContainer).props.disabled).toBe(true)
})
it('disables the calculator option', () => {
testbed.render({overrideEditableForRegrading: true})
expect(testbed.findChildByType(CalculatorOptionWithOqaatAlert).props.disabled).toBe(true)
})
it('disables lock choice button', () => {
testbed.render({overrideEditableForRegrading: true})
const choiceInput = testbed.findChildrenByType(ChoiceInput)
choiceInput.forEach(choice => {
expect(choice.props.disabledFields.includes('lockChoiceButton')).toBe(true)
})
})
})
describe('QuestionConfigContainer', () => {
it('renders an QuestionSettingsContainer', () => {
testbed.render()
const items = testbed.findChildrenByType(QuestionSettingsContainer)
expect(items.length).toBe(1)
})
})
describe('#onCheckboxClick', () => {
it('calls changeItemState with the correct arguments', () => {
render(<MultipleAnswerEdit {...props} />)
const checkbox = screen.getAllByRole('checkbox')[1]
userEvent.click(checkbox)
const scoringData = changeItemStateStub.mock.lastCall[0].scoringData
expect(scoringData).toEqual({value: [firstChoiceID, secondChoiceID]})
})
})
describe('ScreenReaderContent for checkbox choices', () => {
it('has the label text', () => {
testbed.render()
const itemBody = props.interactionData.choices[0].itemBody
const screenReaders = testbed
.findChildrenByType(ScreenReaderContent)
.filter(reader => reader.props.children === `Checkbox for distractor ${itemBody}`)
expect(screenReaders.length).toBe(1)
})
it('has Checkbox for blank distractor for empty choices', () => {
testbed.render()
const screenReaders = testbed
.findChildrenByType(ScreenReaderContent)
.filter(reader => reader.props.children === 'Checkbox for blank distractor')
expect(screenReaders.length).toBe(1)
})
})
describe('#onCheckboxInputChange', () => {
it('calls changeItemState with the correct arguments', () => {
testbed.render()
const inputs = testbed.findChildrenByType(RichContentInput)
const lastInput = inputs[inputs.length - 1]
lastInput.props.onChange({}, {editorContent: 'new checkbox input val'})
const newChoices = changeItemStateStub.mock.lastCall[0].interactionData.choices
const newItemBody = newChoices.find(c => {
return c.id === secondChoiceID
}).itemBody
expect(newItemBody).toBe('new checkbox input val')
})
})
describe('#createChoice', () => {
it('calls changeItemState with an additional choice', () => {
render(<MultipleAnswerEdit {...props} />)
const addAnswerButton = screen.getByRole('button', {name: /add answer/i})
userEvent.click(addAnswerButton)
const choicesInNewState = changeItemStateStub.mock.lastCall[0].interactionData.choices
expect(choicesInNewState.length).toBe(3)
})
it('adds a new choice with a position one greater than the last greatest', () => {
render(<MultipleAnswerEdit {...props} />)
const addAnswerButton = screen.getByRole('button', {name: /add answer/i})
userEvent.click(addAnswerButton)
const newStateData = changeItemStateStub.mock.lastCall[0]
const newChoice = newStateData.interactionData.choices.find(c => c.position === 3)
expect(newChoice).toBeDefined()
})
})
describe('#onRemoveChoice', () => {
it('calls changeItemState with the correct arguments', () => {
render(<MultipleAnswerEdit {...props} />)
const [firstButton] = screen.getAllByRole('button', {name: /remove answer value/i})
userEvent.click(firstButton)
expect(changeItemStateStub).toHaveBeenCalled()
expect(changeItemStateStub.mock.lastCall[0]).toEqual(
expect.objectContaining({
interactionData: {
choices: [{id: secondChoiceID, itemBody: '', position: 2}],
},
scoringData: {
value: [],
},
properties: props.properties,
}),
)
})
it('removes answer feedback when choice is removed', () => {
const propsWithFeedback = {
...props,
answerFeedback: {
[firstChoiceID]: 'Feedback for first choice',
[secondChoiceID]: 'Feedback for second choice',
},
}
render(<MultipleAnswerEdit {...propsWithFeedback} />)
const [firstButton] = screen.getAllByRole('button', {name: /remove answer value/i})
userEvent.click(firstButton)
expect(changeItemStateStub).toHaveBeenCalled()
const updatedState = changeItemStateStub.mock.lastCall[0]
expect(updatedState.answerFeedback).toEqual({
[secondChoiceID]: 'Feedback for second choice',
})
})
})
describe('#toggleChoiceLocked', () => {
it('calls changeItemState with the correct arguments', () => {
render(
<MultipleAnswerEdit
{...props}
properties={{
shuffleRules: {
choices: {
shuffled: true,
toLock: [],
},
},
}}
/>,
)
const lockButton = screen.getAllByRole('button', {name: /Lock Distractor Position/i})[0]
userEvent.click(lockButton)
expect(changeItemStateStub).toHaveBeenCalled()
expect(changeItemStateStub.mock.lastCall[0]).toEqual(
expect.objectContaining({
properties: {
shuffleRules: {
choices: {
shuffled: true,
toLock: [0],
},
},
},
}),
)
})
it('calls notifyScreenreader with Distractor locked', () => {
const notifyScreenReaderStub = vi.fn()
render(
<MultipleAnswerEdit
{...props}
notifyScreenreader={notifyScreenReaderStub}
properties={{
shuffleRules: {
choices: {
shuffled: true,
toLock: [],
},
},
}}
/>,
)
const lockButton = screen.getAllByRole('button', {name: /Lock Distractor Position/i})[0]
userEvent.click(lockButton)
expect(notifyScreenReaderStub).toHaveBeenCalled()
expect(notifyScreenReaderStub.mock.calls[0][0]).toBe('Distractor locked')
})
it('calls notifyScreenreader with Distractor unlocked', () => {
const notifyScreenReaderStub = vi.fn()
render(
<MultipleAnswerEdit
{...props}
notifyScreenreader={notifyScreenReaderStub}
properties={{
shuffleRules: {
choices: {
shuffled: true,
toLock: [0],
},
},
}}
/>,
)
const lockButton = screen.getAllByRole('button', {name: /Lock Distractor Position/i})[0]
userEvent.click(lockButton)
expect(notifyScreenReaderStub).toHaveBeenCalled()
expect(notifyScreenReaderStub.mock.calls[0][0]).toBe('Distractor unlocked')
})
})
describe('scoring algorithm choice', () => {
describe('with partialScoring disabled', () => {
it('should not set scoringAlgorithm by default', () => {
testbed.render()
expect(changeItemStateStub).not.toHaveBeenCalledWith({
scoringAlgorithm: 'PartialScore',
})
})
})
describe('with partialScoring enabled', () => {
it('should set scoringAlgorithm to PartialScore by default', () => {
testbed.render({partialScoringEnabled: true})
expect(changeItemStateStub).toHaveBeenCalledWith({
scoringAlgorithm: 'PartialScore',
})
})
// TODO: Cannot fully migrate - testbed.findChildByType(RadioInputGroup).props.onChange()
// doesn't trigger correctly through the fiber tree. Needs RTL migration with user interaction.
it.skip('should set the scoringAlgorithm when an option is selected', () => {
testbed.render({partialScoringEnabled: true})
const gradingOptions = testbed.findChildByType(RadioInputGroup)
gradingOptions.props.onChange(null, 'AllOrNothing')
expect(changeItemStateStub).toHaveBeenCalledWith({
scoringAlgorithm: 'AllOrNothing',
})
})
it('opens the explanatory modal when helper icon is clicked', () => {
testbed.render({partialScoringEnabled: true})
const setStateStub = vi.fn()
const wrappedComponent = testbed
.findChildrenByType(MultipleAnswerEdit.WrappedComponent)
.pop()
wrappedComponent.setState = setStateStub
const buttons = testbed.findChildrenByTagName('button')
const modalTrigger = buttons[buttons.length - 1]
modalTrigger.click()
expect(setStateStub).toHaveBeenCalledTimes(1)
expect(setStateStub).toHaveBeenCalledWith({isModalOpen: true})
})
})
})
describe('#toggleShuffleChoices', () => {
it('calls changeItemState with the correct arguments', () => {
testbed.render({
properties: {
shuffleRules: void 0,
},
})
const editComponent = testbed.findChildrenByType(Checkbox).pop()
editComponent.props.onChange({target: {value: 'shuffleChoices', checked: true}})
expect(changeItemStateStub).toHaveBeenCalled()
expect(changeItemStateStub.mock.lastCall[0]).toEqual(
expect.objectContaining({
properties: {
shuffleRules: {
choices: {shuffled: true},
},
},
}),
)
})
it('calls notifyScreenreader with Shuffling turned on', () => {
const notifyScreenReaderStub = vi.fn()
testbed.render({
notifyScreenreader: notifyScreenReaderStub,
properties: {
shuffleRules: void 0,
},
})
const editComponent = testbed.findChildrenByType(Checkbox).pop()
editComponent.props.onChange({target: {value: 'shuffleChoices', checked: true}})
expect(notifyScreenReaderStub).toHaveBeenCalled()
expect(notifyScreenReaderStub.mock.calls[0][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()
testbed.render({
notifyScreenreader: notifyScreenReaderStub,
properties: {
shuffleRules: {
choices: {shuffled: true},
},
},
})
const editComponent = testbed.findChildrenByType(Checkbox).pop()
editComponent.props.onChange({target: {value: 'shuffleChoices', checked: false}})
expect(notifyScreenReaderStub).toHaveBeenCalled()
expect(notifyScreenReaderStub.mock.calls[0][0]).toBe('Shuffling turned off.')
})
})
describe('#onDescriptionChange', () => {
it('calls changeItemState with the correct arguments', () => {
testbed.render()
const questionCont = testbed.findChildrenByType(QuestionContainer)[0]
questionCont.props.onDescriptionChange('new stem val')
const newBody = changeItemStateStub.mock.lastCall[0].itemBody
expect(newBody).toBe('new stem val')
})
})
describe('a11y tests', () => {
it('should meet a11y standards', async () => {
testbed.render()
// frame-title-unique is because each RCE iframe has the same default title
// (ok for now, would be nice to fix later)
expect(
await runAxeCheck(testbed.rootNode, {
ignores: ['checkboxgroup', 'label-title-only', 'frame-title-unique'],
}),
).toBe(true)
})
})
describe('calculator per question option', () => {
it('does not render the calculator checkbox if showCalculatorOption is false', () => {
render(<MultipleAnswerEdit {...props} showCalculatorOption={false} />)
expect(screen.queryByRole('checkbox', {name: /show on-screen calculator/i})).toBeNull()
})
it('renders the calculator per question option', () => {
testbed.render()
const items = testbed.findChildrenByType(CalculatorOptionWithOqaatAlert)
expect(items).toHaveLength(1)
})
it('calls the correct callback function with the correct data when the calculator type is changed', () => {
const value = 'basic'
testbed.render()
const items = testbed.findChildrenByType(CalculatorOptionWithOqaatAlert)
items[0].props.onCalculatorTypeChange(null, value)
expect(changeItemStateStub).toHaveBeenCalledWith(
expect.objectContaining({
calculatorType: value,
}),
)
})
it('calls the correct callback function with the correct data when OQAAT is changed', () => {
testbed.render()
const items = testbed.findChildrenByType(CalculatorOptionWithOqaatAlert)
items[0].props.onOqaatChange(true)
expect(setOneQuestionAtATimeStub).toHaveBeenCalledWith(true)
})
})
})