@object-ui/fields 3.0.3 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1043 @@
1
+ /**
2
+ * Cell Renderer Tests
3
+ *
4
+ * Tests for getCellRenderer() select/date/boolean type renderers
5
+ * and formatRelativeDate() edge cases.
6
+ */
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+ import { render, screen } from '@testing-library/react';
9
+ import '@testing-library/jest-dom';
10
+ import React from 'react';
11
+ import {
12
+ getCellRenderer,
13
+ SelectCellRenderer,
14
+ LookupCellRenderer,
15
+ UserCellRenderer,
16
+ TextCellRenderer,
17
+ DateCellRenderer,
18
+ BooleanCellRenderer,
19
+ EmailCellRenderer,
20
+ PhoneCellRenderer,
21
+ PercentCellRenderer,
22
+ humanizeLabel,
23
+ formatDate,
24
+ formatRelativeDate,
25
+ } from '../index';
26
+
27
+ // =========================================================================
28
+ // 1. getCellRenderer type resolution
29
+ // =========================================================================
30
+ describe('getCellRenderer', () => {
31
+ it('should return SelectCellRenderer for select type', () => {
32
+ const renderer = getCellRenderer('select');
33
+ expect(renderer).toBeDefined();
34
+ // Verify it renders a badge
35
+ const { container } = render(
36
+ React.createElement(renderer, {
37
+ value: 'Active',
38
+ field: { name: 'status', type: 'select', options: [{ value: 'Active', label: 'Active', color: 'green' }] } as any,
39
+ })
40
+ );
41
+ expect(container.querySelector('[class*="bg-green"]')).toBeInTheDocument();
42
+ });
43
+
44
+ it('should return SelectCellRenderer for status type', () => {
45
+ const renderer = getCellRenderer('status');
46
+ expect(renderer).toBeDefined();
47
+ // Verify it renders a badge (same as select)
48
+ const { container } = render(
49
+ React.createElement(renderer, {
50
+ value: 'Active',
51
+ field: { name: 'status', type: 'status', options: [{ value: 'Active', label: 'Active', color: 'green' }] } as any,
52
+ })
53
+ );
54
+ expect(container.querySelector('[class*="bg-green"]')).toBeInTheDocument();
55
+ });
56
+
57
+ it('should return DateCellRenderer for date type', () => {
58
+ const renderer = getCellRenderer('date');
59
+ expect(renderer).toBeDefined();
60
+ });
61
+
62
+ it('should return BooleanCellRenderer for boolean type', () => {
63
+ const renderer = getCellRenderer('boolean');
64
+ expect(renderer).toBeDefined();
65
+ });
66
+
67
+ it('should fallback to TextCellRenderer for unknown types', () => {
68
+ const renderer = getCellRenderer('unknown-type');
69
+ expect(renderer).toBeDefined();
70
+ });
71
+
72
+ it('should return LookupCellRenderer for lookup type', () => {
73
+ const renderer = getCellRenderer('lookup');
74
+ expect(renderer).toBe(LookupCellRenderer);
75
+ });
76
+
77
+ it('should return LookupCellRenderer for master_detail type', () => {
78
+ const renderer = getCellRenderer('master_detail');
79
+ expect(renderer).toBe(LookupCellRenderer);
80
+ });
81
+
82
+ it('should return SelectCellRenderer for status type', () => {
83
+ const renderer = getCellRenderer('status');
84
+ expect(renderer).toBe(SelectCellRenderer);
85
+ });
86
+
87
+ it('should return UserCellRenderer for user type', () => {
88
+ const renderer = getCellRenderer('user');
89
+ expect(renderer).toBe(UserCellRenderer);
90
+ });
91
+
92
+ it('should return UserCellRenderer for owner type', () => {
93
+ const renderer = getCellRenderer('owner');
94
+ expect(renderer).toBe(UserCellRenderer);
95
+ });
96
+ });
97
+
98
+ // =========================================================================
99
+ // 2. TextCellRenderer
100
+ // =========================================================================
101
+ describe('TextCellRenderer', () => {
102
+ it('should render text value', () => {
103
+ render(<TextCellRenderer value="hello" field={{ name: 'title', type: 'text' } as any} />);
104
+ expect(screen.getByText('hello')).toBeInTheDocument();
105
+ });
106
+
107
+ it('should render dash for null', () => {
108
+ render(<TextCellRenderer value={null} field={{ name: 'title', type: 'text' } as any} />);
109
+ expect(screen.getByText('-')).toBeInTheDocument();
110
+ });
111
+
112
+ it('should render dash for undefined', () => {
113
+ render(<TextCellRenderer value={undefined} field={{ name: 'title', type: 'text' } as any} />);
114
+ expect(screen.getByText('-')).toBeInTheDocument();
115
+ });
116
+
117
+ it('should render dash for empty string', () => {
118
+ render(<TextCellRenderer value="" field={{ name: 'title', type: 'text' } as any} />);
119
+ expect(screen.getByText('-')).toBeInTheDocument();
120
+ });
121
+
122
+ it('should render "0" for numeric zero (not dash)', () => {
123
+ render(<TextCellRenderer value={0} field={{ name: 'count', type: 'text' } as any} />);
124
+ expect(screen.getByText('0')).toBeInTheDocument();
125
+ });
126
+
127
+ it('should render "false" for boolean false (not dash)', () => {
128
+ render(<TextCellRenderer value={false} field={{ name: 'flag', type: 'text' } as any} />);
129
+ expect(screen.getByText('false')).toBeInTheDocument();
130
+ });
131
+ });
132
+
133
+ // =========================================================================
134
+ // 3. SelectCellRenderer
135
+ // =========================================================================
136
+ describe('SelectCellRenderer', () => {
137
+ it('should render badge with explicit color from options', () => {
138
+ const { container } = render(
139
+ <SelectCellRenderer
140
+ value="In Progress"
141
+ field={{
142
+ name: 'status',
143
+ type: 'select',
144
+ options: [{ value: 'In Progress', label: 'In Progress', color: 'blue' }],
145
+ } as any}
146
+ />
147
+ );
148
+ expect(screen.getByText('In Progress')).toBeInTheDocument();
149
+ expect(container.querySelector('[class*="bg-blue-100"]')).toBeInTheDocument();
150
+ });
151
+
152
+ it('should use muted style when no color configured', () => {
153
+ const { container } = render(
154
+ <SelectCellRenderer
155
+ value="Unknown"
156
+ field={{
157
+ name: 'status',
158
+ type: 'select',
159
+ options: [{ value: 'Unknown', label: 'Unknown' }],
160
+ } as any}
161
+ />
162
+ );
163
+ expect(screen.getByText('Unknown')).toBeInTheDocument();
164
+ expect(container.querySelector('[class*="bg-muted"]')).toBeInTheDocument();
165
+ });
166
+
167
+ it('should auto-detect priority semantic colors (Critical → red)', () => {
168
+ const { container } = render(
169
+ <SelectCellRenderer
170
+ value="Critical"
171
+ field={{ name: 'priority', type: 'select', options: [] } as any}
172
+ />
173
+ );
174
+ expect(screen.getByText('Critical')).toBeInTheDocument();
175
+ expect(container.querySelector('[class*="bg-red-100"]')).toBeInTheDocument();
176
+ });
177
+
178
+ it('should auto-detect priority semantic colors (High → orange)', () => {
179
+ const { container } = render(
180
+ <SelectCellRenderer
181
+ value="High"
182
+ field={{ name: 'priority', type: 'select', options: [] } as any}
183
+ />
184
+ );
185
+ expect(container.querySelector('[class*="bg-orange-100"]')).toBeInTheDocument();
186
+ });
187
+
188
+ it('should auto-detect priority semantic colors (Medium → yellow)', () => {
189
+ const { container } = render(
190
+ <SelectCellRenderer
191
+ value="Medium"
192
+ field={{ name: 'priority', type: 'select', options: [] } as any}
193
+ />
194
+ );
195
+ expect(container.querySelector('[class*="bg-yellow-100"]')).toBeInTheDocument();
196
+ });
197
+
198
+ it('should auto-detect priority semantic colors (Low → gray)', () => {
199
+ const { container } = render(
200
+ <SelectCellRenderer
201
+ value="Low"
202
+ field={{ name: 'priority', type: 'select', options: [] } as any}
203
+ />
204
+ );
205
+ expect(container.querySelector('[class*="bg-gray-100"]')).toBeInTheDocument();
206
+ });
207
+
208
+ it('should auto-detect status semantic colors (Paid → green)', () => {
209
+ const { container } = render(
210
+ <SelectCellRenderer
211
+ value="Paid"
212
+ field={{ name: 'status', type: 'select', options: [] } as any}
213
+ />
214
+ );
215
+ expect(container.querySelector('[class*="bg-green-100"]')).toBeInTheDocument();
216
+ });
217
+
218
+ it('should auto-detect status semantic colors (Pending → yellow)', () => {
219
+ const { container } = render(
220
+ <SelectCellRenderer
221
+ value="Pending"
222
+ field={{ name: 'status', type: 'select', options: [] } as any}
223
+ />
224
+ );
225
+ expect(container.querySelector('[class*="bg-yellow-100"]')).toBeInTheDocument();
226
+ });
227
+
228
+ it('should auto-detect status semantic colors (Shipped → blue)', () => {
229
+ const { container } = render(
230
+ <SelectCellRenderer
231
+ value="Shipped"
232
+ field={{ name: 'status', type: 'select', options: [] } as any}
233
+ />
234
+ );
235
+ expect(container.querySelector('[class*="bg-blue-100"]')).toBeInTheDocument();
236
+ });
237
+
238
+ it('should auto-detect status semantic colors (Draft → gray)', () => {
239
+ const { container } = render(
240
+ <SelectCellRenderer
241
+ value="Draft"
242
+ field={{ name: 'status', type: 'select', options: [] } as any}
243
+ />
244
+ );
245
+ expect(container.querySelector('[class*="bg-gray-100"]')).toBeInTheDocument();
246
+ });
247
+
248
+ it('should auto-detect status semantic colors (Cancelled → red)', () => {
249
+ const { container } = render(
250
+ <SelectCellRenderer
251
+ value="Cancelled"
252
+ field={{ name: 'status', type: 'select', options: [] } as any}
253
+ />
254
+ );
255
+ expect(container.querySelector('[class*="bg-red-100"]')).toBeInTheDocument();
256
+ });
257
+
258
+ it('should auto-detect status semantic colors (Delivered → purple)', () => {
259
+ const { container } = render(
260
+ <SelectCellRenderer
261
+ value="Delivered"
262
+ field={{ name: 'status', type: 'select', options: [] } as any}
263
+ />
264
+ );
265
+ expect(container.querySelector('[class*="bg-purple-100"]')).toBeInTheDocument();
266
+ });
267
+
268
+ it('should auto-detect status semantic colors with snake_case (in_progress → blue)', () => {
269
+ const { container } = render(
270
+ <SelectCellRenderer
271
+ value="in_progress"
272
+ field={{ name: 'status', type: 'select', options: [] } as any}
273
+ />
274
+ );
275
+ expect(container.querySelector('[class*="bg-blue-100"]')).toBeInTheDocument();
276
+ });
277
+
278
+ it('should auto-detect status semantic colors with hyphen (in-progress → blue)', () => {
279
+ const { container } = render(
280
+ <SelectCellRenderer
281
+ value="in-progress"
282
+ field={{ name: 'status', type: 'select', options: [] } as any}
283
+ />
284
+ );
285
+ expect(container.querySelector('[class*="bg-blue-100"]')).toBeInTheDocument();
286
+ });
287
+
288
+ it('should auto-detect status semantic colors with spaces (in progress → blue)', () => {
289
+ const { container } = render(
290
+ <SelectCellRenderer
291
+ value="in progress"
292
+ field={{ name: 'status', type: 'select', options: [] } as any}
293
+ />
294
+ );
295
+ expect(container.querySelector('[class*="bg-blue-100"]')).toBeInTheDocument();
296
+ });
297
+
298
+ it('should render dash for null/empty value', () => {
299
+ render(
300
+ <SelectCellRenderer
301
+ value={null}
302
+ field={{ name: 'status', type: 'select' } as any}
303
+ />
304
+ );
305
+ expect(screen.getByText('-')).toBeInTheDocument();
306
+ });
307
+
308
+ it('should render multiple badges for array values', () => {
309
+ render(
310
+ <SelectCellRenderer
311
+ value={['Draft', 'Active']}
312
+ field={{
313
+ name: 'tags',
314
+ type: 'select',
315
+ options: [
316
+ { value: 'Draft', label: 'Draft', color: 'yellow' },
317
+ { value: 'Active', label: 'Active', color: 'green' },
318
+ ],
319
+ } as any}
320
+ />
321
+ );
322
+ expect(screen.getByText('Draft')).toBeInTheDocument();
323
+ expect(screen.getByText('Active')).toBeInTheDocument();
324
+ });
325
+
326
+ it('should prefer explicit option color over auto-detected', () => {
327
+ const { container } = render(
328
+ <SelectCellRenderer
329
+ value="High"
330
+ field={{
331
+ name: 'priority',
332
+ type: 'select',
333
+ options: [{ value: 'High', label: 'High', color: 'purple' }],
334
+ } as any}
335
+ />
336
+ );
337
+ // Explicit purple should override auto-detected orange
338
+ expect(container.querySelector('[class*="bg-purple-100"]')).toBeInTheDocument();
339
+ });
340
+ });
341
+
342
+ // =========================================================================
343
+ // 3. DateCellRenderer
344
+ // =========================================================================
345
+ describe('DateCellRenderer', () => {
346
+ beforeEach(() => {
347
+ // Fix "now" to a known date: 2026-02-24T12:00:00Z using fake timers
348
+ // so that new Date() in formatRelativeDate returns a predictable value
349
+ vi.useFakeTimers();
350
+ vi.setSystemTime(new Date('2026-02-24T12:00:00Z'));
351
+ });
352
+
353
+ afterEach(() => {
354
+ vi.useRealTimers();
355
+ });
356
+
357
+ it('should render today date as "Today"', () => {
358
+ render(
359
+ <DateCellRenderer
360
+ value="2026-02-24T08:00:00Z"
361
+ field={{ name: 'due_date', type: 'date' } as any}
362
+ />
363
+ );
364
+ expect(screen.getByText('Today')).toBeInTheDocument();
365
+ });
366
+
367
+ it('should render yesterday date as "Yesterday"', () => {
368
+ render(
369
+ <DateCellRenderer
370
+ value="2026-02-23T08:00:00Z"
371
+ field={{ name: 'due_date', type: 'date' } as any}
372
+ />
373
+ );
374
+ expect(screen.getByText('Yesterday')).toBeInTheDocument();
375
+ });
376
+
377
+ it('should render recent past date as "Overdue Xd"', () => {
378
+ render(
379
+ <DateCellRenderer
380
+ value="2026-02-20T08:00:00Z"
381
+ field={{ name: 'due_date', type: 'date' } as any}
382
+ />
383
+ );
384
+ expect(screen.getByText('Overdue 4d')).toBeInTheDocument();
385
+ });
386
+
387
+ it('should render overdue date with red text styling', () => {
388
+ const { container } = render(
389
+ <DateCellRenderer
390
+ value="2026-02-20T08:00:00Z"
391
+ field={{ name: 'due_date', type: 'date' } as any}
392
+ />
393
+ );
394
+ expect(container.querySelector('.text-red-600')).toBeInTheDocument();
395
+ });
396
+
397
+ it('should show ISO string as tooltip', () => {
398
+ const { container } = render(
399
+ <DateCellRenderer
400
+ value="2026-02-20T08:00:00Z"
401
+ field={{ name: 'due_date', type: 'date' } as any}
402
+ />
403
+ );
404
+ const span = container.querySelector('span[title]');
405
+ expect(span).toBeInTheDocument();
406
+ expect(span?.getAttribute('title')).toContain('2026-02-20');
407
+ });
408
+
409
+ it('should render dash for null value', () => {
410
+ const { container } = render(
411
+ <DateCellRenderer
412
+ value={null}
413
+ field={{ name: 'due_date', type: 'date' } as any}
414
+ />
415
+ );
416
+ expect(container.textContent).toBe('-');
417
+ });
418
+
419
+ it('should use explicit format when provided', () => {
420
+ render(
421
+ <DateCellRenderer
422
+ value="2026-01-15T08:00:00Z"
423
+ field={{ name: 'created', type: 'date', format: 'short' } as any}
424
+ />
425
+ );
426
+ // Short format: "Jan 15, '26"
427
+ expect(screen.getByText("Jan 15, '26")).toBeInTheDocument();
428
+ });
429
+
430
+ it('should not show red styling for future dates', () => {
431
+ const { container } = render(
432
+ <DateCellRenderer
433
+ value="2026-03-01T08:00:00Z"
434
+ field={{ name: 'due_date', type: 'date' } as any}
435
+ />
436
+ );
437
+ expect(container.querySelector('.text-red-600')).not.toBeInTheDocument();
438
+ });
439
+ });
440
+
441
+ // =========================================================================
442
+ // 4. BooleanCellRenderer
443
+ // =========================================================================
444
+ describe('BooleanCellRenderer', () => {
445
+ it('should render checked checkbox for true', () => {
446
+ render(
447
+ <BooleanCellRenderer
448
+ value={true}
449
+ field={{ name: 'active', type: 'boolean' } as any}
450
+ />
451
+ );
452
+ const checkbox = screen.getByRole('checkbox');
453
+ expect(checkbox).toHaveAttribute('data-state', 'checked');
454
+ });
455
+
456
+ it('should render unchecked checkbox for false', () => {
457
+ render(
458
+ <BooleanCellRenderer
459
+ value={false}
460
+ field={{ name: 'flagged', type: 'boolean' } as any}
461
+ />
462
+ );
463
+ const checkbox = screen.getByRole('checkbox');
464
+ expect(checkbox).toHaveAttribute('data-state', 'unchecked');
465
+ });
466
+
467
+ it('should render dash for null', () => {
468
+ render(
469
+ <BooleanCellRenderer
470
+ value={null}
471
+ field={{ name: 'active', type: 'boolean' } as any}
472
+ />
473
+ );
474
+ expect(screen.getByText('—')).toBeInTheDocument();
475
+ });
476
+
477
+ it('should render dash for undefined', () => {
478
+ render(
479
+ <BooleanCellRenderer
480
+ value={undefined}
481
+ field={{ name: 'active', type: 'boolean' } as any}
482
+ />
483
+ );
484
+ expect(screen.getByText('—')).toBeInTheDocument();
485
+ });
486
+
487
+ it('should render green circle indicator for is_completed=true', () => {
488
+ const { container } = render(
489
+ <BooleanCellRenderer
490
+ value={true}
491
+ field={{ name: 'is_completed', type: 'boolean' } as any}
492
+ />
493
+ );
494
+ const indicator = container.querySelector('[data-testid="completion-indicator"]');
495
+ expect(indicator).toBeInTheDocument();
496
+ expect(indicator).toHaveClass('bg-green-500');
497
+ });
498
+
499
+ it('should render empty circle indicator for is_completed=false', () => {
500
+ const { container } = render(
501
+ <BooleanCellRenderer
502
+ value={false}
503
+ field={{ name: 'is_completed', type: 'boolean' } as any}
504
+ />
505
+ );
506
+ const indicator = container.querySelector('[data-testid="completion-indicator"]');
507
+ expect(indicator).toBeInTheDocument();
508
+ expect(indicator).toHaveClass('border-2');
509
+ });
510
+
511
+ it('should render green circle indicator for completed=true', () => {
512
+ const { container } = render(
513
+ <BooleanCellRenderer
514
+ value={true}
515
+ field={{ name: 'completed', type: 'boolean' } as any}
516
+ />
517
+ );
518
+ const indicator = container.querySelector('[data-testid="completion-indicator"]');
519
+ expect(indicator).toBeInTheDocument();
520
+ expect(indicator).toHaveClass('bg-green-500');
521
+ });
522
+
523
+ it('should render standard checkbox for non-completion boolean fields', () => {
524
+ render(
525
+ <BooleanCellRenderer
526
+ value={true}
527
+ field={{ name: 'active', type: 'boolean' } as any}
528
+ />
529
+ );
530
+ const checkbox = screen.getByRole('checkbox');
531
+ expect(checkbox).toHaveAttribute('data-state', 'checked');
532
+ });
533
+ });
534
+
535
+ // =========================================================================
536
+ // 4b. BooleanCellRenderer — Warning badge for status fields
537
+ // =========================================================================
538
+ describe('BooleanCellRenderer warning badge', () => {
539
+ it('should render warning badge for active=false', () => {
540
+ const { container } = render(
541
+ <BooleanCellRenderer
542
+ value={false}
543
+ field={{ name: 'active', type: 'boolean', label: 'Active' } as any}
544
+ />
545
+ );
546
+ const badge = container.querySelector('[data-testid="boolean-warning-badge"]');
547
+ expect(badge).toBeInTheDocument();
548
+ expect(badge?.textContent).toContain('Off');
549
+ });
550
+
551
+ it('should render warning badge for is_enabled=false', () => {
552
+ const { container } = render(
553
+ <BooleanCellRenderer
554
+ value={false}
555
+ field={{ name: 'is_enabled', type: 'boolean', label: 'Enabled' } as any}
556
+ />
557
+ );
558
+ const badge = container.querySelector('[data-testid="boolean-warning-badge"]');
559
+ expect(badge).toBeInTheDocument();
560
+ });
561
+
562
+ it('should render normal checkbox for active=true', () => {
563
+ render(
564
+ <BooleanCellRenderer
565
+ value={true}
566
+ field={{ name: 'active', type: 'boolean' } as any}
567
+ />
568
+ );
569
+ const checkbox = screen.getByRole('checkbox');
570
+ expect(checkbox).toHaveAttribute('data-state', 'checked');
571
+ });
572
+ });
573
+
574
+ // =========================================================================
575
+ // 4c. EmailCellRenderer — mailto + copy button
576
+ // =========================================================================
577
+ describe('EmailCellRenderer', () => {
578
+ it('should render mailto link', () => {
579
+ render(<EmailCellRenderer value="test@example.com" field={{ name: 'email', type: 'email' } as any} />);
580
+ const link = screen.getByRole('link');
581
+ expect(link).toHaveAttribute('href', 'mailto:test@example.com');
582
+ expect(screen.getByText('test@example.com')).toBeInTheDocument();
583
+ });
584
+
585
+ it('should render copy button', () => {
586
+ render(<EmailCellRenderer value="test@example.com" field={{ name: 'email', type: 'email' } as any} />);
587
+ const copyBtn = screen.getByLabelText('Copy email');
588
+ expect(copyBtn).toBeInTheDocument();
589
+ });
590
+
591
+ it('should render dash for empty value', () => {
592
+ render(<EmailCellRenderer value={null} field={{ name: 'email', type: 'email' } as any} />);
593
+ expect(screen.getByText('-')).toBeInTheDocument();
594
+ });
595
+ });
596
+
597
+ // =========================================================================
598
+ // 4d. PhoneCellRenderer — tel link + call icon + copy
599
+ // =========================================================================
600
+ describe('PhoneCellRenderer', () => {
601
+ it('should render tel link with phone icon', () => {
602
+ render(<PhoneCellRenderer value="+1-555-1234" field={{ name: 'phone', type: 'phone' } as any} />);
603
+ const link = screen.getByRole('link');
604
+ expect(link).toHaveAttribute('href', 'tel:+1-555-1234');
605
+ expect(screen.getByText('+1-555-1234')).toBeInTheDocument();
606
+ });
607
+
608
+ it('should render copy button', () => {
609
+ render(<PhoneCellRenderer value="+1-555-1234" field={{ name: 'phone', type: 'phone' } as any} />);
610
+ const copyBtn = screen.getByLabelText('Copy phone number');
611
+ expect(copyBtn).toBeInTheDocument();
612
+ });
613
+
614
+ it('should render dash for empty value', () => {
615
+ render(<PhoneCellRenderer value={null} field={{ name: 'phone', type: 'phone' } as any} />);
616
+ expect(screen.getByText('-')).toBeInTheDocument();
617
+ });
618
+ });
619
+
620
+ // =========================================================================
621
+ // 5. formatRelativeDate edge cases
622
+ // =========================================================================
623
+ describe('formatRelativeDate', () => {
624
+ let nowSpy: ReturnType<typeof vi.spyOn>;
625
+
626
+ beforeEach(() => {
627
+ nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-02-24T12:00:00Z').getTime());
628
+ });
629
+
630
+ afterEach(() => {
631
+ nowSpy.mockRestore();
632
+ });
633
+
634
+ it('should return "-" for null', () => {
635
+ expect(formatRelativeDate(null as any)).toBe('-');
636
+ });
637
+
638
+ it('should return "-" for empty string', () => {
639
+ expect(formatRelativeDate('')).toBe('-');
640
+ });
641
+
642
+ it('should return "-" for invalid date', () => {
643
+ expect(formatRelativeDate('not-a-date')).toBe('-');
644
+ });
645
+
646
+ it('should return "Today" for today', () => {
647
+ const today = new Date();
648
+ expect(formatRelativeDate(today)).toBe('Today');
649
+ });
650
+
651
+ it('should return "Yesterday" for yesterday', () => {
652
+ const yesterday = new Date();
653
+ yesterday.setDate(yesterday.getDate() - 1);
654
+ expect(formatRelativeDate(yesterday)).toBe('Yesterday');
655
+ });
656
+
657
+ it('should return "Tomorrow" for tomorrow', () => {
658
+ const tomorrow = new Date();
659
+ tomorrow.setDate(tomorrow.getDate() + 1);
660
+ expect(formatRelativeDate(tomorrow)).toBe('Tomorrow');
661
+ });
662
+
663
+ it('should return "Overdue Xd" for 2-7 days ago', () => {
664
+ const threeDaysAgo = new Date();
665
+ threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
666
+ expect(formatRelativeDate(threeDaysAgo)).toBe('Overdue 3d');
667
+ });
668
+
669
+ it('should return formatted date for >7 days ago', () => {
670
+ const tenDaysAgo = new Date();
671
+ tenDaysAgo.setDate(tenDaysAgo.getDate() - 10);
672
+ const result = formatRelativeDate(tenDaysAgo);
673
+ const expected = tenDaysAgo.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
674
+ expect(result).toBe(expected);
675
+ });
676
+
677
+ it('should return "In X days" for 2-7 days in the future', () => {
678
+ const fourDaysFromNow = new Date();
679
+ fourDaysFromNow.setDate(fourDaysFromNow.getDate() + 4);
680
+ expect(formatRelativeDate(fourDaysFromNow)).toBe('In 4 days');
681
+ });
682
+
683
+ it('should return formatted date for >7 days in the future', () => {
684
+ const fifteenDaysFromNow = new Date();
685
+ fifteenDaysFromNow.setDate(fifteenDaysFromNow.getDate() + 15);
686
+ const result = formatRelativeDate(fifteenDaysFromNow);
687
+ const expected = fifteenDaysFromNow.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
688
+ expect(result).toBe(expected);
689
+ });
690
+ });
691
+
692
+ // =========================================================================
693
+ // 6. formatDate with relative style
694
+ // =========================================================================
695
+ describe('formatDate', () => {
696
+ it('should return "-" for null', () => {
697
+ expect(formatDate(null as any)).toBe('-');
698
+ });
699
+
700
+ it('should return "-" for invalid date', () => {
701
+ expect(formatDate('invalid')).toBe('-');
702
+ });
703
+
704
+ it('should format as relative when style is "relative"', () => {
705
+ // Just ensure it delegates to formatRelativeDate
706
+ const result = formatDate(new Date(), 'relative');
707
+ expect(result).toBe('Today');
708
+ });
709
+
710
+ it('should format in short style', () => {
711
+ const result = formatDate('2026-01-15T08:00:00Z', 'short');
712
+ expect(result).toBe("Jan 15, '26");
713
+ });
714
+
715
+ it('should format in default style (MMM DD, YYYY)', () => {
716
+ const result = formatDate('2026-01-15T08:00:00Z');
717
+ expect(result).toContain('Jan');
718
+ expect(result).toContain('15');
719
+ expect(result).toContain('2026');
720
+ });
721
+ });
722
+
723
+ // =========================================================================
724
+ // 7. LookupCellRenderer
725
+ // =========================================================================
726
+ describe('LookupCellRenderer', () => {
727
+ it('should render dash for null value', () => {
728
+ render(
729
+ <LookupCellRenderer
730
+ value={null}
731
+ field={{ name: 'customer', type: 'lookup' } as any}
732
+ />
733
+ );
734
+ expect(screen.getByText('-')).toBeInTheDocument();
735
+ });
736
+
737
+ it('should render dash for empty string', () => {
738
+ render(
739
+ <LookupCellRenderer
740
+ value=""
741
+ field={{ name: 'customer', type: 'lookup' } as any}
742
+ />
743
+ );
744
+ expect(screen.getByText('-')).toBeInTheDocument();
745
+ });
746
+
747
+ it('should resolve primitive ID to label via field options', () => {
748
+ render(
749
+ <LookupCellRenderer
750
+ value={2}
751
+ field={{
752
+ name: 'customer',
753
+ type: 'lookup',
754
+ options: [
755
+ { value: 1, label: 'Alice' },
756
+ { value: 2, label: 'Bob' },
757
+ ],
758
+ } as any}
759
+ />
760
+ );
761
+ expect(screen.getByText('Bob')).toBeInTheDocument();
762
+ });
763
+
764
+ it('should resolve string ID to label via field options', () => {
765
+ render(
766
+ <LookupCellRenderer
767
+ value="contact_1"
768
+ field={{
769
+ name: 'customer',
770
+ type: 'lookup',
771
+ options: [{ value: 'contact_1', label: 'Alice Smith' }],
772
+ } as any}
773
+ />
774
+ );
775
+ expect(screen.getByText('Alice Smith')).toBeInTheDocument();
776
+ });
777
+
778
+ it('should render raw primitive when no options available', () => {
779
+ render(
780
+ <LookupCellRenderer
781
+ value={42}
782
+ field={{ name: 'customer', type: 'lookup' } as any}
783
+ />
784
+ );
785
+ expect(screen.getByText('42')).toBeInTheDocument();
786
+ });
787
+
788
+ it('should render object name when value is an object', () => {
789
+ render(
790
+ <LookupCellRenderer
791
+ value={{ name: 'Acme Corp', _id: '123' }}
792
+ field={{ name: 'account', type: 'lookup' } as any}
793
+ />
794
+ );
795
+ expect(screen.getByText('Acme Corp')).toBeInTheDocument();
796
+ });
797
+
798
+ it('should render object label when name is missing', () => {
799
+ render(
800
+ <LookupCellRenderer
801
+ value={{ label: 'Widget Co', _id: '456' }}
802
+ field={{ name: 'account', type: 'lookup' } as any}
803
+ />
804
+ );
805
+ expect(screen.getByText('Widget Co')).toBeInTheDocument();
806
+ });
807
+
808
+ it('should render object _id as fallback', () => {
809
+ render(
810
+ <LookupCellRenderer
811
+ value={{ _id: '789' }}
812
+ field={{ name: 'account', type: 'lookup' } as any}
813
+ />
814
+ );
815
+ expect(screen.getByText('789')).toBeInTheDocument();
816
+ });
817
+
818
+ it('should render tags for array of objects', () => {
819
+ render(
820
+ <LookupCellRenderer
821
+ value={[{ name: 'Alice' }, { name: 'Bob' }]}
822
+ field={{ name: 'contacts', type: 'lookup' } as any}
823
+ />
824
+ );
825
+ expect(screen.getByText('Alice')).toBeInTheDocument();
826
+ expect(screen.getByText('Bob')).toBeInTheDocument();
827
+ });
828
+
829
+ it('should resolve array of primitive IDs via options', () => {
830
+ render(
831
+ <LookupCellRenderer
832
+ value={[1, 3]}
833
+ field={{
834
+ name: 'contacts',
835
+ type: 'lookup',
836
+ options: [
837
+ { value: 1, label: 'Alice' },
838
+ { value: 2, label: 'Bob' },
839
+ { value: 3, label: 'Charlie' },
840
+ ],
841
+ } as any}
842
+ />
843
+ );
844
+ expect(screen.getByText('Alice')).toBeInTheDocument();
845
+ expect(screen.getByText('Charlie')).toBeInTheDocument();
846
+ expect(screen.queryByText('Bob')).not.toBeInTheDocument();
847
+ });
848
+ });
849
+
850
+ // =========================================================================
851
+ // 8. UserCellRenderer
852
+ // =========================================================================
853
+ describe('UserCellRenderer', () => {
854
+ it('should render dash for null value', () => {
855
+ render(
856
+ <UserCellRenderer
857
+ value={null}
858
+ field={{ name: 'owner', type: 'user' } as any}
859
+ />
860
+ );
861
+ expect(screen.getByText('-')).toBeInTheDocument();
862
+ });
863
+
864
+ it('should render text for primitive user ID (number)', () => {
865
+ render(
866
+ <UserCellRenderer
867
+ value={5}
868
+ field={{ name: 'owner', type: 'user' } as any}
869
+ />
870
+ );
871
+ expect(screen.getByText('5')).toBeInTheDocument();
872
+ });
873
+
874
+ it('should render text for primitive user ID (string)', () => {
875
+ render(
876
+ <UserCellRenderer
877
+ value="user_abc"
878
+ field={{ name: 'owner', type: 'user' } as any}
879
+ />
880
+ );
881
+ expect(screen.getByText('user_abc')).toBeInTheDocument();
882
+ });
883
+
884
+ it('should render avatar and name for object value', () => {
885
+ render(
886
+ <UserCellRenderer
887
+ value={{ name: 'John Doe', username: 'jdoe' }}
888
+ field={{ name: 'owner', type: 'user' } as any}
889
+ />
890
+ );
891
+ expect(screen.getByText('John Doe')).toBeInTheDocument();
892
+ });
893
+
894
+ it('should use username when name is missing', () => {
895
+ render(
896
+ <UserCellRenderer
897
+ value={{ username: 'jdoe' }}
898
+ field={{ name: 'owner', type: 'user' } as any}
899
+ />
900
+ );
901
+ expect(screen.getByText('jdoe')).toBeInTheDocument();
902
+ });
903
+
904
+ it('should render multiple avatars for array of user objects', () => {
905
+ render(
906
+ <UserCellRenderer
907
+ value={[{ name: 'Alice' }, { name: 'Bob' }]}
908
+ field={{ name: 'assignees', type: 'user' } as any}
909
+ />
910
+ );
911
+ // Avatar fallbacks contain initials
912
+ expect(screen.getByTitle('Alice')).toBeInTheDocument();
913
+ expect(screen.getByTitle('Bob')).toBeInTheDocument();
914
+ });
915
+ });
916
+
917
+ // =========================================================================
918
+ // 9. humanizeLabel
919
+ // =========================================================================
920
+
921
+ describe('humanizeLabel', () => {
922
+ it('should convert snake_case to Title Case', () => {
923
+ expect(humanizeLabel('in_progress')).toBe('In Progress');
924
+ });
925
+
926
+ it('should convert kebab-case to Title Case', () => {
927
+ expect(humanizeLabel('high-priority')).toBe('High Priority');
928
+ });
929
+
930
+ it('should handle single word', () => {
931
+ expect(humanizeLabel('active')).toBe('Active');
932
+ });
933
+
934
+ it('should handle already Title Case', () => {
935
+ expect(humanizeLabel('Active')).toBe('Active');
936
+ });
937
+
938
+ it('should handle multiple underscores', () => {
939
+ expect(humanizeLabel('not_yet_started')).toBe('Not Yet Started');
940
+ });
941
+
942
+ it('should handle empty string', () => {
943
+ expect(humanizeLabel('')).toBe('');
944
+ });
945
+
946
+ it('should handle mixed separators', () => {
947
+ expect(humanizeLabel('in_progress-now')).toBe('In Progress Now');
948
+ });
949
+ });
950
+
951
+ // =========================================================================
952
+ // 10. SelectCellRenderer humanizeLabel fallback
953
+ // =========================================================================
954
+ describe('SelectCellRenderer humanizeLabel fallback', () => {
955
+ it('should humanize snake_case value when no option label exists', () => {
956
+ render(
957
+ <SelectCellRenderer
958
+ value="in_progress"
959
+ field={{ name: 'status', type: 'select', options: [] } as any}
960
+ />
961
+ );
962
+ expect(screen.getByText('In Progress')).toBeInTheDocument();
963
+ });
964
+
965
+ it('should prefer explicit option.label over humanized fallback', () => {
966
+ render(
967
+ <SelectCellRenderer
968
+ value="in_progress"
969
+ field={{
970
+ name: 'status',
971
+ type: 'select',
972
+ options: [{ value: 'in_progress', label: 'WIP' }],
973
+ } as any}
974
+ />
975
+ );
976
+ expect(screen.getByText('WIP')).toBeInTheDocument();
977
+ expect(screen.queryByText('In Progress')).not.toBeInTheDocument();
978
+ });
979
+
980
+ it('should humanize snake_case values in arrays', () => {
981
+ render(
982
+ <SelectCellRenderer
983
+ value={['not_started', 'in_progress']}
984
+ field={{ name: 'status', type: 'select', options: [] } as any}
985
+ />
986
+ );
987
+ expect(screen.getByText('Not Started')).toBeInTheDocument();
988
+ expect(screen.getByText('In Progress')).toBeInTheDocument();
989
+ });
990
+ });
991
+
992
+ // =========================================================================
993
+ // 11. PercentCellRenderer with progress-type fields
994
+ // =========================================================================
995
+ describe('PercentCellRenderer progress-type fields', () => {
996
+ it('should render progress field value 75 as 75% with correct bar', () => {
997
+ const { container } = render(
998
+ <PercentCellRenderer
999
+ value={75}
1000
+ field={{ name: 'progress', type: 'percent' } as any}
1001
+ />
1002
+ );
1003
+ expect(screen.getByText('75%')).toBeInTheDocument();
1004
+ const bar = container.querySelector('[role="progressbar"]');
1005
+ expect(bar).toHaveAttribute('aria-valuenow', '75');
1006
+ });
1007
+
1008
+ it('should render completion field value 50 as 50% with correct bar', () => {
1009
+ const { container } = render(
1010
+ <PercentCellRenderer
1011
+ value={50}
1012
+ field={{ name: 'completion', type: 'percent' } as any}
1013
+ />
1014
+ );
1015
+ expect(screen.getByText('50%')).toBeInTheDocument();
1016
+ const bar = container.querySelector('[role="progressbar"]');
1017
+ expect(bar).toHaveAttribute('aria-valuenow', '50');
1018
+ });
1019
+
1020
+ it('should render probability field value 0.75 as 75%', () => {
1021
+ const { container } = render(
1022
+ <PercentCellRenderer
1023
+ value={0.75}
1024
+ field={{ name: 'probability', type: 'percent' } as any}
1025
+ />
1026
+ );
1027
+ expect(screen.getByText('75%')).toBeInTheDocument();
1028
+ const bar = container.querySelector('[role="progressbar"]');
1029
+ expect(bar).toHaveAttribute('aria-valuenow', '75');
1030
+ });
1031
+
1032
+ it('should render rate field value 0.5 as 50%', () => {
1033
+ const { container } = render(
1034
+ <PercentCellRenderer
1035
+ value={0.5}
1036
+ field={{ name: 'rate', type: 'percent' } as any}
1037
+ />
1038
+ );
1039
+ expect(screen.getByText('50%')).toBeInTheDocument();
1040
+ const bar = container.querySelector('[role="progressbar"]');
1041
+ expect(bar).toHaveAttribute('aria-valuenow', '50');
1042
+ });
1043
+ });