@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
680 lines (570 loc) • 25.6 kB
JavaScript
import {vi} from 'vitest'
import React from 'react'
import {screen, render, waitFor} from '../../../../../tests/util/rtlRenderOverride'
import userEvent from '@testing-library/user-event'
import {default as RichFillBlankEdit} from '../index'
/*
User Stories to cover:
1. As a user, if type `example`in the rce I should see a new blank created, with all the option below.
2. As user, I should be able to change the answertype for an existing blank.
3. As a user, if I put a new blank between two previous one I should see all the previous blanks unchanged.
4. As a user, if I remove a blank from the rce I should see the blank removed from the list of blanks.
5. As a user, if I remove a blank from the rce and then add it back
I should see the previous configuration for that blank.
6. As a user if I remove a blank from the rce and click save, when I go
back to editing mode and I add back the same blank, I should not see
the previous configuration for that blank.
7. As a user, if I change the content in the rce, the options should not reset.
*/
function createProps(changeItemStateStub, setOneQuestionAtATimeStub) {
return {
notifyScreenreader: vi.fn(),
changeItemState: changeItemStateStub,
openImportModal() {},
setOneQuestionAtATime: setOneQuestionAtATimeStub,
interactionData: {
blanks: [
{
id: 'blank1',
answerType: 'openEntry',
},
{
id: 'blank2',
answerType: 'dropdown',
choices: [
{id: 'choice2', itemBody: '1942'},
{id: 'choice1', itemBody: '1492'},
{id: 'choice3', itemBody: '1429'},
],
},
{
id: 'blank3',
answerType: 'wordbank',
},
],
wordBankChoices: [
{id: 'wordbankchoice2', itemBody: 'Canada'},
{id: 'wordbankchoice1', itemBody: 'America'},
{id: 'wordbankchoice3', itemBody: 'Mexico'},
],
},
interactionType: {
validBlankRegex: '`[^<>`]*`',
},
scoringData: {
workingItemBody: '<p>`Christopher` Columbus sailed in `1492` and found `America`.<p>',
value: [
{
id: 'blank1',
scoringAlgorithm: 'TextRegex',
scoringData: {
value: 'Chris(topher)?',
blankText: 'Christopher',
},
},
{
id: 'blank2',
scoringAlgorithm: 'Equivalence',
scoringData: {
value: 'choice1',
blankText: '1492',
},
},
{
id: 'blank3',
scoringAlgorithm: 'TextEquivalence',
scoringData: {
value: 'America',
blankText: 'America',
choiceId: 'wordbankchoice1',
},
},
],
},
}
}
describe('RichFillBlankEdit', () => {
let changeItemStateStub
let setOneQuestionAtATimeStub
beforeEach(() => {
changeItemStateStub = vi.fn()
setOneQuestionAtATimeStub = vi.fn()
})
afterEach(() => {
changeItemStateStub.mockClear()
setOneQuestionAtATimeStub.mockClear()
})
describe('Creating a new blank in the RCE', () => {
it('should create a new blank with default options when using `` in the RCE', async () => {
const props = createProps(changeItemStateStub, setOneQuestionAtATimeStub)
const {rerender} = render(<RichFillBlankEdit {...props} />)
// Verify initial state - should have 3 existing blanks
expect(screen.getByText('Blank 1')).toBeInTheDocument()
expect(screen.getByText('Blank 2')).toBeInTheDocument()
expect(screen.getByText('Blank 3')).toBeInTheDocument()
// Verify existing blank configurations
const answerTypeSelects = screen.getAllByRole('combobox')
// Verify blank 1 (openEntry)
expect(answerTypeSelects[0]).toHaveProperty('value', 'Open Entry')
expect(screen.getByDisplayValue('Chris(topher)?')).toBeInTheDocument()
// Verify blank 2 (dropdown)
expect(answerTypeSelects[1]).toHaveProperty('value', 'Regular Expression Match')
expect(answerTypeSelects[2]).toHaveProperty('value', 'Dropdown')
expect(screen.getByDisplayValue('1942')).toBeInTheDocument()
expect(screen.getByDisplayValue('1492')).toBeInTheDocument()
expect(screen.getByDisplayValue('1429')).toBeInTheDocument()
// Verify blank 3 (wordbank)
expect(answerTypeSelects[3]).toHaveProperty('value', 'Word Bank')
expect(screen.getByDisplayValue('America')).toBeInTheDocument()
expect(screen.getByDisplayValue('Canada')).toBeInTheDocument()
expect(screen.getByDisplayValue('Mexico')).toBeInTheDocument()
changeItemStateStub.mockClear()
const newContent = '`Christopher` Columbus sailed in `1492` and found `America` in `October`.'
await screen.fillRceByLabel('Question Stem', newContent)
await waitFor(() => {
expect(changeItemStateStub).toHaveBeenCalled()
})
const lastCall = changeItemStateStub.mock.lastCall
const stateUpdates = lastCall[0]
const updatedProps = {
...props,
...(stateUpdates.interactionData && {interactionData: stateUpdates.interactionData}),
...(stateUpdates.scoringData && {scoringData: stateUpdates.scoringData}),
}
rerender(<RichFillBlankEdit {...updatedProps} />)
expect(screen.getByText('Blank 1')).toBeInTheDocument()
expect(screen.getByText('Blank 2')).toBeInTheDocument()
expect(screen.getByText('Blank 3')).toBeInTheDocument()
expect(screen.getByText('Blank 4')).toBeInTheDocument()
// Verify buttons and interactive elements
const addAnswerButtons = screen.getAllByText(/Answer/i).filter(el => el.closest('button'))
expect(addAnswerButtons.length).toBeGreaterThan(0)
const addDistractorButtons = screen
.getAllByText(/Distractor/i)
.filter(el => el.closest('button'))
expect(addDistractorButtons.length).toBeGreaterThan(0)
// Verify remove buttons exist
const removeButtons = screen.getAllByRole('button', {name: /Remove/i})
expect(removeButtons.length).toBeGreaterThan(0)
// Test checkbox interactions for options
const checkboxes = screen.getAllByRole('checkbox')
await userEvent.click(checkboxes[0])
await userEvent.click(checkboxes[1])
// Verify Options section is expandable
const optionsButton = screen.getAllByRole('button', {name: /Options/i})[0]
expect(optionsButton).toBeInTheDocument()
await userEvent.click(optionsButton)
})
})
describe('Changing answer type for existing blank', () => {
it('should allow changing the answer type from openEntry to dropdown', async () => {
const props = createProps(changeItemStateStub, setOneQuestionAtATimeStub)
render(<RichFillBlankEdit {...props} />)
// Find the blank type select for the first blank (openEntry)
const blankTypeSelect = screen.getAllByRole('combobox')[0]
expect(blankTypeSelect).toBeInTheDocument()
userEvent.click(blankTypeSelect)
const dropdown = screen.getByRole('option', {name: 'Dropdown'})
userEvent.click(dropdown)
await waitFor(() => {
expect(changeItemStateStub).toHaveBeenCalled()
})
// Verify the answer type was changed
const updateCall = changeItemStateStub.mock.calls.find(call => {
return call[0].interactionData?.blanks?.[0]?.answerType === 'dropdown'
})
expect(updateCall).toBeDefined()
})
it('should allow changing the answer type from dropdown to wordbank', async () => {
const props = createProps(changeItemStateStub, setOneQuestionAtATimeStub)
render(<RichFillBlankEdit {...props} />)
// Find the blank type select for the second blank (dropdown)
const blankTypeSelect = screen.getAllByRole('combobox')[2]
expect(blankTypeSelect).toBeInTheDocument()
userEvent.click(blankTypeSelect)
const dropdown = screen.getByRole('option', {name: 'Word Bank'})
userEvent.click(dropdown)
await waitFor(() => {
expect(changeItemStateStub).toHaveBeenCalled()
})
// Verify the answer type was changed
const updateCall = changeItemStateStub.mock.calls.find(call => {
return call[0].interactionData?.blanks?.[1]?.answerType === 'wordbank'
})
expect(updateCall).toBeDefined()
})
})
describe('Inserting blank between existing ones preserves configuration', () => {
it('should restore blank configuration when removed and re-added with new blanks', async () => {
const props = createProps(changeItemStateStub, setOneQuestionAtATimeStub)
const originalBlanks = props.interactionData.blanks
const blank2Original = originalBlanks[1] // The 1492 dropdown blank
const {rerender} = render(<RichFillBlankEdit {...props} />)
changeItemStateStub.mockClear()
// Step 1: Remove the middle blank (1492) - this puts it in the deletedBlanksPool
await screen.fillRceByLabel(
'Question Stem',
'`Christopher` Columbus sailed and found `America`.',
)
await waitFor(() => {
expect(changeItemStateStub).toHaveBeenCalled()
})
const stateAfterRemoval = changeItemStateStub.mock.lastCall[0]
const propsAfterRemoval = {
...props,
...(stateAfterRemoval.interactionData && {
interactionData: stateAfterRemoval.interactionData,
}),
...(stateAfterRemoval.scoringData && {scoringData: stateAfterRemoval.scoringData}),
}
rerender(<RichFillBlankEdit {...propsAfterRemoval} />)
const blanksAfterRemoval = stateAfterRemoval.interactionData.blanks
expect(blanksAfterRemoval).toHaveLength(2)
changeItemStateStub.mockClear()
// Step 2: Re-add 1492 AND add new Columbus
await screen.fillRceByLabel(
'Question Stem',
'`Christopher` `Columbus` sailed in `1492` and found `America`.',
)
await waitFor(() => {
expect(changeItemStateStub).toHaveBeenCalled()
})
const lastCall = changeItemStateStub.mock.lastCall
const updatedBlanks = lastCall[0].interactionData.blanks
const updatedScoringData = lastCall[0].scoringData.value
// Should have 4 blanks total
expect(updatedBlanks).toHaveLength(4)
const yearBlank = updatedBlanks.find(
b => updatedScoringData.find(s => s.id === b.id)?.scoringData.blankText === '1492',
)
// 1492: Should be restored from pool
expect(yearBlank).toBeDefined()
expect(yearBlank.id).toBe(blank2Original.id)
expect(yearBlank.answerType).toBe('dropdown')
expect(yearBlank.choices).toBeDefined()
expect(yearBlank.choices).toHaveLength(3)
// Verify the dropdown choices are the same ones
expect(yearBlank.choices[0].id).toBe(blank2Original.choices[0].id)
expect(yearBlank.choices[0].itemBody).toBe('1942')
expect(yearBlank.choices[1].id).toBe(blank2Original.choices[1].id)
expect(yearBlank.choices[1].itemBody).toBe('1492')
expect(yearBlank.choices[2].id).toBe(blank2Original.choices[2].id)
expect(yearBlank.choices[2].itemBody).toBe('1429')
})
})
describe('Removing a blank from RCE removes it from the list', () => {
it('should remove a blank from the list when removed from the RCE', async () => {
const props = createProps(changeItemStateStub, setOneQuestionAtATimeStub)
const originalBlanksCount = props.interactionData.blanks.length
const blank2Id = props.interactionData.blanks[1].id
render(<RichFillBlankEdit {...props} />)
changeItemStateStub.mockClear()
// Simulate removing the middle blank (1492)
// Original: `Christopher` Columbus sailed in `1492` and found `America`
// New: `Christopher` Columbus sailed and found `America`
const newItemBody = '`Christopher` Columbus sailed and found `America`.'
await screen.fillRceByLabel('Question Stem', newItemBody)
await waitFor(() => {
expect(changeItemStateStub).toHaveBeenCalled()
})
const lastCall = changeItemStateStub.mock.lastCall
const updatedBlanks = lastCall[0].interactionData.blanks
// Should have 2 blanks now (original 3 - 1)
expect(updatedBlanks).toHaveLength(originalBlanksCount - 1)
// Verify the removed blank is not in the list
const removedBlank = updatedBlanks.find(b => b.id === blank2Id)
expect(removedBlank).not.toBeDefined()
})
})
describe('Removing and re-adding blank restores configuration', () => {
it('should restore blank configuration and distractors when re-added after removal without save', async () => {
const props = createProps(changeItemStateStub, setOneQuestionAtATimeStub)
// Enable common wordbank to test distractor restoration logic
props.fitbCommonWordbankEnabled = true
const blank3Original = props.interactionData.blanks[2] // Wordbank blank
const originalWordBankChoices = props.interactionData.wordBankChoices
// Count original distractors (choices not used by any blank)s
const usedChoiceIds = props.scoringData.value
.filter(sd => sd.scoringData.choiceId)
.map(sd => sd.scoringData.choiceId)
const originalDistractors = originalWordBankChoices.filter(
choice => !usedChoiceIds.includes(choice.id),
)
// Verify we have distractors to test restoration
expect(originalDistractors.length).toBeGreaterThan(0)
render(<RichFillBlankEdit {...props} />)
changeItemStateStub.mockClear()
// Step 1: Remove the wordbank blank (America)
const itemBodyWithoutBlank3 = '<p>`Christopher` Columbus sailed in `1492`.<p>'
await screen.fillRceByLabel('Question Stem', itemBodyWithoutBlank3)
await waitFor(() => {
expect(changeItemStateStub).toHaveBeenCalled()
})
// Verify wordbank choices were removed
const callAfterRemoval = changeItemStateStub.mock.lastCall
const blanksAfterRemoval = callAfterRemoval[0].interactionData.blanks
const wordBankChoicesAfterRemoval = callAfterRemoval[0].interactionData.wordBankChoices
// Should only have 2 blanks now
expect(blanksAfterRemoval).toHaveLength(2)
// Verify no blanks are wordbank type
expect(blanksAfterRemoval.every(b => b.answerType !== 'wordbank')).toBe(true)
// Wordbank choices should be removed since no wordbank blanks exist
expect(wordBankChoicesAfterRemoval).not.toBeDefined()
changeItemStateStub.mockClear()
const itemBodyWithBlank3Back =
'<p>`Christopher` Columbus sailed in `1492` and found `America`.<p>'
await screen.fillRceByLabel('Question Stem', itemBodyWithBlank3Back)
await waitFor(() => {
expect(changeItemStateStub).toHaveBeenCalled()
})
const lastCall = changeItemStateStub.mock.lastCall
const updatedBlanks = lastCall[0].interactionData.blanks
const updatedScoringData = lastCall[0].scoringData.value
const restoredWordBankChoices = lastCall[0].interactionData.wordBankChoices
// Should have 3 blanks again
expect(updatedBlanks).toHaveLength(3)
// Find the restored blank
const restoredBlank = updatedBlanks.find(
b => updatedScoringData.find(s => s.id === b.id)?.scoringData.blankText === 'America',
)
expect(restoredBlank).toBeDefined()
// Should have the same ID (restored from pool)
expect(restoredBlank.id).toBe(blank3Original.id)
// Should have the same answer type (wordbank)
expect(restoredBlank.answerType).toBe('wordbank')
// Wordbank choices should be restored (including distractors)
expect(restoredWordBankChoices).toBeDefined()
expect(restoredWordBankChoices).toHaveLength(originalWordBankChoices.length)
// Verify the wordbank choice for "America" is restored
const americaChoice = restoredWordBankChoices.find(choice => choice.itemBody === 'America')
expect(americaChoice).toBeDefined()
expect(americaChoice.id).toBe(
updatedScoringData.find(s => s.scoringData.blankText === 'America').scoringData.choiceId,
)
// Verify distractors are restored (this tests the key logic in lines 272-278)
const restoredUsedChoiceIds = updatedScoringData
.filter(sd => sd.scoringData.choiceId)
.map(sd => sd.scoringData.choiceId)
const restoredDistractors = restoredWordBankChoices.filter(
choice => !restoredUsedChoiceIds.includes(choice.id),
)
// Should have the same number of distractors (Canada and Mexico)
expect(restoredDistractors).toHaveLength(originalDistractors.length)
expect(restoredDistractors.length).toBe(2)
// Verify each original distractor is present with correct id and itemBody
originalDistractors.forEach(originalDistractor => {
const foundDistractor = restoredDistractors.find(d => d.id === originalDistractor.id)
expect(foundDistractor).toBeDefined()
expect(foundDistractor.itemBody).toBe(originalDistractor.itemBody)
})
// Specifically verify Canada and Mexico are restored
expect(restoredDistractors.some(d => d.itemBody === 'Canada')).toBe(true)
expect(restoredDistractors.some(d => d.itemBody === 'Mexico')).toBe(true)
})
})
describe('Configuration not restored after save', () => {
it('should not restore blank configuration when re-added after save (simulated by re-mounting)', async () => {
const props = createProps(changeItemStateStub, setOneQuestionAtATimeStub)
const originalBlank2Id = props.interactionData.blanks[1].id
// Step 1: Initial render with all blanks, then unmount to simulate save
const {unmount} = render(<RichFillBlankEdit {...props} />)
unmount()
// Step 2: Create new props without blank2 (simulating saved state after removal)
const savedProps = createProps(changeItemStateStub, setOneQuestionAtATimeStub)
savedProps.interactionData.blanks = [
savedProps.interactionData.blanks[0],
savedProps.interactionData.blanks[2],
]
savedProps.scoringData.value = [
savedProps.scoringData.value[0],
savedProps.scoringData.value[2],
]
savedProps.scoringData.workingItemBody = '<p>`Christopher` Columbus found `America`.<p>'
// Step 3: Re-mount component (simulating re-entering edit mode after save)
render(<RichFillBlankEdit {...savedProps} />)
changeItemStateStub.mockClear()
// Step 4: Add blank2 back with text "1492"
const itemBodyWithBlank2Back = '`Christopher` Columbus sailed in `1492` and found `America`.'
await screen.fillRceByLabel('Question Stem', itemBodyWithBlank2Back)
await waitFor(() => {
expect(changeItemStateStub).toHaveBeenCalled()
})
const lastCall = changeItemStateStub.mock.lastCall
const updatedBlanks = lastCall[0].interactionData.blanks
const updatedScoringData = lastCall[0].scoringData.value
// Find the re-added blank
const reAddedBlank = updatedBlanks.find(
b => updatedScoringData.find(s => s.id === b.id)?.scoringData.blankText === '1492',
)
expect(reAddedBlank).toBeDefined()
// Should have a NEW ID (not restored from pool because component was unmounted)
expect(reAddedBlank.id).not.toBe(originalBlank2Id)
// Should have default answerType
expect(reAddedBlank.answerType).toBe('openEntry')
// Should NOT have choices
expect(reAddedBlank.choices).not.toBeDefined()
})
})
describe('Survey functionality', () => {
let surveyProps
beforeEach(() => {
surveyProps = {
...createProps(changeItemStateStub, setOneQuestionAtATimeStub),
isSurvey: true,
}
})
describe('Open entry blank', () => {
it('should hide text match options and scoring controls in survey mode', () => {
const props = {
...surveyProps,
interactionData: {
blanks: [
{
id: 'blank1',
answerType: 'openEntry',
},
],
},
scoringData: {
workingItemBody: '<p>Roses are `red`</p>',
value: [
{
id: 'blank1',
scoringAlgorithm: 'TextRegex',
scoringData: {
value: 'red',
blankText: 'red',
},
},
],
},
}
render(<RichFillBlankEdit {...props} />)
expect(screen.queryByDisplayValue('red')).toBeNull()
expect(screen.queryByLabelText(/text match/i)).toBeNull()
expect(screen.queryByText(/exact match/i)).toBeNull()
expect(screen.queryByText(/close enough/i)).toBeNull()
expect(screen.getByRole('combobox', {name: /answer type/i}).value).toBe('Open Entry')
})
})
describe('Dropdown blank', () => {
it('should rename distractors to answers in survey mode', () => {
const props = {
...surveyProps,
interactionData: {
blanks: [
{
id: 'blank2',
answerType: 'dropdown',
choices: [
{id: 'choice2', itemBody: '1942'},
{id: 'choice1', itemBody: '1492'},
{id: 'choice3', itemBody: '1429'},
],
},
],
},
scoringData: {
workingItemBody: '<p>Columbus sailed in `1492`</p>',
value: [
{
id: 'blank2',
scoringAlgorithm: 'Equivalence',
scoringData: {
value: 'choice1',
blankText: '1492',
},
},
],
},
}
render(<RichFillBlankEdit {...props} />)
expect(screen.getAllByText('Answer')).not.toHaveLength(0)
expect(screen.queryByText('Distractor')).toBeNull()
})
})
describe('Word bank blank', () => {
it('should rename distractors to answers and hide correct answer in survey mode', () => {
const props = {
...surveyProps,
interactionData: {
blanks: [
{
id: 'blank3',
answerType: 'wordbank',
},
],
wordBankChoices: [
{id: 'wordbankchoice1', itemBody: 'America'},
{id: 'wordbankchoice2', itemBody: 'Canada'},
],
},
scoringData: {
workingItemBody: '<p>Columbus found `America`</p>',
value: [
{
id: 'blank3',
scoringAlgorithm: 'TextEquivalence',
scoringData: {
value: 'America',
blankText: 'America',
choiceId: 'wordbankchoice1',
},
},
],
},
}
render(<RichFillBlankEdit {...props} />)
// Verify survey-specific behavior
expect(screen.getByText('Answers')).toBeInTheDocument()
expect(screen.queryByText('Word Bank Distractors')).toBeNull()
expect(screen.queryByText('Distractor')).toBeNull()
expect(screen.queryByLabelText('Correct Answer')).toBeNull()
expect(screen.queryByDisplayValue('America')).toBeNull()
// Verify basic functionality
expect(screen.getByRole('combobox', {name: /answer type/i})).toHaveProperty(
'value',
'Word Bank',
)
})
})
})
describe('Changing RCE content does not reset options', () => {
it('should not reset existing blank options when changing RCE content', async () => {
const changeItemStateStub = vi.fn()
const setOneQuestionAtATimeStub = vi.fn()
const props = createProps(changeItemStateStub, setOneQuestionAtATimeStub)
const {unmount} = render(<RichFillBlankEdit {...props} />)
// Step 1: Enable the "reuse word bank choices" option
const checkbox = screen.getByRole('checkbox', {name: /Allow word bank choices to be reused/i})
userEvent.click(checkbox)
await waitFor(() => {
expect(changeItemStateStub).toHaveBeenCalled()
})
const lastCall1 = changeItemStateStub.mock.lastCall
const reuseWordBankChoicesFirst = lastCall1[0].interactionData.reuseWordBankChoices
expect(reuseWordBankChoicesFirst).toBe(true)
// Step 2: Unmount and re-render with updated props (simulating saved state)
unmount()
const updatedProps = {
...props,
interactionData: {
...props.interactionData,
reuseWordBankChoices: true,
},
}
render(<RichFillBlankEdit {...updatedProps} />)
changeItemStateStub.mockClear()
// Step 3: Change RCE content
const newItemBody = '`Christopher` Columbus sailed in `1492` and found `America` .'
await screen.fillRceByLabel('Question Stem', newItemBody)
await waitFor(() => {
expect(changeItemStateStub).toHaveBeenCalled()
})
// Step 4: Verify that reuseWordBankChoices is still true
const lastCall = changeItemStateStub.mock.lastCall
const reuseWordBankChoicesSecond = lastCall[0].interactionData.reuseWordBankChoices
expect(reuseWordBankChoicesSecond).toBe(true)
})
})
})