1+ import { useLocation } from 'react-router-dom' ;
12import { fireEvent , render , screen , waitFor , within } from 'test-utils' ;
23
34import toast from 'lib/hooks/toast' ;
45
56import fetchGradebook from '../operations' ;
67import GradebookIndex from '../pages/GradebookIndex' ;
78
9+ // TestApp mounts a MemoryRouter, whose location lives in memory and never
10+ // touches window.location. This spy surfaces the router's current search
11+ // string into the DOM so tests can assert on URL changes.
12+ const LocationSearch = ( ) : JSX . Element => (
13+ < div data-testid = "location-search" > { useLocation ( ) . search } </ div >
14+ ) ;
15+
816jest . mock ( '../../container/CourseLoader' , ( ) => ( {
917 useCourseContext : ( ) : { courseTitle : string ; id : number } => ( {
1018 courseTitle : 'Test Course' ,
@@ -24,6 +32,8 @@ jest.mock('../operations', () => ({
2432
2533const mockFetchGradebook = fetchGradebook as jest . Mock ;
2634
35+ const WEIGHTED_TABLE_TESTID = 'gradebook-weighted-table' ;
36+
2737const emptyState = {
2838 gradebook : {
2939 categories : [ ] ,
@@ -99,6 +109,16 @@ const populatedState = {
99109 } ,
100110} ;
101111
112+ const studentsNoAssessmentsState = {
113+ gradebook : {
114+ ...populatedState . gradebook ,
115+ categories : [ ] ,
116+ tabs : [ ] ,
117+ assessments : [ ] ,
118+ submissions : [ ] ,
119+ } ,
120+ } ;
121+
102122const populatedStateWithGamification = {
103123 gradebook : {
104124 ...populatedState . gradebook ,
@@ -139,6 +159,55 @@ const populatedStateManagerWeightedOn = {
139159 } ,
140160} ;
141161
162+ const populatedStateExternalInRange = {
163+ gradebook : {
164+ ...populatedState . gradebook ,
165+ assessments : [
166+ {
167+ id : 200 ,
168+ title : 'External Midterm' ,
169+ tabId : 10 ,
170+ maxGrade : 100 ,
171+ external : true ,
172+ capAtMaximum : true ,
173+ floorAtZero : true ,
174+ } ,
175+ ] ,
176+ submissions : [
177+ { studentId : 1 , assessmentId : 200 , submissionId : 200 , grade : 90 } , // within [0,100]
178+ ] ,
179+ } ,
180+ } ;
181+
182+ const populatedStateWithOutOfRangeGrade = {
183+ gradebook : {
184+ ...populatedState . gradebook ,
185+ assessments : [
186+ { id : 100 , title : 'Quiz 1' , tabId : 10 , maxGrade : 10 } ,
187+ {
188+ id : 200 ,
189+ title : 'External Midterm' ,
190+ tabId : 10 ,
191+ maxGrade : 100 ,
192+ external : true ,
193+ capAtMaximum : true ,
194+ floorAtZero : true ,
195+ } ,
196+ ] ,
197+ submissions : [
198+ { studentId : 1 , assessmentId : 100 , submissionId : 1000 , grade : 8 } ,
199+ { studentId : 1 , assessmentId : 200 , submissionId : 200 , grade : 110 } , // above max, capped
200+ ] ,
201+ } ,
202+ } ;
203+
204+ const populatedStateWithOutOfRangeGradeWeighted = {
205+ gradebook : {
206+ ...populatedStateWithOutOfRangeGrade . gradebook ,
207+ weightedViewEnabled : true ,
208+ } ,
209+ } ;
210+
142211beforeEach ( ( ) => {
143212 jest . clearAllMocks ( ) ;
144213 mockFetchGradebook . mockReturnValue ( ( ) : Promise < void > => Promise . resolve ( ) ) ;
@@ -162,18 +231,44 @@ describe('GradebookIndex', () => {
162231 expect ( await screen . findByText ( 'Gradebook' ) ) . toBeInTheDocument ( ) ;
163232 } ) ;
164233
165- it ( 'shows empty students message when there are no students ' , async ( ) => {
166- render ( < GradebookIndex /> , { state : noStudentsState } ) ;
234+ it ( 'shows the grade-link hint in the all-assessments view ' , async ( ) => {
235+ render ( < GradebookIndex /> , { state : populatedState } ) ;
167236 expect (
168- await screen . findByText ( 'No students enrolled yet' ) ,
237+ await screen . findByText (
238+ / C l i c k a n y g r a d e t o o p e n t h a t s u b m i s s i o n a n d a d j u s t t h e m a r k s / i,
239+ ) ,
169240 ) . toBeInTheDocument ( ) ;
170241 } ) ;
171242
172- it ( 'shows empty students message when both assessments and students are absent' , async ( ) => {
243+ it ( 'hides the grade-link hint in the weighted-total view' , async ( ) => {
244+ render ( < GradebookIndex /> , { state : populatedStateWithWeightedView } ) ;
245+ const byWeightButton = await screen . findByText ( / w e i g h t e d t o t a l / i) ;
246+ fireEvent . click ( byWeightButton ) ;
247+ await screen . findByTestId ( WEIGHTED_TABLE_TESTID ) ;
248+ expect (
249+ screen . queryByText (
250+ / C l i c k a n y g r a d e t o o p e n t h a t s u b m i s s i o n a n d a d j u s t t h e m a r k s / i,
251+ ) ,
252+ ) . not . toBeInTheDocument ( ) ;
253+ } ) ;
254+
255+ it ( 'shows empty students message and renders no gradebook table when there are no students' , async ( ) => {
173256 render ( < GradebookIndex /> , { state : emptyState } ) ;
174257 expect (
175258 await screen . findByText ( 'No students enrolled yet' ) ,
176259 ) . toBeInTheDocument ( ) ;
260+ expect ( screen . queryByTestId ( WEIGHTED_TABLE_TESTID ) ) . not . toBeInTheDocument ( ) ;
261+ } ) ;
262+
263+ it ( 'renders the gradebook table when there are students but no assessments' , async ( ) => {
264+ render ( < GradebookIndex /> , { state : studentsNoAssessmentsState } ) ;
265+ expect (
266+ await screen . findByRole ( 'button' , { name : / e x p o r t / i } ) ,
267+ ) . toBeInTheDocument ( ) ;
268+ expect ( screen . getByText ( 'Alice' ) ) . toBeInTheDocument ( ) ;
269+ expect (
270+ screen . queryByText ( 'No students enrolled yet' ) ,
271+ ) . not . toBeInTheDocument ( ) ;
177272 } ) ;
178273
179274 it ( 'shows error toast when fetch fails' , async ( ) => {
@@ -196,7 +291,7 @@ describe('GradebookIndex', () => {
196291 ) . toBeInTheDocument ( ) ;
197292 } ) ;
198293
199- it ( 'shows grade-and-gamification hint in column picker when gamification is enabled and no data cols selected' , async ( ) => {
294+ it ( 'shows grade-and-gamification hint in column picker after enabling a gamification column with no grade columns selected' , async ( ) => {
200295 render ( < GradebookIndex /> , { state : populatedStateWithGamification } ) ;
201296 fireEvent . click (
202297 await screen . findByRole ( 'button' , { name : / s e l e c t c o l u m n s / i } ) ,
@@ -224,12 +319,38 @@ describe('GradebookIndex', () => {
224319 expect ( await screen . findByText ( / w e i g h t e d t o t a l / i) ) . toBeInTheDocument ( ) ;
225320 } ) ;
226321
227- it ( 'switches to Weighted Total view on toggle click' , async ( ) => {
228- render ( < GradebookIndex /> , { state : populatedStateWithWeightedView } ) ;
322+ it ( 'switches to Weighted total view on toggle click and reflects it in the URL' , async ( ) => {
323+ render (
324+ < >
325+ < GradebookIndex />
326+ < LocationSearch />
327+ </ > ,
328+ { state : populatedStateWithWeightedView } ,
329+ ) ;
229330 const byWeightButton = await screen . findByText ( / w e i g h t e d t o t a l / i) ;
230331 fireEvent . click ( byWeightButton ) ;
231332 expect (
232- await screen . findByTestId ( 'gradebook-weighted-table' ) ,
333+ await screen . findByTestId ( WEIGHTED_TABLE_TESTID ) ,
334+ ) . toBeInTheDocument ( ) ;
335+ expect ( screen . getByTestId ( 'location-search' ) ) . toHaveTextContent (
336+ 'view=weighted' ,
337+ ) ;
338+
339+ fireEvent . click ( await screen . findByText ( / a l l a s s e s s m e n t s / i) ) ;
340+ await waitFor ( ( ) =>
341+ expect ( screen . getByTestId ( 'location-search' ) ) . not . toHaveTextContent (
342+ 'view=weighted' ,
343+ ) ,
344+ ) ;
345+ } ) ;
346+
347+ it ( 'starts in Weighted total view when the URL requests it' , async ( ) => {
348+ render ( < GradebookIndex /> , {
349+ state : populatedStateWithWeightedView ,
350+ at : [ '/?view=weighted' ] ,
351+ } ) ;
352+ expect (
353+ await screen . findByTestId ( WEIGHTED_TABLE_TESTID ) ,
233354 ) . toBeInTheDocument ( ) ;
234355 } ) ;
235356
@@ -239,7 +360,7 @@ describe('GradebookIndex', () => {
239360 } ) ;
240361 const byWeightButton = await screen . findByText ( / w e i g h t e d t o t a l / i) ;
241362 fireEvent . click ( byWeightButton ) ;
242- await screen . findByTestId ( 'gradebook-weighted-table' ) ;
363+ await screen . findByTestId ( WEIGHTED_TABLE_TESTID ) ;
243364 fireEvent . click (
244365 await screen . findByRole ( 'button' , { name : / s e l e c t c o l u m n s / i } ) ,
245366 ) ;
@@ -248,6 +369,75 @@ describe('GradebookIndex', () => {
248369 expect ( within ( dialog ) . queryByText ( 'Total XP' ) ) . not . toBeInTheDocument ( ) ;
249370 } ) ;
250371
372+ it ( 'shows the manage button and not the old import/add buttons' , async ( ) => {
373+ render ( < GradebookIndex /> , { state : populatedStateManagerWeightedOff } ) ;
374+ expect (
375+ await screen . findByRole ( 'button' , {
376+ name : 'Manage external assessments' ,
377+ } ) ,
378+ ) . toBeVisible ( ) ;
379+ expect (
380+ screen . queryByRole ( 'button' , { name : 'Import external assessments' } ) ,
381+ ) . not . toBeInTheDocument ( ) ;
382+ expect (
383+ screen . queryByRole ( 'button' , { name : 'Add external assessment' } ) ,
384+ ) . not . toBeInTheDocument ( ) ;
385+ } ) ;
386+
387+ it ( 'shows the manage button in the weighted-total view for managers' , async ( ) => {
388+ render ( < GradebookIndex /> , { state : populatedStateManagerWeightedOn } ) ;
389+ const byWeightButton = await screen . findByText ( / w e i g h t e d t o t a l / i) ;
390+ fireEvent . click ( byWeightButton ) ;
391+ await screen . findByTestId ( WEIGHTED_TABLE_TESTID ) ;
392+ expect (
393+ screen . getByRole ( 'button' , { name : 'Manage external assessments' } ) ,
394+ ) . toBeVisible ( ) ;
395+ } ) ;
396+
397+ it ( 'does not show the manage button to staff who cannot manage weights' , async ( ) => {
398+ render ( < GradebookIndex /> , { state : populatedState } ) ;
399+ await screen . findByRole ( 'button' , { name : / e x p o r t / i } ) ; // wait for load
400+ expect (
401+ screen . queryByRole ( 'button' , { name : 'Manage external assessments' } ) ,
402+ ) . not . toBeInTheDocument ( ) ;
403+ } ) ;
404+
405+ describe ( 'out-of-range banner' , ( ) => {
406+ it ( 'shows the banner when there are out-of-range grades' , async ( ) => {
407+ render ( < GradebookIndex /> , { state : populatedStateWithOutOfRangeGrade } ) ;
408+ expect (
409+ await screen . findByText ( / o u t s i d e t h e i r r a n g e / i) ,
410+ ) . toBeInTheDocument ( ) ;
411+ } ) ;
412+
413+ it ( 'shows the weighted-total wording when the weighted view is enabled' , async ( ) => {
414+ render ( < GradebookIndex /> , {
415+ state : populatedStateWithOutOfRangeGradeWeighted ,
416+ } ) ;
417+ expect (
418+ await screen . findByText (
419+ / b e i n g c a p p e d o r f l o o r e d i n t h e w e i g h t e d t o t a l / i,
420+ ) ,
421+ ) . toBeInTheDocument ( ) ;
422+ } ) ;
423+
424+ it ( 'does not show the banner when all grades are in range' , async ( ) => {
425+ render ( < GradebookIndex /> , { state : populatedStateExternalInRange } ) ;
426+ await screen . findByRole ( 'button' , { name : / e x p o r t / i } ) ; // wait for load
427+ expect (
428+ screen . queryByText ( / o u t s i d e t h e i r r a n g e / i) ,
429+ ) . not . toBeInTheDocument ( ) ;
430+ } ) ;
431+
432+ it ( 'does not show the banner when there are no students' , async ( ) => {
433+ render ( < GradebookIndex /> , { state : noStudentsState } ) ;
434+ await screen . findByText ( 'No students enrolled yet' ) ;
435+ expect (
436+ screen . queryByText ( / o u t s i d e t h e i r r a n g e / i) ,
437+ ) . not . toBeInTheDocument ( ) ;
438+ } ) ;
439+ } ) ;
440+
251441 describe ( 'weighted-view discoverability hint' , ( ) => {
252442 it ( 'shows the hint to managers when the weighted view is off' , async ( ) => {
253443 render ( < GradebookIndex /> , { state : populatedStateManagerWeightedOff } ) ;
0 commit comments