diff --git a/.eslintrc.js b/.eslintrc.js index 0e87381978..9ed6c9fe81 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,3 +1,11 @@ const { createConfig } = require('@edx/frontend-build'); -module.exports = createConfig('eslint'); \ No newline at end of file +module.exports = createConfig('eslint', { + overrides: [{ + files: ["**/__tests__/**/*.[jt]s?(x)", "**/?(*.)+(spec|test).[jt]s?(x)", "setupTest.js"], + rules: { + 'import/named': 'off', + 'import/no-extraneous-dependencies': 'off', + }, + }], +}); diff --git a/jest.config.js b/jest.config.js index 7c936b4c09..dc38c063ea 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ const { createConfig } = require('@edx/frontend-build'); module.exports = createConfig('jest', { - setupFiles: [ + setupFilesAfterEnv: [ '/src/setupTest.js', ], coveragePathIgnorePatterns: [ diff --git a/package-lock.json b/package-lock.json index 9097043aa5..af6043082b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2833,6 +2833,15 @@ "@testing-library/dom": "^7.14.2" } }, + "@testing-library/user-event": { + "version": "12.0.11", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.0.11.tgz", + "integrity": "sha512-r7QNfktLE2n8IODEl32orup/HNOMueJpoXRDeTMlvWR4nZIHJwx59+8SkLf6nqV4Ot5Xo6qNeaWrvC1KO4eOng==", + "dev": true, + "requires": { + "@babel/runtime": "^7.10.2" + } + }, "@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", diff --git a/package.json b/package.json index c6387e5392..b9423c4471 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "@testing-library/dom": "^7.16.2", "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^10.3.0", + "@testing-library/user-event": "^12.0.2", "axios-mock-adapter": "^1.18.1", "codecov": "^3.6.1", "es-check": "^5.1.0", diff --git a/src/course-home/data/redux.test.js b/src/course-home/data/redux.test.js index db87abf66e..adf9b36bcf 100644 --- a/src/course-home/data/redux.test.js +++ b/src/course-home/data/redux.test.js @@ -8,8 +8,6 @@ import * as thunks from './thunks'; import executeThunk from '../../utils'; -import './__factories__'; -import '../../courseware/data/__factories__/courseMetadata.factory'; import initializeMockApp from '../../setupTest'; import initializeStore from '../../store'; diff --git a/src/courseware/CoursewareContainer.test.jsx b/src/courseware/CoursewareContainer.test.jsx index 26b9a5773a..ed2a088bb3 100644 --- a/src/courseware/CoursewareContainer.test.jsx +++ b/src/courseware/CoursewareContainer.test.jsx @@ -14,7 +14,6 @@ import tabMessages from '../tab-page/messages'; import initializeMockApp from '../setupTest'; import CoursewareContainer from './CoursewareContainer'; -import './data/__factories__'; import buildSimpleCourseBlocks from './data/__factories__/courseBlocks.factory'; import initializeStore from '../store'; @@ -79,28 +78,29 @@ describe('CoursewareContainer', () => { it('should successfully render sequence navigation and unit', async () => { const courseMetadata = Factory.build('courseMetadata'); const courseId = courseMetadata.id; - const { courseBlocks, unitBlock, sequenceBlock } = buildSimpleCourseBlocks(courseId, courseMetadata.name); + const { courseBlocks, unitBlocks, sequenceBlock } = buildSimpleCourseBlocks(courseId, courseMetadata.name); const sequenceMetadata = Factory.build( 'sequenceMetadata', {}, - { courseId, unitBlocks: [unitBlock], sequenceBlock }, + { courseId, unitBlocks, sequenceBlock: sequenceBlock[0] }, ); const courseMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`; const courseBlocksUrlRegExp = new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`); - const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceBlock.id}`; - const unitId = unitBlock.id; + const sequenceMetadataUrl = `${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceBlock[0].id}`; + const unitId = unitBlocks[0].id; axiosMock.onGet(courseMetadataUrl).reply(200, courseMetadata); axiosMock.onGet(courseBlocksUrlRegExp).reply(200, courseBlocks); axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, { - sectionId: sequenceBlock.id, - unitId: unitBlock.id, + sectionId: sequenceBlock[0].id, + unitId: unitBlocks[0].id, }); axiosMock.onGet(sequenceMetadataUrl).reply(200, sequenceMetadata); // Print out any URLs that we didn't handle above - useful for debugging the test. axiosMock.onAny().reply((config) => { + // eslint-disable-next-line no-console console.log(config.url); return [200, {}]; }); @@ -126,7 +126,7 @@ describe('CoursewareContainer', () => { expect(sequenceNavButtons[0]).toHaveTextContent('Previous'); // Prove this button is rendering an SVG book icon, meaning it's a unit. - expect(sequenceNavButtons[1].querySelector('svg')).toHaveClass('fa-book'); + expect(sequenceNavButtons[1].querySelector('svg')).toHaveClass('fa-tasks'); expect(sequenceNavButtons[2]).toHaveTextContent('Next'); expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents'); @@ -146,11 +146,11 @@ describe('CoursewareContainer', () => { }, }); const courseId = courseMetadata.id; - const { courseBlocks, unitBlock, sequenceBlock } = buildSimpleCourseBlocks(courseId, courseMetadata.name); + const { courseBlocks, unitBlocks, sequenceBlock } = buildSimpleCourseBlocks(courseId, courseMetadata.name); const sequenceMetadata = Factory.build( 'sequenceMetadata', {}, - { courseId, unitBlocks: [unitBlock], sequenceBlock }, + { courseId, unitBlocks, sequenceBlock: sequenceBlock[0] }, ); const forbiddenCourseUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`; diff --git a/src/courseware/course/sequence/Sequence.test.jsx b/src/courseware/course/sequence/Sequence.test.jsx new file mode 100644 index 0000000000..966525b24d --- /dev/null +++ b/src/courseware/course/sequence/Sequence.test.jsx @@ -0,0 +1,344 @@ +import React from 'react'; +import { Factory } from 'rosie'; +import { sendTrackEvent } from '@edx/frontend-platform/analytics'; +import { + loadUnit, render, screen, fireEvent, waitFor, initializeTestStore, +} from '../../../setupTest'; +import Sequence from './Sequence'; +import { fetchSequenceFailure } from '../../data/slice'; + +jest.mock('@edx/frontend-platform/analytics'); + +describe('Sequence', () => { + let mockData; + const courseMetadata = Factory.build('courseMetadata'); + const unitBlocks = Array.from({ length: 3 }).map(() => Factory.build( + 'block', + { type: 'vertical' }, + { courseId: courseMetadata.id }, + )); + + beforeAll(async () => { + const store = await initializeTestStore({ courseMetadata, unitBlocks }); + const { courseware } = store.getState(); + mockData = { + unitId: unitBlocks[0].id, + sequenceId: courseware.sequenceId, + courseId: courseware.courseId, + unitNavigationHandler: () => {}, + nextSequenceHandler: () => {}, + previousSequenceHandler: () => {}, + }; + }); + + it('renders correctly without data', async () => { + const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false); + render(, { store: testStore }); + + expect(screen.getByText('There is no content here.')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('renders correctly for gated content', async () => { + const sequenceBlock = [Factory.build( + 'block', + { type: 'sequential', children: [unitBlocks.map(block => block.id)] }, + { courseId: courseMetadata.id }, + )]; + const gatedContent = { + gated: true, + prereq_id: `${sequenceBlock[0].id}-prereq`, + prereq_section_name: `${sequenceBlock[0].display_name}-prereq`, + gated_section_name: sequenceBlock[0].display_name, + }; + const sequenceMetadata = [Factory.build( + 'sequenceMetadata', + { gated_content: gatedContent }, + { courseId: courseMetadata.id, unitBlocks, sequenceBlock: sequenceBlock[0] }, + )]; + const testStore = await initializeTestStore( + { + courseMetadata, unitBlocks, sequenceBlock, sequenceMetadata, + }, false, + ); + const { container } = render( + , + { store: testStore }, + ); + + expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument(); + // Only `Previous`, `Next` and `Bookmark` buttons. + expect(screen.getAllByRole('button').length).toEqual(3); + + expect(await screen.findByText('Content Locked')).toBeInTheDocument(); + const unitContainer = container.querySelector('.unit-container'); + expect(unitContainer.querySelector('svg')).toHaveClass('fa-lock'); + expect(screen.getByText(/You must complete the prerequisite/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Go To Prerequisite Section' })).toBeInTheDocument(); + expect(screen.queryByText('Loading locked content messaging...')).not.toBeInTheDocument(); + }); + + it('displays error message on sequence load failure', async () => { + const testStore = await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true }, false); + testStore.dispatch(fetchSequenceFailure({ sequenceId: mockData.sequenceId })); + render(, { store: testStore }); + + expect(screen.getByText('There was an error loading this course.')).toBeInTheDocument(); + }); + + it('handles loading unit', async () => { + render(); + expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument(); + // Renders navigation buttons plus one button for each unit. + expect(screen.getAllByRole('button')).toHaveLength(3 + unitBlocks.length); + + loadUnit(); + 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); + }); + + describe('sequence and unit navigation buttons', () => { + let testStore; + const sequenceBlock = [Factory.build( + 'block', + { type: 'sequential', children: [unitBlocks.map(block => block.id)] }, + { courseId: courseMetadata.id }, + ), Factory.build( + 'block', + { type: 'sequential', children: [unitBlocks.map(block => block.id)] }, + { courseId: courseMetadata.id }, + )]; + + beforeAll(async () => { + testStore = await initializeTestStore({ courseMetadata, unitBlocks, sequenceBlock }, false); + }); + + beforeEach(() => { + sendTrackEvent.mockClear(); + }); + + it('navigates to the previous sequence if the unit is the first in the sequence', async () => { + const testData = { + ...mockData, + sequenceId: sequenceBlock[1].id, + previousSequenceHandler: jest.fn(), + }; + render(, { store: testStore }); + + const sequencePreviousButton = screen.getByRole('button', { name: /previous/i }); + fireEvent.click(sequencePreviousButton); + expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(1); + expect(sendTrackEvent).toHaveBeenCalledTimes(1); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.previous_selected', { + current_tab: 1, + id: testData.unitId, + tab_count: unitBlocks.length, + widget_placement: 'top', + }); + + loadUnit(); + 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(testData.previousSequenceHandler).toHaveBeenCalledTimes(2); + expect(sendTrackEvent).toHaveBeenCalledTimes(2); + expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.previous_selected', { + current_tab: 1, + id: testData.unitId, + tab_count: unitBlocks.length, + widget_placement: 'bottom', + }); + }); + + it('navigates to the next sequence if the unit is the last in the sequence', async () => { + const testData = { + ...mockData, + unitId: unitBlocks[unitBlocks.length - 1].id, + sequenceId: sequenceBlock[0].id, + nextSequenceHandler: jest.fn(), + }; + render(, { store: testStore }); + + const sequenceNextButton = screen.getByRole('button', { name: /next/i }); + fireEvent.click(sequenceNextButton); + expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(1); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.next_selected', { + current_tab: unitBlocks.length, + id: testData.unitId, + tab_count: unitBlocks.length, + widget_placement: 'top', + }); + + loadUnit(); + 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(testData.nextSequenceHandler).toHaveBeenCalledTimes(2); + expect(sendTrackEvent).toHaveBeenCalledTimes(2); + expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.next_selected', { + current_tab: unitBlocks.length, + id: testData.unitId, + tab_count: unitBlocks.length, + widget_placement: 'bottom', + }); + }); + + it('navigates to the previous/next unit if the unit is not in the corner of the sequence', () => { + const unitNumber = 1; + const testData = { + ...mockData, + unitId: unitBlocks[unitNumber].id, + sequenceId: sequenceBlock[0].id, + unitNavigationHandler: jest.fn(), + previousSequenceHandler: jest.fn(), + nextSequenceHandler: jest.fn(), + }; + render(, { store: testStore }); + + fireEvent.click(screen.getByRole('button', { name: /previous/i })); + expect(testData.previousSequenceHandler).not.toHaveBeenCalled(); + expect(testData.unitNavigationHandler).toHaveBeenCalledWith(unitBlocks[unitNumber - 1].id); + + fireEvent.click(screen.getByRole('button', { name: /next/i })); + expect(testData.nextSequenceHandler).not.toHaveBeenCalled(); + // As `previousSequenceHandler` and `nextSequenceHandler` are mocked, we aren't really changing the position here. + // Therefore the next unit will still be `the initial one + 1`. + expect(testData.unitNavigationHandler).toHaveBeenNthCalledWith(2, unitBlocks[unitNumber + 1].id); + + expect(sendTrackEvent).toHaveBeenCalledTimes(2); + }); + + it('handles the `Previous` buttons for the first unit in the first sequence', async () => { + const testData = { + ...mockData, + unitId: unitBlocks[0].id, + sequenceId: sequenceBlock[0].id, + unitNavigationHandler: jest.fn(), + previousSequenceHandler: jest.fn(), + }; + render(, { store: testStore }); + loadUnit(); + await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); + + screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button)); + + expect(testData.previousSequenceHandler).not.toHaveBeenCalled(); + expect(testData.unitNavigationHandler).not.toHaveBeenCalled(); + expect(sendTrackEvent).not.toHaveBeenCalled(); + }); + + it('handles the `Next` buttons for the last unit in the last sequence', async () => { + const testData = { + ...mockData, + unitId: unitBlocks[unitBlocks.length - 1].id, + sequenceId: sequenceBlock[sequenceBlock.length - 1].id, + unitNavigationHandler: jest.fn(), + nextSequenceHandler: jest.fn(), + }; + render(, { store: testStore }); + loadUnit(); + await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); + + screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button)); + + expect(testData.nextSequenceHandler).not.toHaveBeenCalled(); + expect(testData.unitNavigationHandler).not.toHaveBeenCalled(); + expect(sendTrackEvent).not.toHaveBeenCalled(); + }); + + it('handles the navigation buttons for empty sequence', async () => { + const testSequenceBlock = [Factory.build( + 'block', + { type: 'sequential', children: [unitBlocks.map(block => block.id)] }, + { courseId: courseMetadata.id }, + ), Factory.build( + 'block', + { type: 'sequential', children: [] }, + { courseId: courseMetadata.id }, + ), Factory.build( + 'block', + { type: 'sequential', children: [unitBlocks.map(block => block.id)] }, + { courseId: courseMetadata.id }, + )]; + const testSequenceMetadata = testSequenceBlock.map(block => Factory.build( + 'sequenceMetadata', + {}, + { courseId: courseMetadata.id, unitBlocks: block.children.length ? unitBlocks : [], sequenceBlock: block }, + )); + const innerTestStore = await initializeTestStore({ + courseMetadata, unitBlocks, sequenceBlock: testSequenceBlock, sequenceMetadata: testSequenceMetadata, + }, false); + const testData = { + ...mockData, + unitId: unitBlocks[0].id, + sequenceId: testSequenceBlock[1].id, + unitNavigationHandler: jest.fn(), + previousSequenceHandler: jest.fn(), + nextSequenceHandler: jest.fn(), + }; + + render(, { store: innerTestStore }); + loadUnit(); + await waitFor(() => expect(screen.queryByText('Loading learning sequence...')).not.toBeInTheDocument()); + + screen.getAllByRole('button', { name: /previous/i }).forEach(button => fireEvent.click(button)); + expect(testData.previousSequenceHandler).toHaveBeenCalledTimes(2); + expect(testData.unitNavigationHandler).not.toHaveBeenCalled(); + + screen.getAllByRole('button', { name: /next/i }).forEach(button => fireEvent.click(button)); + expect(testData.nextSequenceHandler).toHaveBeenCalledTimes(2); + expect(testData.unitNavigationHandler).not.toHaveBeenCalled(); + + expect(sendTrackEvent).toHaveBeenNthCalledWith(1, 'edx.ui.lms.sequence.previous_selected', { + current_tab: 1, + id: testData.unitId, + tab_count: 0, + widget_placement: 'top', + }); + expect(sendTrackEvent).toHaveBeenNthCalledWith(2, 'edx.ui.lms.sequence.previous_selected', { + current_tab: 1, + id: testData.unitId, + tab_count: 0, + widget_placement: 'bottom', + }); + expect(sendTrackEvent).toHaveBeenNthCalledWith(3, 'edx.ui.lms.sequence.next_selected', { + current_tab: 1, + id: testData.unitId, + tab_count: 0, + widget_placement: 'top', + }); + expect(sendTrackEvent).toHaveBeenNthCalledWith(4, 'edx.ui.lms.sequence.next_selected', { + current_tab: 1, + id: testData.unitId, + tab_count: 0, + widget_placement: 'bottom', + }); + }); + + it('handles unit navigation button', () => { + const currentTabNumber = 1; + const targetUnitNumber = 2; + const targetUnit = unitBlocks[targetUnitNumber - 1]; + const testData = { + ...mockData, + unitId: unitBlocks[currentTabNumber - 1].id, + sequenceId: sequenceBlock[0].id, + unitNavigationHandler: jest.fn(), + }; + render(, { store: testStore }); + + fireEvent.click(screen.getByRole('button', { name: targetUnit.display_name })); + expect(testData.unitNavigationHandler).toHaveBeenCalledWith(targetUnit.id); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.ui.lms.sequence.tab_selected', { + current_tab: currentTabNumber, + id: testData.unitId, + target_tab: targetUnitNumber, + tab_count: unitBlocks.length, + widget_placement: 'top', + }); + }); + }); +}); diff --git a/src/courseware/course/sequence/SequenceContent.test.jsx b/src/courseware/course/sequence/SequenceContent.test.jsx new file mode 100644 index 0000000000..23c60a272f --- /dev/null +++ b/src/courseware/course/sequence/SequenceContent.test.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { initializeTestStore, render, screen } from '../../../setupTest'; +import SequenceContent from './SequenceContent'; + +describe('Sequence Content', () => { + let mockData; + let store; + + beforeAll(async () => { + store = await initializeTestStore(); + const { models, courseware } = store.getState(); + mockData = { + gated: false, + courseId: courseware.courseId, + sequenceId: courseware.sequenceId, + unitId: models.sequences[courseware.sequenceId].unitIds[0], + unitLoadedHandler: () => {}, + }; + }); + + it('displays loading message', () => { + render(); + expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument(); + }); + + it('displays messages for the locked content', async () => { + const { gatedContent } = store.getState().models.sequences[mockData.sequenceId]; + const { container } = render(); + + expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument(); + expect(await screen.findByText('Content Locked')).toBeInTheDocument(); + expect(screen.queryByText('Loading locked content messaging...')).not.toBeInTheDocument(); + expect(container.querySelector('svg')).toHaveClass('fa-lock'); + expect(screen.getByText( + `You must complete the prerequisite: '${gatedContent.gatedSectionName}' to access this content.`, + )).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Go To Prerequisite Section' })).toBeInTheDocument(); + }); + + it('displays message for no content', () => { + render(); + expect(screen.getByText('There is no content here.')).toBeInTheDocument(); + }); +}); diff --git a/src/courseware/course/sequence/Unit.test.jsx b/src/courseware/course/sequence/Unit.test.jsx new file mode 100644 index 0000000000..6f65251a91 --- /dev/null +++ b/src/courseware/course/sequence/Unit.test.jsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { Factory } from 'rosie'; +import { + initializeTestStore, loadUnit, messageEvent, render, screen, waitFor, +} from '../../../setupTest'; +import Unit from './Unit'; + +describe('Unit', () => { + let mockData; + const courseMetadata = Factory.build( + 'courseMetadata', + { content_type_gating_enabled: true }, + ); + const unitBlocks = [Factory.build( + 'block', + { type: 'problem' }, + { courseId: courseMetadata.id }, + ), Factory.build( + 'block', + { type: 'vertical', graded: true, bookmarked: true }, + { courseId: courseMetadata.id }, + )]; + const [unit, gradedUnit] = unitBlocks; + + beforeAll(async () => { + await initializeTestStore({ courseMetadata, unitBlocks }); + mockData = { + id: unit.id, + courseId: courseMetadata.id, + }; + }); + + it('renders correctly', () => { + render(); + + expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument(); + const renderedUnit = screen.getByTitle(unit.display_name); + expect(renderedUnit).toHaveAttribute('height', String(0)); + expect(renderedUnit).toHaveAttribute( + 'src', `http://localhost:18000/xblock/${mockData.id}?show_title=0&show_bookmark_button=0`, + ); + }); + + it('renders proper message for gated content', () => { + render(); + + expect(screen.getByText('Loading learning sequence...')).toBeInTheDocument(); + expect(screen.getByText('Loading locked content messaging...')).toBeInTheDocument(); + }); + + it('handles receiving MessageEvent', async () => { + render(); + loadUnit(); + + // 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(unit.display_name)).toHaveAttribute('height', String(messageEvent.payload.height)); + }); + + it('calls onLoaded after receiving MessageEvent', async () => { + const onLoaded = jest.fn(); + render(); + loadUnit(); + + 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(); + loadUnit(); + + await waitFor(() => expect(screen.getByTitle(unit.display_name)).toHaveAttribute('height', String(messageEvent.payload.height))); + window.postMessage(testMessageWithOtherHeight, '*'); + await waitFor(() => expect(screen.getByTitle(unit.display_name)).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(); + 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(unit.display_name)).toHaveAttribute('height', String(testMessageWithUnhandledType.payload.height)), + { timeout: 100 }, + )).rejects.toThrowError(/Expected the element to have attribute/); + }); +}); diff --git a/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx b/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx index 44ab4f7077..c5d38cb8ab 100644 --- a/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx +++ b/src/courseware/course/sequence/sequence-navigation/SequenceNavigation.jsx @@ -11,6 +11,7 @@ import UnitButton from './UnitButton'; import SequenceNavigationTabs from './SequenceNavigationTabs'; import { useSequenceNavigationMetadata } from './hooks'; import { useModel } from '../../../../generic/model-store'; +import { LOADED } from '../../../data/slice'; export default function SequenceNavigation({ unitId, @@ -22,8 +23,10 @@ export default function SequenceNavigation({ }) { const sequence = useModel('sequences', sequenceId); const { isFirstUnit, isLastUnit } = useSequenceNavigationMetadata(sequenceId, unitId); - const isLocked = sequence.gatedContent !== undefined && sequence.gatedContent.gated; const sequenceStatus = useSelector(state => state.courseware.sequenceStatus); + const isLocked = sequenceStatus === LOADED ? ( + sequence.gatedContent !== undefined && sequence.gatedContent.gated + ) : undefined; const renderUnitButtons = () => { if (isLocked) { @@ -46,7 +49,7 @@ export default function SequenceNavigation({ ); }; - return sequenceStatus === 'loaded' && ( + return sequenceStatus === LOADED && (