refactor: update test coverage (#202)

* clean up and test segment integration

* add tests for action utils

* add tests for store aggregator and utils

* clean up un-used paths in thunkAction testUtils

* clean up filter reducer coverage

* add filter reducer tests for filterMenu actions

* clean up grades selectors coverage

* separate App and add unit tests

* ignore external files in coverage analysis

* remove old/unused code from StrictDict and clean up tests

* clean up FileUploadForm coverage

* more cleanup for StrictDict tests

* clean up GradesTab test coverage

* clean up GradesTab coverage

* ignore reducer-mapping for unit-test coverage

* clean up AssignmentFilter test coverage

* add index-level test coverage

* temp remove snapshots

* re-add App snapshot

* v1.4.41
This commit is contained in:
Ben Warzeski
2021-07-02 12:32:18 -04:00
committed by GitHub
parent 15b76edb5d
commit a4df8f7238
28 changed files with 911 additions and 174 deletions

36
src/App.jsx Executable file
View File

@@ -0,0 +1,36 @@
import React from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { Provider } from 'react-redux';
import { IntlProvider } from 'react-intl';
import Footer from '@edx/frontend-component-footer';
import { routePath } from 'data/constants/app';
import store from 'data/store';
import GradebookPage from 'containers/GradebookPage';
import EdxHeader from 'components/EdxHeader';
import './App.scss';
const App = () => (
<IntlProvider locale="en">
<Provider store={store}>
<Router>
<div>
<EdxHeader />
<main>
<Switch>
<Route
exact
path={routePath}
component={GradebookPage}
/>
</Switch>
</main>
<Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} />
</div>
</Router>
</Provider>
</IntlProvider>
);
export default App;

86
src/App.test.jsx Normal file
View File

@@ -0,0 +1,86 @@
import React from 'react';
import { shallow } from 'enzyme';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { Provider } from 'react-redux';
import { IntlProvider } from 'react-intl';
import Footer from '@edx/frontend-component-footer';
import { routePath } from 'data/constants/app';
import store from 'data/store';
import GradebookPage from 'containers/GradebookPage';
import EdxHeader from 'components/EdxHeader';
import App from './App';
jest.mock('react-router-dom', () => ({
BrowserRouter: () => 'BrowserRouter',
Route: () => 'Route',
Switch: () => 'Switch',
}));
jest.mock('react-redux', () => ({
Provider: () => 'Provider',
}));
jest.mock('react-intl', () => ({
IntlProvider: () => 'IntlProvider',
}));
jest.mock('data/constants/app', () => ({
routePath: '/:courseId',
}));
jest.mock('@edx/frontend-component-footer', () => 'Footer');
jest.mock('data/store', () => 'testStore');
jest.mock('containers/GradebookPage', () => 'GradebookPage');
jest.mock('components/EdxHeader', () => 'EdxHeader');
const logo = 'fakeLogo.png';
let el;
let router;
describe('App router component', () => {
test('snapshot', () => {
expect(shallow(<App />)).toMatchSnapshot();
});
describe('component', () => {
beforeEach(() => {
process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG = logo;
el = shallow(<App />);
router = el.childAt(0).childAt(0);
});
describe('IntlProvider', () => {
test('outer-wrapper component', () => {
expect(el.type()).toBe(IntlProvider);
});
test('"en" locale', () => {
expect(el.props().locale).toEqual('en');
});
});
describe('Provider, inside IntlProvider', () => {
test('first child, passed the redux store props', () => {
expect(el.childAt(0).type()).toBe(Provider);
expect(el.childAt(0).props().store).toEqual(store);
});
});
describe('Router', () => {
test('first child of Provider', () => {
expect(router.type()).toBe(Router);
});
test('EdxHeader is above/outside-of the routing', () => {
expect(router.childAt(0).childAt(0).type()).toBe(EdxHeader);
expect(router.childAt(0).childAt(1).type()).toBe('main');
});
test('Routing - GradebookPage is only route', () => {
expect(router.find('main')).toEqual(shallow(
<main>
<Switch>
<Route exact path={routePath} component={GradebookPage} />
</Switch>
</main>,
));
});
});
test('Footer logo drawn from env variable', () => {
expect(router.find(Footer).props().logo).toEqual(logo);
});
});
});

View File

@@ -0,0 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`App router component snapshot 1`] = `
<IntlProvider
locale="en"
>
<Provider
store="testStore"
>
<BrowserRouter>
<div>
<EdxHeader />
<main>
<Switch>
<Route
component="GradebookPage"
exact={true}
path="/:courseId"
/>
</Switch>
</main>
<Footer />
</div>
</BrowserRouter>
</Provider>
</IntlProvider>
`;

View File

