UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

553 lines (474 loc) • 19 kB
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) }) }) })