Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"@testing-library/dom": "^7.16.2",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.3.0",
"axios-mock-adapter": "^1.18.1",
"axios-mock-adapter": "^1.18.2",
"codecov": "^3.6.1",
"es-check": "^5.1.0",
"glob": "^7.1.6",
Expand Down
11 changes: 7 additions & 4 deletions src/course-home/data/__factories__/outlineTabData.factory.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies

import '../../../courseware/data/__factories__/courseBlocks.factory';
import buildSimpleCourseBlocks from '../../../courseware/data/__factories__/courseBlocks.factory';

Factory.define('outlineTabData')
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
Expand All @@ -10,7 +10,10 @@ Factory.define('outlineTabData')
title: 'Bookmarks',
url: `${host}/courses/${courseId}/bookmarks/`,
}))
.attr('course_blocks', ['courseId'], courseId => ({
blocks: Factory.build('courseBlocks', { courseId }).blocks,
}))
.attr('course_blocks', ['courseId'], courseId => {
const { courseBlocks } = buildSimpleCourseBlocks(courseId, null);
return {
blocks: courseBlocks.blocks,
};
})
.attr('handouts_html', [], () => '<ul><li>Handout 1</li></ul>');
18 changes: 12 additions & 6 deletions src/courseware/CoursewareContainer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,14 @@ const checkContentRedirect = memoize((courseId, sequenceStatus, sequenceId, sequ
});

