UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

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