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