@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
496 lines (425 loc) • 17.9 kB
JavaScript
import React from 'react'
import {vi} from 'vitest'
import runAxeCheck from '@instructure/ui-axe-check'
import {verifyItemChangesAreValidated} from '../../../../../tests/util/shouldValidateItemChanges'
import {
verifyAtLeastOneRichContentEditorExists,
verifyNoRichContentEditorsExist,
} from '../../../../../tests/util/enableRichContentEditorChecks'
import CategorizationEdit from '../index'
import {fireEvent, render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
const uuid1 = 'uuid1'
const uuid2 = 'uuid2'
const uuid3 = 'uuid3'
const uuid4 = 'uuid4'
const uuid5 = 'uuid5'
const uuid6 = 'uuid6'
const changeItemStateStub = vi.fn()
const setOneQuestionAtATimeStub = vi.fn()
const props = {
itemBody: 'Match the name of the celestial body on the left with its correct classification:',
interactionData: {
categoryOrder: [uuid1, uuid4],
categories: {
[uuid1]: {id: uuid1, itemBody: 'Category 1'},
[uuid4]: {id: uuid4, itemBody: 'Category 2'},
},
distractors: {
[uuid2]: {id: uuid2, itemBody: 'Answer 1'},
[uuid5]: {id: uuid5, itemBody: 'Answer 2'},
[uuid6]: {id: uuid6, itemBody: 'Answer 3'},
[uuid3]: {id: uuid3, itemBody: 'Distractor 1'},
},
},
scoringData: {
value: [
{
id: uuid1,
scoringAlgorithm: 'AllOrNothing',
scoringData: {
value: [uuid2],
},
},
{
id: uuid4,
scoringAlgorithm: 'AllOrNothing',
scoringData: {
value: [uuid5, uuid6],
},
},
],
},
changeItemState: changeItemStateStub,
setOneQuestionAtATime: setOneQuestionAtATimeStub,
openImportModal: () => {},
notifyScreenreader: () => {},
}
describe('Categorization Edit', () => {
afterEach(() => {
changeItemStateStub.mockClear()
setOneQuestionAtATimeStub.mockClear()
})
it('includes validation errors in changeItemState', async () => {
await verifyItemChangesAreValidated(CategorizationEdit, {
openImportModal: props.openImportModal,
notifyScreenreader: props.notifyScreenreader,
})
})
it('renders RichContentInput by default', () => {
verifyAtLeastOneRichContentEditorExists(CategorizationEdit, props)
})
it('does not render RichContentInput when enableRichContentEditor is false', () => {
verifyNoRichContentEditorsExist(CategorizationEdit, {...props, enableRichContentEditor: false})
})
describe('rendering', () => {
describe('QuestionContainer', () => {
it('renders a question container', () => {
const {container} = render(<CategorizationEdit {...props} />)
const items = container.querySelectorAll('[data-automation="sdk-question-stem"]')
expect(items.length).toBe(1)
})
it('calls changeItemState with the correct arguments', () => {
// Use enableRichContentEditor: false so we get a plain text textarea
const {container} = render(
<CategorizationEdit
{...props}
enableRichContentEditor={false}
changeItemState={changeItemStateStub}
/>,
)
const textarea = container.querySelector('[data-automation="sdk-question-stem"] textarea')
fireEvent.change(textarea, {target: {value: 'new stem val'}})
const newBody = changeItemStateStub.mock.calls[0][0].itemBody
expect(newBody).toBe('new stem val')
})
})
describe('overrideEditableForRegrading', () => {
it('does not render remove choice buttons anywhere', () => {
const {container} = render(
<CategorizationEdit {...props} overrideEditableForRegrading={true} />,
)
const removeButtons = container.querySelectorAll('[data-automation^="sdk-remove-"]')
expect(removeButtons.length).toBe(0)
})
it('disables question stem input', () => {
const {container} = render(
<CategorizationEdit {...props} overrideEditableForRegrading={true} />,
)
const stemContainer = container.querySelector('[data-automation="sdk-question-stem"]')
expect(stemContainer).toBeDefined()
})
it('disables the calculator option', () => {
render(<CategorizationEdit {...props} overrideEditableForRegrading={true} />)
const checkbox = screen.getByRole('checkbox', {name: /Show on-screen calculator/i})
expect(checkbox).toBeDisabled()
})
it('Removes the add distractor, question, category buttons', () => {
render(<CategorizationEdit {...props} overrideEditableForRegrading={true} />)
expect(screen.queryByRole('button', {name: /Add Distractor/i})).toBeNull()
expect(screen.queryByRole('button', {name: /Add Category/i})).toBeNull()
})
it('disables lock choice button', () => {
const {container} = render(
<CategorizationEdit {...props} overrideEditableForRegrading={true} />,
)
// When overrideEditableForRegrading is true, ChoiceInput gets disabledFields=['answerInput']
// All answer inputs should be disabled
const answerInputs = container.querySelectorAll(
'input[data-automation^="sdk-category-"][type="text"]',
)
answerInputs.forEach(input => {
expect(input).toBeDisabled()
})
})
})
describe('calculator per question option', () => {
it('does not renders the options if showCalculatorOption is false', () => {
render(<CategorizationEdit {...props} showCalculatorOption={false} />)
expect(screen.queryByRole('button', {name: /options/i})).toBeNull()
})
it('renders the calculator per question option', () => {
render(<CategorizationEdit {...props} />)
expect(screen.getByRole('checkbox', {name: /Show on-screen calculator/i})).toBeDefined()
})
it('calls the correct callback function with the correct data when the calculator type is changed', () => {
render(<CategorizationEdit {...props} />)
const checkbox = screen.getByRole('checkbox', {name: /Show on-screen calculator/i})
fireEvent.click(checkbox)
expect(changeItemStateStub).toHaveBeenCalledWith(
expect.objectContaining({
calculatorType: 'basic',
}),
)
})
it('calls the correct callback function with the correct data when OQAAT is changed', () => {
// Need to render with calculator already enabled so OQAAT checkbox is visible
render(<CategorizationEdit {...props} calculatorType="basic" />)
const oqaatCheckbox = screen.getByRole('checkbox', {
name: /Enable One Question at a Time/i,
})
fireEvent.click(oqaatCheckbox)
expect(setOneQuestionAtATimeStub).toHaveBeenCalled()
})
})
describe('Category', () => {
it('renders a category component', () => {
render(<CategorizationEdit {...props} />)
// Verify CategoryForm is rendered by checking for category elements
expect(screen.getByText('Categories')).toBeDefined()
expect(screen.getByText('Category 1')).toBeDefined()
expect(screen.getByText('Category 2')).toBeDefined()
})
it('updates categories when interaction data changes', () => {
const updatedInteractionData = {
...props.interactionData,
categoryOrder: [uuid1],
categories: {
[uuid1]: props.interactionData.categories[uuid1],
},
}
const updatedScoringData = {
value: props.scoringData.value.filter(s => s.id === uuid1),
}
// Render with updated data directly
render(
<CategorizationEdit
{...props}
interactionData={updatedInteractionData}
scoringData={updatedScoringData}
/>,
)
// Verify only Category 1 is rendered
expect(screen.getByText('Category 1')).toBeDefined()
expect(screen.queryByText('Category 2')).toBeNull()
})
it('updates categories when order changes', () => {
const updatedInteractionData = {
...props.interactionData,
categoryOrder: [uuid4, uuid1],
}
// Render with updated category order
render(<CategorizationEdit {...props} interactionData={updatedInteractionData} />)
// Verify both categories are rendered
expect(screen.getByText('Category 1')).toBeDefined()
expect(screen.getByText('Category 2')).toBeDefined()
})
it('displays new distractor errors', () => {
const {rerender} = render(<CategorizationEdit {...props} errorsAreShowing={true} />)
rerender(
<CategorizationEdit
{...props}
errors={{
interactionData: {
distractors: {
[uuid2]: {itemBody: ['Nope']},
},
},
}}
errorsAreShowing={true}
/>,
)
expect(document.body.textContent).toContain('Nope')
})
describe('#onRemoveCategory', () => {
it('focuses the previous categorys +Answer button', () => {
render(<CategorizationEdit {...props} />)
const removeButton = screen.getByRole('button', {name: /Remove Category 2/i})
const expectedFocusedButton = screen.getByRole('button', {
name: /Add answer to category 1/i,
})
userEvent.click(removeButton)
expect(document.activeElement).toBe(expectedFocusedButton)
})
})
})
describe('Answer', () => {
describe('#onRemoveAnswer', () => {
it('focuses the previous answers trash icon/button', () => {
render(<CategorizationEdit {...props} />)
const removeAnswerButton = screen.getByRole('button', {
name: /Remove Distractor: Answer 3/i,
})
const previousDistractorRemoveButton = screen.getByRole('button', {
name: /Remove Distractor: Answer 2/i,
})
userEvent.click(removeAnswerButton)
expect(document.activeElement).toBe(previousDistractorRemoveButton)
})
})
})
describe('Distractor', () => {
it('renders a distractor container with distractors', () => {
render(<CategorizationEdit {...props} />)
expect(screen.queryByText('Additional Distractors')).not.toBeNull()
expect(screen.queryAllByRole('textbox', {name: /Answer Value/}).length).toBe(3)
expect(screen.queryByRole('button', {name: /Add Distractor/})).not.toBeNull()
})
})
})
describe('#Distractor', () => {
describe('#onCreateDistractor', () => {
it('calls changeItemState with the correct arguments', () => {
render(<CategorizationEdit {...props} />)
const button = screen.getByRole('button', {name: /Add Distractor/i})
userEvent.click(button)
const newStateDistractors = changeItemStateStub.mock.calls[0][0].interactionData.distractors
expect(Object.values(newStateDistractors).length).toBe(5)
})
})
describe('#onRemoveDistractor', () => {
it('calls changeItemState with the correct arguments', () => {
render(<CategorizationEdit {...props} />)
const button = screen.getByRole('button', {name: /Remove Distractor: Distractor 1/i})
userEvent.click(button)
const newDistractors = changeItemStateStub.mock.calls[0][0].interactionData.distractors
expect(newDistractors[uuid3]).toBeUndefined()
})
})
describe('#onDistractorInputChange', () => {
it('calls changeItemState with the correct arguments', () => {
render(<CategorizationEdit {...props} />)
const newInputValue = 'Distractor 2'
const input = screen.getByRole('textbox', {name: /Distractor 1/})
fireEvent.change(input, {target: {value: newInputValue}})
const updatedInputValue =
changeItemStateStub.mock.calls[0][0].interactionData.distractors[uuid3].itemBody
expect(updatedInputValue).toBe(newInputValue)
})
})
describe('grading options', () => {
it('does not render grading options when partialDeepScoringEnabled is false', () => {
render(<CategorizationEdit {...props} partialDeepScoringEnabled={false} />)
expect(screen.queryByText('Grading')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Partial credit')).not.toBeInTheDocument()
expect(screen.queryByLabelText('Exact match')).not.toBeInTheDocument()
})
it('renders grading options when partialDeepScoringEnabled is true', () => {
render(
<CategorizationEdit
{...props}
partialDeepScoringEnabled={true}
scoringAlgorithm="Categorization"
/>,
)
// expect(screen.getByText('Grading')).to.exist()
expect(screen.getByLabelText('Partial credit')).to.toBeInTheDocument()
expect(screen.getByLabelText('Exact match')).to.toBeInTheDocument()
})
it('calls changeItemState when grading option is changed to partial credit', () => {
render(
<CategorizationEdit
{...props}
partialDeepScoringEnabled={true}
scoringAlgorithm="Categorization"
/>,
)
const partialCreditRadio = screen.getByLabelText('Partial credit')
partialCreditRadio.click()
expect(changeItemStateStub).toHaveBeenCalledWith({
scoringData: {
...props.scoringData,
scoreMethod: 'partial_credit',
},
})
})
it('calls changeItemState when grading option is changed to exact match', () => {
const partialCreditProps = {
...props,
scoringData: {
...props.scoringData,
scoreMethod: 'partial_credit',
},
}
render(
<CategorizationEdit
{...partialCreditProps}
partialDeepScoringEnabled={true}
scoringAlgorithm="Categorization"
/>,
)
const exactMatchRadio = screen.getByLabelText('Exact match')
exactMatchRadio.click()
expect(changeItemStateStub).toHaveBeenCalledWith({
scoringData: {
...partialCreditProps.scoringData,
scoreMethod: 'all_or_nothing',
},
})
})
it('opens grading information modal when info button is clicked', () => {
render(
<CategorizationEdit
{...props}
partialDeepScoringEnabled={true}
scoringAlgorithm="Categorization"
/>,
)
const infoButton = screen.getByRole('button', {name: 'Open grading option information'})
infoButton.click()
expect(
screen.getByText(
'Students earn points for each correct decision: placing correct items in the right category AND leaving distractors uncategorized. Each decision is worth an equal portion of the total points.',
),
).toBeInTheDocument()
expect(
screen.getByText(
'Students are awarded full credit if all correct items are categorized correctly and all distractors are left uncategorized.',
),
).toBeInTheDocument()
})
})
})
describe('Helpers', () => {
describe('#categoryScoringData', () => {
it('returns the right scoring data values given a categoryId', () => {
const {container} = render(<CategorizationEdit {...props} />)
// Each category renders in a div.categoryContainer
const categoryContainers = container.querySelectorAll('.categoryContainer')
expect(categoryContainers.length).toBe(2)
// Category 1 should contain Answer 1 (uuid2)
const cat1Inputs = categoryContainers[0].querySelectorAll('input[type="text"]')
const cat1Values = Array.from(cat1Inputs).map(input => input.value)
expect(cat1Values).toContain('Answer 1')
expect(cat1Values).not.toContain('Answer 2')
expect(cat1Values).not.toContain('Answer 3')
// Category 2 should contain Answer 2 (uuid5) and Answer 3 (uuid6)
const cat2Inputs = categoryContainers[1].querySelectorAll('input[type="text"]')
const cat2Values = Array.from(cat2Inputs).map(input => input.value)
expect(cat2Values).toContain('Answer 2')
expect(cat2Values).toContain('Answer 3')
expect(cat2Values).not.toContain('Answer 1')
})
})
describe('#onCategoryInputChange', () => {
it('calls onCategoryInputChange with the correct arguments', () => {
render(<CategorizationEdit {...props} />)
const newInputValue = 'Category 2'
const textbox = screen.getAllByRole('textbox', {name: /Category Description/i})[0]
fireEvent.change(textbox, {target: {value: newInputValue}})
expect(changeItemStateStub.mock.lastCall[0].interactionData.categories.uuid1.itemBody).toBe(
newInputValue,
)
})
})
describe('#onAnswerInputChange', () => {
it('calls onAnswerInputChange with the correct arguments', () => {
render(<CategorizationEdit {...props} />)
const newInputValue = 'Answer 2'
const input = screen.getAllByRole('textbox').find(input => {
return input.value === 'Answer 1'
})
fireEvent.change(input, {target: {value: newInputValue}})
expect(
changeItemStateStub.mock.lastCall[0].interactionData.distractors.uuid2.itemBody,
).toBe(newInputValue)
})
})
})
describe('a11y tests', () => {
it('should meet a11y standards', async () => {
render(<CategorizationEdit {...props} />)
expect(await runAxeCheck(document.body, {ignores: ['region']})).toBe(true)
})
})
})