class CoursewareContainer extends Component {
checkSaveSequencePosition = memoize((courseId, sequenceStatus, sequenceId, sequence, unitId) => {
if (sequenceStatus === 'loaded' && sequence.savePosition) {
checkSaveSequencePosition = memoize((unitId) => {
const {
courseId,
sequenceId,
sequenceStatus,
sequence,
} = this.props;
if (sequenceStatus === 'loaded' && sequence.saveUnitPosition && unitId) {
const activeUnitIndex = sequence.unitIds.indexOf(unitId);
this.props.saveSequencePosition(courseId, sequenceId, activeUnitIndex);
}
Expand Down Expand Up @@ -87,7 +93,6 @@ class CoursewareContainer extends Component {
const {
courseId,
sequenceId,
unitId,
courseStatus,
sequenceStatus,
sequence,
Expand All @@ -96,6 +101,7 @@ class CoursewareContainer extends Component {
params: {
courseId: routeCourseId,
sequenceId: routeSequenceId,
unitId: routeUnitId,
},
},
} = this.props;
Expand All @@ -108,13 +114,13 @@ class CoursewareContainer extends Component {
checkExamRedirect(sequenceStatus, sequence);

// Determine if we need to redirect because our URL is incomplete.
checkContentRedirect(courseId, sequenceStatus, sequenceId, sequence, unitId);
checkContentRedirect(courseId, sequenceStatus, sequenceId, sequence, routeUnitId);

// Determine if we can resume where we left off.
checkResumeRedirect(courseStatus, courseId, sequenceId, firstSequenceId);

// Check if we should save our sequence position.
this.checkSaveSequencePosition(courseId, sequenceStatus, sequenceId, sequence, unitId);
// Check if we should save our sequence position. Only do this when the route unit ID changes.
this.checkSaveSequencePosition(routeUnitId);
}

handleUnitNavigationClick = (nextUnitId) => {
Expand Down
259 changes: 203 additions & 56 deletions src/courseware/CoursewareContainer.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,13 @@ jest.mock(

initializeMockApp();

const axiosMock = new MockAdapter(getAuthenticatedHttpClient());

describe('CoursewareContainer', () => {
let store;
let component;
let axiosMock;

beforeEach(() => {
axiosMock.reset();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());

store = initializeStore();

Expand Down Expand Up @@ -76,62 +75,210 @@ 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 sequenceMetadata = Factory.build(
'sequenceMetadata',
{},
{ courseId, unitBlocks: [unitBlock], sequenceBlock },
);
describe('when receiving successful course data', () => {
let courseId;
let courseMetadata;
let courseBlocks;
let sequenceMetadata;

let sequenceBlock;
let unitBlocks;

function assertLoadedHeader(container) {
const courseHeader = container.querySelector('.course-header');
// Ensure the course number and org appear - this proves we loaded course metadata properly.
expect(courseHeader).toHaveTextContent(courseMetadata.number);
expect(courseHeader).toHaveTextContent(courseMetadata.org);
// Ensure the course title is showing up in the header. This means we loaded course blocks properly.
expect(courseHeader.querySelector('.course-title')).toHaveTextContent(courseMetadata.name);
}

function assertSequenceNavigation(container) {
// Ensure we had appropriate sequence navigation buttons. We should only have one unit.
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
expect(sequenceNavButtons).toHaveLength(5);

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[4]).toHaveTextContent('Next');
}

function setupMockRequests() {
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`).reply(200, courseMetadata);
axiosMock.onGet(new RegExp(`${getConfig().LMS_BASE_URL}/api/courses/v2/blocks/*`)).reply(200, courseBlocks);
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/sequence/${sequenceBlock.id}`).reply(200, sequenceMetadata);
}

beforeEach(async () => {
// On page load, SequenceContext attempts to scroll to the top of the page.
global.scrollTo = jest.fn();

courseMetadata = Factory.build('courseMetadata');
courseId = courseMetadata.id;

const result = buildSimpleCourseBlocks(courseId, courseMetadata.name, 3); // 3 is for 3 units
courseBlocks = result.courseBlocks;
unitBlocks = result.unitBlocks;
sequenceBlock = result.sequenceBlock;

sequenceMetadata = Factory.build(
'sequenceMetadata',
{},
{ courseId, unitBlocks, sequenceBlock },
);

setupMockRequests();
});

describe('when the URL only contains a course ID', () => {
it('should use the resume block repsonse to pick a unit if it contains one', async () => {
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {
sectionId: sequenceBlock.id,
unitId: unitBlocks[1].id,
});

history.push(`/course/${courseId}`);
const { container } = render(component);

// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));

assertLoadedHeader(container);
assertSequenceNavigation(container);

expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[1].id);
});

it('should use the first sequence ID and activeUnitIndex if the resume block response is empty', async () => {
// OVERRIDE SEQUENCE METADATA:
// set the position to the third unit so we can prove activeUnitIndex is working
sequenceMetadata = Factory.build(
'sequenceMetadata',
{ position: 3 }, // position index is 1-based and is converted to 0-based for activeUnitIndex
{ courseId, unitBlocks, sequenceBlock },
);

// Re-call the mock setup now that sequenceMetadata is different.
setupMockRequests();
// Note how there is no sectionId/unitId returned in this mock response!
axiosMock.onGet(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`).reply(200, {});

history.push(`/course/${courseId}`);
const { container } = render(component);

// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));

assertLoadedHeader(container);
assertSequenceNavigation(container);

expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id);
});
});

describe('when the URL contains a course ID and sequence ID', () => {
it('should pick the first unit if position was not defined (activeUnitIndex becomes 0)', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}`);
const { container } = render(component);

// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));

assertLoadedHeader(container);
assertSequenceNavigation(container);

expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[0].id);
});

it('should use activeUnitIndex to pick a unit from the sequence', async () => {
// OVERRIDE SEQUENCE METADATA:
sequenceMetadata = Factory.build(
'sequenceMetadata',
{ position: 3 }, // position index is 1-based and is converted to 0-based for activeUnitIndex
{ courseId, unitBlocks, sequenceBlock },
);

// Re-call the mock setup now that sequenceMetadata is different.
setupMockRequests();

history.push(`/course/${courseId}/${sequenceBlock.id}`);
const { container } = render(component);

// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));

assertLoadedHeader(container);
assertSequenceNavigation(container);

expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].id);
});
});

describe('when the URL contains a course, sequence, and unit ID', () => {
it('should load the specified unit', async () => {
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
const { container } = render(component);

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;
// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));

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,
assertLoadedHeader(container);
assertSequenceNavigation(container);

expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitBlocks[2].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) => {
console.log(config.url);
return [200, {}];
describe('when the current sequence is an exam', () => {
const { location } = window;

beforeEach(() => {
delete window.location;
window.location = {
assign: jest.fn(),
};
});

afterEach(() => {
window.location = location;
});

it('should redirect to the sequence lmsWebUrl', async () => {
// OVERRIDE SEQUENCE METADATA:
sequenceMetadata = Factory.build(
'sequenceMetadata',
{ is_time_limited: true }, // position index is 1-based and is converted to 0-based for activeUnitIndex
{ courseId, unitBlocks, sequenceBlock },
);

// Re-call the mock setup now that sequenceMetadata is different.
setupMockRequests();
history.push(`/course/${courseId}/${sequenceBlock.id}/${unitBlocks[2].id}`);
render(component);

// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a thought, is it worth extracting this into a helper?
I know it's just a single line of code, but the longer comment is useful and we seem to repeating this pattern a few times. I could imagine it being a bit easier to read if we were invoking, ensureMainContentLoaded(screen).

Not a big deal if it's a wonky refactor though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not a bad thought.

It's funny, I always find when I come into a test suite that has a lot of things extracted to helpers I find it very hard to find my way around, but when I'm writing a test suite I want to do it... hard to know when to bother. :)


expect(global.location.assign).toHaveBeenCalledWith(sequenceBlock.lms_web_url);
});
});
// On page load, SequenceContext attempts to scroll to the top of the page.
global.scrollTo = jest.fn();
history.push(`/course/${courseId}`);
const { container } = render(component);

// This is an important line that ensures the spinner has been removed - and thus our main
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.getByRole('status'));

const courseHeader = container.querySelector('.course-header');
// Ensure the course number and org appear - this proves we loaded course metadata properly.
expect(courseHeader).toHaveTextContent(courseMetadata.number);
expect(courseHeader).toHaveTextContent(courseMetadata.org);
// Ensure the course title is showing up in the header. This means we loaded course blocks properly.
expect(courseHeader.querySelector('.course-title')).toHaveTextContent(courseMetadata.name);

// Ensure we had appropriate sequence navigation buttons. We should only have one unit.
const sequenceNavButtons = container.querySelectorAll('nav.sequence-navigation button');
expect(sequenceNavButtons).toHaveLength(3);

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[2]).toHaveTextContent('Next');

expect(container.querySelector('.fake-unit')).toHaveTextContent('Unit Contents');
expect(container.querySelector('.fake-unit')).toHaveTextContent(courseId);
expect(container.querySelector('.fake-unit')).toHaveTextContent(unitId);
});

describe('when receiving a can_load_courseware error_code', () => {
Expand All @@ -146,11 +293,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 },
);

const forbiddenCourseUrl = `${getConfig().LMS_BASE_URL}/api/courseware/course/${courseId}`;
Expand Down
Loading