Skip to content
Open
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
208 changes: 199 additions & 9 deletions client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
import { useLocation } from 'react-router-dom';
import { fireEvent, render, screen, waitFor, within } from 'test-utils';

import toast from 'lib/hooks/toast';

import fetchGradebook from '../operations';
import GradebookIndex from '../pages/GradebookIndex';

// TestApp mounts a MemoryRouter, whose location lives in memory and never
// touches window.location. This spy surfaces the router's current search
// string into the DOM so tests can assert on URL changes.
const LocationSearch = (): JSX.Element => (
<div data-testid="location-search">{useLocation().search}</div>
);

jest.mock('../../container/CourseLoader', () => ({
useCourseContext: (): { courseTitle: string; id: number } => ({
courseTitle: 'Test Course',
Expand All @@ -24,6 +32,8 @@ jest.mock('../operations', () => ({

const mockFetchGradebook = fetchGradebook as jest.Mock;

const WEIGHTED_TABLE_TESTID = 'gradebook-weighted-table';

const emptyState = {
gradebook: {
categories: [],
Expand Down Expand Up @@ -74,6 +84,16 @@ const populatedState = {
},
};

const studentsNoAssessmentsState = {
gradebook: {
...populatedState.gradebook,
categories: [],
tabs: [],
assessments: [],
submissions: [],
},
};

const populatedStateWithGamification = {
gradebook: {
...populatedState.gradebook,
Expand Down Expand Up @@ -114,6 +134,55 @@ const populatedStateManagerWeightedOn = {
},
};

const populatedStateExternalInRange = {
gradebook: {
...populatedState.gradebook,
assessments: [
{
id: 200,
title: 'External Midterm',
tabId: 10,
maxGrade: 100,
external: true,
capAtMaximum: true,
floorAtZero: true,
},
],
submissions: [
{ studentId: 1, assessmentId: 200, submissionId: 200, grade: 90 }, // within [0,100]
],
},
};

const populatedStateWithOutOfRangeGrade = {
gradebook: {
...populatedState.gradebook,
assessments: [
{ id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 },
{
id: 200,
title: 'External Midterm',
tabId: 10,
maxGrade: 100,
external: true,
capAtMaximum: true,
floorAtZero: true,
},
],
submissions: [
{ studentId: 1, assessmentId: 100, submissionId: 1000, grade: 8 },
{ studentId: 1, assessmentId: 200, submissionId: 200, grade: 110 }, // above max, capped
],
},
};

const populatedStateWithOutOfRangeGradeWeighted = {
gradebook: {
...populatedStateWithOutOfRangeGrade.gradebook,
weightedViewEnabled: true,
},
};

beforeEach(() => {
jest.clearAllMocks();
mockFetchGradebook.mockReturnValue((): Promise<void> => Promise.resolve());
Expand All @@ -137,18 +206,44 @@ describe('GradebookIndex', () => {
expect(await screen.findByText('Gradebook')).toBeInTheDocument();
});

it('shows empty students message when there are no students', async () => {
render(<GradebookIndex />, { state: noStudentsState });
it('shows the grade-link hint in the all-assessments view', async () => {
render(<GradebookIndex />, { state: populatedState });
expect(
await screen.findByText('No students enrolled yet'),
await screen.findByText(
/Click any grade to open that submission and adjust the marks/i,
),
).toBeInTheDocument();
});

it('shows empty students message when both assessments and students are absent', async () => {
it('hides the grade-link hint in the weighted-total view', async () => {
render(<GradebookIndex />, { state: populatedStateWithWeightedView });
const byWeightButton = await screen.findByText(/weighted total/i);
fireEvent.click(byWeightButton);
await screen.findByTestId(WEIGHTED_TABLE_TESTID);
expect(
screen.queryByText(
/Click any grade to open that submission and adjust the marks/i,
),
).not.toBeInTheDocument();
});

it('shows empty students message and renders no gradebook table when there are no students', async () => {
render(<GradebookIndex />, { state: emptyState });
expect(
await screen.findByText('No students enrolled yet'),
).toBeInTheDocument();
expect(screen.queryByTestId(WEIGHTED_TABLE_TESTID)).not.toBeInTheDocument();
});

it('renders the gradebook table when there are students but no assessments', async () => {
render(<GradebookIndex />, { state: studentsNoAssessmentsState });
expect(
await screen.findByRole('button', { name: /export/i }),
).toBeInTheDocument();
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(
screen.queryByText('No students enrolled yet'),
).not.toBeInTheDocument();
});

it('shows error toast when fetch fails', async () => {
Expand All @@ -171,7 +266,7 @@ describe('GradebookIndex', () => {
).toBeInTheDocument();
});

it('shows grade-and-gamification hint in column picker when gamification is enabled and no data cols selected', async () => {
it('shows grade-and-gamification hint in column picker after enabling a gamification column with no grade columns selected', async () => {
render(<GradebookIndex />, { state: populatedStateWithGamification });
fireEvent.click(
await screen.findByRole('button', { name: /select columns/i }),
Expand Down Expand Up @@ -199,12 +294,38 @@ describe('GradebookIndex', () => {
expect(await screen.findByText(/weighted total/i)).toBeInTheDocument();
});

it('switches to Weighted total view on toggle click', async () => {
render(<GradebookIndex />, { state: populatedStateWithWeightedView });
it('switches to Weighted total view on toggle click and reflects it in the URL', async () => {
render(
<>
<GradebookIndex />
<LocationSearch />
</>,
{ state: populatedStateWithWeightedView },
);
const byWeightButton = await screen.findByText(/weighted total/i);
fireEvent.click(byWeightButton);
expect(
await screen.findByTestId('gradebook-weighted-table'),
await screen.findByTestId(WEIGHTED_TABLE_TESTID),
).toBeInTheDocument();
expect(screen.getByTestId('location-search')).toHaveTextContent(
'view=weighted',
);

fireEvent.click(await screen.findByText(/all assessments/i));
await waitFor(() =>
expect(screen.getByTestId('location-search')).not.toHaveTextContent(
'view=weighted',
),
);
});

it('starts in Weighted total view when the URL requests it', async () => {
render(<GradebookIndex />, {
state: populatedStateWithWeightedView,
at: ['/?view=weighted'],
});
expect(
await screen.findByTestId(WEIGHTED_TABLE_TESTID),
).toBeInTheDocument();
});

Expand All @@ -214,7 +335,7 @@ describe('GradebookIndex', () => {
});
const byWeightButton = await screen.findByText(/weighted total/i);
fireEvent.click(byWeightButton);
await screen.findByTestId('gradebook-weighted-table');
await screen.findByTestId(WEIGHTED_TABLE_TESTID);
fireEvent.click(
await screen.findByRole('button', { name: /select columns/i }),
);
Expand All @@ -223,6 +344,75 @@ describe('GradebookIndex', () => {
expect(within(dialog).queryByText('Total XP')).not.toBeInTheDocument();
});

it('shows the manage button and not the old import/add buttons', async () => {
render(<GradebookIndex />, { state: populatedStateManagerWeightedOff });
expect(
await screen.findByRole('button', {
name: 'Manage external assessments',
}),
).toBeVisible();
expect(
screen.queryByRole('button', { name: 'Import external assessments' }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: 'Add external assessment' }),
).not.toBeInTheDocument();
});

it('shows the manage button in the weighted-total view for managers', async () => {
render(<GradebookIndex />, { state: populatedStateManagerWeightedOn });
const byWeightButton = await screen.findByText(/weighted total/i);
fireEvent.click(byWeightButton);
await screen.findByTestId(WEIGHTED_TABLE_TESTID);
expect(
screen.getByRole('button', { name: 'Manage external assessments' }),
).toBeVisible();
});

it('does not show the manage button to staff who cannot manage weights', async () => {
render(<GradebookIndex />, { state: populatedState });
await screen.findByRole('button', { name: /export/i }); // wait for load
expect(
screen.queryByRole('button', { name: 'Manage external assessments' }),
).not.toBeInTheDocument();
});

describe('out-of-range banner', () => {
it('shows the banner when there are out-of-range grades', async () => {
render(<GradebookIndex />, { state: populatedStateWithOutOfRangeGrade });
expect(
await screen.findByText(/outside their range/i),
).toBeInTheDocument();
});

it('shows the weighted-total wording when the weighted view is enabled', async () => {
render(<GradebookIndex />, {
state: populatedStateWithOutOfRangeGradeWeighted,
});
expect(
await screen.findByText(
/being capped or floored in the weighted total/i,
),
).toBeInTheDocument();
});

it('does not show the banner when all grades are in range', async () => {
render(<GradebookIndex />, { state: populatedStateExternalInRange });
await screen.findByRole('button', { name: /export/i }); // wait for load
expect(
screen.queryByText(/outside their range/i),
).not.toBeInTheDocument();
});

it('does not show the banner when there are no students', async () => {
render(<GradebookIndex />, { state: noStudentsState });
await screen.findByText('No students enrolled yet');
expect(
screen.queryByText(/outside their range/i),
).not.toBeInTheDocument();
});
});

describe('weighted-view discoverability hint', () => {
it('shows the hint to managers when the weighted view is off', async () => {
render(<GradebookIndex />, { state: populatedStateManagerWeightedOff });
Expand Down
Loading