Files
frontend-app-learning/src/courseware/course/Course.test.jsx
Brian Smith 13eea81fe7 fix(deps): regenerate package-lock.json (#1855)
* fix(deps): regenerate package-lock.json

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(deps): regenerate package-lock.json

Moved @openedx/frontend-build from dependencies to devDependencies.
Removed direct jest devDependency which was causing ts-jest hoisting issues.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(tests): use require() for MockedPluginSlot in jest.mock

Jest hoists jest.mock() calls to the top of the file, which caused
MockedPluginSlot to be undefined when the mock factory executed.
Using require() inside the factory ensures it loads at runtime.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(types): handle nullable breakpoint types

Paragon's breakpoint types now have optional minWidth/maxWidth properties.
Added non-null assertions since these values are always defined in practice.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(tests): add IntlProvider to ContentIFrame tests

Paragon's ModalDialog now uses useIntl() (openedx/paragon#3624),
requiring an IntlProvider in the component ancestry.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(tests): await async operations in Course tests

Fixed dangling waitFor blocks that weren't awaited, causing tests
to not actually wait for async operations. Changed to properly use
await with screen.findBy*() queries.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(tests): use dynamic imports in LearnerToolsSlot tests

Jest hoists mock calls but ES imports run before the test body.
Using dynamic imports in beforeEach ensures mocks are set up
before modules are loaded.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-02-13 17:29:07 -05:00

406 lines
16 KiB
JavaScript

import React from 'react';
import { Factory } from 'rosie';
import { breakpoints } from '@openedx/paragon';
import {
fireEvent, getByRole, initializeTestStore, loadUnit, render, screen, waitFor,
} from '../../setupTest';
import * as celebrationUtils from './celebration/utils';
import { handleNextSectionCelebration } from './celebration';
import Course from './Course';
import setupDiscussionSidebar from './test-utils';
jest.mock('@edx/frontend-platform/analytics');
jest.mock('@edx/frontend-lib-special-exams', () => {
const actual = jest.requireActual('@edx/frontend-lib-special-exams');
return {
...actual,
__esModule: true,
// Mock the default export (SequenceExamWrapper) to just render children
// eslint-disable-next-line react/prop-types
default: ({ children }) => <div data-testid="sequence-exam-wrapper">{children}</div>,
};
});
const mockLearnerToolsTestId = 'fake-learner-tools';
jest.mock(
'../../plugin-slots/LearnerToolsSlot',
() => ({
// eslint-disable-next-line react/prop-types
LearnerToolsSlot({ courseId }) {
return <div className="fake-learner-tools" data-testid={mockLearnerToolsTestId}>LearnerTools contents {courseId} </div>;
},
}),
);
const recordFirstSectionCelebration = jest.fn();
// eslint-disable-next-line no-import-assign
celebrationUtils.recordFirstSectionCelebration = recordFirstSectionCelebration;
describe('Course', () => {
let store;
const mockData = {
nextSequenceHandler: () => {},
previousSequenceHandler: () => {},
unitNavigationHandler: () => {},
};
beforeAll(async () => {
store = await initializeTestStore();
const { courseware, models } = store.getState();
const { courseId, sequenceId } = courseware;
Object.assign(mockData, {
courseId,
sequenceId,
unitId: Object.values(models.units)[0].id,
});
global.innerWidth = breakpoints.extraLarge.minWidth;
});
// This was passing when it shouldn't have been because of improper
// waitFor use. With the React 18 upgrade it no longer improperly passes
// so we are skipping it. See https://github.com/openedx/frontend-app-learning/issues/1669
// for details.
it.skip('loads learning sequence', () => {
render(<Course {...mockData} />, { wrapWithRouter: true });
expect(screen.queryByRole('navigation', { name: 'breadcrumb' })).not.toBeInTheDocument();
waitFor(() => {
expect(screen.findByText('Loading learning sequence...')).toBeInTheDocument();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Learn About Verified Certificates' })).not.toBeInTheDocument();
loadUnit();
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
const { models } = store.getState();
const sequence = models.sequences[mockData.sequenceId];
const section = models.sections[sequence.sectionId];
const course = models.coursewareMeta[mockData.courseId];
expect(document.title).toMatch(
`${sequence.title} | ${section.title} | ${course.title} | edX`,
);
});
});
it('removes breadcrumbs when navigation is disabled', async () => {
const sequenceBlocks = [Factory.build(
'block',
{ type: 'sequential', children: [] },
{ courseId: mockData.courseId },
)];
const sequenceMetadata = [Factory.build(
'sequenceMetadata',
{ navigation_disabled: true },
{ courseId: mockData.courseId, sequenceBlock: sequenceBlocks[0] },
)];
const testStore = await initializeTestStore({ sequenceBlocks, sequenceMetadata }, false);
const testData = {
...mockData,
sequenceId: sequenceBlocks[0].id,
onNavigate: jest.fn(),
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
expect(screen.queryByRole('navigation', { name: 'breadcrumb' })).not.toBeInTheDocument();
});
// This was passing when it shouldn't have been because of improper
// waitFor use. With the React 18 upgrade it no longer improperly passes
// so we are skipping it. See https://github.com/openedx/frontend-app-learning/issues/1669
// for details.
it.skip('displays first section celebration modal', async () => {
const courseHomeMetadata = Factory.build('courseHomeMetadata', { celebrations: { firstSection: true } });
const testStore = await initializeTestStore({ courseHomeMetadata }, false);
const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
...mockData,
courseId,
sequenceId,
unitId: Object.values(models.units)[0].id,
};
// Set up LocalStorage for testing.
handleNextSectionCelebration(sequenceId, sequenceId, testData.unitId);
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
waitFor(() => {
const firstSectionCelebrationModal = screen.getByRole('dialog');
expect(firstSectionCelebrationModal).toBeInTheDocument();
expect(getByRole(firstSectionCelebrationModal, 'heading', { name: 'Congratulations!' })).toBeInTheDocument();
});
});
// This was passing when it shouldn't have been because of improper
// waitFor use. With the React 18 upgrade it no longer improperly passes
// so we are skipping it. See https://github.com/openedx/frontend-app-learning/issues/1669
// for details.
it.skip('displays weekly goal celebration modal', async () => {
const courseHomeMetadata = Factory.build('courseHomeMetadata', { celebrations: { weeklyGoal: true } });
const testStore = await initializeTestStore({ courseHomeMetadata }, false);
const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
...mockData,
courseId,
sequenceId,
unitId: Object.values(models.units)[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
waitFor(() => {
const weeklyGoalCelebrationModal = screen.getByRole('dialog');
expect(weeklyGoalCelebrationModal).toBeInTheDocument();
expect(getByRole(weeklyGoalCelebrationModal, 'heading', { name: 'You met your goal!' })).toBeInTheDocument();
});
});
it('handles click to open/close discussions sidebar', async () => {
await setupDiscussionSidebar();
waitFor(() => {
expect(screen.getByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
expect(screen.getByTestId('sidebar-DISCUSSIONS')).not.toHaveClass('d-none');
const discussionsTrigger = screen.getByRole('button', { name: /Show discussions tray/i });
expect(discussionsTrigger).toBeInTheDocument();
fireEvent.click(discussionsTrigger);
expect(screen.queryByTestId('sidebar-DISCUSSIONS')).not.toBeInTheDocument();
fireEvent.click(discussionsTrigger);
expect(screen.queryByTestId('sidebar-DISCUSSIONS')).toBeInTheDocument();
});
});
it('displays discussions sidebar when unit changes', async () => {
const testStore = await initializeTestStore();
const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
...mockData,
courseId,
sequenceId,
unitId: Object.values(models.units)[0].id,
};
await setupDiscussionSidebar();
const { rerender } = render(<Course {...testData} />, { store: testStore });
loadUnit();
const sidebar = await screen.findByTestId('sidebar-DISCUSSIONS');
expect(sidebar).toBeInTheDocument();
expect(sidebar).not.toHaveClass('d-none');
rerender(null);
});
it('handles click to open/close notification tray', async () => {
await setupDiscussionSidebar();
const notificationShowButton = await screen.findByRole('button', { name: /Show notification tray/i });
expect(screen.queryByRole('region', { name: /notification tray/i })).not.toBeInTheDocument();
fireEvent.click(notificationShowButton);
const notificationTray = await screen.findByRole('region', { name: /notification tray/i });
expect(notificationTray).toBeInTheDocument();
expect(notificationTray).not.toHaveClass('d-none');
});
it('doesn\'t renders course breadcrumbs by default', async () => {
const courseMetadata = Factory.build('courseMetadata');
const unitBlocks = Array.from({ length: 3 }).map(() => Factory.build(
'block',
{ type: 'vertical' },
{ courseId: courseMetadata.id },
));
const testStore = await initializeTestStore({
courseMetadata, unitBlocks,
}, false);
const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
...mockData,
courseId,
sequenceId,
unitId: Object.values(models.units)[1].id, // Corner cases are already covered in `Sequence` tests.
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
loadUnit();
await waitFor(() => {
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
});
// expect the section and sequence "titles" not to be loaded in as breadcrumb labels.
await waitFor(() => {
expect(screen.queryByText(Object.values(models.sections)[0].title)).not.toBeInTheDocument();
expect(screen.queryByText(Object.values(models.sequences)[0].title)).not.toBeInTheDocument();
});
});
it('passes handlers to the sequence', async () => {
const nextSequenceHandler = jest.fn();
const previousSequenceHandler = jest.fn();
const unitNavigationHandler = jest.fn();
const courseMetadata = Factory.build('courseMetadata');
const unitBlocks = Array.from({ length: 3 }).map(() => Factory.build(
'block',
{ type: 'vertical' },
{ courseId: courseMetadata.id },
));
const testStore = await initializeTestStore({ courseMetadata, unitBlocks }, false);
const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
...mockData,
courseId,
sequenceId,
unitId: Object.values(models.units)[1].id, // Corner cases are already covered in `Sequence` tests.
nextSequenceHandler,
previousSequenceHandler,
unitNavigationHandler,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
loadUnit();
waitFor(() => {
expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument();
screen.getAllByRole('link', { name: /previous/i }).forEach(link => fireEvent.click(link));
screen.getAllByRole('link', { name: /next/i }).forEach(link => fireEvent.click(link));
// We are in the middle of the sequence, so no
expect(previousSequenceHandler).not.toHaveBeenCalled();
expect(nextSequenceHandler).not.toHaveBeenCalled();
expect(unitNavigationHandler).toHaveBeenCalledTimes(4);
});
});
describe('Sequence alerts display', () => {
it('renders banner text alert', async () => {
const courseMetadata = Factory.build('courseMetadata');
const sequenceBlocks = [Factory.build('block', { type: 'sequential', banner_text: 'Some random banner text to display.' })];
const sequenceMetadata = [Factory.build(
'sequenceMetadata',
{ banner_text: sequenceBlocks[0].banner_text },
{ courseId: courseMetadata.id, sequenceBlock: sequenceBlocks[0] },
)];
const testStore = await initializeTestStore({ courseMetadata, sequenceBlocks, sequenceMetadata });
const testData = {
...mockData,
courseId: courseMetadata.id,
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
waitFor(() => expect(screen.findByText('Some random banner text to display.')).toBeInTheDocument());
});
it('renders Entrance Exam alert with passing score', async () => {
const sectionId = 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@entrance_exam';
const testCourseMetadata = Factory.build('courseMetadata', {
entrance_exam_data: {
entrance_exam_current_score: 1.0,
entrance_exam_enabled: true,
entrance_exam_id: sectionId,
entrance_exam_minimum_score_pct: 0.7,
entrance_exam_passed: true,
},
});
const sequenceBlocks = [Factory.build(
'block',
{ type: 'sequential', sectionId },
{ courseId: testCourseMetadata.id },
)];
const sectionBlocks = [Factory.build(
'block',
{ type: 'chapter', children: sequenceBlocks.map(block => block.id), id: sectionId },
{ courseId: testCourseMetadata.id },
)];
const testStore = await initializeTestStore({
courseMetadata: testCourseMetadata, sequenceBlocks, sectionBlocks,
});
const testData = {
...mockData,
courseId: testCourseMetadata.id,
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
waitFor(() => expect(screen.findByText('Your score is 100%. You have passed the entrance exam.')).toBeInTheDocument());
});
it('renders Entrance Exam alert with non-passing score', async () => {
const sectionId = 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@entrance_exam';
const testCourseMetadata = Factory.build('courseMetadata', {
entrance_exam_data: {
entrance_exam_current_score: 0.3,
entrance_exam_enabled: true,
entrance_exam_id: sectionId,
entrance_exam_minimum_score_pct: 0.7,
entrance_exam_passed: false,
},
});
const sequenceBlocks = [Factory.build(
'block',
{ type: 'sequential', sectionId },
{ courseId: testCourseMetadata.id },
)];
const sectionBlocks = [Factory.build(
'block',
{ type: 'chapter', children: sequenceBlocks.map(block => block.id), id: sectionId },
{ courseId: testCourseMetadata.id },
)];
const testStore = await initializeTestStore({
courseMetadata: testCourseMetadata, sequenceBlocks, sectionBlocks,
});
const testData = {
...mockData,
courseId: testCourseMetadata.id,
sequenceId: sequenceBlocks[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
waitFor(() => expect(screen.findByText('To access course materials, you must score 70% or higher on this exam. Your current score is 30%.')).toBeInTheDocument());
});
});
it('displays learner tools when screen is wide enough (browser)', async () => {
const courseMetadata = Factory.build('courseMetadata', {
enrollment: { mode: 'verified' },
});
const testStore = await initializeTestStore({ courseMetadata }, false);
const { courseware, models } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
...mockData,
courseId,
sequenceId,
unitId: Object.values(models.units)[0].id,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const learnerTools = screen.queryByTestId(mockLearnerToolsTestId);
await waitFor(() => expect(learnerTools).toBeInTheDocument());
});
it('does not display learner tools when screen is too narrow (mobile)', async () => {
global.innerWidth = breakpoints.extraSmall.minWidth;
const courseMetadata = Factory.build('courseMetadata', {
enrollment: { mode: 'verified' },
});
const testStore = await initializeTestStore({ courseMetadata }, false);
const { courseware } = testStore.getState();
const { courseId, sequenceId } = courseware;
const testData = {
...mockData,
courseId,
sequenceId,
};
render(<Course {...testData} />, { store: testStore, wrapWithRouter: true });
const learnerTools = screen.queryByTestId(mockLearnerToolsTestId);
await expect(learnerTools).not.toBeInTheDocument();
});
});