@instructure/quiz-taking
Version:
223 lines (194 loc) • 8.54 kB
JavaScript
import React from 'react'
import {List} from 'immutable'
import InteractionType from '@instructure/quiz-interactions/records/InteractionType'
import Item from '@instructure/quiz-core/common/records/Item'
import SessionItem from '@instructure/quiz-core/common/records/SessionItem'
import {TakingSidebar as Sidebar} from '../presenter'
import {FORMULA_SLUG, RICH_FILL_BLANK_SLUG} from '@instructure/quiz-common/constants'
import {beforeEach, describe, expect, it, vi} from 'vitest'
import {render, screen, waitFor} from '../../../../../tests/utils/rtlRenderOverride'
import userEvent from '@testing-library/user-event'
import runAxeCheck from '@instructure/ui-axe-check'
describe('Taking Sidebar Presenter', () => {
const intTypeA = new InteractionType({name: 'Fill in the Blank', slug: RICH_FILL_BLANK_SLUG})
const intTypeB = new InteractionType({name: 'Multiple Choice'})
const itemA = new Item({
id: '5',
position: 4,
itemBody: '<div>first</div>&<div>second <span id="blank_abcd-1234"></span></div>',
})
const itemB = new Item({id: '6', position: 8})
const itemC = new Item({
id: '7',
position: 5,
itemBody: 'what is 5 + `x`?',
interactionData: {variables: [{name: 'x', value: 7}]},
})
const sessionItemA = new SessionItem({id: '4', position: 1, questionNumber: 1, pointsPossible: 5})
const sessionItemB = new SessionItem({id: '6', position: 2, questionNumber: 2, pointsPossible: 5})
const sessionItemC = new SessionItem({id: '7', position: 4, questionNumber: 3, pointsPossible: 5})
const sessionItems = List([sessionItemA, sessionItemB, sessionItemC])
const noop = () => {}
const defaultProps = {
allowBacktracking: true,
isOneQuestionAtATime: false,
quizTitle: 'Super fun quiz',
scrollToItem: noop,
sidebarOpen: true,
goToItem: noop,
screenreaderNotification: noop,
sessionItems,
sessionNotLoaded: false,
pinnedItems: List(),
setFocusTitleAndInstructions: () => {},
toggleSidebar: () => {},
}
beforeEach(() => {
vi.spyOn(itemA, 'getInteractionType').mockReturnValue(intTypeA)
vi.spyOn(itemB, 'getInteractionType').mockReturnValue(intTypeB)
vi.spyOn(itemC, 'getInteractionType').mockReturnValue(
new InteractionType({
name: 'Formula',
slug: FORMULA_SLUG,
}),
)
vi.spyOn(sessionItemA, 'getItem').mockReturnValue(itemA)
vi.spyOn(sessionItemB, 'getItem').mockReturnValue(itemB)
vi.spyOn(sessionItemC, 'getItem').mockReturnValue(itemC)
})
describe('#renderSidebarItems', () => {
it('renders the correct number', () => {
render(<Sidebar {...defaultProps} />)
expect(
screen.getAllByRole('link', {
name: /navigate to question at position/i,
}),
).toHaveLength(3)
})
it('should extract text content of item body HTML', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByText(/first&second _____/i)).toBeInTheDocument()
})
it('should replace the variables in formula questions', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByText(/what is 5 \+ 7\?/i)).toBeInTheDocument()
})
})
describe('scrolling and changing items', () => {
const goToItemStub = vi.fn()
const scrollToItemStub = vi.fn()
const screenreaderNotificationStub = vi.fn()
const setFocusTitleAndInstructionsStub = vi.fn()
const overriddenProps = {
goToItem: goToItemStub,
scrollToItem: scrollToItemStub,
screenreaderNotification: screenreaderNotificationStub,
setFocusTitleAndInstructions: setFocusTitleAndInstructionsStub,
}
describe('oqaat on', () => {
it('alerts the screenreader when scrollToItem is called', async () => {
render(<Sidebar {...defaultProps} {...overriddenProps} isOneQuestionAtATime={true} />)
userEvent.click(
screen.getByRole('link', {name: /Navigate to question at position 1, unanswered/i}),
)
await waitFor(() =>
expect(screenreaderNotificationStub).toHaveBeenCalledWith(
'Current question changed to question 1',
),
)
})
it('gives a relevant SR message when clicking title/instructions', async () => {
render(<Sidebar {...defaultProps} {...overriddenProps} isOneQuestionAtATime={true} />)
userEvent.click(screen.getByRole('link', {name: /Navigate to title/i}))
await waitFor(() => {
expect(screenreaderNotificationStub).toHaveBeenCalledWith(
'Navigated to quiz title and instructions. Current question changed to question 1',
)
})
})
it('sets focusTitleAndInstructions as true when clicking title/instructions', async () => {
render(<Sidebar {...defaultProps} {...overriddenProps} isOneQuestionAtATime={true} />)
userEvent.click(screen.getByRole('link', {name: /Navigate to title/i}))
await waitFor(() => expect(setFocusTitleAndInstructionsStub).toHaveBeenCalledWith(true))
})
it('calls goToItem when scrollToItem is called', async () => {
render(<Sidebar {...defaultProps} {...overriddenProps} isOneQuestionAtATime={true} />)
userEvent.click(
screen.getByRole('link', {name: /Navigate to question at position 1, unanswered/i}),
)
await waitFor(() => expect(goToItemStub).toHaveBeenCalled())
})
})
describe('oqaat off', () => {
it('binds scrollToItem to the itemId', async () => {
render(<Sidebar {...defaultProps} {...overriddenProps} />)
userEvent.click(
screen.getByRole('link', {name: /Navigate to question at position 1, unanswered/i}),
)
await waitFor(() => expect(scrollToItemStub).toHaveBeenCalledWith('4'))
})
})
})
describe('#render', () => {
it('renders the common sidebar', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByRole('heading', {name: /question navigator/i})).toBeInTheDocument()
})
it('renders nothing if OQAAT non-backtracking is on', () => {
render(<Sidebar {...defaultProps} allowBacktracking={false} isOneQuestionAtATime={true} />)
expect(screen.queryByRole('heading', {name: /question navigator/i})).not.toBeInTheDocument()
})
it('renders nothing if sessionNotLoaded is true', () => {
render(<Sidebar {...defaultProps} sessionNotLoaded />)
expect(screen.queryByRole('heading', {name: /question navigator/i})).not.toBeInTheDocument()
})
it('passes the role prop to the CommonSidebar component', () => {
render(<Sidebar {...defaultProps} role="complementary" />)
expect(screen.getByRole('complementary')).toBeInTheDocument()
})
})
describe('a11y tests', () => {
it('should meet a11y standards', async () => {
render(<Sidebar {...defaultProps} />)
expect(await runAxeCheck(document.body, {ignores: ['aria-tooltip-name']})).toBe(true)
})
})
describe('QUIZ-17004: position prop for answered status', () => {
it('should correctly identify answered status when position differs from questionNumber', () => {
// Test case: SessionItem with position=4 and questionNumber=3 (happens with text blocks)
// Response is stored at position 4
const stateWithResponse = {
quizSessions: {
activeQuizSessionId: '1',
1: {id: '1'},
},
taking: {
responses: {
4: {
position: 4,
itemId: '7',
userResponse: {value: 'test answer'},
},
},
},
}
// Note: sessionItemC has position=4 but questionNumber=3
const propsWithState = {
...defaultProps,
sessionItems: List([sessionItemA, sessionItemB, sessionItemC]),
}
render(<Sidebar {...propsWithState} />, {storeOptions: {state: stateWithResponse}})
// Find all sidebar items
const sidebarItems = screen.getAllByRole('link', {
name: /navigate to question at position/i,
})
// Verify 3 items rendered
expect(sidebarItems).toHaveLength(3)
// The third item (position 4, questionNumber 3) should NOT show as unanswered
// because we have a response at position 4
// If we incorrectly passed questionNumber (3) instead of position (4),
// it would show as unanswered since there's no response at position 3
expect(screen.queryByText(/navigate to question at position 3, unanswered/i)).toBeNull()
})
})
})