@instructure/quiz-interactions
Version:
A React UI component Library for quiz interaction types.
444 lines (390 loc) • 14.1 kB
JavaScript
import React from 'react'
import {render, screen} from '@testing-library/react'
import runAxeCheck from '@instructure/ui-axe-check'
import update from 'immutability-helper'
import omit from 'lodash/omit'
import words from 'lodash/words'
import difference from 'lodash/difference'
import FillBlankResult from '../index'
const defaultScoredData = {
value: {
fitb_uuid1: {
resultScore: 1,
userResponse: 'Christopfer',
correctAnswer: 'Christopher',
},
fitb_uuid2: {
resultScore: 1,
userResponse: '1492',
},
fitb_uuid3: {
value: {
Spain: {
resultScore: 1,
userResponded: false,
},
Espana: {
resultScore: 1,
userResponded: false,
},
'Kingdom of Spain': {
resultScore: 1,
userResponded: false,
},
Portugal: {
resultScore: 0,
userResponded: true,
},
},
},
fitb_uuid4: {
resultScore: 1,
userResponse: 'Asia',
correctAnswer: 'Europe',
},
fitb_uuid5: {
resultScore: 1,
correctAnswer: 'Atlantic',
userResponse: 'Atlantic Ocean',
},
fitb_uuid6: {
value: {
choice_uuid13_america: {
resultScore: 1,
userResponded: true,
},
},
},
fitb_uuid7: {
value: {
choice_uuid13_confused: {
resultScore: 1,
userResponded: false,
},
choice_uuid11_peaceful: {
resultScore: 0,
userResponded: true,
},
},
},
},
}
const props = {
interactionData: {
stemItems: [
{id: 'stem_uuid0', position: 1, type: 'blank', blankId: 'fitb_uuid1'},
{id: 'stem_uuid1', position: 2, type: 'text', value: ' Columbus sailed in '},
{id: 'stem_uuid2', position: 3, type: 'blank', blankId: 'fitb_uuid2'},
{id: 'stem_uuid3', position: 4, type: 'text', value: ' from the country of '},
{id: 'stem_uuid4', position: 5, type: 'blank', blankId: 'fitb_uuid3'},
{id: 'stem_uuid5', position: 6, type: 'text', value: ' on the continent of '},
{id: 'stem_uuid6', position: 7, type: 'blank', blankId: 'fitb_uuid4'},
{id: 'stem_uuid7', position: 8, type: 'text', value: ' across the '},
{id: 'stem_uuid8', position: 9, type: 'blank', blankId: 'fitb_uuid5'},
{id: 'stem_uuid9', position: 10, type: 'text', value: ' and found '},
{id: 'stem_uuid10', position: 11, type: 'blank', blankId: 'fitb_uuid6'},
{id: 'stem_uuid11', position: 12, type: 'text', value: '.'},
{id: 'stem_uuid12', position: 13, type: 'text', value: ' Where the people were '},
{id: 'stem_uuid13', position: 14, type: 'blank', blankId: 'fitb_uuid7'},
{id: 'stem_uuid14', position: 15, type: 'text', value: '.'},
],
blanks: [
{
id: 'fitb_uuid1',
answerType: 'openEntry',
},
{
id: 'fitb_uuid2',
answerType: 'openEntry',
},
{
id: 'fitb_uuid3',
answerType: 'openEntry',
},
{
id: 'fitb_uuid4',
answerType: 'openEntry',
},
{
id: 'fitb_uuid5',
answerType: 'openEntry',
},
{
id: 'fitb_uuid6',
answerType: 'wordbank',
},
{
id: 'fitb_uuid7',
choices: [
{id: 'choice_uuid11_peaceful', position: 1, itemBody: 'peaceful'},
{id: 'choice_uuid12_war-torn', position: 2, itemBody: 'war-torn'},
{id: 'choice_uuid13_confused', position: 3, itemBody: 'confused'},
],
answerType: 'dropdown',
},
],
},
scoredData: defaultScoredData,
}
// A dumb helper for stripping some keys from the complex scoredData above
const cloneWithoutKey = (obj, key) => {
let objCopy = obj.constructor()
let prop
for (prop in obj) {
if (obj.hasOwnProperty(prop) && prop !== key) {
if (typeof obj[prop] === 'object') {
objCopy[prop] = cloneWithoutKey(obj[prop], key)
} else {
objCopy[prop] = obj[prop]
}
}
}
return objCopy
}
describe('Fill in the Blank Result', () => {
it('should render', () => {
render(<FillBlankResult {...props} />)
expect(document.body.textContent).toBeDefined()
})
describe('stem items', () => {
it('renders a Feedback wrapper for each blank item', () => {
render(<FillBlankResult {...props} />)
const showingText = document.body.textContent
expect(showingText).toContain('Columbus sailed in')
expect(showingText).toContain('and found')
})
describe('blanks', () => {
it('renders a Feedback wrapper for each blank item', () => {
const {container} = render(<FillBlankResult {...props} />)
const blankComponents = container.querySelectorAll('.fs-mask')
expect(blankComponents.length).toBe(7)
})
it('passes the correct props to the FeedbackWrapper', () => {
render(<FillBlankResult {...props} />)
// The first blank has status 'correct', userResponse 'Christopfer', correctAnswer 'Christopher'
// status 'correct' means no "Correct Answer:" subtitle is shown
expect(screen.getByText('Christopfer')).toBeInTheDocument()
// correctAnswer 'Christopher' is only shown when status is not correct,
// but it's still passed. Since status is 'correct', correctAnswer subtitle won't render.
// Verify the user response is present
expect(document.body.textContent).toContain('Christopfer')
})
})
})
describe('scoredData variants', () => {
it("renders if wordbank choice id is not in blank's choice list", () => {
const newVal = {
choice_uuid13_confused: {
resultScore: 1,
userResponded: false,
},
// changed to match other blank's choice
choice_uuid11_brazil: {
resultScore: 0,
userResponded: true,
},
}
const scoredDataWithWordbankChoice = update(defaultScoredData, {
value: {fitb_uuid7: {value: {$set: newVal}}},
})
const {container} = render(
<FillBlankResult {...props} scoredData={scoredDataWithWordbankChoice} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('renders if wordbank choice id does not match option', () => {
const newVal = {
choice_uuid13_confused: {
resultScore: 1,
userResponded: false,
},
some_key_that_does_not_exist: {
resultScore: 0,
userResponded: true,
},
}
const scoredDataWithUnmatchedWordbankChoice = update(defaultScoredData, {
value: {fitb_uuid7: {value: {$set: newVal}}},
})
const {container} = render(
<FillBlankResult {...props} scoredData={scoredDataWithUnmatchedWordbankChoice} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('renders if wordbank was unanswered', () => {
const newVal = {
choice_uuid13_confused: {
resultScore: 1,
userResponded: false,
},
}
const scoredDataWithNoWordbankChoice = update(defaultScoredData, {
value: {fitb_uuid7: {value: {$set: newVal}}},
})
const {container} = render(
<FillBlankResult {...props} scoredData={scoredDataWithNoWordbankChoice} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('renders if value is completely missing', () => {
const scoredDataWithNonResponse = update(defaultScoredData, {
value: {$apply: val => omit(val, ['fitb_uuid3'])},
})
const {container} = render(
<FillBlankResult {...props} scoredData={scoredDataWithNonResponse} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('renders with missing scores', () => {
const scoredDataMissingScores = cloneWithoutKey(defaultScoredData, 'resultScore')
const {container} = render(
<FillBlankResult {...props} scoredData={scoredDataMissingScores} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('renders with no user responses', () => {
const scoredDataMissingScores = cloneWithoutKey(defaultScoredData, 'userResponse')
const {container} = render(
<FillBlankResult {...props} scoredData={scoredDataMissingScores} />,
)
expect(container.firstChild).toBeInTheDocument()
})
})
describe('results display', () => {
it('renders the correctAnswer if given and incorrect', () => {
const withAtlanticWrong = update(defaultScoredData, {
value: {fitb_uuid5: {resultScore: {$set: 0}}},
})
render(<FillBlankResult {...props} scoredData={withAtlanticWrong} />)
const innerTextWords = words(document.body.textContent)
const expectedWords = words('Correct Answer:Atlantic')
expect(difference(expectedWords, innerTextWords).length).toBe(0)
})
it('renders correctAnswer if multiple choice blank was unanswered', () => {
const newVal = {
choice_uuid13_confused: {
resultScore: 1,
userResponded: false,
},
}
const scoredDataWithNoWordbankChoice = update(defaultScoredData, {
value: {fitb_uuid7: {value: {$set: newVal}}},
})
render(<FillBlankResult {...props} scoredData={scoredDataWithNoWordbankChoice} />)
// When the dropdown blank is unanswered, the correct answer 'confused' should appear
expect(document.body.textContent).toContain('confused')
})
it('renders student response and correctAnswer if word bank blank is malformed', () => {
const newVal = {
correct: false,
value: {
America: {
resultScore: 1,
userResponded: false,
},
Brazil: {
resultScore: 0,
userResponded: true,
},
},
}
const malformedScoredData = update(defaultScoredData, {
value: {fitb_uuid6: {$set: newVal}},
})
render(<FillBlankResult {...props} scoredData={malformedScoredData} />)
// The malformed wordbank blank should show userResponse 'Brazil' and correctAnswer 'America'
expect(document.body.textContent).toContain('Brazil')
expect(document.body.textContent).toContain('America')
})
it('does not render rich blanks when richFITB is false, even after rerender with new interactionData', () => {
const {rerender, queryByText} = render(<FillBlankResult {...props} richFITB={false} />)
const updatedProps = {
...props,
interactionData: {
...props.interactionData,
blanks: [
...props.interactionData.blanks,
{
id: 'fitb_uuid_extra',
answerType: 'dropdown',
choices: [{id: 'choice_1', position: 1, itemBody: 'rich-only choice'}],
},
],
stemItems: [
...props.interactionData.stemItems,
{id: 'stem_uuid_extra', position: 99, type: 'blank', blankId: 'fitb_uuid_extra'},
],
},
}
rerender(<FillBlankResult {...updatedProps} richFITB={false} />)
expect(queryByText('rich-only choice')).toBeNull()
})
describe('renderRichBlanks', () => {
it('renders updated blanks when interactionData.blanks changes', async () => {
const {queryByText, rerender} = render(<FillBlankResult {...props} />)
const updatedProps = {
...props,
interactionData: {
...props.interactionData,
blanks: [
...props.interactionData.blanks,
{
id: 'fitb_uuid8',
answerType: 'openEntry',
},
],
},
}
rerender(<FillBlankResult {...updatedProps} />)
expect(queryByText('Atlantic Ocean')).not.toBeNull()
expect(queryByText('peaceful')).not.toBeNull()
})
it('re-renders blanks when images load in richFITB', async () => {
const propsWithImage = {
...props,
itemBody: `<p><img src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" />
<span id="blank_fitb_uuid1"></span> Columbus sailed in
<span id="blank_fitb_uuid2"></span></p>`,
}
const {queryByText, container} = render(<FillBlankResult {...propsWithImage} />)
// Wait for image to load
const img = container.querySelector('img')
if (img && !img.complete) {
await new Promise(resolve => {
img.addEventListener('load', resolve, {once: true})
})
}
// Give the component time to re-render after image load
await new Promise(resolve => setTimeout(resolve, 50))
// Verify that the blank answers are rendered
expect(queryByText('Christopfer')).not.toBeNull()
})
it('observes DOM mutations for new blank elements in richFITB', async () => {
const {container, queryByText} = render(<FillBlankResult {...props} />)
// Verify initial render
expect(queryByText('Christopfer')).not.toBeNull()
// Simulate adding a new blank element to the DOM
const itemBodyWrapper = container.querySelector('[data-automation="sdk-item-wrapper"]')
if (itemBodyWrapper) {
const newBlankElement = document.createElement('span')
newBlankElement.id = 'blank_fitb_uuid8'
itemBodyWrapper.appendChild(newBlankElement)
// Give MutationObserver time to detect the change
await new Promise(resolve => setTimeout(resolve, 50))
// The component should re-render
expect(queryByText('Atlantic Ocean')).not.toBeNull()
}
})
})
})
describe('a11y tests', () => {
it('should meet a11y standards', async () => {
render(<FillBlankResult {...props} />)
expect(
await runAxeCheck(document.body, {
ignores: ['region'],
}),
).toBe(true)
})
})
})