chore: update integration tests through grade submission (#43)

This commit is contained in:
Ben Warzeski
2022-01-13 14:18:58 -05:00
committed by GitHub
parent d629e4495b
commit 21dcc972ed
4 changed files with 385 additions and 208 deletions

View File

@@ -71,6 +71,10 @@ selected.response = createSelector(
(current) => current.response,
);
export const gradingStatusTransform = ({ gradeStatus, lockStatus }) => (
lockStatus === lockStatuses.unlocked ? gradeStatus : lockStatus
);
/**
* Returns the "grading" status for the selected submission,
* which is a combination of the grade and lock statuses.
@@ -78,7 +82,7 @@ selected.response = createSelector(
*/
selected.gradingStatus = createSelector(
[module.selected.gradeStatus, module.selected.lockStatus],
(gradeStatus, lockStatus) => (lockStatus === lockStatuses.unlocked ? gradeStatus : lockStatus),
(gradeStatus, lockStatus) => gradingStatusTransform({ gradeStatus, lockStatus }),
);
selected.isGrading = createSelector(

View File

@@ -57,7 +57,7 @@ const rubricConfig = {
{
name: 'second criterion',
orderNum: 1,
prompt: 'A criterion prompt',
prompt: 'A second criterion prompt',
feedback: 'required',
options: [
{

View File

@@ -15,7 +15,7 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
import urls from 'data/services/lms/urls';
import { ErrorStatuses, RequestKeys, RequestStates } from 'data/constants/requests';
import { lockStatuses } from 'data/services/lms/constants';
import { gradeStatuses, lockStatuses } from 'data/services/lms/constants';
import fakeData from 'data/services/lms/fakeData';
import api from 'data/services/lms/api';
import reducers from 'data/redux';
@@ -23,6 +23,7 @@ import messages from 'i18n';
import { selectors } from 'data/redux';
import App from 'App';
import Inspector from './inspector';
import appMessages from './messages';
jest.unmock('@edx/paragon');
@@ -58,10 +59,9 @@ let el;
let store;
let state;
let retryLink;
let find;
let get;
let inspector;
const { rubricConfig } = fakeData.oraMetadata;
/**
* Simple wrapper for updating the top-level state variable, that also returns the new value
@@ -82,6 +82,9 @@ const submissionUUIDs = [
];
const submissions = submissionUUIDs.map(id => fakeData.mockSubmission(id));
/**
* Object to be filled with resolve/reject functions for all controlled network comm channels
*/
const resolveFns = {};
/**
* Mock the api with jest functions that can be tested against.
@@ -89,6 +92,7 @@ const resolveFns = {};
const mockNetworkError = (reject) => () => reject(new Error({
response: { status: ErrorStatuses.badRequest },
}));
const mockApi = () => {
api.initializeApp = jest.fn(() => new Promise(
(resolve, reject) => {
@@ -132,6 +136,18 @@ const mockApi = () => {
};
},
));
api.updateGrade = jest.fn((uuid, gradeData) => new Promise(
(resolve, reject) => {
resolveFns.updateGrade = {
success: () => resolve({
gradeData,
gradeStatus: gradeStatuses.graded,
lockStatus: lockStatuses.unlocked,
}),
networkError: mockNetworkError(reject),
};
},
));
};
/**
@@ -149,40 +165,12 @@ const renderEl = async () => {
getState();
};
class Inspector {
listTable = () => el.getByRole('table');
modal = () => el.getByRole('dialog');
modalEl = () => within(this.modal());
listTable = () => el.getByRole('table');
listTableRows = () => this.listTable().querySelectorAll('tbody tr');
listCheckbox = (index) => within(this.listTableRows().item(index)).getByTitle('Toggle Row Selected');
listViewSelectedBtn = () => el.getByText('View selected responses (5)');
nextNav = () => el.getByLabelText(appMessages.ReviewActionsComponents.loadNext);
prevNav = () => el.getByLabelText(appMessages.ReviewActionsComponents.loadPrevious);
reviewUsername = (index) => this.modalEl().getByText(fakeData.ids.username(index));
reviewLoadingResponse = () => this.modalEl().getByText(appMessages.ReviewModal.loadingResponse);
reviewRetryLink = () => (
el.getByText(appMessages.ReviewErrors.reloadSubmission).closest('button')
);
reviewGradingStatus = (submission) => (
this.modalEl().getByText(appMessages.lms[gradingStatus(submission)])
);
}
class Finder {
listViewAllResponsesBtn = () => el.findByText(appMessages.ListView.viewAllResponses);
listLoadErrorHeading = () => el.findByText(appMessages.ListView.loadErrorHeading);
prevNav = () => get.modalEl().findByLabelText(appMessages.ReviewActionsComponents.loadPrevious);
reviewLoadErrorHeading = () => el.findByText(appMessages.ReviewErrors.loadErrorHeading);
}
/**
* resolve the initalization promise, and update state object
*/
const initialize = async () => {
resolveFns.init.success();
await find.listViewAllResponsesBtn();
await inspector.find.listView.viewAllResponsesBtn();
getState();
};
@@ -191,23 +179,17 @@ const initialize = async () => {
* Wait for the review page to show and update the top-level state object.
*/
const makeTableSelections = async () => {
[0, 1, 2, 3, 4].forEach(index => userEvent.click(get.listCheckbox(index)));
userEvent.click(get.listViewSelectedBtn());
[0, 1, 2, 3, 4].forEach(index => userEvent.click(inspector.listView.listCheckbox(index)));
userEvent.click(inspector.listView.selectedBtn());
// wait for navigation, which will show while request is pending
try {
await find.prevNav();
await inspector.find.review.prevNav();
} catch (e) {
throw(e);
}
getState();
};
// Click the 'next' button in review modal
const clickNext = async () => { userEvent.click(get.nextNav()); };
// Click the 'next' button in review modal
const clickPrev = async () => { userEvent.click(get.prevNav()); };
const waitForEqual = async (valFn, expected, key) => waitFor(() => {
expect(valFn(), `${key} is expected to equal ${expected}`).toEqual(expected);
});
@@ -217,194 +199,276 @@ const waitForRequestStatus = (key, status) => waitForEqual(
key,
);
const gradingStatus = ({ lockStatus, gradeStatus }) => (
lockStatus === lockStatuses.unlocked ? gradeStatus : lockStatus
);
describe('ESG app integration tests', () => {
test('initialState', async () => {
beforeEach(async () => {
mockApi();
await renderEl();
get = new Inspector();
find = new Finder();
await waitForRequestStatus(RequestKeys.initialize, RequestStates.pending);
const testInitialState = (key) => expect(
state[key],
`${key} store should have its configured initial state`,
).toEqual(
jest.requireActual(`data/redux/${key}/reducer`).initialState,
);
testInitialState('app');
testInitialState('submissions');
testInitialState('grading');
expect(
el.getByText(appMessages.ListView.loadingResponses),
'Loading Responses pending state text should be displayed in the ListView',
).toBeVisible();
inspector = new Inspector(el);
});
test('initialization', async (done) => {
const verifyInitialState = async () => {
await waitForRequestStatus(RequestKeys.initialize, RequestStates.pending);
const testInitialState = (key) => expect(
state[key],
`${key} store should have its configured initial state`,
).toEqual(
jest.requireActual(`data/redux/${key}/reducer`).initialState,
);
testInitialState('app');
testInitialState('submissions');
testInitialState('grading');
expect(
inspector.listView.loadingResponses(),
'Loading Responses pending state text should be displayed in the ListView',
).toBeVisible();
}
await verifyInitialState();
// initialization network error
resolveFns.init.networkError();
await waitForRequestStatus(RequestKeys.initialize, RequestStates.failed);
expect(
await find.listLoadErrorHeading(),
'List Error should be available (by heading component)',
).toBeVisible();
const backLink = el.getByText(appMessages.ListView.backToResponsesLowercase).closest('a');
expect(
backLink.href,
'Back to responses button href should link to urls.openResponse(courseId)',
).toEqual(urls.openResponse(getState().app.courseMetadata.courseId));
retryLink = el.getByText(appMessages.ListView.reloadSubmissions).closest('button');
const forceAndVerifyInitNetworkError = async () => {
resolveFns.init.networkError();
await waitForRequestStatus(RequestKeys.initialize, RequestStates.failed);
expect(
await inspector.find.listView.loadErrorHeading(),
'List Error should be available (by heading component)',
).toBeVisible();
const backLink = inspector.listView.backLink();
expect(
backLink.href,
'Back to responses button href should link to urls.openResponse(courseId)',
).toEqual(urls.openResponse(getState().app.courseMetadata.courseId));
};
await forceAndVerifyInitNetworkError();
// initialization retry/pending
retryLink = inspector.listView.reloadBtn();
await userEvent.click(retryLink);
await waitForRequestStatus(RequestKeys.initialize, RequestStates.pending);
// initialization success
await initialize();
await waitForRequestStatus(RequestKeys.initialize, RequestStates.completed);
expect(
state.app.courseMetadata,
'Course metadata in redux should be populated with fake data',
).toEqual(fakeData.courseMetadata);
expect(
state.app.oraMetadata,
'ORA metadata in redux should be populated with fake data',
).toEqual(fakeData.oraMetadata);
expect(
state.submissions.allSubmissions,
'submissions data in redux should be populated with fake data',
).toEqual(fakeData.submissions);
// Table selection
await makeTableSelections();
// Review pending
await waitForRequestStatus(RequestKeys.fetchSubmission, RequestStates.pending);
expect(
state.grading.selected,
'submission IDs should be loaded',
).toEqual(submissionUUIDs);
// expect(get.modal(), 'ReviewModal should be visible').toBeVisible();
expect(state.app.showReview, 'app store should have showReview: true').toEqual(true);
expect(get.reviewUsername(0), 'username should be visible').toBeVisible();
expect(get.nextNav(), 'next nav should be displayed').toBeVisible();
expect(get.nextNav(), 'next nav should be disabled').toHaveAttribute('disabled');
expect(get.prevNav(), 'prev nav should be displayed').toBeVisible();
expect(get.prevNav(), 'prev nav should be disabled').toHaveAttribute('disabled');
expect(
get.reviewLoadingResponse(),
'Loading Responses pending state text should be displayed in the ReviewModal',
).toBeVisible();
// Review: Fetch - Network Error
await resolveFns.fetch.networkError();
await waitForRequestStatus(RequestKeys.fetchSubmission, RequestStates.failed);
expect(
await find.reviewLoadErrorHeading(),
'Load Submission error should be displayed in ReviewModal',
).toBeVisible();
// fetch: retry and succeed
await userEvent.click(get.reviewRetryLink());
await waitForRequestStatus(RequestKeys.fetchSubmission, RequestStates.pending);
let showRubric = false;
// fetch: success
const verifyFetchSuccess = async (submissionIndex) => {
const submissionString = `for submission ${submissionIndex}`;
const submission = submissions[submissionIndex];
await resolveFns.fetch.success();
await waitForRequestStatus(RequestKeys.fetchSubmission, RequestStates.completed);
const forceAndVerifyInitSuccess = async () => {
await initialize();
await waitForRequestStatus(RequestKeys.initialize, RequestStates.completed);
expect(
get.reviewGradingStatus(submission),
`Should display current submission grading status ${submissionString}`,
).toBeVisible();
showRubric = showRubric || selectors.grading.selected.isGrading(getState());
getState();
state.app.courseMetadata,
'Course metadata in redux should be populated with fake data',
).toEqual(fakeData.courseMetadata);
expect(
state.app.showRubric,
`${showRubric ? 'Should' : 'Should not'} show rubric ${submissionString}`,
).toEqual(showRubric);
if (showRubric) {
expect(
el.getByText(appMessages.ReviewActions.hideRubric),
`Hide Rubric button should be visible when rubric is shown ${submissionString}`,
).toBeVisible();
} else {
expect(
el.getByText(appMessages.ReviewActions.showRubric),
`Show Rubric button should be visible when rubric is hidden ${submissionString}`,
).toBeVisible();
}
// loads current submission
state.app.oraMetadata,
'ORA metadata in redux should be populated with fake data',
).toEqual(fakeData.oraMetadata);
expect(
state.grading.current,
`Redux current grading state should load the current submission ${submissionString}`,
).toEqual({
submissionUUID: submissionUUIDs[submissionIndex],
...submissions[submissionIndex],
});
expect(get.nextNav(), `Next nav should be visible ${submissionString}`).toBeVisible();
expect(get.prevNav(), `Prev nav should be visible ${submissionString}`).toBeVisible();
if (submissionIndex > 0) {
expect(
get.prevNav(),
`Prev nav should be enabled ${submissionString}`,
).not.toHaveAttribute('disabled');
} else {
expect(
get.prevNav(),
`Prev nav should be disabled ${submissionString}`,
).toHaveAttribute('disabled');
}
if (submissionIndex < submissions.length - 1) {
expect(
get.nextNav(),
`Next nav should be enabled ${submissionString}`,
).not.toHaveAttribute('disabled');
} else {
expect(
get.nextNav(),
`Next nav should be disabled ${submissionString}`,
).toHaveAttribute('disabled');
}
state.submissions.allSubmissions,
'submissions data in redux should be populated with fake data',
).toEqual(fakeData.submissions);
};
await verifyFetchSuccess(0);
await forceAndVerifyInitSuccess();
await clickNext();
await verifyFetchSuccess(1);
await makeTableSelections();
await waitForRequestStatus(RequestKeys.fetchSubmission, RequestStates.pending);
done();
});
await clickNext();
await verifyFetchSuccess(2);
describe('initialized', () => {
beforeEach(async () => {
await initialize();
await waitForRequestStatus(RequestKeys.initialize, RequestStates.completed);
await makeTableSelections();
await waitForRequestStatus(RequestKeys.fetchSubmission, RequestStates.pending);
});
await clickNext();
await verifyFetchSuccess(3);
test('initial review state', async (done) => {
// Make table selection and load Review pane
expect(
state.grading.selected,
'submission IDs should be loaded',
).toEqual(submissionUUIDs);
expect(state.app.showReview, 'app store should have showReview: true').toEqual(true);
expect(inspector.review.username(0), 'username should be visible').toBeVisible();
const nextNav = inspector.review.nextNav();
const prevNav = inspector.review.prevNav();
expect(nextNav, 'next nav should be displayed').toBeVisible();
expect(nextNav, 'next nav should be disabled').toHaveAttribute('disabled');
expect(prevNav, 'prev nav should be displayed').toBeVisible();
expect(prevNav, 'prev nav should be disabled').toHaveAttribute('disabled');
expect(
inspector.review.loadingResponse(),
'Loading Responses pending state text should be displayed in the ReviewModal',
).toBeVisible();
done();
});
await clickNext();
await verifyFetchSuccess(4);
test('fetch network error and retry', async (done) => {
await resolveFns.fetch.networkError();
await waitForRequestStatus(RequestKeys.fetchSubmission, RequestStates.failed);
expect(
await inspector.find.review.loadErrorHeading(),
'Load Submission error should be displayed in ReviewModal',
).toBeVisible();
// fetch: retry and succeed
await userEvent.click(inspector.review.retryFetchLink());
await waitForRequestStatus(RequestKeys.fetchSubmission, RequestStates.pending);
done()
});
await clickPrev();
await verifyFetchSuccess(3);
test('fetch success and nav chain', async (done) => {
let showRubric = false;
// fetch: success with chained navigation
const verifyFetchSuccess = async (submissionIndex) => {
const submissionString = `for submission ${submissionIndex}`;
const submission = submissions[submissionIndex];
const forceAndVerifyFetchSuccess = async () => {
await resolveFns.fetch.success();
await waitForRequestStatus(RequestKeys.fetchSubmission, RequestStates.completed);
expect(
inspector.review.gradingStatus(submission),
`Should display current submission grading status ${submissionString}`,
).toBeVisible();
};
await forceAndVerifyFetchSuccess();
await clickNext();
await verifyFetchSuccess(4);
showRubric = showRubric || selectors.grading.selected.isGrading(getState());
await clickPrev();
await verifyFetchSuccess(3);
const verifyRubricVisibility = async () => {
getState();
expect(
state.app.showRubric,
`${showRubric ? 'Should' : 'Should not'} show rubric ${submissionString}`,
).toEqual(showRubric);
if (showRubric) {
expect(
inspector.review.hideRubricBtn(),
`Hide Rubric button should be visible when rubric is shown ${submissionString}`,
).toBeVisible();
await clickPrev();
await verifyFetchSuccess(2, true);
} else {
expect(
inspector.review.showRubricBtn(),
`Show Rubric button should be visible when rubric is hidden ${submissionString}`,
).toBeVisible();
}
}
await verifyRubricVisibility();
await clickPrev();
await verifyFetchSuccess(1);
// loads current submission
const testSubmissionGradingState = () => {
expect(
state.grading.current,
`Redux current grading state should load the current submission ${submissionString}`,
).toEqual({
submissionUUID: submissionUUIDs[submissionIndex],
...submissions[submissionIndex],
});
};
testSubmissionGradingState();
await clickPrev();
await verifyFetchSuccess(0);
///done();
const testNavState = () => {
const expectDisabled = (getNav, name) => (
 expect(getNav(), `${name} should be disabled`).toHaveAttribute('disabled')
);
const expectEnabled = (getNav, name) => (
 expect(getNav(), `${name} should be enabled`).not.toHaveAttribute('disabled')
);
(submissionIndex > 0 ? expectEnabled : expectDisabled)(
inspector.review.prevNav,
'Prev nav',
);
const hasNext = submissionIndex < submissions.length - 1;
(hasNext ? expectEnabled : expectDisabled)(inspector.review.nextNav, 'Next nav');
};
testNavState();
};
await verifyFetchSuccess(0);
for (let i = 1; i < 5; i++) {
await userEvent.click(inspector.review.nextNav());
await verifyFetchSuccess(i);
}
for (let i = 3; i >= 0; i--) {
await userEvent.click(inspector.review.prevNav());
await verifyFetchSuccess(i);
}
done();
});
describe('grading (basic)', () => {
beforeEach(async () => {
await resolveFns.fetch.success();
await waitForRequestStatus(RequestKeys.fetchSubmission, RequestStates.completed);
await userEvent.click(await inspector.find.review.startGradingBtn());
});
/*
test('pending', async (done) => {
done();
});
test('error', async (done) => {
done();
});
*/
describe('active grading', () => {
beforeEach(async () => {
await resolveFns.lock.success();
});
const selectedOptions = [1, 2];
const feedback = ['feedback 0', 'feedback 1'];
const overallFeedback = 'some overall feedback';
// Set basic grade and feedback
const setGrade = async () => {
for (let i of [0, 1]) {
await userEvent.click(inspector.review.rubric.criterionOption(i, selectedOptions[i]));
await userEvent.type(inspector.review.rubric.criterionFeedback(i), feedback[i]);
}
await userEvent.type(inspector.review.rubric.feedbackInput(), overallFeedback);
};
// Verify active-grading state
const checkGradingState = () => {
const { gradingData } = getState().grading;
const entry = gradingData[submissionUUIDs[0]];
const checkCriteria = (index) => {
const criterion = entry.criteria[index];
const rubricOptions = rubricConfig.criteria[index].options;
expect(criterion.selectedOption).toEqual(rubricOptions[selectedOptions[index]].name);
expect(criterion.feedback).toEqual(feedback[index]);
}
[0, 1].forEach(checkCriteria);
expect(entry.overallFeedback).toEqual(overallFeedback);
}
// Verify after-submission-success grade state
const checkGradeSuccess = () => {
const { gradeData, current } = getState().grading;
const entry = gradeData[submissionUUIDs[0]];
const checkCriteria = (index) => {
const criterion = entry.criteria[index];
const rubricOptions = rubricConfig.criteria[index].options;
expect(criterion.selectedOption).toEqual(rubricOptions[selectedOptions[index]].name);
expect(criterion.feedback).toEqual(feedback[index]);
}
[0, 1].forEach(checkCriteria);
expect(entry.overallFeedback).toEqual(overallFeedback);
expect(current.gradeStatus).toEqual(gradeStatuses.graded);
expect(current.lockStatus).toEqual(lockStatuses.unlocked);
}
/*
test('submit pending', async (done) => {
done();
});
test('submit failed', async (done) => {
done();
});
*/
test('submit grade (success)', async (done) => {
expect(await inspector.find.review.submitGradeBtn()).toBeVisible();
await setGrade();
checkGradingState();
await userEvent.click(inspector.review.rubric.submitGradeBtn());
await resolveFns.updateGrade.success();
checkGradeSuccess();
done();
});
});
});
});
});

109
src/test/inspector.js Normal file
View File

@@ -0,0 +1,109 @@
/* eslint-disable import/no-extraneous-dependencies */
import { within } from '@testing-library/react';
import fakeData from 'data/services/lms/fakeData';
import { gradingStatusTransform } from 'data/redux/grading/selectors';
import appMessages from './messages';
const { rubricConfig } = fakeData.oraMetadata;
/**
* App inspector class providing methods to return elements from within
* the virtual DOM
* @props {Root Node} el - Root app render node.
*/
class Inspector {
constructor(el) {
this.el = el;
this.getByRole = this.el.getByRole;
this.getByText = this.el.getByText;
this.getByLabelText = this.el.getByLabelText;
this.findByText = this.el.findByText;
this.findByLabelText = this.el.findByLabelText;
}
/**
* Returns listView elements (immediate return methods)
*/
get listView() {
const table = () => this.getByRole('table');
const tableRows = () => table().querySelectorAll('tbody tr');
return {
table,
tableRows,
selectedBtn: () => this.getByText('View selected responses (5)'),
loadingResponses: () => this.getByText(appMessages.ListView.loadingResponses),
listCheckbox: (index) => (
within(tableRows().item(index)).getByTitle('Toggle Row Selected')
),
backLink: () => this.getByText(appMessages.ListView.backToResponsesLowercase).closest('a'),
reloadBtn: () => this.getByText(appMessages.ListView.reloadSubmissions).closest('button'),
};
}
/**
* Returns Review Modal elements (immediate return methods)
*/
get review() {
const modal = this.getByRole('dialog');
const modalEl = within(modal);
const { getByLabelText, getByText } = modalEl;
const rubricContent = () => modalEl.getByText(appMessages.Rubric.rubric).closest('div');
const rubricFeedback = () => (
within(rubricContent()).getByText(appMessages.Rubric.overallComments).closest('div')
);
const rubricCriterion = (index) => (
within(rubricContent()).getByText(rubricConfig.criteria[index].prompt).closest('div')
);
return {
modal: () => modal,
modalEl: () => modalEl,
nextNav: () => getByLabelText(appMessages.ReviewActionsComponents.loadNext),
prevNav: () => getByLabelText(appMessages.ReviewActionsComponents.loadPrevious),
hideRubricBtn: () => getByText(appMessages.ReviewActions.hideRubric),
showRubricBtn: () => getByText(appMessages.ReviewActions.showRubric),
loadingResponse: () => getByText(appMessages.ReviewModal.loadingResponse),
retryFetchLink: () => (
getByText(appMessages.ReviewErrors.reloadSubmission).closest('button')
),
username: (index) => getByText(fakeData.ids.username(index)),
gradingStatus: (submission) => (
getByText(appMessages.lms[gradingStatusTransform(submission)])
),
rubric: {
criteria: () => modal.querySelectorAll('.rubric-criteria'),
feedbackInput: () => within(rubricFeedback()).getByRole('textbox'),
submitGradeBtn: () => modalEl.getByText(appMessages.Rubric.submitGrade),
criterion: rubricCriterion,
criterionOption: (criterionIndex, optionIndex) => (
within(rubricCriterion(criterionIndex))
.getByText(rubricConfig.criteria[criterionIndex].options[optionIndex].label)
),
criterionFeedback: (index) => within(rubricCriterion(index)).getByRole('textbox'),
},
};
}
/**
* Returns promises for attempting to find elements within the DOM
*/
get find() {
return {
listView: {
viewAllResponsesBtn: () => this.findByText(appMessages.ListView.viewAllResponses),
loadErrorHeading: () => this.findByText(appMessages.ListView.loadErrorHeading),
},
review: {
prevNav: () => (
this.review.modalEl().findByLabelText(appMessages.ReviewActionsComponents.loadPrevious)
),
loadErrorHeading: () => this.findByText(appMessages.ReviewErrors.loadErrorHeading),
startGradingBtn: () => this.findByText(appMessages.ReviewActionsComponents.startGrading),
submitGradeBtn: () => this.findByText(appMessages.Rubric.submitGrade),
},
};
}
}
export default Inspector;