diff --git a/src/courseware/course/sequence/Sequence.test.jsx b/src/courseware/course/sequence/Sequence.test.jsx new file mode 100644 index 00000000..edc1f528 --- /dev/null +++ b/src/courseware/course/sequence/Sequence.test.jsx @@ -0,0 +1,240 @@ +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/dom'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { cloneDeep } from 'lodash'; +import { sendTrackEvent } from '@edx/frontend-platform/analytics'; +import { + initialState, + messageEvent, render, screen, testUnits, +} from '../../../setupTest'; +import Sequence from './Sequence'; + +jest.mock('@edx/frontend-platform/analytics'); + +describe('Sequence', () => { + const mockData = { + unitId: '3', + sequenceId: '1', + courseId: '1', + unitNavigationHandler: () => {}, + nextSequenceHandler: () => {}, + previousSequenceHandler: () => {}, + intl: {}, + }; + + it('renders correctly without data', () => { + const { asFragment } = render( + , { initialState: {} }, + ); + expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(asFragment()).toMatchSnapshot(); + }); + + it('renders correctly for gated content', async () => { + const { asFragment } = render(); + expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument(); + // Only `Previous`, `Next` and `Bookmark` buttons. + expect(screen.getAllByRole('button').length).toEqual(3); + + const beforeLoadingUnit = asFragment(); + expect(beforeLoadingUnit).toMatchSnapshot(); + + window.postMessage(messageEvent, '*'); + await waitFor(() => expect(screen.getByText(/You must complete the prerequisite/)).toBeInTheDocument()); + expect(beforeLoadingUnit).toMatchDiffSnapshot(asFragment()); + }); + + it('displays error message on sequence load failure', () => { + const testState = cloneDeep(initialState); + testState.courseware.sequenceStatus = 'failed'; + const { asFragment } = render(, { initialState: testState }); + + expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument(); + expect(asFragment()).toMatchSnapshot(); + }); + + it('handles loading unit', async () => { + const { asFragment } = render(); + expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument(); + // Renders navigation buttons plus one button for each unit. + expect(screen.getAllByRole('button').length).toEqual(3 + testUnits.length); + + const beforeLoadingUnit = asFragment(); + expect(beforeLoadingUnit).toMatchSnapshot(); + + window.postMessage(messageEvent, '*'); + await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); + // At this point there will be 2 `Previous` and 2 `Next` buttons. + expect(screen.getAllByRole('button', { name: /previous|next/i }).length).toEqual(4); + expect(beforeLoadingUnit).toMatchDiffSnapshot(asFragment()); + }); + + it('navigates to the previous sequence if the unit is the first in the sequence', async () => { + sendTrackEvent.mockClear(); + const unitId = '1'; + const sequenceId = '2'; + const previousSequenceHandler = jest.fn(); + render(); + + const sequencePreviousButton = screen.getByRole('button', { name: /previous/i }); + fireEvent.click(sequencePreviousButton); + expect(previousSequenceHandler).toHaveBeenCalledTimes(1); + expect(sendTrackEvent).toHaveBeenCalledTimes(1); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.previous_selected', { + current_tab: Number(unitId), id: unitId, tab_count: testUnits.length, widget_placement: 'top', + }); + + window.postMessage(messageEvent, '*'); + await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); + const unitPreviousButton = screen.getAllByRole('button', { name: /previous/i }) + .filter(button => button !== sequencePreviousButton)[0]; + fireEvent.click(unitPreviousButton); + expect(previousSequenceHandler).toHaveBeenCalledTimes(2); + expect(sendTrackEvent).toHaveBeenCalledTimes(2); + expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.previous_selected', { + current_tab: Number(unitId), id: unitId, tab_count: testUnits.length, widget_placement: 'bottom', + }); + }); + + it('navigates to the next sequence if the unit is the last in the sequence', async () => { + sendTrackEvent.mockClear(); + const unitId = String(testUnits.length); + const sequenceId = '1'; + const nextSequenceHandler = jest.fn(); + render(); + + const sequenceNextButton = screen.getByRole('button', { name: /next/i }); + fireEvent.click(sequenceNextButton); + expect(nextSequenceHandler).toHaveBeenCalledTimes(1); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.next_selected', { + current_tab: Number(unitId), id: unitId, tab_count: testUnits.length, widget_placement: 'top', + }); + + window.postMessage(messageEvent, '*'); + await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); + const unitNextButton = screen.getAllByRole('button', { name: /next/i }) + .filter(button => button !== sequenceNextButton)[0]; + fireEvent.click(unitNextButton); + expect(nextSequenceHandler).toHaveBeenCalledTimes(2); + expect(sendTrackEvent).toHaveBeenCalledTimes(2); + expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.next_selected', { + current_tab: Number(unitId), id: unitId, tab_count: testUnits.length, widget_placement: 'bottom', + }); + }); + + it('navigates to the previous/next unit if the unit is not in the corner of the sequence', () => { + sendTrackEvent.mockClear(); + const unitNavigationHandler = jest.fn(); + const previousSequenceHandler = jest.fn(); + const nextSequenceHandler = jest.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /previous/i })); + expect(previousSequenceHandler).not.toHaveBeenCalled(); + expect(unitNavigationHandler).toHaveBeenCalledWith(String(Number(mockData.unitId) - 1)); + + fireEvent.click(screen.getByRole('button', { name: /next/i })); + expect(nextSequenceHandler).not.toHaveBeenCalled(); + expect(unitNavigationHandler).toHaveBeenNthCalledWith(2, String(Number(mockData.unitId) + 1)); + + expect(sendTrackEvent).toHaveBeenCalledTimes(2); + }); + + it('handles the `Previous` buttons for the first unit in the first sequence', async () => { + sendTrackEvent.mockClear(); + const unitNavigationHandler = jest.fn(); + const previousSequenceHandler = jest.fn(); + const unitId = '1'; + render(); + window.postMessage(messageEvent, '*'); + await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); + + screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button)); + + expect(previousSequenceHandler).not.toHaveBeenCalled(); + expect(unitNavigationHandler).not.toHaveBeenCalled(); + expect(sendTrackEvent).not.toHaveBeenCalled(); + }); + + it('handles the `Next` buttons for the last unit in the last sequence', async () => { + sendTrackEvent.mockClear(); + const unitNavigationHandler = jest.fn(); + const nextSequenceHandler = jest.fn(); + const unitId = String(testUnits.length); + const sequenceId = String(Object.keys(initialState.models.sequences).length); + render(); + window.postMessage(messageEvent, '*'); + await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); + + screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button)); + + expect(nextSequenceHandler).toHaveBeenCalledTimes(1); + expect(unitNavigationHandler).not.toHaveBeenCalled(); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.next_selected', { + current_tab: Number(unitId), id: unitId, tab_count: testUnits.length, widget_placement: 'top', + }); + }); + + it('handles the navigation buttons for empty sequence', async () => { + sendTrackEvent.mockClear(); + const testState = cloneDeep(initialState); + testState.models.sequences['1'].unitIds = []; + + const unitNavigationHandler = jest.fn(); + const previousSequenceHandler = jest.fn(); + const nextSequenceHandler = jest.fn(); + render(, { initialState: testState }); + window.postMessage(messageEvent, '*'); + await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); + + screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button)); + expect(previousSequenceHandler).toHaveBeenCalledTimes(2); + expect(unitNavigationHandler).not.toHaveBeenCalled(); + + screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button)); + expect(nextSequenceHandler).toHaveBeenCalledTimes(2); + expect(unitNavigationHandler).not.toHaveBeenCalled(); + + expect(sendTrackEvent).toHaveBeenNthCalledWith(1, 'edx.ui.lms.sequence.previous_selected', { + current_tab: 1, id: mockData.unitId, tab_count: 0, widget_placement: 'top', + }); + expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.previous_selected', { + current_tab: 1, id: mockData.unitId, tab_count: 0, widget_placement: 'bottom', + }); + expect(sendTrackEvent).toHaveBeenNthCalledWith(3, 'edx.ui.lms.sequence.next_selected', { + current_tab: 1, id: mockData.unitId, tab_count: 0, widget_placement: 'top', + }); + expect(sendTrackEvent).toHaveBeenNthCalledWith(4, 'edx.ui.lms.sequence.next_selected', { + current_tab: 1, id: mockData.unitId, tab_count: 0, widget_placement: 'bottom', + }); + }); + + it('handles unit navigation button', () => { + sendTrackEvent.mockClear(); + const unitNavigationHandler = jest.fn(); + const targetUnit = '4'; + render(); + + fireEvent.click(screen.getByRole('button', { name: targetUnit })); + expect(unitNavigationHandler).toHaveBeenCalledWith(targetUnit); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.tab_selected', { + current_tab: Number(mockData.unitId), id: mockData.unitId, target_tab: Number(targetUnit), tab_count: testUnits.length, widget_placement: 'top', + }); + }); +}); diff --git a/src/courseware/course/sequence/SequenceContent.test.jsx b/src/courseware/course/sequence/SequenceContent.test.jsx index f0bbf2e3..a0aa14c2 100644 --- a/src/courseware/course/sequence/SequenceContent.test.jsx +++ b/src/courseware/course/sequence/SequenceContent.test.jsx @@ -1,65 +1,8 @@ import React from 'react'; -import { render, screen } from '../../../test/test-utils'; +import { initialState, render, screen } from '../../../setupTest'; import SequenceContent from './SequenceContent'; describe('Sequence Content', () => { - window.scrollTo = jest.fn(); - // HACK: Mock the MutationObserver as it's breaking async testing. - // According to StackOverflow it should be fixed in `jest-environment-jsdom` v16, - // but upgrading `jest` to v26 didn't fix this problem. - // ref: https://stackoverflow.com/questions/61036156/react-typescript-testing-typeerror-mutationobserver-is-not-a-constructor - global.MutationObserver = class { - // eslint-disable-next-line no-unused-vars,no-useless-constructor,no-empty-function - constructor(callback) {} - - disconnect() {} - - // eslint-disable-next-line no-unused-vars - observe(element, initObject) {} - }; - - const testUnits = [...Array(10).keys()].map(i => String(i + 1)); - const initialState = { - courseware: { - sequenceStatus: 'loaded', - courseStatus: 'loaded', - courseId: '1', - }, - models: { - courses: { - 1: { - sectionIds: ['1'], - }, - }, - sections: { - 1: { - sequenceIds: ['1', '2'], - }, - }, - sequences: { - 1: { - unitIds: testUnits, - showCompletion: true, - title: 'test-sequence', - gatedContent: { - prereqId: '1', - gatedSectionName: 'test-gated-section', - }, - }, - }, - units: testUnits.reduce( - (acc, unitId) => Object.assign(acc, { - [unitId]: { - id: unitId, - contentType: 'other', - title: unitId, - }, - }), - {}, - ), - }, - }; - const mockData = { gated: false, courseId: '1', diff --git a/src/courseware/course/sequence/Unit.test.jsx b/src/courseware/course/sequence/Unit.test.jsx new file mode 100644 index 00000000..02ec4998 --- /dev/null +++ b/src/courseware/course/sequence/Unit.test.jsx @@ -0,0 +1,80 @@ +import React from 'react'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { cloneDeep } from 'lodash'; +import { waitFor } from '@testing-library/dom'; +import { + initialState, messageEvent, render, screen, +} from '../../../setupTest'; +import Unit from './Unit'; + +describe('Unit', () => { + const mockData = { + id: '3', + courseId: '1', + intl: {}, + }; + + it('renders correctly', () => { + const { asFragment } = render(, { initialState }); + + expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument(); + expect(screen.getByTitle(mockData.id)).toHaveAttribute('height', String(0)); + expect(asFragment()).toMatchSnapshot(); + }); + + it('renders proper message for gated content', () => { + // Clone initialState. + const testState = cloneDeep(initialState); + testState.models.units[mockData.id].graded = true; + const { asFragment } = render(, { initialState: testState }); + + expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument(); + expect(asFragment()).toMatchSnapshot(); + }); + + it('handles receiving MessageEvent', async () => { + const { asFragment } = render(, { initialState }); + const beforePostingMessage = asFragment(); + + window.postMessage(messageEvent, '*'); + // Loading message is gone now. + await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); + // Iframe's height is set via message. + expect(screen.getByTitle(mockData.id)).toHaveAttribute('height', String(messageEvent.payload.height)); + expect(beforePostingMessage).toMatchDiffSnapshot(asFragment()); + }); + + it('handles onLoaded after receiving MessageEvent', async () => { + const onLoaded = jest.fn(); + render(, { initialState }); + + window.postMessage(messageEvent, '*'); + await waitFor(() => expect(onLoaded).toHaveBeenCalledTimes(1)); + }); + + it('resizes iframe on second MessageEvent, does not call onLoaded again', async () => { + const onLoaded = jest.fn(); + // Clone message and set different height. + const testMessageWithOtherHeight = { ...messageEvent, payload: { height: 200 } }; + render(, { initialState }); + + window.postMessage(messageEvent, '*'); + await waitFor(() => expect(screen.getByTitle(mockData.id)).toHaveAttribute('height', String(messageEvent.payload.height))); + window.postMessage(testMessageWithOtherHeight, '*'); + await waitFor(() => expect(screen.getByTitle(mockData.id)).toHaveAttribute('height', String(testMessageWithOtherHeight.payload.height))); + expect(onLoaded).toHaveBeenCalledTimes(1); + }); + + it('ignores MessageEvent with unhandled type', async () => { + // Clone message and set different type. + const testMessageWithUnhandledType = { ...messageEvent, type: 'wrong type' }; + render(, { initialState }); + + window.postMessage(testMessageWithUnhandledType, '*'); + // HACK: We don't have a function we could reliably await here, so this test relies on the timeout of `waitFor`. + await expect(waitFor( + () => expect(screen.getByTitle(mockData.id)).toHaveAttribute('height', String(testMessageWithUnhandledType.payload.height)), + { timeout: 100 }, + )).rejects.toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/src/courseware/course/sequence/__snapshots__/Sequence.test.jsx.snap b/src/courseware/course/sequence/__snapshots__/Sequence.test.jsx.snap new file mode 100644 index 00000000..19ba71c7 --- /dev/null +++ b/src/courseware/course/sequence/__snapshots__/Sequence.test.jsx.snap @@ -0,0 +1,475 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Sequence displays error message on sequence load failure 1`] = ` + +

+ There was an error loading this course. +

+
+`; + +exports[`Sequence handles loading unit 1`] = ` + +
+
+ +
+
+

+ 3 +

+ +
+
+
+ + Loading learning sequence... + +
+
+
+
+