-
Notifications
You must be signed in to change notification settings - Fork 282
Expand file tree
/
Copy pathMilestonesList.test.tsx
More file actions
1356 lines (1123 loc) · 50.5 KB
/
Copy pathMilestonesList.test.tsx
File metadata and controls
1356 lines (1123 loc) · 50.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import MilestonesList from '../MilestonesList';
import type { Milestone } from '../MilestonesList';
import { parseLocalDate, isDueSoon } from '../../lib/dueSoon';
import { ToastProvider } from '@/components/toast/toast-provider';
import { PreferencesProvider } from '@/lib/preferences';
import { resetCache } from '@/lib/safeStorage';
function r(element: React.ReactElement) {
return render(element, { wrapper: ToastProvider });
}
const SAMPLE: Milestone[] = [
{
id: '1',
title: 'Milestone 1',
status: 'Pending',
payout: 500,
currency: 'USD',
dueDate: 'May 10, 2026',
},
{
id: '2',
title: 'Milestone 2',
status: 'Completed',
payout: 1000,
currency: 'USD',
dueDate: 'Jun 1, 2026',
},
];
const MIXED_CURRENCY_SAMPLE: Milestone[] = [
{
id: '1',
title: 'Milestone 1',
status: 'Pending',
payout: 500,
currency: 'USD',
dueDate: 'May 10, 2026',
},
{
id: '2',
title: 'Milestone 2',
status: 'Completed',
payout: 1000,
currency: 'EUR',
dueDate: 'Jun 1, 2026',
},
{
id: '3',
title: 'Milestone 3',
status: 'Pending',
payout: 250,
currency: 'GBP',
dueDate: 'Jun 15, 2026',
},
];
const scrollRegion = (container: HTMLElement) =>
container.querySelector('.max-h-\\[calc\\(100vh-260px\\)\\]') as HTMLElement;
describe('MilestonesList', () => {
it('renders each milestone item with status and payout', () => {
r(<MilestonesList milestones={SAMPLE} />);
expect(screen.getByText('Milestone 1')).toBeInTheDocument();
expect(screen.getByText('Milestone 2')).toBeInTheDocument();
expect(screen.getAllByText('Pending')).toHaveLength(2);
expect(screen.getAllByText('Completed')).toHaveLength(2);
expect(screen.getByText('$500.00')).toBeInTheDocument();
expect(screen.getByText('$1,000.00')).toBeInTheDocument();
});
describe('scroll region labelling', () => {
it('associates the region with the visible heading via aria-labelledby', () => {
const { container } = r(<MilestonesList milestones={SAMPLE} />);
const heading = screen.getByRole('heading', { name: 'Milestones' });
expect(heading).toHaveAttribute('id', 'milestones-title');
const region = scrollRegion(container);
expect(region).toHaveAttribute('role', 'region');
expect(region.getAttribute('aria-labelledby')).toContain(
'milestones-title',
);
});
it('includes the count span id in aria-labelledby', () => {
const { container } = r(<MilestonesList milestones={SAMPLE} />);
const countSpan = container.querySelector('#milestones-count');
expect(countSpan).toBeInTheDocument();
expect(countSpan).toHaveTextContent('2 total');
const region = scrollRegion(container);
expect(region.getAttribute('aria-labelledby')).toContain(
'milestones-count',
);
});
it('count span reflects a single-item list', () => {
r(<MilestonesList milestones={[SAMPLE[0]]} />);
expect(screen.getByText('1 total')).toBeInTheDocument();
});
it('does not apply region attributes when the list is empty', () => {
const { container } = r(<MilestonesList milestones={[]} />);
const region = scrollRegion(container);
expect(region).not.toHaveAttribute('role');
expect(region).not.toHaveAttribute('tabIndex');
expect(region).not.toHaveAttribute('aria-labelledby');
});
it('does not use a static aria-label on the scroll region', () => {
const { container } = r(<MilestonesList milestones={SAMPLE} />);
expect(scrollRegion(container)).not.toHaveAttribute('aria-label');
});
});
describe('density toggle', () => {
beforeEach(() => {
localStorage.clear();
resetCache();
});
const renderWithProvider = (ui: React.ReactElement) =>
render(<PreferencesProvider>{ui}</PreferencesProvider>);
it('renders the density toggle button with comfortable as default', () => {
renderWithProvider(<MilestonesList milestones={SAMPLE} />);
const toggle = screen.getByRole('button', { name: 'Switch to compact density' });
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveAttribute('aria-pressed', 'false');
expect(toggle).toHaveTextContent('Comfortable');
});
it('renders the toggle button reflecting stored compact preference', () => {
localStorage.setItem(
'talenttrust-user-preferences',
JSON.stringify({ milestonesDensity: 'compact' }),
);
renderWithProvider(<MilestonesList milestones={SAMPLE} />);
const toggle = screen.getByRole('button', { name: 'Switch to comfortable density' });
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveAttribute('aria-pressed', 'true');
expect(toggle).toHaveTextContent('Compact');
});
it('toggles from comfortable to compact on click', () => {
renderWithProvider(<MilestonesList milestones={SAMPLE} />);
const toggle = screen.getByRole('button', { name: 'Switch to compact density' });
fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-pressed', 'true');
expect(toggle).toHaveTextContent('Compact');
expect(toggle).toHaveAttribute('aria-label', 'Switch to comfortable density');
});
it('toggles from compact back to comfortable on second click', () => {
localStorage.setItem(
'talenttrust-user-preferences',
JSON.stringify({ milestonesDensity: 'compact' }),
);
renderWithProvider(<MilestonesList milestones={SAMPLE} />);
const toggle = screen.getByRole('button', { name: 'Switch to comfortable density' });
fireEvent.click(toggle);
expect(toggle).toHaveAttribute('aria-pressed', 'false');
expect(toggle).toHaveTextContent('Comfortable');
expect(toggle).toHaveAttribute('aria-label', 'Switch to compact density');
});
it('persists density preference to localStorage on toggle', () => {
renderWithProvider(<MilestonesList milestones={SAMPLE} />);
const toggle = screen.getByRole('button', { name: 'Switch to compact density' });
fireEvent.click(toggle);
const saved = JSON.parse(
localStorage.getItem('talenttrust-user-preferences') || '{}',
);
expect(saved.milestonesDensity).toBe('compact');
});
it('applies comfortable (default) spacing classes', () => {
const { container } = renderWithProvider(<MilestonesList milestones={SAMPLE} />);
const region = scrollRegion(container);
expect(region.className).toContain('space-y-4');
expect(region.className).toContain('mt-6');
expect(region.className).not.toContain('space-y-2');
});
it('applies compact spacing classes when toggled', () => {
localStorage.setItem(
'talenttrust-user-preferences',
JSON.stringify({ milestonesDensity: 'compact' }),
);
const { container } = renderWithProvider(<MilestonesList milestones={SAMPLE} />);
const region = scrollRegion(container);
expect(region.className).toContain('space-y-2');
expect(region.className).toContain('mt-4');
expect(region.className).not.toContain('space-y-4');
});
it('falls back to comfortable when stored value is invalid', () => {
localStorage.setItem(
'talenttrust-user-preferences',
JSON.stringify({ milestonesDensity: 'invalid' }),
);
renderWithProvider(<MilestonesList milestones={SAMPLE} />);
const toggle = screen.getByRole('button', { name: 'Switch to compact density' });
expect(toggle).toHaveAttribute('aria-pressed', 'false');
});
it('passes axe accessibility checks with density toggle present', async () => {
const { container } = renderWithProvider(<MilestonesList milestones={SAMPLE} />);
expect(await axe(container)).toHaveNoViolations();
});
});
it('makes the scroll region keyboard-focusable with focus-ring styles when populated', () => {
const { container } = r(<MilestonesList milestones={SAMPLE} />);
const region = scrollRegion(container);
expect(region).toHaveAttribute('tabIndex', '0');
expect(region).toHaveClass(
'focus-visible:outline-none',
'focus-visible:ring-2',
'focus-visible:ring-[var(--ring)]',
'focus-visible:ring-offset-2',
);
});
it('matches the expected per-status text counts (1 tally chip + 1 StatusBadge per row)', () => {
render(<MilestonesList milestones={SAMPLE} />);
// Each pending row contributes 2 'Pending' text nodes: one in the
// status tally chip at the top of the list, one inside that row's
// StatusBadge. With SAMPLE containing 1 Pending row and 1 Completed
// row we expect exactly 2 of each.
expect(screen.getAllByText('Pending')).toHaveLength(2);
expect(screen.getAllByText('Completed')).toHaveLength(2);
});
it('does not render a currency warning when the contract currency is absent', () => {
r(<MilestonesList milestones={MIXED_CURRENCY_SAMPLE} />);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('renders an accessible warning for milestone currencies that differ from the contract', () => {
r(
<MilestonesList
milestones={MIXED_CURRENCY_SAMPLE}
contractCurrency="usd"
/>,
);
const alert = screen.getByRole('alert');
expect(alert).toHaveTextContent('2 milestones use EUR, GBP instead of USD.');
// The same formatted amount can appear multiple times on the page
// (e.g. inside the row's payout AND inside the warning's list), so
// assert presence with getAllByText + index 0 to avoid strict-mode errors.
expect(screen.getAllByText(/€1,000\.00/)[0]).toBeInTheDocument();
expect(screen.getAllByText(/£250\.00/)[0]).toBeInTheDocument();
expect(alert).toHaveTextContent('Milestone 2:');
expect(alert).toHaveTextContent('Milestone 3:');
});
it('enters edit mode with the current values prefilled', async () => {
render(<MilestonesList milestones={SAMPLE} />);
fireEvent.click(
screen.getAllByRole('button', { name: /edit milestone/i })[0],
);
const titleInput = screen.getByLabelText(/title/i);
expect(titleInput).toHaveValue('Milestone 1');
expect(screen.getByLabelText(/payout amount/i)).toHaveValue('500');
expect(screen.getByLabelText(/currency/i)).toHaveValue('USD');
});
it('saves inline edits and calls the update handler once', async () => {
const user = userEvent.setup();
const onUpdateMilestone = jest.fn().mockReturnValue(true);
render(
<MilestonesList
milestones={SAMPLE}
onUpdateMilestone={onUpdateMilestone}
/>,
);
fireEvent.click(
screen.getAllByRole('button', { name: /edit milestone/i })[0],
);
await user.clear(screen.getByLabelText(/title/i));
await user.type(screen.getByLabelText(/title/i), 'Updated milestone');
await user.click(screen.getByRole('button', { name: /save/i }));
expect(onUpdateMilestone).toHaveBeenCalledTimes(1);
expect(onUpdateMilestone).toHaveBeenCalledWith(
'1',
expect.objectContaining({
title: 'Updated milestone',
payout: 500,
currency: 'USD',
dueDate: 'May 10, 2026',
}),
);
});
it('blocks save and keeps the row in edit mode when validation fails', async () => {
const user = userEvent.setup();
render(<MilestonesList milestones={SAMPLE} />);
fireEvent.click(
screen.getAllByRole('button', { name: /edit milestone/i })[0],
);
await user.clear(screen.getByLabelText(/title/i));
await user.click(screen.getByRole('button', { name: /save/i }));
expect(screen.getAllByText('Title is required').length).toBeGreaterThan(0);
// Focus moves to the error summary so assistive tech announces every
// validation failure at once, matching the ErrorSummary pattern used
// elsewhere (e.g. ContractCreationForm).
expect(screen.getByRole('alert', { name: /there is a problem/i })).toHaveFocus();
});
it('cancels edits and restores the original values', async () => {
const user = userEvent.setup();
render(<MilestonesList milestones={SAMPLE} />);
fireEvent.click(
screen.getAllByRole('button', { name: /edit milestone/i })[0],
);
await user.clear(screen.getByLabelText(/title/i));
await user.type(screen.getByLabelText(/title/i), 'Changed title');
await user.click(screen.getByRole('button', { name: /cancel/i }));
expect(screen.getByText('Milestone 1')).toBeInTheDocument();
expect(screen.queryByLabelText(/title/i)).not.toBeInTheDocument();
});
it('cancels editing on Escape', async () => {
const user = userEvent.setup();
render(<MilestonesList milestones={SAMPLE} />);
fireEvent.click(
screen.getAllByRole('button', { name: /edit milestone/i })[0],
);
await user.type(screen.getByLabelText(/title/i), '{Escape}');
expect(screen.getByText('Milestone 1')).toBeInTheDocument();
expect(screen.queryByLabelText(/title/i)).not.toBeInTheDocument();
});
it('passes axe accessibility checks with a populated list', async () => {
const { container } = r(<MilestonesList milestones={SAMPLE} />);
expect(await axe(container)).toHaveNoViolations();
});
it('passes axe accessibility checks with a currency mismatch warning', async () => {
const { container } = r(
<MilestonesList
milestones={MIXED_CURRENCY_SAMPLE}
contractCurrency="USD"
/>,
);
expect(await axe(container)).toHaveNoViolations();
});
it('passes axe accessibility checks with an empty list', async () => {
const { container } = r(<MilestonesList milestones={[]} />);
expect(await axe(container)).toHaveNoViolations();
});
describe('due-soon reminder banner', () => {
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date('2026-05-10T12:00:00'));
});
afterEach(() => {
jest.useRealTimers();
});
it('does not render banner if no milestones are due soon', () => {
const milestones: Milestone[] = [
{
id: '1',
title: 'Future Milestone',
status: 'Pending',
payout: 500,
currency: 'USD',
dueDate: 'May 20, 2026',
}, // 10 days away
{
id: '2',
title: 'TBD Milestone',
status: 'Pending',
payout: 1000,
currency: 'USD',
dueDate: undefined,
},
];
r(<MilestonesList milestones={milestones} />);
expect(screen.queryByText(/due within/i)).not.toBeInTheDocument();
});
it('renders banner with correct pluralization for 1 due-soon milestone', () => {
const milestones: Milestone[] = [
{
id: '1',
title: 'Due Soon Milestone',
status: 'Pending',
payout: 500,
currency: 'USD',
dueDate: 'May 15, 2026',
}, // 5 days away
];
r(<MilestonesList milestones={milestones} />);
expect(screen.getByText('1 milestone is due within 7 days')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Due Soon Milestone' })).toHaveAttribute('href', '#milestone-1');
});
it('renders banner with correct pluralization for multiple due-soon milestones', () => {
const milestones: Milestone[] = [
{
id: '1',
title: 'Milestone A',
status: 'Pending',
payout: 500,
currency: 'USD',
dueDate: 'May 12, 2026',
}, // 2 days away
{
id: '2',
title: 'Milestone B',
status: 'Active',
payout: 1000,
currency: 'USD',
dueDate: 'May 17, 2026',
}, // 7 days away
];
r(<MilestonesList milestones={milestones} />);
expect(screen.getByText('2 milestones are due within 7 days')).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Milestone A' })).toHaveAttribute('href', '#milestone-1');
expect(screen.getByRole('link', { name: 'Milestone B' })).toHaveAttribute('href', '#milestone-2');
});
it('excludes milestones with terminal statuses (Paid, Completed)', () => {
const milestones: Milestone[] = [
{
id: '1',
title: 'Milestone A',
status: 'Paid',
payout: 500,
currency: 'USD',
dueDate: 'May 12, 2026',
}, // 2 days away (Paid)
{
id: '2',
title: 'Milestone B',
status: 'Completed',
payout: 1000,
currency: 'USD',
dueDate: 'May 15, 2026',
}, // 5 days away (Completed)
];
r(<MilestonesList milestones={milestones} />);
expect(screen.queryByText(/due within/i)).not.toBeInTheDocument();
});
it('handles exactly-at-boundary due dates (today and 7 days from now)', () => {
const milestones: Milestone[] = [
{
id: '1',
title: 'Due Today',
status: 'Pending',
payout: 500,
currency: 'USD',
dueDate: '2026-05-10',
}, // Today (May 10)
{
id: '2',
title: 'Due in 7 Days',
status: 'Pending',
payout: 1000,
currency: 'USD',
dueDate: '2026-05-17',
}, // Exactly 7 days
];
r(<MilestonesList milestones={milestones} />);
expect(screen.getByText('2 milestones are due within 7 days')).toBeInTheDocument();
});
it('ignores milestones with invalid/unparseable due dates', () => {
const milestones: Milestone[] = [
{
id: '1',
title: 'Invalid Date',
status: 'Pending',
payout: 500,
currency: 'USD',
dueDate: 'Not a Date',
},
];
r(<MilestonesList milestones={milestones} />);
expect(screen.queryByText(/due within/i)).not.toBeInTheDocument();
});
it('hides the banner on dismiss and shifts focus to the scroll region', async () => {
const milestones: Milestone[] = [
{
id: '1',
title: 'Due Soon',
status: 'Pending',
payout: 500,
currency: 'USD',
dueDate: 'May 15, 2026',
},
];
const { container } = r(<MilestonesList milestones={milestones} />);
const dismissBtn = screen.getByRole('button', { name: 'Dismiss reminder' });
expect(dismissBtn).toBeInTheDocument();
// Focus the dismiss button first to simulate user keyboard interaction
dismissBtn.focus();
expect(document.activeElement).toBe(dismissBtn);
// Click the dismiss button
fireEvent.click(dismissBtn);
// Banner should be removed
expect(screen.queryByText(/due within/i)).not.toBeInTheDocument();
// Focus should shift to the scroll container
const region = container.querySelector(
'.max-h-\\[calc\\(100vh-260px\\)\\]',
);
expect(document.activeElement).toBe(region);
});
it('dismisses the reminder with the keyboard and keeps focus in the list', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
const milestones: Milestone[] = [
{ id: '1', title: 'Due Soon', status: 'Pending', payout: 500, currency: 'USD', dueDate: 'May 15, 2026' },
];
const { container } = render(<MilestonesList milestones={milestones} />);
const dismissButton = screen.getByRole('button', { name: 'Dismiss reminder' });
dismissButton.focus();
await user.keyboard('{Enter}');
expect(screen.queryByRole('button', { name: 'Dismiss reminder' })).not.toBeInTheDocument();
expect(document.activeElement).toBe(scrollRegion(container));
});
});
it('passes axe accessibility checks when banner is rendered', async () => {
const today = new Date();
const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
const tomorrowStr = tomorrow.toLocaleDateString('en-US');
const milestones: Milestone[] = [
{
id: '1',
title: 'Due Soon',
status: 'Pending',
payout: 500,
currency: 'USD',
dueDate: tomorrowStr,
},
];
const { container } = r(<MilestonesList milestones={milestones} />);
expect(await axe(container)).toHaveNoViolations();
});
describe('due status badges on milestone rows', () => {
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date('2026-05-10T12:00:00'));
});
afterEach(() => {
jest.useRealTimers();
});
it('renders Overdue badge on overdue milestone rows', () => {
const milestones: Milestone[] = [
{ id: '1', title: 'Overdue Milestone', status: 'Pending', payout: 500, currency: 'USD', dueDate: '2026-05-01' },
];
render(<MilestonesList milestones={milestones} />);
expect(screen.getByTestId('due-badge-overdue')).toBeInTheDocument();
expect(screen.getByText('Overdue')).toBeInTheDocument();
});
it('renders Due Soon badge on due-soon milestone rows', () => {
const milestones: Milestone[] = [
{ id: '1', title: 'Due Soon Milestone', status: 'Pending', payout: 500, currency: 'USD', dueDate: '2026-05-15' },
];
render(<MilestonesList milestones={milestones} />);
expect(screen.getByTestId('due-badge-due-soon')).toBeInTheDocument();
expect(screen.getByText('Due soon')).toBeInTheDocument();
});
it('does not render due status badge for completed or paid milestones even if date is past', () => {
const milestones: Milestone[] = [
{ id: '1', title: 'Completed Past', status: 'Completed', payout: 500, currency: 'USD', dueDate: '2026-05-01' },
{ id: '2', title: 'Paid Past', status: 'Paid', payout: 500, currency: 'USD', dueDate: '2026-05-01' },
];
render(<MilestonesList milestones={milestones} />);
expect(screen.queryByTestId('due-badge-overdue')).not.toBeInTheDocument();
expect(screen.queryByTestId('due-badge-due-soon')).not.toBeInTheDocument();
});
it('does not render due status badge for normal/future milestone (beyond 7 days)', () => {
const milestones: Milestone[] = [
{ id: '1', title: 'Far Future', status: 'Pending', payout: 500, currency: 'USD', dueDate: '2026-05-25' },
];
render(<MilestonesList milestones={milestones} />);
expect(screen.queryByTestId('due-badge-overdue')).not.toBeInTheDocument();
expect(screen.queryByTestId('due-badge-due-soon')).not.toBeInTheDocument();
});
});
describe('dueSoon helper utilities', () => {
it('parseLocalDate returns null for invalid types and empty values', () => {
expect(parseLocalDate('')).toBeNull();
expect(parseLocalDate(null as any)).toBeNull();
expect(parseLocalDate(undefined as any)).toBeNull();
expect(parseLocalDate(123 as any)).toBeNull();
});
it('parseLocalDate returns null for invalid date strings', () => {
expect(parseLocalDate('not-a-date')).toBeNull();
expect(parseLocalDate('2026-99-99')).toBeNull();
});
it('parseLocalDate parses ISO format to local midnight correctly', () => {
const date = parseLocalDate('2026-05-15');
expect(date).not.toBeNull();
expect(date?.getFullYear()).toBe(2026);
expect(date?.getMonth()).toBe(4); // 0-indexed May
expect(date?.getDate()).toBe(15);
});
it('isDueSoon returns false for missing or invalid dates', () => {
const today = new Date('2026-05-10');
expect(isDueSoon(undefined, today, 7)).toBe(false);
expect(isDueSoon('not-a-date', today, 7)).toBe(false);
});
});
describe('pagination / load-more', () => {
const SEVEN_MILESTONES: Milestone[] = [
{ id: 'a', title: 'Alpha', status: 'Pending', payout: 100, currency: 'USD', dueDate: 'May 10, 2026' },
{ id: 'b', title: 'Bravo', status: 'Active', payout: 200, currency: 'USD', dueDate: 'May 11, 2026' },
{ id: 'c', title: 'Charlie', status: 'Completed', payout: 300, currency: 'USD', dueDate: 'May 12, 2026' },
{ id: 'd', title: 'Delta', status: 'Pending', payout: 400, currency: 'USD', dueDate: 'May 13, 2026' },
{ id: 'e', title: 'Echo', status: 'Paid', payout: 500, currency: 'USD', dueDate: 'May 14, 2026' },
{ id: 'f', title: 'Foxtrot', status: 'Pending', payout: 600, currency: 'USD', dueDate: 'May 15, 2026' },
{ id: 'g', title: 'Golf', status: 'Disputed', payout: 700, currency: 'USD', dueDate: 'May 16, 2026' },
];
it('shows only the first page (pageSize items) on initial render', () => {
render(<MilestonesList milestones={SEVEN_MILESTONES} pageSize={3} />);
expect(screen.getByText('Alpha')).toBeInTheDocument();
expect(screen.getByText('Bravo')).toBeInTheDocument();
expect(screen.getByText('Charlie')).toBeInTheDocument();
expect(screen.queryByText('Delta')).not.toBeInTheDocument();
expect(screen.queryByText('Echo')).not.toBeInTheDocument();
expect(screen.queryByText('Foxtrot')).not.toBeInTheDocument();
expect(screen.queryByText('Golf')).not.toBeInTheDocument();
expect(screen.getByTestId('load-more-btn')).toBeInTheDocument();
});
it('load-more button displays the remaining count', () => {
render(<MilestonesList milestones={SEVEN_MILESTONES} pageSize={3} />);
const btn = screen.getByTestId('load-more-btn');
expect(btn).toHaveTextContent('Load More (4 remaining)');
});
it('clicking load-more appends the next page of milestones', () => {
render(<MilestonesList milestones={SEVEN_MILESTONES} pageSize={3} />);
fireEvent.click(screen.getByTestId('load-more-btn'));
expect(screen.getByText('Alpha')).toBeInTheDocument();
expect(screen.getByText('Bravo')).toBeInTheDocument();
expect(screen.getByText('Charlie')).toBeInTheDocument();
expect(screen.getByText('Delta')).toBeInTheDocument();
expect(screen.getByText('Echo')).toBeInTheDocument();
expect(screen.getByText('Foxtrot')).toBeInTheDocument();
expect(screen.queryByText('Golf')).not.toBeInTheDocument();
expect(screen.getByTestId('load-more-btn')).toHaveTextContent('Load More (1 remaining)');
});
it('load-more button disappears when all milestones are visible', () => {
render(<MilestonesList milestones={SEVEN_MILESTONES} pageSize={3} />);
fireEvent.click(screen.getByTestId('load-more-btn'));
fireEvent.click(screen.getByTestId('load-more-btn'));
expect(screen.getByText('Golf')).toBeInTheDocument();
expect(screen.queryByTestId('load-more-btn')).not.toBeInTheDocument();
});
it('no load-more button when total milestones equals or is less than pageSize', () => {
render(<MilestonesList milestones={SEVEN_MILESTONES.slice(0, 3)} pageSize={3} />);
expect(screen.queryByTestId('load-more-btn')).not.toBeInTheDocument();
});
it('resets pagination when milestones prop changes (filter change)', () => {
const { rerender } = render(<MilestonesList milestones={SEVEN_MILESTONES} pageSize={3} />);
expect(screen.queryByText('Delta')).not.toBeInTheDocument();
expect(screen.getByTestId('load-more-btn')).toBeInTheDocument();
rerender(<MilestonesList milestones={SEVEN_MILESTONES.slice(0, 4)} pageSize={3} />);
expect(screen.getByText('Alpha')).toBeInTheDocument();
expect(screen.queryByText('Delta')).not.toBeInTheDocument();
expect(screen.getByTestId('load-more-btn')).toHaveTextContent('Load More (1 remaining)');
});
it('passes axe accessibility checks with load-more button visible', async () => {
const { container } = render(<MilestonesList milestones={SEVEN_MILESTONES} pageSize={3} />);
expect(await axe(container)).toHaveNoViolations();
});
});
});
// ===========================================================================
// Multi-Select & Bulk Action Toolbar
// ===========================================================================
describe('MilestonesList – multi-select and bulk actions', () => {
const THREE_SAMPLE: Milestone[] = [
{ id: 'm1', title: 'Milestone One', status: 'Pending', payout: 100, currency: 'USD', dueDate: 'May 10, 2026' },
{ id: 'm2', title: 'Milestone Two', status: 'Active', payout: 200, currency: 'USD', dueDate: 'May 20, 2026' },
{ id: 'm3', title: 'Milestone Three', status: 'Completed', payout: 300, currency: 'USD', dueDate: 'May 30, 2026' },
];
const TITLE_BY_ID: Record<string, string> = Object.fromEntries(
THREE_SAMPLE.map((m) => [m.id, m.title]),
);
const itemCheckbox = (id: string) => {
const title = TITLE_BY_ID[id] ?? id;
return screen.getByRole('checkbox', {
name: new RegExp(`(select|deselect).*${title}`, 'i'),
}) as HTMLInputElement;
};
const selectAllCheckbox = () =>
screen.getByRole('checkbox', { name: /select all milestones|deselect all milestones/i }) as HTMLInputElement;
beforeEach(() => {
jest.clearAllMocks();
});
// -------------------------------------------------------------------------
// 1. Selection Controls
// -------------------------------------------------------------------------
describe('selection controls visibility', () => {
it('renders Select All group when milestones are present', () => {
render(<MilestonesList milestones={THREE_SAMPLE} />);
expect(screen.getByRole('group', { name: /milestone selection controls/i })).toBeInTheDocument();
expect(selectAllCheckbox()).toBeInTheDocument();
});
it('does not render Select All group for an empty list', () => {
render(<MilestonesList milestones={[]} />);
expect(screen.queryByRole('group', { name: /milestone selection controls/i })).not.toBeInTheDocument();
});
it('renders a checkbox on every milestone item', () => {
render(<MilestonesList milestones={THREE_SAMPLE} />);
const checkboxes = screen.getAllByRole('checkbox');
// 3 items + 1 select-all
expect(checkboxes).toHaveLength(4);
});
});
describe('single row selection', () => {
it('toggles an individual row via its checkbox click', async () => {
const user = userEvent.setup();
const onSelectionChange = jest.fn();
render(<MilestonesList milestones={THREE_SAMPLE} onSelectionChange={onSelectionChange} />);
const cb = itemCheckbox('m1');
expect(cb.checked).toBe(false);
await user.click(cb);
expect(cb.checked).toBe(true);
expect(onSelectionChange).toHaveBeenLastCalledWith(['m1']);
});
it('toggles the same row off when clicked a second time', async () => {
const user = userEvent.setup();
const onSelectionChange = jest.fn();
render(<MilestonesList milestones={THREE_SAMPLE} onSelectionChange={onSelectionChange} />);
const cb = itemCheckbox('m2');
await user.click(cb);
await user.click(cb);
expect(cb.checked).toBe(false);
expect(onSelectionChange).toHaveBeenLastCalledWith([]);
});
it('marks the article data-selected=true and checkbox aria-checked=true when a row is selected', async () => {
const user = userEvent.setup();
render(<MilestonesList milestones={THREE_SAMPLE} />);
const article = screen.getByRole('article', { name: /Milestone Two/i });
const cb = itemCheckbox('m2');
expect(article).toHaveAttribute('data-selected', 'false');
expect(cb.checked).toBe(false);
await user.click(cb);
expect(article).toHaveAttribute('data-selected', 'true');
expect(cb.checked).toBe(true);
});
it('rows carry a data-milestone-row attribute for forced-colors targeting', () => {
render(<MilestonesList milestones={THREE_SAMPLE} />);
const article = screen.getByRole('article', { name: /Milestone Two/i });
expect(article).toHaveAttribute('data-milestone-row');
});
});
// ---------------------------------------------------------------------------
// High-contrast / forced-colors
// ---------------------------------------------------------------------------
describe('a11y: high-contrast — selected row', () => {
it('selected row keeps both data-milestone-row and data-selected="true" so the forced-colors rule can target it', async () => {
const user = userEvent.setup();
render(<MilestonesList milestones={THREE_SAMPLE} />);
const article = screen.getByRole('article', { name: /Milestone Two/i });
await user.click(itemCheckbox('m2'));
expect(article).toHaveAttribute('data-milestone-row');
expect(article).toHaveAttribute('data-selected', 'true');
});
it('has no axe violations with a row selected', async () => {
const user = userEvent.setup();
const { container } = render(<MilestonesList milestones={THREE_SAMPLE} />);
await user.click(itemCheckbox('m2'));
expect(await axe(container)).toHaveNoViolations();
});
});
describe('multi row selection', () => {
it('selects multiple rows independently', async () => {
const user = userEvent.setup();
const onSelectionChange = jest.fn();
render(<MilestonesList milestones={THREE_SAMPLE} onSelectionChange={onSelectionChange} />);
await user.click(itemCheckbox('m1'));
await user.click(itemCheckbox('m3'));
expect(onSelectionChange).toHaveBeenLastCalledWith(expect.arrayContaining(['m1', 'm3']));
expect(itemCheckbox('m1').checked).toBe(true);
expect(itemCheckbox('m3').checked).toBe(true);
expect(itemCheckbox('m2').checked).toBe(false);
});
});
describe('Select All toggle', () => {
it('checks every item checkbox when Select All is turned on', async () => {
const user = userEvent.setup();
const onSelectionChange = jest.fn();
render(<MilestonesList milestones={THREE_SAMPLE} onSelectionChange={onSelectionChange} />);
await user.click(selectAllCheckbox());
expect(itemCheckbox('m1').checked).toBe(true);
expect(itemCheckbox('m2').checked).toBe(true);
expect(itemCheckbox('m3').checked).toBe(true);
expect(onSelectionChange).toHaveBeenLastCalledWith(['m1', 'm2', 'm3']);
});
it('deselects every item checkbox when Select All is turned off', async () => {
const user = userEvent.setup();
const onSelectionChange = jest.fn();
render(<MilestonesList milestones={THREE_SAMPLE} onSelectionChange={onSelectionChange} />);
await user.click(selectAllCheckbox()); // all on
await user.click(selectAllCheckbox()); // all off
expect(itemCheckbox('m1').checked).toBe(false);
expect(itemCheckbox('m2').checked).toBe(false);
expect(itemCheckbox('m3').checked).toBe(false);
expect(onSelectionChange).toHaveBeenLastCalledWith([]);
});
it('re-checks Select All when the user manually selects every remaining item', async () => {
const user = userEvent.setup();
render(<MilestonesList milestones={THREE_SAMPLE} />);
await user.click(itemCheckbox('m1'));
await user.click(itemCheckbox('m2'));
await user.click(itemCheckbox('m3'));
expect(selectAllCheckbox().checked).toBe(true);
});
});
describe('indeterminate state', () => {
it('sets indeterminate on Select All when a subset is selected', async () => {
const user = userEvent.setup();
render(<MilestonesList milestones={THREE_SAMPLE} />);
await user.click(itemCheckbox('m2'));
expect(selectAllCheckbox().indeterminate).toBe(true);
expect(selectAllCheckbox().checked).toBe(false);
});
it('sets aria-checked="mixed" when partial selection is active', async () => {
const user = userEvent.setup();
render(<MilestonesList milestones={THREE_SAMPLE} />);
await user.click(itemCheckbox('m1'));
await user.click(itemCheckbox('m3'));
expect(selectAllCheckbox()).toHaveAttribute('aria-checked', 'mixed');
});
it('clears indeterminate once all are deselected', async () => {
const user = userEvent.setup();
render(<MilestonesList milestones={THREE_SAMPLE} />);
await user.click(itemCheckbox('m2'));
expect(selectAllCheckbox().indeterminate).toBe(true);
await user.click(itemCheckbox('m2'));
expect(selectAllCheckbox().indeterminate).toBe(false);
expect(selectAllCheckbox().checked).toBe(false);
});
it('clears indeterminate once all are selected', async () => {
const user = userEvent.setup();
render(<MilestonesList milestones={THREE_SAMPLE} />);
await user.click(itemCheckbox('m1'));
await user.click(itemCheckbox('m2'));
expect(selectAllCheckbox().indeterminate).toBe(true);
await user.click(itemCheckbox('m3'));
expect(selectAllCheckbox().indeterminate).toBe(false);
expect(selectAllCheckbox().checked).toBe(true);
});
});
// -------------------------------------------------------------------------
// 2. Bulk Toolbar Display
// -------------------------------------------------------------------------
describe('bulk toolbar visibility', () => {
it('hides the toolbar when no items are selected', () => {
render(<MilestonesList milestones={THREE_SAMPLE} />);
expect(screen.queryByRole('toolbar')).not.toBeInTheDocument();
});
it('shows the toolbar once at least one item is selected', async () => {
const user = userEvent.setup();
render(<MilestonesList milestones={THREE_SAMPLE} />);
await user.click(itemCheckbox('m2'));
const toolbar = screen.getByRole('toolbar');
expect(toolbar).toBeInTheDocument();
expect(screen.getByText(/1 of 3 item selected/i)).toBeInTheDocument();
});
it('hides the toolbar again when the selection returns to zero', async () => {
const user = userEvent.setup();
render(<MilestonesList milestones={THREE_SAMPLE} />);
await user.click(itemCheckbox('m2'));
expect(screen.getByRole('toolbar')).toBeInTheDocument();
await user.click(itemCheckbox('m2'));
expect(screen.queryByRole('toolbar')).not.toBeInTheDocument();
});
});