@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
474 lines (413 loc) • 16.4 kB
JavaScript
import React from 'react'
import {render, screen, fireEvent} from '@testing-library/react'
import {vi} from 'vitest'
import runAxeCheck from '@instructure/ui-axe-check'
import {v4 as uuid} from 'uuid'
import {
verifyAtLeastOneRichContentEditorExists,
verifyNoRichContentEditorsExist,
} from '../../../../../tests/util/enableRichContentEditorChecks'
import {verifyItemChangesAreValidated} from '../../../../../tests/util/shouldValidateItemChanges'
import FillBlankEdit from '../index'
const changeItemStateStub = vi.fn()
const notifyScreenreaderStub = vi.fn()
const setOneQuestionAtATimeStub = vi.fn()
const props = {
notifyScreenreader: vi.fn(),
changeItemState: changeItemStateStub,
openImportModal() {},
setOneQuestionAtATime: setOneQuestionAtATimeStub,
interactionData: {
stemItems: [
{id: uuid(), position: 1, type: 'text', value: 'Columbus sailed in '},
{id: uuid(), position: 2, type: 'blank', blankId: 'blank1'},
{id: uuid(), position: 3, type: 'text', value: ' and found '},
{id: uuid(), position: 4, type: 'blank', blankId: 'blank2'},
{id: uuid(), position: 5, type: 'text', value: '.'},
],
blanks: [
{
id: 'blank1',
answerType: 'openEntry',
},
{
id: 'blank2',
answerType: 'dropdown',
choices: [
{id: 'choice1', itemBody: 'America', position: 1},
{id: 'choice2', itemBody: 'Asia', position: 2},
{id: 'choice3', itemBody: 'Africa', position: 3},
],
},
],
},
scoringData: {
value: [
{
id: 'blank1',
scoringAlgorithm: 'TextRegex',
scoringData: {
value: '.*1492',
blankText: '1492',
},
},
{
id: 'blank2',
scoringAlgorithm: 'Equivalence',
scoringData: {
blankText: 'America',
value: 'choice1',
},
},
],
},
}
describe('Fill in the Blank Item Edit', () => {
afterEach(() => {
changeItemStateStub.mockClear()
notifyScreenreaderStub.mockClear()
})
it('includes validation errors in changeItemState', async () => {
await verifyItemChangesAreValidated(FillBlankEdit, props)
})
it('renders RichContentInput by default', () => {
verifyAtLeastOneRichContentEditorExists(FillBlankEdit, props)
})
it('does not render RichContentInput when enableRichContentEditor is false', () => {
verifyNoRichContentEditorsExist(FillBlankEdit, {...props, enableRichContentEditor: false})
})
describe('QuestionConfigContainer', () => {
it('renders an QuestionSettingsContainer', () => {
render(<FillBlankEdit {...props} />)
expect(screen.getAllByTestId('question-settings-container')).toHaveLength(1)
})
})
describe('overrideEditableForRegrading', () => {
it('Does not render remove choice buttons anywhere', () => {
render(<FillBlankEdit {...props} overrideEditableForRegrading={true} />)
const removeButtons = document.querySelectorAll('[data-automation*="sdk-remove-distractor"]')
expect(removeButtons).toHaveLength(0)
})
it('disables the calculator option', () => {
render(<FillBlankEdit {...props} overrideEditableForRegrading={true} />)
// The "Show on-screen calculator" checkbox should be disabled
const checkbox = document.querySelector(
'[data-automation="sdk-show-on-screen-calculator-checkbox"]',
)
expect(checkbox).toBeInTheDocument()
expect(checkbox.closest('input') || checkbox.querySelector('input')).toBeInTheDocument()
const input =
checkbox.tagName === 'INPUT'
? checkbox
: checkbox.closest('input') || checkbox.querySelector('input')
expect(input.disabled).toBe(true)
})
it('Removes the add distractor, question, category buttons', () => {
render(<FillBlankEdit {...props} overrideEditableForRegrading={true} />)
const addButtons = document.querySelectorAll('[data-automation*="sdk-add-answer"]')
expect(addButtons).toHaveLength(0)
})
it('Makes blanks non-dismissible', () => {
render(<FillBlankEdit {...props} overrideEditableForRegrading={true} />)
// When Tags are not dismissible, they don't have the dismiss icon SVG
// Tags with dismissible=true render as buttons; without they render as spans
const tagButtons = document.querySelectorAll('button[class*="tag"]')
expect(tagButtons).toHaveLength(0)
})
it('Disables the content editable', () => {
render(<FillBlankEdit {...props} overrideEditableForRegrading={true} />)
const contentEditables = document.querySelectorAll('span[contenteditable]')
contentEditables.forEach(el => {
expect(el.getAttribute('contenteditable')).toBe('false')
})
})
})
describe('#onStemChange', () => {
it("makes change to a stemItem's itemBody", () => {
render(<FillBlankEdit {...props} changeItemState={changeItemStateStub} />)
// Clear any calls from initial render (validation etc.)
changeItemStateStub.mockClear()
const contentEditable = document.querySelectorAll('span[contenteditable="true"]')[0]
// onStemChange reads e.target.innerText - set the innerText on the element itself
contentEditable.innerText = 'Jean-Claude sailed in '
// ContentEditable fires onChange on blur (handleBlur calls props.onChange(e))
fireEvent.blur(contentEditable)
const lastCall = changeItemStateStub.mock.lastCall[0]
expect(lastCall.itemBody).toBe('Jean-Claude sailed in _____ and found _____.')
})
})
describe('#onPaste', () => {
it("makes change to a stemItem's itemBody", () => {
render(<FillBlankEdit {...props} changeItemState={changeItemStateStub} />)
window.getSelection().removeAllRanges()
// Clear any calls from initial render
changeItemStateStub.mockClear()
const contentEditable = document.querySelectorAll('span[contenteditable="true"]')[0]
// The paste handler inserts text via insertTextAtCursor, then onStemChange reads e.target.innerText.
// In jsdom, insertTextAtCursor may not reliably update innerText, so we set it explicitly.
contentEditable.innerText = 'Jean-Claude sailed in '
fireEvent.paste(contentEditable, {
clipboardData: {getData: () => 'Jean-Claude sailed in '},
})
const lastCall = changeItemStateStub.mock.lastCall[0]
expect(lastCall.itemBody).toBe('Jean-Claude sailed in _____ and found _____.')
})
})
describe('#createBlank', () => {
const pause = timeoutLength => {
let _resolve
const p = new Promise((resolve, reject) => {
_resolve = resolve
})
setTimeout(() => {
_resolve()
}, timeoutLength)
return p
}
const withNonEmptySelection = async (cb, notifiesSR = true) => {
render(
<FillBlankEdit
{...props}
changeItemState={changeItemStateStub}
notifyScreenreader={notifyScreenreaderStub}
/>,
)
// select text to make blank with
const range = document.createRange()
const contentEditable = document.querySelector('span[contenteditable="true"]')
// The content is set as a single text node via componentDidMount
const textNode = contentEditable.childNodes[0]
if (textNode) {
range.selectNodeContents(textNode)
}
const selection = window.getSelection()
selection.removeAllRanges()
selection.addRange(range)
await pause(500)
await cb()
// Blur event above also calls changeItemState
const newStateData = changeItemStateStub.mock.lastCall[0]
const blanks = newStateData.interactionData.blanks
const stemItems = newStateData.interactionData.stemItems
expect(blanks.length).toBe(props.interactionData.blanks.length + 1)
expect(stemItems.length).toBe(props.interactionData.stemItems.length + 2)
expect(notifyScreenreaderStub.mock.calls.length > 0).toBe(notifiesSR)
await pause(200)
expect(window.getSelection().focusNode).toBeNull()
}
const withEmptySelection = async (cb, notifiesSR = true) => {
render(
<FillBlankEdit
{...props}
changeItemState={changeItemStateStub}
notifyScreenreader={notifyScreenreaderStub}
/>,
)
// select text to make blank with
const range = document.createRange()
const contentEditable = document.querySelector('span[contenteditable="true"]')
const textNode = contentEditable.childNodes[0]
if (textNode) {
range.selectNodeContents(textNode)
}
const selection = window.getSelection()
selection.removeAllRanges()
selection.addRange(range)
selection.collapseToEnd()
// Try to select a whitespace character
try {
selection.extend(selection.anchorNode.childNodes[0], 18)
} catch {
// In jsdom the selection may not work perfectly; select a space range manually
const newRange = document.createRange()
if (textNode) {
const text = textNode.textContent || ''
const spaceIdx = text.indexOf(' ')
if (spaceIdx >= 0) {
newRange.setStart(textNode, spaceIdx)
newRange.setEnd(textNode, spaceIdx + 1)
selection.removeAllRanges()
selection.addRange(newRange)
}
}
}
expect(selection.toString().trim()).toBe('')
await pause(200)
const callCountBefore = changeItemStateStub.mock.calls.length
cb()
expect(changeItemStateStub.mock.calls.length).toBe(callCountBefore)
expect(notifyScreenreaderStub.mock.calls.length > 0).toBe(notifiesSR)
}
// TODO: QUIZ-17438 - Skipped: jsdom doesn't properly support text selection ranges,
// so the "Create Blank Space" button never appears in the DOM. The same behavior
// is verified via hotkey press tests below which do pass.
it.skip('creates a new blank in the interactionData', async () => {
await withNonEmptySelection(() => {
const createBlankBtn = Array.from(document.querySelectorAll('button')).find(button =>
button.textContent.includes('Create Blank Space'),
)
expect(createBlankBtn).toBeDefined()
createBlankBtn.click()
}, false)
})
it('creates a new blank in response to hotkey press', async () => {
await withNonEmptySelection(() => {
const span = document.querySelectorAll('span[contenteditable="true"]')[0]
fireEvent.keyPress(span, {
key: 'Enter',
code: 'Enter',
keyCode: 13,
charCode: 13,
})
})
})
it('creates a new blank in response to VO hotkey press', async () => {
await withNonEmptySelection(() => {
const span = document.querySelectorAll('span[contenteditable="true"]')[0]
fireEvent.keyPress(span, {
key: 'Space',
code: 'Space',
keyCode: 32,
charCode: 32,
which: 32,
ctrlKey: true,
altKey: true,
})
})
})
it('does not allow whitespace to be a blank', async () => {
await withEmptySelection(() => {
const buttons = Array.from(document.querySelectorAll('button'))
const createBlankBtn = buttons.find(button => button.innerText === 'Create Blank Space')
expect(createBlankBtn).toBeUndefined()
}, false)
})
it('does not allow whitespace to be blank for hotkey presses', async () => {
await withEmptySelection(() => {
const span = document.querySelectorAll('span[contenteditable="true"]')[0]
fireEvent.keyPress(span, {
key: 'Enter',
code: 'Enter',
keyCode: 13,
charCode: 13,
})
})
})
it('does not allow whitespace to be blank for VO hotkey presses', async () => {
await withEmptySelection(() => {
const span = document.querySelectorAll('span[contenteditable="true"]')[0]
fireEvent.keyPress(span, {
key: 'Space',
code: 'Space',
keyCode: 32,
charCode: 32,
ctrlKey: true,
altKey: true,
})
})
})
})
describe('#destroyBlank', () => {
it('removes the blanks from the interactionData and stem items', () => {
render(<FillBlankEdit {...props} changeItemState={changeItemStateStub} />)
// Tags render as inline elements with dismiss buttons. Find the first tag's dismiss button.
const tagButtons = screen.getAllByRole('button').filter(b => {
return b.textContent.includes('1492') || b.textContent.includes('Delete')
})
// Click the first blank tag to dismiss it
tagButtons[0].click()
const newStateData = changeItemStateStub.mock.lastCall[0]
const blanks = newStateData.interactionData.blanks
const stemItems = newStateData.interactionData.stemItems
expect(blanks.length).toBe(1)
expect(stemItems.length).toBe(3)
})
})
describe('#renderBlanksOptions', () => {
it('renders a `BlankTypeSelect` component', () => {
render(<FillBlankEdit {...props} />)
// BlankTypeSelect renders a SimpleSelect with "Blank N" labels
const blankLabels = screen.getAllByText(/^Blank \d+$/)
expect(blankLabels.length).toBe(props.interactionData.blanks.length)
})
it('passes errors to `BlankTypeSelect` when they are present', () => {
const modifiedProps = {
...props,
scoringData: {
...props.scoringData,
value: [
props.scoringData.value[0],
{
...props.scoringData.value[1],
scoringData: {
...props.scoringData.value[1].scoringData,
value: null,
},
},
],
},
errorsAreShowing: true,
errors: {
scoringData: {
value: {
1: {
scoringData: {
value: {
$errors: ['Correct value cannot be blank'],
},
},
},
},
},
},
}
render(<FillBlankEdit {...modifiedProps} />)
expect(screen.getByText('Correct value cannot be blank')).toBeInTheDocument()
})
})
describe('a11y tests', () => {
it('should meet a11y standards', async () => {
render(<FillBlankEdit {...props} />)
expect(
await runAxeCheck(document.body, {
ignores: [
'aria-allowed-role', // TODO: remove this when instui fixes Select
'radiogroup',
'region',
],
}),
).toBe(true)
})
})
describe('calculator per question option', () => {
it('does not render the options if showCalculatorOption is false', () => {
render(<FillBlankEdit {...props} showCalculatorOption={false} />)
expect(screen.queryByRole('button', {name: /options/i})).toBeNull()
})
it('renders the calculator per question option', () => {
render(<FillBlankEdit {...props} />)
// CalculatorOption renders a "Show on-screen calculator" checkbox
const checkbox = document.querySelector(
'[data-automation="sdk-show-on-screen-calculator-checkbox"]',
)
expect(checkbox).toBeInTheDocument()
})
it('calls the correct callback function with the correct data when the calculator type is changed', () => {
render(<FillBlankEdit {...props} />)
// Clear any calls from initial render
changeItemStateStub.mockClear()
// First enable the calculator by checking the checkbox
const checkbox = screen.getByLabelText(/show on-screen calculator/i)
fireEvent.click(checkbox)
const lastCall = changeItemStateStub.mock.lastCall[0]
expect(lastCall.calculatorType).toBe('basic')
})
it('calls the correct callback function with the correct data when OQAAT is changed', () => {
// Render with calculator already enabled so OqaatAlert is visible
render(<FillBlankEdit {...props} calculatorType="basic" />)
const oqaatCheckbox = screen.getByLabelText(/enable one question at a time/i)
fireEvent.click(oqaatCheckbox)
expect(setOneQuestionAtATimeStub).toHaveBeenCalled()
})
})
})