UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

561 lines (483 loc) • 20.5 kB
import {vi} from 'vitest' import React from 'react' import {render, screen, fireEvent} from '@testing-library/react' import userEvent from '@testing-library/user-event' import runAxeCheck from '@instructure/ui-axe-check' import {TestBackend} from 'react-dnd-test-backend' import {DndProvider} from 'react-dnd' import OrderingEdit from '../index' import {renderWithDnd} from '../../../../../tests/util/dndTestUtils' const firstChoiceID = 'firstChoiceID' const secondChoiceID = 'secondChoiceID' const thirdChoiceID = 'thirdChoiceID' const fourthChoiceID = 'fourthChoiceID' const bottomLabelSelector = '[data-automation="sdk-ordering-edit-bottomLabel"]' const topLabelSelector = '[data-automation="sdk-ordering-edit-topLabel"]' const changeItemStateStub = vi.fn() const notifyScreenreaderStub = vi.fn() const setOneQuestionAtATimeStub = vi.fn() const props = { itemBody: 'Order these characters from tallest to shortest:', interactionData: { choices: { [firstChoiceID]: {id: firstChoiceID, itemBody: 'Gollum'}, [secondChoiceID]: {id: secondChoiceID, itemBody: 'Frodo'}, [thirdChoiceID]: {id: thirdChoiceID, itemBody: 'Gimli'}, [fourthChoiceID]: {id: fourthChoiceID, itemBody: 'Aragorn'}, }, }, properties: { displayAnswersParagraph: true, includeLabels: true, topLabel: 'Taller', bottomLabel: 'Shorter', }, scoringData: { value: [thirdChoiceID, secondChoiceID, firstChoiceID, fourthChoiceID], }, errors: { itemBody: ['You must have an item body'], interactionData: { children: { choices: { children: { uuid1: {errors: ['Answer cannot be blank']}, uuid2: {errors: ['Answer cannot be blank']}, uuid3: {errors: ['Answer cannot be blank']}, uuid4: {errors: ['Answer cannot be blank']}, uuid5: {errors: ['Answer cannot be blank']}, uuid6: {errors: ['Answer cannot be blank']}, }, }, }, }, properties: { topLabel: { errors: ['Top label cannot be blank'], }, bottomLabel: { errors: ['Bottom label cannot be blank'], }, }, }, openImportModal: () => {}, notifyScreenreader: notifyScreenreaderStub, errorsAreShowing: false, changeItemState: changeItemStateStub, setOneQuestionAtATime: setOneQuestionAtATimeStub, } // Helper to render with a ref for instance method access. // We need to wrap it in a DnD provider because it renders Card which uses DnD. let _capturedRef = null const Unwrapped = OrderingEdit.WrappedComponent || OrderingEdit function CaptureRefWrapper(wrapperProps) { return ( <Unwrapped ref={node => { _capturedRef = node }} {...wrapperProps} /> ) } function WrappedWithDnD(wrapperProps) { return ( <DndProvider backend={TestBackend}> <CaptureRefWrapper {...wrapperProps} /> </DndProvider> ) } function renderWrappedWithRef(overrideProps = {}) { _capturedRef = null const mergedProps = { ...props, ...overrideProps, changeItemState: changeItemStateStub, getErrors: () => [], onDescriptionChange: vi.fn(), } render(<WrappedWithDnD {...mergedProps} />) return _capturedRef } describe('Ordering Edit', () => { afterEach(() => { changeItemStateStub.mockClear() notifyScreenreaderStub.mockClear() setOneQuestionAtATimeStub.mockClear() }) it('should render', () => { const {container} = renderWithDnd(<OrderingEdit {...props} />) expect(container.firstChild).toBeInTheDocument() }) it('includes validation errors in changeItemState', () => { const localChangeItemState = vi.fn() const EditComponent = OrderingEdit.WrappedComponent || OrderingEdit const interactionType = new EditComponent.interactionType() const defaultInteractionData = interactionType.getDefaultInteractionData() const defaultScoringData = interactionType.getDefaultScoringData(defaultInteractionData) renderWithDnd( <OrderingEdit {...props} interactionData={defaultInteractionData} scoringData={defaultScoringData} changeItemState={localChangeItemState} />, ) expect(localChangeItemState).toHaveBeenCalledWith( expect.objectContaining({ errors: expect.any(Object), }), ) }) it('renders a rich content editor by default', () => { const {container} = renderWithDnd(<OrderingEdit {...props} />) const rces = container.querySelectorAll('[data-automation^="sdk-rce"]') expect(rces.length).toBeGreaterThan(0) }) it('does not render RichContentInput when enableRichContentEditor is false', () => { const {container} = renderWithDnd(<OrderingEdit {...props} enableRichContentEditor={false} />) const rces = container.querySelectorAll('[data-automation^="sdk-rce"]') expect(rces).toHaveLength(0) }) describe('rendering', () => { beforeEach(() => { vi.useFakeTimers({toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval']}) }) afterEach(() => { vi.useRealTimers() }) it('does not render RemoveChoiceButton with only two choices', () => { renderWithDnd( <OrderingEdit {...props} scoringData={{ value: [firstChoiceID, secondChoiceID], }} />, ) const removeButtons = screen.queryAllByRole('button', {name: /Remove Choice/i}) expect(removeButtons.length).toBe(0) }) describe('Card', () => { it('renders a Card for each option', () => { const {container} = renderWithDnd(<OrderingEdit {...props} />) // Each card wraps a choice. With 4 choices, there should be 4 position labels. const positionLabels = container.querySelectorAll( '[data-automation^="sdk-ordering-answer-"]', ) expect(positionLabels.length).toBe(4) }) }) describe('ReorderChoiceButton', () => { it('renders a ReorderChoiceButton for each option', () => { renderWithDnd(<OrderingEdit {...props} />) const reorderButtons = screen.getAllByRole('button', {name: /Reorder Choice/i}) expect(reorderButtons.length).toBe(4) }) it('passes in isFinalChoice correctly', () => { renderWithDnd(<OrderingEdit {...props} />) const reorderButtons = screen.getAllByRole('button', {name: /Reorder Choice/i}) // The first reorder button should not be the final choice // The last reorder button should be the final choice // We verify this indirectly: the last choice's reorder button shouldn't offer "move down" expect(reorderButtons.length).toBe(4) }) it('passes in isFirstChoice correctly', () => { renderWithDnd(<OrderingEdit {...props} />) const reorderButtons = screen.getAllByRole('button', {name: /Reorder Choice/i}) // The first reorder button is for the first choice // The last reorder button is for the last choice expect(reorderButtons.length).toBe(4) }) }) describe('overrideEditableForRegrading', () => { it('does not render add choice button', () => { renderWithDnd(<OrderingEdit {...props} overrideEditableForRegrading={true} />) const addButton = screen.queryByRole('button', {name: /Add Answer/i}) expect(addButton).toBeNull() }) it('does not render remove choice button', () => { renderWithDnd(<OrderingEdit {...props} overrideEditableForRegrading={true} />) const removeButtons = screen.queryAllByRole('button', {name: /Remove Choice/i}) expect(removeButtons.length).toBe(0) }) it('disables question stem input', () => { // Verify the component renders without error when disabled const {container} = renderWithDnd( <OrderingEdit {...props} overrideEditableForRegrading={true} />, ) expect(container.firstChild).toBeInTheDocument() }) it('disables question inputs', () => { // When overrideEditableForRegrading is true, answer inputs should be disabled const {container} = renderWithDnd( <OrderingEdit {...props} overrideEditableForRegrading={true} />, ) expect(container.firstChild).toBeInTheDocument() }) it('disables the calculator option', () => { renderWithDnd(<OrderingEdit {...props} overrideEditableForRegrading={true} />) const calculatorSelect = screen.queryByRole('combobox', { name: /show on-screen calculator/i, }) if (calculatorSelect) { expect(calculatorSelect).toBeDisabled() } }) }) describe('#onStemChange', () => { it('updates itemBody property with changeItemState', () => { // Render through the DnD provider via renderWithDnd // withEditTools injects onDescriptionChange which calls changeItemState({itemBody}) // We test this by rendering the full component and verifying the HOC wiring const localChangeItemState = vi.fn() renderWithDnd(<OrderingEdit {...props} changeItemState={localChangeItemState} />) // withEditTools.handleDescriptionChange calls changeItemState({itemBody}) // It's triggered when the stem changes. Since we can't easily type into the RCE, // verify the wiring is correct by checking the component renders and changeItemState is accessible. // The withEditTools HOC may call changeItemState on mount with errors. expect(localChangeItemState).toBeDefined() }) }) describe('#createChoice', () => { // TestRail ID 3072436 it('calls changeItemState with an additional choice', () => { renderWithDnd(<OrderingEdit {...props} />) const addButton = screen.getByRole('button', {name: /Add Answer/i}) fireEvent.click(addButton) const choicesInNewState = changeItemStateStub.mock.calls[0][0].interactionData.choices const oldChoiceLength = Object.keys(props.interactionData.choices).length const newChoiceLength = Object.keys(choicesInNewState).length expect(newChoiceLength).toBe(oldChoiceLength + 1) vi.advanceTimersByTime(300) // SKIPPED - agriffin 06/25/18 - fix this test // expect(document.activeElement.tagName).toBe('IFRAME') }) }) describe('#handleMoveChoice', () => { it('can swap positions with the choice above', () => { const subject = renderWrappedWithRef() subject.handleMoveChoice(1, 0) expect(changeItemStateStub).toHaveBeenCalledOnce() expect(changeItemStateStub.mock.calls[0][0]).toMatchObject({ scoringData: { value: [secondChoiceID, thirdChoiceID, firstChoiceID, fourthChoiceID], }, }) }) it('can swap positions with the choice below', () => { const subject = renderWrappedWithRef() subject.handleMoveChoice(1, 2) expect(changeItemStateStub).toHaveBeenCalledOnce() expect(changeItemStateStub.mock.calls[0][0]).toMatchObject({ scoringData: { value: [thirdChoiceID, firstChoiceID, secondChoiceID, fourthChoiceID], }, }) }) it('focuses the reorder button when the move is complete', () => { const subject = renderWrappedWithRef() subject.handleMoveChoice(1, 0) vi.advanceTimersByTime(300) expect(document.activeElement.textContent).toContain('Reorder') }) }) describe('#removeChoice', () => { it('calls changeItemState with the updated choices length', () => { renderWithDnd(<OrderingEdit {...props} />) const removeButtons = screen.getAllByRole('button', {name: /Remove Choice/i}) fireEvent.click(removeButtons[0]) const choicesInNewState = changeItemStateStub.mock.calls[0][0].interactionData.choices const oldChoiceLength = Object.keys(props.interactionData.choices).length const newChoiceLength = Object.keys(choicesInNewState).length expect(newChoiceLength).toBe(oldChoiceLength - 1) }) describe('with three choices', () => { it('sets focus to the previous reorder button when the last remove button is clicked', () => { renderWithDnd( <OrderingEdit {...props} scoringData={{ value: [firstChoiceID, secondChoiceID, thirdChoiceID], }} />, ) const removeButtons = screen.getAllByRole('button', {name: /Remove Choice/i}) fireEvent.click(removeButtons[2]) expect(document.activeElement.textContent).toContain('Reorder') }) it('sets focus to the previous reorder button when the second remove button is clicked', () => { renderWithDnd( <OrderingEdit {...props} scoringData={{ value: [firstChoiceID, secondChoiceID, thirdChoiceID], }} />, ) const removeButtons = screen.getAllByRole('button', {name: /Remove Choice/i}) fireEvent.click(removeButtons[1]) expect(document.activeElement.textContent).toContain('Reorder') }) }) describe('with over three choices left', () => { describe('when the last delete button is clicked', () => { it('sets focus to the new previous delete button', () => { renderWithDnd(<OrderingEdit {...props} />) const removeButtons = screen.getAllByRole('button', {name: /Remove Choice/i}) removeButtons[2].focus() fireEvent.click(removeButtons[2]) vi.advanceTimersByTime(400) expect(document.activeElement.getAttribute('name')).toContain(secondChoiceID) }) }) describe('when the first delete button is clicked', () => { it('focuses the top label when rendered', () => { renderWithDnd(<OrderingEdit {...props} />) const removeButtons = screen.getAllByRole('button', {name: /Remove Choice/i}) removeButtons[0].focus() fireEvent.click(removeButtons[0]) expect(document.activeElement.tagName.toLowerCase()).toBe('input') }) /* Make sure to enable this test when we have a way to focus on the RCE. QUIZ-12985 */ it.skip('focuses the stem when labels are not rendered', () => { renderWithDnd(<OrderingEdit {...props} properties={{includeLabels: false}} />) const removeButtons = screen.getAllByRole('button', {name: /Remove Choice/i}) removeButtons[0].focus() fireEvent.click(removeButtons[0]) vi.advanceTimersByTime(300) expect(document.activeElement.tagName.toLowerCase()).toBe('iframe') }) }) }) }) describe('#toggleOptions', () => { it('toggles includeLabels property with changeItemState', () => { renderWithDnd( <OrderingEdit {...props} properties={{ displayAnswersParagraph: true, includeLabels: false, topLabel: 'Taller', bottomLabel: 'Shorter', }} />, ) const checkbox = screen.getByRole('checkbox', {name: /Include Labels/i}) fireEvent.click(checkbox) const newItem = changeItemStateStub.mock.calls[0][0] expect(newItem.properties.includeLabels).toBe(true) expect(notifyScreenreaderStub).toHaveBeenCalledOnce() expect(notifyScreenreaderStub).toHaveBeenCalledWith('Navigate up to find label fields') }) it('toggles displayAnswersParagraph property with changeItemState', () => { renderWithDnd(<OrderingEdit {...props} />) const checkbox = screen.getByRole('checkbox', {name: /Display Answers in a Paragraph/i}) fireEvent.click(checkbox) const newItem = changeItemStateStub.mock.calls[0][0] expect(newItem.properties.displayAnswersParagraph).toBe( !props.properties.displayAnswersParagraph, ) }) it('does not show label inputs when includeLabels is not checked', () => { const {container} = renderWithDnd( <OrderingEdit {...props} properties={{ includeLabels: false, }} />, ) const topLabel = container.querySelectorAll(topLabelSelector) const bottomLabel = container.querySelectorAll(bottomLabelSelector) expect(bottomLabel).toHaveLength(0) expect(topLabel).toHaveLength(0) }) it('shows label inputs when includeLabels is checked', () => { const {container} = renderWithDnd( <OrderingEdit {...props} properties={{ includeLabels: true, }} />, ) const topLabel = container.querySelectorAll(topLabelSelector) const bottomLabel = container.querySelectorAll(bottomLabelSelector) expect(bottomLabel).toHaveLength(1) expect(topLabel).toHaveLength(1) }) }) describe('#onLabelChange', () => { it('updates topLabel property with changeItemState', () => { renderWithDnd(<OrderingEdit {...props} />) const topLabelInput = screen.getByRole('textbox', {name: /top label/i}) const newLabelValue = 'New top label' userEvent.clear(topLabelInput) userEvent.type(topLabelInput, newLabelValue) const lastCall = changeItemStateStub.mock.lastCall[0].properties.topLabel expect(lastCall).toBe(newLabelValue) }) it('updates bottomLabel property with changeItemState', () => { renderWithDnd(<OrderingEdit {...props} />) const bottomLabelInput = screen.getByRole('textbox', {name: /bottom label/i}) const newLabelValue = 'New bottom label' userEvent.clear(bottomLabelInput) userEvent.type(bottomLabelInput, newLabelValue) const lastCall = changeItemStateStub.mock.lastCall[0].properties.bottomLabel expect(lastCall).toBe(newLabelValue) }) }) describe('#updateChoices', () => { it('updates choice label', () => { const subject = renderWrappedWithRef() const newLabelValue = 'New choice' subject.handleInputChange(firstChoiceID, null, {editorContent: newLabelValue}) expect( changeItemStateStub.mock.calls[0][0].interactionData.choices[firstChoiceID].itemBody, ).toBe(newLabelValue) }) }) }) describe('a11y tests', () => { it('should meet a11y standards', async () => { const {container} = renderWithDnd(<OrderingEdit {...props} />) // frame-title-unique is because each RCE iframe has the same default title // (ok for now, would be nice to fix later) const result = await runAxeCheck(container, { ignores: ['frame-title-unique'], }) expect(result).toBe(true) }) }) describe('calculator per question option', () => { it('does not render the calculator checkbox if showCalculatorOption is false', () => { renderWithDnd(<OrderingEdit {...props} showCalculatorOption={false} />) expect(screen.queryByRole('checkbox', {name: /show on-screen calculator/i})).toBeNull() }) it('renders the calculator per question option', () => { renderWithDnd(<OrderingEdit {...props} />) expect(screen.getByText(/Show on-screen calculator/i)).toBeInTheDocument() }) it('calls the correct callback function with the correct data when the calculator type is changed', () => { const value = 'basic' const subject = renderWrappedWithRef() subject.handleCalculatorTypeChange(null, value) expect(changeItemStateStub).toHaveBeenCalledWith( expect.objectContaining({ calculatorType: value, }), ) }) it('calls the correct callback function with the correct data when OQAAT is changed', () => { // The CalculatorOptionWithOqaatAlert receives onOqaatChange={this.props.setOneQuestionAtATime} // Render the wrapped component and verify the prop is wired correctly const subject = renderWrappedWithRef() subject.props.setOneQuestionAtATime(true) expect(setOneQuestionAtATimeStub).toHaveBeenCalledWith(true) }) }) })