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 : [ ] ,
@@ -74,6 +84,16 @@ const populatedState = {
7484 } ,
7585} ;
7686
87+ const studentsNoAssessmentsState = {
88+ gradebook : {
89+ ...populatedState . gradebook ,
90+ categories : [ ] ,
91+ tabs : [ ] ,
92+ assessments : [ ] ,
93+ submissions : [ ] ,
94+ } ,
95+ } ;
96+
7797const populatedStateWithGamification = {
7898 gradebook : {
7999 ...populatedState . gradebook ,
@@ -114,6 +134,55 @@ const populatedStateManagerWeightedOn = {
114134 } ,
115135} ;
116136
137+ const populatedStateExternalInRange = {
138+ gradebook : {
139+ ...populatedState . gradebook ,
140+ assessments : [
141+ {
142+ id : 200 ,
143+ title : 'External Midterm' ,
144+ tabId : 10 ,
145+ maxGrade : 100 ,
146+ external : true ,
147+ capAtMaximum : true ,
148+ floorAtZero : true ,
149+ } ,
150+ ] ,
151+ submissions : [
152+ { studentId : 1 , assessmentId : 200 , submissionId : 200 , grade : 90 } , // within [0,100]
153+ ] ,
154+ } ,
155+ } ;
156+
157+ const populatedStateWithOutOfRangeGrade = {
158+ gradebook : {
159+ ...populatedState . gradebook ,
160+ assessments : [
161+ { id : 100 , title : 'Quiz 1' , tabId : 10 , maxGrade : 10 } ,
162+ {
163+ id : 200 ,
164+ title : 'External Midterm' ,
165+ tabId : 10 ,
166+ maxGrade : 100 ,
167+ external : true ,
168+ capAtMaximum : true ,
169+ floorAtZero : true ,
170+ } ,
171+ ] ,
172+ submissions : [
173+ { studentId : 1 , assessmentId : 100 , submissionId : 1000 , grade : 8 } ,
174+ { studentId : 1 , assessmentId : 200 , submissionId : 200 , grade : 110 } , // above max, capped
175+ ] ,
176+ } ,
177+ } ;
178+
179+ const populatedStateWithOutOfRangeGradeWeighted = {
180+ gradebook : {
181+ ...populatedStateWithOutOfRangeGrade . gradebook ,
182+ weightedViewEnabled : true ,
183+ } ,
184+ } ;
185+
117186beforeEach ( ( ) => {
118187 jest . clearAllMocks ( ) ;
119188 mockFetchGradebook . mockReturnValue ( ( ) : Promise < void > => Promise . resolve ( ) ) ;
@@ -137,18 +206,44 @@ describe('GradebookIndex', () => {
137206 expect ( await screen . findByText ( 'Gradebook' ) ) . toBeInTheDocument ( ) ;
138207 } ) ;
139208
140- it ( 'shows empty students message when there are no students ' , async ( ) => {
141- render ( < GradebookIndex /> , { state : noStudentsState } ) ;
209+ it ( 'shows the grade-link hint in the all-assessments view ' , async ( ) => {
210+ render ( < GradebookIndex /> , { state : populatedState } ) ;
142211 expect (
143- await screen . findByText ( 'No students enrolled yet' ) ,
212+ await screen . findByText (
213+ / 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,
214+ ) ,
144215 ) . toBeInTheDocument ( ) ;
145216 } ) ;
146217
147- it ( 'shows empty students message when both assessments and students are absent' , async ( ) => {
218+ it ( 'hides the grade-link hint in the weighted-total view' , async ( ) => {
219+ render ( < GradebookIndex /> , { state : populatedStateWithWeightedView } ) ;
220+ const byWeightButton = await screen . findByText ( / w e i g h t e d t o t a l / i) ;
221+ fireEvent . click ( byWeightButton ) ;
222+ await screen . findByTestId ( WEIGHTED_TABLE_TESTID ) ;
223+ expect (
224+ screen . queryByText (
225+ / 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,
226+ ) ,
227+ ) . not . toBeInTheDocument ( ) ;
228+ } ) ;
229+
230+ it ( 'shows empty students message and renders no gradebook table when there are no students' , async ( ) => {
148231 render ( < GradebookIndex /> , { state : emptyState } ) ;
149232 expect (
150233 await screen . findByText ( 'No students enrolled yet' ) ,
151234 ) . toBeInTheDocument ( ) ;
235+ expect ( screen . queryByTestId ( WEIGHTED_TABLE_TESTID ) ) . not . toBeInTheDocument ( ) ;
236+ } ) ;
237+
238+ it ( 'renders the gradebook table when there are students but no assessments' , async ( ) => {
239+ render ( < GradebookIndex /> , { state : studentsNoAssessmentsState } ) ;
240+ expect (
241+ await screen . findByRole ( 'button' , { name : / e x p o r t / i } ) ,
242+ ) . toBeInTheDocument ( ) ;
243+ expect ( screen . getByText ( 'Alice' ) ) . toBeInTheDocument ( ) ;
244+ expect (
245+ screen . queryByText ( 'No students enrolled yet' ) ,
246+ ) . not . toBeInTheDocument ( ) ;
152247 } ) ;
153248
154249 it ( 'shows error toast when fetch fails' , async ( ) => {
@@ -171,7 +266,7 @@ describe('GradebookIndex', () => {
171266 ) . toBeInTheDocument ( ) ;
172267 } ) ;
173268
174- it ( 'shows grade-and-gamification hint in column picker when gamification is enabled and no data cols selected' , async ( ) => {
269+ it ( 'shows grade-and-gamification hint in column picker after enabling a gamification column with no grade columns selected' , async ( ) => {
175270 render ( < GradebookIndex /> , { state : populatedStateWithGamification } ) ;
176271 fireEvent . click (
177272 await screen . findByRole ( 'button' , { name : / s e l e c t c o l u m n s / i } ) ,
@@ -199,12 +294,38 @@ describe('GradebookIndex', () => {
199294 expect ( await screen . findByText ( / w e i g h t e d t o t a l / i) ) . toBeInTheDocument ( ) ;
200295 } ) ;
201296
202- it ( 'switches to Weighted total view on toggle click' , async ( ) => {
203- render ( < GradebookIndex /> , { state : populatedStateWithWeightedView } ) ;
297+ it ( 'switches to Weighted total view on toggle click and reflects it in the URL' , async ( ) => {
298+ render (
299+ < >
300+ < GradebookIndex />
301+ < LocationSearch />
302+ </ > ,
303+ { state : populatedStateWithWeightedView } ,
304+ ) ;
204305 const byWeightButton = await screen . findByText ( / w e i g h t e d t o t a l / i) ;
205306 fireEvent . click ( byWeightButton ) ;
206307 expect (
207- await screen . findByTestId ( 'gradebook-weighted-table' ) ,
308+ await screen . findByTestId ( WEIGHTED_TABLE_TESTID ) ,
309+ ) . toBeInTheDocument ( ) ;
310+ expect ( screen . getByTestId ( 'location-search' ) ) . toHaveTextContent (
311+ 'view=weighted' ,
312+ ) ;
313+
314+ fireEvent . click ( await screen . findByText ( / a l l a s s e s s m e n t s / i) ) ;
315+ await waitFor ( ( ) =>
316+ expect ( screen . getByTestId ( 'location-search' ) ) . not . toHaveTextContent (
317+ 'view=weighted' ,
318+ ) ,
319+ ) ;
320+ } ) ;
321+
322+ it ( 'starts in Weighted total view when the URL requests it' , async ( ) => {
323+ render ( < GradebookIndex /> , {
324+ state : populatedStateWithWeightedView ,
325+ at : [ '/?view=weighted' ] ,
326+ } ) ;
327+ expect (
328+ await screen . findByTestId ( WEIGHTED_TABLE_TESTID ) ,
208329 ) . toBeInTheDocument ( ) ;
209330 } ) ;
210331
@@ -214,7 +335,7 @@ describe('GradebookIndex', () => {
214335 } ) ;
215336 const byWeightButton = await screen . findByText ( / w e i g h t e d t o t a l / i) ;
216337 fireEvent . click ( byWeightButton ) ;
217- await screen . findByTestId ( 'gradebook-weighted-table' ) ;
338+ await screen . findByTestId ( WEIGHTED_TABLE_TESTID ) ;
218339 fireEvent . click (
219340 await screen . findByRole ( 'button' , { name : / s e l e c t c o l u m n s / i } ) ,
220341 ) ;
@@ -223,6 +344,75 @@ describe('GradebookIndex', () => {
223344 expect ( within ( dialog ) . queryByText ( 'Total XP' ) ) . not . toBeInTheDocument ( ) ;
224345 } ) ;
225346
347+ it ( 'shows the manage button and not the old import/add buttons' , async ( ) => {
348+ render ( < GradebookIndex /> , { state : populatedStateManagerWeightedOff } ) ;
349+ expect (
350+ await screen . findByRole ( 'button' , {
351+ name : 'Manage external assessments' ,
352+ } ) ,
353+ ) . toBeVisible ( ) ;
354+ expect (
355+ screen . queryByRole ( 'button' , { name : 'Import external assessments' } ) ,
356+ ) . not . toBeInTheDocument ( ) ;
357+ expect (
358+ screen . queryByRole ( 'button' , { name : 'Add external assessment' } ) ,
359+ ) . not . toBeInTheDocument ( ) ;
360+ } ) ;
361+
362+ it ( 'shows the manage button in the weighted-total view for managers' , async ( ) => {
363+ render ( < GradebookIndex /> , { state : populatedStateManagerWeightedOn } ) ;
364+ const byWeightButton = await screen . findByText ( / w e i g h t e d t o t a l / i) ;
365+ fireEvent . click ( byWeightButton ) ;
366+ await screen . findByTestId ( WEIGHTED_TABLE_TESTID ) ;
367+ expect (
368+ screen . getByRole ( 'button' , { name : 'Manage external assessments' } ) ,
369+ ) . toBeVisible ( ) ;
370+ } ) ;
371+
372+ it ( 'does not show the manage button to staff who cannot manage weights' , async ( ) => {
373+ render ( < GradebookIndex /> , { state : populatedState } ) ;
374+ await screen . findByRole ( 'button' , { name : / e x p o r t / i } ) ; // wait for load
375+ expect (
376+ screen . queryByRole ( 'button' , { name : 'Manage external assessments' } ) ,
377+ ) . not . toBeInTheDocument ( ) ;
378+ } ) ;
379+
380+ describe ( 'out-of-range banner' , ( ) => {
381+ it ( 'shows the banner when there are out-of-range grades' , async ( ) => {
382+ render ( < GradebookIndex /> , { state : populatedStateWithOutOfRangeGrade } ) ;
383+ expect (
384+ await screen . findByText ( / o u t s i d e t h e i r r a n g e / i) ,
385+ ) . toBeInTheDocument ( ) ;
386+ } ) ;
387+
388+ it ( 'shows the weighted-total wording when the weighted view is enabled' , async ( ) => {
389+ render ( < GradebookIndex /> , {
390+ state : populatedStateWithOutOfRangeGradeWeighted ,
391+ } ) ;
392+ expect (
393+ await screen . findByText (
394+ / 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,
395+ ) ,
396+ ) . toBeInTheDocument ( ) ;
397+ } ) ;
398+
399+ it ( 'does not show the banner when all grades are in range' , async ( ) => {
400+ render ( < GradebookIndex /> , { state : populatedStateExternalInRange } ) ;
401+ await screen . findByRole ( 'button' , { name : / e x p o r t / i } ) ; // wait for load
402+ expect (
403+ screen . queryByText ( / o u t s i d e t h e i r r a n g e / i) ,
404+ ) . not . toBeInTheDocument ( ) ;
405+ } ) ;
406+
407+ it ( 'does not show the banner when there are no students' , async ( ) => {
408+ render ( < GradebookIndex /> , { state : noStudentsState } ) ;
409+ await screen . findByText ( 'No students enrolled yet' ) ;
410+ expect (
411+ screen . queryByText ( / o u t s i d e t h e i r r a n g e / i) ,
412+ ) . not . toBeInTheDocument ( ) ;
413+ } ) ;
414+ } ) ;
415+
226416 describe ( 'weighted-view discoverability hint' , ( ) => {
227417 it ( 'shows the hint to managers when the weighted view is off' , async ( ) => {
228418 render ( < GradebookIndex /> , { state : populatedStateManagerWeightedOff } ) ;
0 commit comments