UNPKG

@instructure/quiz-interactions

Version:

A React UI component Library for quiz interaction types.

680 lines (570 loc) • 25.6 kB
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) }) }) })