@@ -114,6 +114,14 @@ describe('FileUploadForm', () => {
});
});
});
describe('fileInput helper', () => {
test('links to fileInputRef.current', () => {
el = mount(<FileUploadForm {...props} />);
const ref = 'a-fake-ref';
el.instance().fileInputRef = { current: ref };
expect(el.instance().fileInput).toEqual(ref);
});
});
describe('behavior', () => {
let fileInput;
beforeEach(() => {

View File

@@ -69,7 +69,6 @@ describe('AssignmentFilter', () => {
el = mount(<AssignmentFilter {...props} />);
el.instance().handleChange(event);
});
it('calls props.updateAssignmentFilter with selection', () => {
expect(props.updateAssignmentFilter).toHaveBeenCalledWith({
label: newAssgn,
@@ -87,6 +86,30 @@ describe('AssignmentFilter', () => {
const method = props.fetchGradesIfAssignmentGradeFiltersSet;
expect(method).toHaveBeenCalledWith();
});
describe('no selected option', () => {
const value = 'fake';
beforeEach(() => {
el = mount(<AssignmentFilter {...props} />);
el.instance().handleChange({ target: { value } });
});
it('calls props.updateAssignmentFilter with selection', () => {
expect(props.updateAssignmentFilter).toHaveBeenCalledWith({
label: value,
type: undefined,
id: undefined,
});
});
it('calls props.updateQueryParams with selected assignment id',
() => {
expect(props.updateQueryParams).toHaveBeenCalledWith({
assignment: undefined,
});
});
it('calls props.fetchGradesIfAssignmentGradeFiltersSet', () => {
const method = props.fetchGradesIfAssignmentGradeFiltersSet;
expect(method).toHaveBeenCalledWith();
});
});
});
});
describe('snapshots', () => {

View File

@@ -6,6 +6,7 @@ import thunkActions from 'data/thunkActions';
import {
GradesTab,
mapStateToProps,
mapDispatchToProps,
} from '.';
@@ -78,6 +79,9 @@ describe('GradesTab', () => {
});
});
});
test('mapStateToProps is empty', () => {
expect(mapStateToProps({ some: 'state' })).toEqual({});
});
describe('mapDispatchToProps', () => {
describe('fetchGrades', () => {
test('from thunkActions.grades.fetchGrades', () => {

View File

@@ -1,19 +1,22 @@
import { createAction } from '@reduxjs/toolkit';
export const options = {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
};
export const timeOptions = {
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC',
timeZoneName: 'short',
};
const formatDateForDisplay = (inputDate) => {
const options = {
year: 'numeric',
month: 'long',
day: 'numeric',
timeZone: 'UTC',
};
const timeOptions = {
hour: '2-digit',
minute: '2-digit',
timeZone: 'UTC',
timeZoneName: 'short',
};
return `${inputDate.toLocaleDateString('en-US', options)} at ${inputDate.toLocaleTimeString('en-US', timeOptions)}`;
const date = inputDate.toLocaleDateString('en-US', options);
const time = inputDate.toLocaleTimeString('en-US', timeOptions);
return `${date} at ${time}`;
};
const sortAlphaAsc = (gradeRowA, gradeRowB) => {

View File

@@ -0,0 +1,34 @@
import { createAction } from '@reduxjs/toolkit';
import * as utils from './utils';
jest.mock('@reduxjs/toolkit', () => ({
createAction: (key, ...args) => ({ action: key, args }),
}));
describe('redux action utils', () => {
describe('formatDateForDisplay', () => {
it('returns the datetime as a formatted string', () => {
expect(utils.formatDateForDisplay(new Date('Jun 3 2021 11:59 AM EDT'))).toEqual(
'June 3, 2021 at 03:59 PM UTC',
);
});
});
describe('sortAlphaAsc', () => {
it('returns sorting value (-1, 0, 1) by uppercase username', () => {
const sort = (v1, v2) => utils.sortAlphaAsc({ username: v1 }, { username: v2 });
expect(sort('aName', 'ANAme')).toEqual(0);
expect(sort('aName', 'laterName')).toEqual(-1);
expect(sort('laterName', 'aName')).toEqual(1);
});
});
describe('createActionFactory', () => {
it('returns an action creator with the data key', () => {
const dataKey = 'part-of-the-model';
const actionKey = 'an-action';
const args = ['some', 'args'];
expect(utils.createActionFactory(dataKey)(actionKey, ...args)).toEqual(
createAction(`${dataKey}/${actionKey}`, ...args),
);
});
});
});

View File

@@ -1,4 +1,7 @@
import { StrictDict } from 'utils';
import { getConfig } from '@edx/frontend-platform';
export const routePath = `${getConfig().PUBLIC_PATH}:courseId`;
export const modalFieldKeys = StrictDict({
adjustedGradePossible: 'adjustedGradePossible',

View File

@@ -42,6 +42,37 @@ describe('app reducer', () => {
).toEqual({ ...testingState, courseId: testValue });
});
});
describe('appActions.filterMenu.startTransition', () => {
it('sets filterMenu.transitioning to true', () => {
expect(
app(testingState, appActions.filterMenu.startTransition()),
).toEqual({
...testingState,
filterMenu: { ...testingState.filterMenu, transitioning: true },
});
});
});
describe('appActions.filterMenu.endTransition', () => {
it('sets filterMenu.transitioning to false', () => {
const transitioningState = {
...testingState,
filterMenu: { ...testingState.filterMenu, transitioning: true },
};
expect(
app(transitioningState, appActions.filterMenu.endTransition()),
).toEqual(testingState);
});
});
describe('appActions.filterMenu.toggle', () => {
it('toggles filterMenu.open', () => {
const openState = {
...testingState,
filterMenu: { ...testingState.filterMenu, open: true },
};
expect(app(testingState, appActions.filterMenu.toggle())).toEqual(openState);
expect(app(openState, appActions.filterMenu.toggle())).toEqual(testingState);
});
});
describe('appActions.setLocalFilter', () => {
it('loads filter values from the payload', () => {
expect(

View File

@@ -30,17 +30,13 @@ const reducer = (state = initialState, { type: actionType, payload }) => {
assignmentGradeMax: payload.assignmentGradeMax,
assignmentGradeMin: payload.assignmentGradeMin,
};
case actions.update.assignmentType.toString():
return {
...state,
assignmentType: payload,
assignment: (
(
payload !== ''
&& (state.assignment || {}).type !== payload
) ? '' : state.assignment
),
};
case actions.update.assignmentType.toString(): {
const newState = { ...state, assignmentType: payload };
if (payload !== '' && state.assignment && payload !== state.assignment.type) {
newState.assignment = '';
}
return newState;
}
case actions.update.cohort.toString():
return { ...state, cohort: payload };
case actions.update.courseGradeLimits.toString():

View File

@@ -9,6 +9,7 @@ import grades from './grades';
import roles from './roles';
import tracks from './tracks';
/* istanbul ignore next */
const rootReducer = combineReducers({
app,
assignmentTypes,

View File

@@ -87,6 +87,44 @@ describe('grades selectors', () => {
describe('grade formatters', () => {
const selectedAssignment = { assignmentId: 'block-v1:edX+type@sequential+block@abcde' };
describe('formatGradeOverrideForDisplay', () => {
it('maps history entries with formatted date, grader, reason, and adjusted grade', () => {
const historyArray = [
{
history_date: 'Jan 01 2021',
history_user: 'Grog',
override_reason: 'rage',
earned_graded_override: 0,
},
{
history_date: 'Jan 02 2021',
history_user: 'Keyleth',
override_reason: 'nature',
earned_graded_override: 10,
},
{
history_date: 'Jan 03 2021',
history_user: 'Pike',
override_reason: 'Sarenrae',
earned_graded_override: 9001,
},
];
const mapped = selectors.formatGradeOverrideForDisplay(historyArray);
const testEntry = (index) => {
const entry = historyArray[index];
expect(mapped[index]).toEqual({
date: formatDateForDisplay(new Date(entry.history_date)),
grader: entry.history_user,
reason: entry.override_reason,
adjustedGrade: entry.earned_graded_override,
});
};
testEntry(0);
testEntry(1);
testEntry(2);
});
});
describe('formatMinAssignmentGrade', () => {
const modifiedGrade = '1';
const selector = selectors.formatMinAssignmentGrade;

View File

@@ -0,0 +1,43 @@
import { StrictDict } from 'utils';
import actions from 'data/actions';
export const courseId = window.location.pathname.slice(1);
export const events = StrictDict({
receivedRoles: 'receivedRoles',
receivedGrades: 'receivedGrades',
updateSucceeded: 'updateSucceeded',
updateFailed: 'updateFailed',
uploadOverrideSucceeded: 'uploadOverrideSucceeded',
uploadOverrideFailed: 'uploadOverrideFailed',
filterApplied: 'filterApplied',
gradesReportDownloaded: 'gradesReportDownloaded',
interventionReportDownloaded: 'interventionReportDownloaded',
});
export const eventNames = StrictDict({
[events.receivedGrades]: 'edx.gradebook.grades.displayed',
[events.updateSucceeded]: 'edx.gradebook.grades.grade_override.succeeded',
[events.updateFailed]: 'edx.gradebook.grades.grade_override.failed',
[events.uploadOverrideSucceeded]: 'edx.gradebook.grades.upload.grades_overrides.succeeded',
[events.uploadOverrideFailed]: 'edx.gradebook.grades.upload.grades_overrides.failed',
[events.filterApplied]: 'edx.gradebook.grades.filter_applied',
[events.gradesReportDownloaded]: 'edx.gradebook.reports.grade_export.downloaded',
[events.interventionReportDownloaded]: 'edx.gradebook.reports.intervention.downloaded',
});
export const triggers = StrictDict({
[events.receivedRoles]: actions.roles.fetching.received.toString(),
[events.receivedGrades]: actions.grades.fetching.received.toString(),
[events.updateSucceeded]: actions.grades.update.success.toString(),
[events.updateFailed]: actions.grades.update.failure.toString(),
[events.uploadOverrideSucceeded]: actions.grades.uploadOverride.success.toString(),
[events.uploadOverrideFailed]: actions.grades.uploadOverride.failure.toString(),
[events.filterApplied]: actions.filters.update.courseGradeLimits.toString(),
[events.gradesReportDownloaded]: actions.grades.downloadReport.bulkGrades.toString(),
[events.interventionReportDownloaded]: actions.grades.downloadReport.intervention.toString(),
});
export const trackingCategory = 'gradebook';
export const pageViewEvent = { category: trackingCategory, page: courseId };

View File

@@ -0,0 +1,48 @@
import { trackPageView } from '@redux-beacon/segment';
import { StrictDict } from 'utils';
import { handleEvent } from './utils';
import {
courseId,
events,
eventNames,
trackingCategory,
triggers,
} from './constants';
const eventsMap = {};
const loadTrigger = (event, options = {}) => {
eventsMap[triggers[event]] = handleEvent(eventNames[event], options);
};
eventsMap[triggers[events.receivedRoles]] = trackPageView(() => ({
category: trackingCategory,
page: courseId,
}));
loadTrigger(events.receivedGrades, {
propsFn: (payload) => ({
assignmentType: payload.assignmentType,
cohort: payload.cohort,
prev: payload.prev,
next: payload.next,
track: payload.track,
}),
});
loadTrigger(events.updateSucceeded, {
propsFn: ({ responseData }) => ({ updatedGrades: responseData }),
});
loadTrigger(events.updateFailed, {
propsFn: ({ error }) => ({ error }),
});
loadTrigger(events.uploadOverrideSucceeded);
loadTrigger(events.uploadOverrideFailed, {
propsFn: ({ error }) => ({ error }),
});
loadTrigger(events.filterApplied, {
extrasFn: () => ({ label: courseId }),
});
loadTrigger(events.gradesReportDownloaded);
loadTrigger(events.interventionReportDownloaded);
export default StrictDict(eventsMap);

View File

@@ -0,0 +1,180 @@
/**
* This module houses integration tests for the segment api integration
*/
import actions from 'data/actions';
import eventsMap from './mapping';
import {
courseId,
events,
eventNames,
trackingCategory as category,
triggers,
} from './constants';
jest.mock('./constants', () => ({
...jest.requireActual('./constants'),
courseId: 'a-fake-course-id',
}));
jest.mock('@redux-beacon/segment', () => ({
trackEvent: (event) => ({ trackEvent: event }),
trackPageView: (event) => ({ trackPageView: event }),
}));
describe('segments event map', () => {
const loadEvent = (event) => ({
event,
trigger: triggers[event],
handler: eventsMap[triggers[event]],
});
describe('app initialization (received roles api response)', () => {
const { trigger, handler } = loadEvent(events.receivedRoles);
test('triggers on actions.roles.fetching.received', () => {
expect(trigger).toEqual(actions.roles.fetching.received.toString());
});
it('tracks the page view', () => {
expect(handler.trackPageView()).toEqual({ category, page: courseId });
});
});
describe('received grades update success', () => {
const { event, trigger, handler } = loadEvent(events.receivedGrades);
const payload = {
assignmentType: 'potions',
cohort: 3,
prev: 'aPlace',
next: 'aNotherPlace',
track: 'auror',
};
test('triggers on actions.grades.fetching.received', () => {
expect(trigger).toEqual(actions.grades.fetching.received.toString());
});
it('tracks event with ({ assignmentType, cohort, prev, next, track })', () => {
expect(handler.trackEvent({ payload })).toEqual({
name: eventNames[event],
properties: {
category,
label: courseId,
...payload,
},
});
});
});
describe('grades update success', () => {
const { event, trigger, handler } = loadEvent(events.updateSucceeded);
const payload = { responseData: 'some new GRADES!' };
test('triggers on actions.grades.update.success', () => {
expect(trigger).toEqual(actions.grades.update.success.toString());
});
it('tracks event with ({ updatedGrades: payload.responseData })', () => {
expect(handler.trackEvent({ payload })).toEqual({
name: eventNames[event],
properties: {
category,
label: courseId,
updatedGrades: payload.responseData,
},
});
});
});
describe('grade update failure', () => {
const { event, trigger, handler } = loadEvent(events.updateFailed);
const payload = { error: 'some Errors happened :-(', other: 'stuff also' };
test('triggers on actions.grades.update.failure', () => {
expect(trigger).toEqual(actions.grades.update.failure.toString());
});
it('tracks event with error info', () => {
expect(handler.trackEvent({ payload })).toEqual({
name: eventNames[event],
properties: {
category,
label: courseId,
error: payload.error,
},
});
});
});
describe('grade override upload failure', () => {
const { event, trigger, handler } = loadEvent(events.uploadOverrideSucceeded);
test('triggers on actions.grades.uploadOverride.success', () => {
expect(trigger).toEqual(actions.grades.uploadOverride.success.toString());
});
it('tracks event', () => {
expect(handler.trackEvent()).toEqual({
name: eventNames[event],
properties: {
category,
label: courseId,
},
});
});
});
describe('grade override upload failure', () => {
const { event, trigger, handler } = loadEvent(events.uploadOverrideFailed);
const payload = { error: 'some Errors happened :-(', other: 'stuff also' };
test('triggers on actions.grades.uploadOverride.failure', () => {
expect(trigger).toEqual(actions.grades.uploadOverride.failure.toString());
});
it('tracks event with error info', () => {
expect(handler.trackEvent({ payload })).toEqual({
name: eventNames[event],
properties: {
category,
label: courseId,
error: payload.error,
},
});
});
});
describe('gradebook filter applied', () => {
const { event, trigger, handler } = loadEvent(events.filterApplied);
test('triggers on actions.filters.update.courseGradeLimits', () => {
expect(trigger).toEqual(actions.filters.update.courseGradeLimits.toString());
});
it('tracks event with error info', () => {
expect(handler.trackEvent()).toEqual({
name: eventNames[event],
label: courseId,
properties: {
category,
label: courseId,
},
});
});
});
describe('bulk grade report downloaded', () => {
const { event, trigger, handler } = loadEvent(events.gradesReportDownloaded);
test('triggers on actions.grades.downloadReport.bulkGrades', () => {
expect(trigger).toEqual(actions.grades.downloadReport.bulkGrades.toString());
});
it('tracks event', () => {
expect(handler.trackEvent()).toEqual({
name: eventNames[event],
properties: {
category,
label: courseId,
},
});
});
});
describe('intervention report downloaded', () => {
const { event, trigger, handler } = loadEvent(events.interventionReportDownloaded);
test('triggers on actions.grades.downloadReport.intervention', () => {
expect(trigger).toEqual(actions.grades.downloadReport.intervention.toString());
});
it('tracks event', () => {
expect(handler.trackEvent()).toEqual({
name: eventNames[event],
properties: {
category,
label: courseId,
},
});
});
});
});

View File

@@ -0,0 +1,19 @@
/* eslint-disable import/prefer-default-export */
import { trackEvent } from '@redux-beacon/segment';
import { courseId, trackingCategory as category } from './constants';
export const handleEvent = (name, options = {}) => trackEvent(
(event = {}) => {
const { payload } = event;
const { propsFn, extrasFn } = options;
return {
name,
...(extrasFn && extrasFn(payload)),
properties: {
category,
label: courseId,
...(propsFn && propsFn(payload)),
},
};
},
);

View File

@@ -0,0 +1,59 @@
import * as constants from './constants';
import { handleEvent } from './utils';
jest.mock('@redux-beacon/segment', () => ({
trackEvent: (handleFn) => ({ trackEvent: handleFn }),
}));
const courseId = 'a-fake-course-id';
const category = 'AFakeCategory';
describe('segment service utils', () => {
beforeAll(() => {
global.window = Object.create(window);
const url = 'http://dummy.com';
Object.defineProperty(window, 'location', {
value: {
href: `${url}/${courseId}`,
pathname: `/${courseId}`,
},
writable: true,
});
constants.courseId = courseId;
constants.trackingCategory = category;
});
describe('handleEvent', () => {
const name = 'aName';
const payload = { field1: 'some data', field2: 'other data' };
describe('when called with just a name', () => {
it('returns a TrackEvent call with the name, courseId, and tracking category', () => {
const handler = handleEvent(name).trackEvent;
expect(handler(payload)).toEqual({
name,
properties: { category, label: courseId },
});
});
});
describe('when a propsFn is provided', () => {
it('adds the output of propsFn(event.payload) to properties', () => {
const propsFn = ({ field1 }) => ({ field1 });
const handler = handleEvent(name, { propsFn }).trackEvent;
expect(handler({ payload })).toEqual({
name,
properties: { category, label: courseId, field1: payload.field1 },
});
});
});
describe('when an extrasFn object is provided', () => {
it('adds the output of extrasFn(event.payload) to top-level object', () => {
const extrasFn = ({ field2 }) => ({ field2 });
const handler = handleEvent(name, { extrasFn }).trackEvent;
expect(handler({ payload })).toEqual({
name,
field2: payload.field2,
properties: { category, label: courseId },
});
});
});
});
});

View File

@@ -1,113 +1,41 @@
import { applyMiddleware, createStore } from 'redux';
import * as redux from 'redux';
import thunkMiddleware from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
import { createLogger } from 'redux-logger';
import { createMiddleware } from 'redux-beacon';
import Segment, { trackEvent, trackPageView } from '@redux-beacon/segment';
import Segment from '@redux-beacon/segment';
import actions from './actions';
import selectors from './selectors';
import reducers from './reducers';
import eventsMap from './services/segment/mapping';
import { configuration } from '../config';
const loggerMiddleware = createLogger();
const trackingCategory = 'gradebook';
export const createStore = () => {
const loggerMiddleware = createLogger();
const eventsMap = {
[actions.roles.fetching.received.toString()]: trackPageView((action, prevState) => ({
category: trackingCategory,
page: selectors.app.courseId(prevState),
})),
[actions.grades.fetching.received.toString()]: trackEvent(({ payload }, prevState) => ({
name: 'edx.gradebook.grades.displayed',
properties: {
category: trackingCategory,
label: selectors.app.courseId(prevState),
track: payload.track,
cohort: payload.cohort,
assignmentType: payload.assignmentType,
prev: payload.prev,
next: payload.next,
},
})),
[actions.grades.update.success.toString()]: trackEvent(({ payload }, prevState) => ({
name: 'edx.gradebook.grades.grade_override.succeeded',
properties: {
category: trackingCategory,
label: selectors.app.courseId(prevState),
updatedGrades: payload.responseData,
},
})),
[actions.grades.update.failure.toString()]: trackEvent(({ payload }, prevState) => ({
name: 'edx.gradebook.grades.grade_override.failed',
properties: {
category: trackingCategory,
label: selectors.app.courseId(prevState),
error: payload.error,
},
})),
[actions.grades.uploadOverride.success.toString()]: trackEvent((action, prevState) => ({
name: 'edx.gradebook.grades.upload.grades_overrides.succeeded',
properties: {
category: trackingCategory,
label: selectors.app.courseId(prevState),
},
})),
[actions.grades.uploadOverride.failure.toString()]: trackEvent(({ payload }, prevState) => ({
name: 'edx.gradebook.grades.upload.grades_overrides.failed',
properties: {
category: trackingCategory,
label: selectors.app.courseId(prevState),
error: payload.error,
},
})),
[actions.filters.update.courseGradeLimits]: trackEvent((action, prevState) => ({
name: 'edx.gradebook.grades.filter_applied',
label: selectors.app.courseId(prevState),
properties: {
category: trackingCategory,
label: selectors.app.courseId(prevState),
},
})),
[actions.grades.downloadReport.bulkGrades.toString()]: trackEvent(
(action, prevState) => ({
name: 'edx.gradebook.reports.grade_export.downloaded',
properties: {
category: trackingCategory,
label: selectors.app.courseId(prevState),
},
}),
),
[actions.grades.downloadReport.intervention.toString()]: trackEvent(
(action, prevState) => ({
name: 'edx.gradebook.reports.intervention.downloaded',
properties: {
category: trackingCategory,
label: selectors.app.courseId(prevState),
},
}),
),
const middleware = [thunkMiddleware, loggerMiddleware];
// Conditionally add the segmentMiddleware only if the SEGMENT_KEY environment variable exists.
if (configuration.SEGMENT_KEY) {
middleware.push(createMiddleware(eventsMap, Segment()));
}
const store = redux.createStore(
reducers,
composeWithDevTools(redux.applyMiddleware(...middleware)),
);
/**
* Dev tools for redux work
*/
if (process.env.NODE_ENV === 'development') {
window.store = store;
window.actions = actions;
window.selectors = selectors;
}
return store;
};
const middleware = [thunkMiddleware, loggerMiddleware];
// Conditionally add the segmentMiddleware only if the SEGMENT_KEY environment variable exists.
if (configuration.SEGMENT_KEY) {
middleware.push(createMiddleware(eventsMap, Segment()));
}
const store = createStore();
const store = createStore(
reducers,
composeWithDevTools(applyMiddleware(...middleware)),
);
/**
* Dev tools for redux work
*/
if (process.env.NODE_ENV === 'development') {
window.store = store;
window.actions = actions;
window.selectors = selectors;
}
export { trackingCategory };
export default store;

99
src/data/store.test.js Normal file
View File

@@ -0,0 +1,99 @@
import { applyMiddleware } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction';
import { createLogger } from 'redux-logger';
import { createMiddleware } from 'redux-beacon';
import Segment from '@redux-beacon/segment';
import actions from './actions';
import selectors from './selectors';
import reducers from './reducers';
import eventsMap from './services/segment/mapping';
import { configuration } from '../config';
import exportedStore, { createStore } from './store';
jest.mock('./reducers', () => 'REDUCER');
jest.mock('./actions', () => 'ACTIONS');
jest.mock('./selectors', () => 'SELECTORS');
jest.mock('./services/segment/mapping', () => 'EVENTS MAP');
jest.mock('redux-logger', () => ({
createLogger: () => 'logger',
}));
jest.mock('redux-thunk', () => 'thunkMiddleware');
jest.mock('../config', () => ({
configuration: {
SEGMENT_KEY: 'a-fake-segment-key',
},
}));
jest.mock('redux-beacon', () => ({
createMiddleware: jest.fn((map, model) => ({ map, model })),
}));
jest.mock('@redux-beacon/segment', () => () => 'Segment');
jest.mock('redux', () => ({
applyMiddleware: (...middleware) => ({ applied: middleware }),
createStore: (reducer, middleware) => ({ reducer, middleware }),
}));
jest.mock('redux-devtools-extension/logOnlyInProduction', () => ({
composeWithDevTools: (middleware) => ({ withDevTools: middleware }),
}));
describe('store aggregator module', () => {
describe('exported store', () => {
it('is generated by createStore', () => {
expect(exportedStore).toEqual(createStore());
});
it('creates store with connected reducers', () => {
expect(createStore().reducer).toEqual(reducers);
});
describe('middleware', () => {
describe('if SEGMENT_KEY is in the configuration', () => {
it('also adds segment middleware from eventMap', () => {
expect(createStore().middleware).toEqual(
composeWithDevTools(applyMiddleware(
thunkMiddleware,
createLogger(),
createMiddleware(eventsMap, Segment()),
)),
);
});
});
describe('if no SEGMENT_KEY', () => {
const key = configuration.SEGMENT_KEY;
beforeEach(() => {
configuration.SEGMENT_KEY = false;
});
it('exports thunk and logger middleware, composed and applied with dev tools', () => {
expect(createStore().middleware).toEqual(
composeWithDevTools(applyMiddleware(thunkMiddleware, createLogger())),
);
});
afterEach(() => {
configuration.SEGMENT_KEY = key;
});
});
});
});
describe('dev exposed tools', () => {
beforeEach(() => {
window.store = undefined;
window.actions = undefined;
window.selectors = undefined;
});
it('exposes redux tools if in development env', () => {
process.env.NODE_ENV = 'development';
const store = createStore();
expect(window.store).toEqual(store);
expect(window.actions).toEqual(actions);
expect(window.selectors).toEqual(selectors);
});
it('does not expose redux tools if in production env', () => {
process.env.NODE_ENV = 'production';
createStore();
expect(window.store).toEqual(undefined);
expect(window.actions).toEqual(undefined);
expect(window.selectors).toEqual(undefined);
});
});
});

View File

@@ -35,15 +35,13 @@ export const createTestFetcher = (
) => (
resolveFn,
expectedActions,
verifyFn,
) => {
const store = mockStore({});
mockedMethod.mockReturnValue(new Promise(resolve => {
resolve(new Promise(resolveFn));
}));
return store.dispatch(thunkAction(...args)).then(() => {
if (onDispatch) { onDispatch(); }
if (verifyFn) { verifyFn(); }
onDispatch();
if (expectedActions !== undefined) {
expect(store.getActions()).toEqual(expectedActions);
}

View File

@@ -13,12 +13,14 @@ describe('Redux utilities - creators', () => {
const keys = ['a', 'b'];
const selectors = simpleSelectorFactory(transformer, keys);
expect(Object.keys(selectors)).toEqual(['root', ...keys]);
expect(selectors.root(state)).toEqual(data);
expect(selectors.a(state)).toEqual(data.a);
expect(selectors.b(state)).toEqual(data.b);
});
test('given an object for keys, returns a dict w/ simple selector per key', () => {
const selectors = simpleSelectorFactory(transformer, data);
expect(Object.keys(selectors)).toEqual(['root', ...Object.keys(data)]);
expect(selectors.root(state)).toEqual(data);
expect(selectors.a(state)).toEqual(data.a);
expect(selectors.b(state)).toEqual(data.b);
expect(selectors.c(state)).toEqual(data.c);

View File

@@ -1,43 +1,17 @@
/* eslint-disable import/no-named-as-default */
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { Provider } from 'react-redux';
import {
APP_READY,
getConfig,
initialize,
subscribe,
} from '@edx/frontend-platform';
import { IntlProvider } from 'react-intl';
import Footer, { messages as footerMessages } from '@edx/frontend-component-footer';
import { messages as footerMessages } from '@edx/frontend-component-footer';
import GradebookPage from './containers/GradebookPage';
import EdxHeader from './components/EdxHeader';
import store from './data/store';
import './App.scss';
const App = () => (
<IntlProvider locale="en">
<Provider store={store}>
<Router>
<div>
<EdxHeader />
<main>
<Switch>
<Route exact path={getConfig().PUBLIC_PATH.concat(':courseId')} component={GradebookPage} />
</Switch>
</main>
<Footer logo={process.env.LOGO_POWERED_BY_OPEN_EDX_URL_SVG} />
</div>
</Router>
</Provider>
</IntlProvider>
);
import App from './App';
subscribe(APP_READY, () => {
ReactDOM.render(<App />, document.getElementById('root'));

50
src/index.test.jsx Normal file
View File

@@ -0,0 +1,50 @@
import React from 'react';
import ReactDOM from 'react-dom';
import {
APP_READY,
initialize,
subscribe,
} from '@edx/frontend-platform';
import { messages as footerMessages } from '@edx/frontend-component-footer';
import App from './App';
import '.';
jest.mock('react-dom', () => ({
render: jest.fn(),
}));
jest.mock('@edx/frontend-platform', () => ({
APP_READY: 'app-is-ready-key',
initialize: jest.fn(),
subscribe: jest.fn(),
}));
jest.mock('@edx/frontend-component-footer', () => ({
messages: ['some', 'messages'],
}));
jest.mock('./App', () => 'App');
describe('app registry', () => {
let getElement;
beforeEach(() => {
getElement = window.document.getElementById;
window.document.getElementById = jest.fn(id => ({ id }));
});
afterAll(() => {
window.document.getElementById = getElement;
});
test('subscribe is called for APP_READY, linking App to root element', () => {
const callArgs = subscribe.mock.calls[0];
expect(callArgs[0]).toEqual(APP_READY);
expect(callArgs[1]()).toEqual(
ReactDOM.render(<App />, document.getElementById('root')),
);
});
test('initialize is called with footerMessages and requireAuthenticatedUser', () => {
expect(initialize).toHaveBeenCalledWith({
messages: [footerMessages],
requireAuthenticatedUser: true,
});
});
});

View File

@@ -1,27 +1,8 @@
/* eslint-disable no-console */
import util from 'util';
const staticReturnOptions = [
'dict',
'inspect',
Symbol.toStringTag,
util.inspect.custom,
Symbol.for('nodejs.util.inspect.custom'),
];
const strictGet = (target, name) => {
if (name === Symbol.toStringTag) {
return target;
}
if (name === 'length') {
return target.length;
}
if (staticReturnOptions.indexOf(name) >= 0) {
return target;
}
if (name === Symbol.iterator) {
return { ...target };
}
if (name in target || name === '_reactFragment') {
return target[name];

View File

@@ -0,0 +1,62 @@
import StrictDict from './StrictDict';
const value1 = 'valUE1';
const value2 = 'vALue2';
const key1 = 'Key1';
const key2 = 'keY2';
jest.spyOn(window, 'Error').mockImplementation(error => ({ stack: error }));
describe('StrictDict', () => {
let consoleError;
let consoleLog;
let windowError;
beforeEach(() => {
consoleError = window.console.error;
consoleLog = window.console.lot;
windowError = window.Error;
window.console.error = jest.fn();
window.console.log = jest.fn();
window.Error = jest.fn(error => ({ stack: error }));
});
afterAll(() => {
window.console.error = consoleError;
window.console.log = consoleLog;
window.Error = windowError;
});
const rawDict = {
[key1]: value1,
[key2]: value2,
};
const dict = StrictDict(rawDict);
it('provides key access like a normal dict object', () => {
expect(dict[key1]).toEqual(value1);
});
it('allows key listing', () => {
expect(Object.keys(dict)).toEqual([key1, key2]);
});
it('allows item listing', () => {
expect(Object.values(dict)).toEqual([value1, value2]);
});
it('allows stringification', () => {
expect(dict.toString()).toEqual(rawDict.toString());
expect({ ...dict }).toEqual({ ...rawDict });
});
it('allows entry listing', () => {
expect(Object.entries(dict)).toEqual(Object.entries(rawDict));
});
describe('missing key', () => {
it('logs error with target, name, and error stack', () => {
// eslint-ignore-next-line no-unused-vars
const callBadKey = () => dict.fakeKey;
callBadKey();
expect(window.console.error.mock.calls).toEqual([
[{ target: dict, name: 'fakeKey' }],
[Error('invalid property "fakeKey"').stack],
]);
});
it('returns undefined', () => {
expect(dict.fakeKey).toEqual(undefined);
});
});
});