@object-ui/fields 3.0.2 → 3.1.0

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,953 @@
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 render dash for null/empty value', () => {
209
+ render(
210
+ <SelectCellRenderer
211
+ value={null}
212
+ field={{ name: 'status', type: 'select' } as any}
213
+ />
214
+ );
215
+ expect(screen.getByText('-')).toBeInTheDocument();
216
+ });
217
+
218
+ it('should render multiple badges for array values', () => {
219
+ render(
220
+ <SelectCellRenderer
221
+ value={['Draft', 'Active']}
222
+ field={{
223
+ name: 'tags',
224
+ type: 'select',
225
+ options: [
226
+ { value: 'Draft', label: 'Draft', color: 'yellow' },
227
+ { value: 'Active', label: 'Active', color: 'green' },
228
+ ],
229
+ } as any}
230
+ />
231
+ );
232
+ expect(screen.getByText('Draft')).toBeInTheDocument();
233
+ expect(screen.getByText('Active')).toBeInTheDocument();
234
+ });
235
+
236
+ it('should prefer explicit option color over auto-detected', () => {
237
+ const { container } = render(
238
+ <SelectCellRenderer
239
+ value="High"
240
+ field={{
241
+ name: 'priority',
242
+ type: 'select',
243
+ options: [{ value: 'High', label: 'High', color: 'purple' }],
244
+ } as any}
245
+ />
246
+ );
247
+ // Explicit purple should override auto-detected orange
248
+ expect(container.querySelector('[class*="bg-purple-100"]')).toBeInTheDocument();
249
+ });
250
+ });
251
+
252
+ // =========================================================================
253
+ // 3. DateCellRenderer
254
+ // =========================================================================
255
+ describe('DateCellRenderer', () => {
256
+ beforeEach(() => {
257
+ // Fix "now" to a known date: 2026-02-24T12:00:00Z using fake timers
258
+ // so that new Date() in formatRelativeDate returns a predictable value
259
+ vi.useFakeTimers();
260
+ vi.setSystemTime(new Date('2026-02-24T12:00:00Z'));
261
+ });
262
+
263
+ afterEach(() => {
264
+ vi.useRealTimers();
265
+ });
266
+
267
+ it('should render today date as "Today"', () => {
268
+ render(
269
+ <DateCellRenderer
270
+ value="2026-02-24T08:00:00Z"
271
+ field={{ name: 'due_date', type: 'date' } as any}
272
+ />
273
+ );
274
+ expect(screen.getByText('Today')).toBeInTheDocument();
275
+ });
276
+
277
+ it('should render yesterday date as "Yesterday"', () => {
278
+ render(
279
+ <DateCellRenderer
280
+ value="2026-02-23T08:00:00Z"
281
+ field={{ name: 'due_date', type: 'date' } as any}
282
+ />
283
+ );
284
+ expect(screen.getByText('Yesterday')).toBeInTheDocument();
285
+ });
286
+
287
+ it('should render recent past date as "Overdue Xd"', () => {
288
+ render(
289
+ <DateCellRenderer
290
+ value="2026-02-20T08:00:00Z"
291
+ field={{ name: 'due_date', type: 'date' } as any}
292
+ />
293
+ );
294
+ expect(screen.getByText('Overdue 4d')).toBeInTheDocument();
295
+ });
296
+
297
+ it('should render overdue date with red text styling', () => {
298
+ const { container } = render(
299
+ <DateCellRenderer
300
+ value="2026-02-20T08:00:00Z"
301
+ field={{ name: 'due_date', type: 'date' } as any}
302
+ />
303
+ );
304
+ expect(container.querySelector('.text-red-600')).toBeInTheDocument();
305
+ });
306
+
307
+ it('should show ISO string as tooltip', () => {
308
+ const { container } = render(
309
+ <DateCellRenderer
310
+ value="2026-02-20T08:00:00Z"
311
+ field={{ name: 'due_date', type: 'date' } as any}
312
+ />
313
+ );
314
+ const span = container.querySelector('span[title]');
315
+ expect(span).toBeInTheDocument();
316
+ expect(span?.getAttribute('title')).toContain('2026-02-20');
317
+ });
318
+
319
+ it('should render dash for null value', () => {
320
+ const { container } = render(
321
+ <DateCellRenderer
322
+ value={null}
323
+ field={{ name: 'due_date', type: 'date' } as any}
324
+ />
325
+ );
326
+ expect(container.textContent).toBe('-');
327
+ });
328
+
329
+ it('should use explicit format when provided', () => {
330
+ render(
331
+ <DateCellRenderer
332
+ value="2026-01-15T08:00:00Z"
333
+ field={{ name: 'created', type: 'date', format: 'short' } as any}
334
+ />
335
+ );
336
+ // Short format: "Jan 15, '26"
337
+ expect(screen.getByText("Jan 15, '26")).toBeInTheDocument();
338
+ });
339
+
340
+ it('should not show red styling for future dates', () => {
341
+ const { container } = render(
342
+ <DateCellRenderer
343
+ value="2026-03-01T08:00:00Z"
344
+ field={{ name: 'due_date', type: 'date' } as any}
345
+ />
346
+ );
347
+ expect(container.querySelector('.text-red-600')).not.toBeInTheDocument();
348
+ });
349
+ });
350
+
351
+ // =========================================================================
352
+ // 4. BooleanCellRenderer
353
+ // =========================================================================
354
+ describe('BooleanCellRenderer', () => {
355
+ it('should render checked checkbox for true', () => {
356
+ render(
357
+ <BooleanCellRenderer
358
+ value={true}
359
+ field={{ name: 'active', type: 'boolean' } as any}
360
+ />
361
+ );
362
+ const checkbox = screen.getByRole('checkbox');
363
+ expect(checkbox).toHaveAttribute('data-state', 'checked');
364
+ });
365
+
366
+ it('should render unchecked checkbox for false', () => {
367
+ render(
368
+ <BooleanCellRenderer
369
+ value={false}
370
+ field={{ name: 'flagged', type: 'boolean' } as any}
371
+ />
372
+ );
373
+ const checkbox = screen.getByRole('checkbox');
374
+ expect(checkbox).toHaveAttribute('data-state', 'unchecked');
375
+ });
376
+
377
+ it('should render dash for null', () => {
378
+ render(
379
+ <BooleanCellRenderer
380
+ value={null}
381
+ field={{ name: 'active', type: 'boolean' } as any}
382
+ />
383
+ );
384
+ expect(screen.getByText('—')).toBeInTheDocument();
385
+ });
386
+
387
+ it('should render dash for undefined', () => {
388
+ render(
389
+ <BooleanCellRenderer
390
+ value={undefined}
391
+ field={{ name: 'active', type: 'boolean' } as any}
392
+ />
393
+ );
394
+ expect(screen.getByText('—')).toBeInTheDocument();
395
+ });
396
+
397
+ it('should render green circle indicator for is_completed=true', () => {
398
+ const { container } = render(
399
+ <BooleanCellRenderer
400
+ value={true}
401
+ field={{ name: 'is_completed', type: 'boolean' } as any}
402
+ />
403
+ );
404
+ const indicator = container.querySelector('[data-testid="completion-indicator"]');
405
+ expect(indicator).toBeInTheDocument();
406
+ expect(indicator).toHaveClass('bg-green-500');
407
+ });
408
+
409
+ it('should render empty circle indicator for is_completed=false', () => {
410
+ const { container } = render(
411
+ <BooleanCellRenderer
412
+ value={false}
413
+ field={{ name: 'is_completed', type: 'boolean' } as any}
414
+ />
415
+ );
416
+ const indicator = container.querySelector('[data-testid="completion-indicator"]');
417
+ expect(indicator).toBeInTheDocument();
418
+ expect(indicator).toHaveClass('border-2');
419
+ });
420
+
421
+ it('should render green circle indicator for completed=true', () => {
422
+ const { container } = render(
423
+ <BooleanCellRenderer
424
+ value={true}
425
+ field={{ name: 'completed', type: 'boolean' } as any}
426
+ />
427
+ );
428
+ const indicator = container.querySelector('[data-testid="completion-indicator"]');
429
+ expect(indicator).toBeInTheDocument();
430
+ expect(indicator).toHaveClass('bg-green-500');
431
+ });
432
+
433
+ it('should render standard checkbox for non-completion boolean fields', () => {
434
+ render(
435
+ <BooleanCellRenderer
436
+ value={true}
437
+ field={{ name: 'active', type: 'boolean' } as any}
438
+ />
439
+ );
440
+ const checkbox = screen.getByRole('checkbox');
441
+ expect(checkbox).toHaveAttribute('data-state', 'checked');
442
+ });
443
+ });
444
+
445
+ // =========================================================================
446
+ // 4b. BooleanCellRenderer — Warning badge for status fields
447
+ // =========================================================================
448
+ describe('BooleanCellRenderer warning badge', () => {
449
+ it('should render warning badge for active=false', () => {
450
+ const { container } = render(
451
+ <BooleanCellRenderer
452
+ value={false}
453
+ field={{ name: 'active', type: 'boolean', label: 'Active' } as any}
454
+ />
455
+ );
456
+ const badge = container.querySelector('[data-testid="boolean-warning-badge"]');
457
+ expect(badge).toBeInTheDocument();
458
+ expect(badge?.textContent).toContain('Off');
459
+ });
460
+
461
+ it('should render warning badge for is_enabled=false', () => {
462
+ const { container } = render(
463
+ <BooleanCellRenderer
464
+ value={false}
465
+ field={{ name: 'is_enabled', type: 'boolean', label: 'Enabled' } as any}
466
+ />
467
+ );
468
+ const badge = container.querySelector('[data-testid="boolean-warning-badge"]');
469
+ expect(badge).toBeInTheDocument();
470
+ });
471
+
472
+ it('should render normal checkbox for active=true', () => {
473
+ render(
474
+ <BooleanCellRenderer
475
+ value={true}
476
+ field={{ name: 'active', type: 'boolean' } as any}
477
+ />
478
+ );
479
+ const checkbox = screen.getByRole('checkbox');
480
+ expect(checkbox).toHaveAttribute('data-state', 'checked');
481
+ });
482
+ });
483
+
484
+ // =========================================================================
485
+ // 4c. EmailCellRenderer — mailto + copy button
486
+ // =========================================================================
487
+ describe('EmailCellRenderer', () => {
488
+ it('should render mailto link', () => {
489
+ render(<EmailCellRenderer value="test@example.com" field={{ name: 'email', type: 'email' } as any} />);
490
+ const link = screen.getByRole('link');
491
+ expect(link).toHaveAttribute('href', 'mailto:test@example.com');
492
+ expect(screen.getByText('test@example.com')).toBeInTheDocument();
493
+ });
494
+
495
+ it('should render copy button', () => {
496
+ render(<EmailCellRenderer value="test@example.com" field={{ name: 'email', type: 'email' } as any} />);
497
+ const copyBtn = screen.getByLabelText('Copy email');
498
+ expect(copyBtn).toBeInTheDocument();
499
+ });
500
+
501
+ it('should render dash for empty value', () => {
502
+ render(<EmailCellRenderer value={null} field={{ name: 'email', type: 'email' } as any} />);
503
+ expect(screen.getByText('-')).toBeInTheDocument();
504
+ });
505
+ });
506
+
507
+ // =========================================================================
508
+ // 4d. PhoneCellRenderer — tel link + call icon + copy
509
+ // =========================================================================
510
+ describe('PhoneCellRenderer', () => {
511
+ it('should render tel link with phone icon', () => {
512
+ render(<PhoneCellRenderer value="+1-555-1234" field={{ name: 'phone', type: 'phone' } as any} />);
513
+ const link = screen.getByRole('link');
514
+ expect(link).toHaveAttribute('href', 'tel:+1-555-1234');
515
+ expect(screen.getByText('+1-555-1234')).toBeInTheDocument();
516
+ });
517
+
518
+ it('should render copy button', () => {
519
+ render(<PhoneCellRenderer value="+1-555-1234" field={{ name: 'phone', type: 'phone' } as any} />);
520
+ const copyBtn = screen.getByLabelText('Copy phone number');
521
+ expect(copyBtn).toBeInTheDocument();
522
+ });
523
+
524
+ it('should render dash for empty value', () => {
525
+ render(<PhoneCellRenderer value={null} field={{ name: 'phone', type: 'phone' } as any} />);
526
+ expect(screen.getByText('-')).toBeInTheDocument();
527
+ });
528
+ });
529
+
530
+ // =========================================================================
531
+ // 5. formatRelativeDate edge cases
532
+ // =========================================================================
533
+ describe('formatRelativeDate', () => {
534
+ let nowSpy: ReturnType<typeof vi.spyOn>;
535
+
536
+ beforeEach(() => {
537
+ nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-02-24T12:00:00Z').getTime());
538
+ });
539
+
540
+ afterEach(() => {
541
+ nowSpy.mockRestore();
542
+ });
543
+
544
+ it('should return "-" for null', () => {
545
+ expect(formatRelativeDate(null as any)).toBe('-');
546
+ });
547
+
548
+ it('should return "-" for empty string', () => {
549
+ expect(formatRelativeDate('')).toBe('-');
550
+ });
551
+
552
+ it('should return "-" for invalid date', () => {
553
+ expect(formatRelativeDate('not-a-date')).toBe('-');
554
+ });
555
+
556
+ it('should return "Today" for today', () => {
557
+ const today = new Date();
558
+ expect(formatRelativeDate(today)).toBe('Today');
559
+ });
560
+
561
+ it('should return "Yesterday" for yesterday', () => {
562
+ const yesterday = new Date();
563
+ yesterday.setDate(yesterday.getDate() - 1);
564
+ expect(formatRelativeDate(yesterday)).toBe('Yesterday');
565
+ });
566
+
567
+ it('should return "Tomorrow" for tomorrow', () => {
568
+ const tomorrow = new Date();
569
+ tomorrow.setDate(tomorrow.getDate() + 1);
570
+ expect(formatRelativeDate(tomorrow)).toBe('Tomorrow');
571
+ });
572
+
573
+ it('should return "Overdue Xd" for 2-7 days ago', () => {
574
+ const threeDaysAgo = new Date();
575
+ threeDaysAgo.setDate(threeDaysAgo.getDate() - 3);
576
+ expect(formatRelativeDate(threeDaysAgo)).toBe('Overdue 3d');
577
+ });
578
+
579
+ it('should return formatted date for >7 days ago', () => {
580
+ const tenDaysAgo = new Date();
581
+ tenDaysAgo.setDate(tenDaysAgo.getDate() - 10);
582
+ const result = formatRelativeDate(tenDaysAgo);
583
+ const expected = tenDaysAgo.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
584
+ expect(result).toBe(expected);
585
+ });
586
+
587
+ it('should return "In X days" for 2-7 days in the future', () => {
588
+ const fourDaysFromNow = new Date();
589
+ fourDaysFromNow.setDate(fourDaysFromNow.getDate() + 4);
590
+ expect(formatRelativeDate(fourDaysFromNow)).toBe('In 4 days');
591
+ });
592
+
593
+ it('should return formatted date for >7 days in the future', () => {
594
+ const fifteenDaysFromNow = new Date();
595
+ fifteenDaysFromNow.setDate(fifteenDaysFromNow.getDate() + 15);
596
+ const result = formatRelativeDate(fifteenDaysFromNow);
597
+ const expected = fifteenDaysFromNow.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
598
+ expect(result).toBe(expected);
599
+ });
600
+ });
601
+
602
+ // =========================================================================
603
+ // 6. formatDate with relative style
604
+ // =========================================================================
605
+ describe('formatDate', () => {
606
+ it('should return "-" for null', () => {
607
+ expect(formatDate(null as any)).toBe('-');
608
+ });
609
+
610
+ it('should return "-" for invalid date', () => {
611
+ expect(formatDate('invalid')).toBe('-');
612
+ });
613
+
614
+ it('should format as relative when style is "relative"', () => {
615
+ // Just ensure it delegates to formatRelativeDate
616
+ const result = formatDate(new Date(), 'relative');
617
+ expect(result).toBe('Today');
618
+ });
619
+
620
+ it('should format in short style', () => {
621
+ const result = formatDate('2026-01-15T08:00:00Z', 'short');
622
+ expect(result).toBe("Jan 15, '26");
623
+ });
624
+
625
+ it('should format in default style (MMM DD, YYYY)', () => {
626
+ const result = formatDate('2026-01-15T08:00:00Z');
627
+ expect(result).toContain('Jan');
628
+ expect(result).toContain('15');
629
+ expect(result).toContain('2026');
630
+ });
631
+ });
632
+
633
+ // =========================================================================
634
+ // 7. LookupCellRenderer
635
+ // =========================================================================
636
+ describe('LookupCellRenderer', () => {
637
+ it('should render dash for null value', () => {
638
+ render(
639
+ <LookupCellRenderer
640
+ value={null}
641
+ field={{ name: 'customer', type: 'lookup' } as any}
642
+ />
643
+ );
644
+ expect(screen.getByText('-')).toBeInTheDocument();
645
+ });
646
+
647
+ it('should render dash for empty string', () => {
648
+ render(
649
+ <LookupCellRenderer
650
+ value=""
651
+ field={{ name: 'customer', type: 'lookup' } as any}
652
+ />
653
+ );
654
+ expect(screen.getByText('-')).toBeInTheDocument();
655
+ });
656
+
657
+ it('should resolve primitive ID to label via field options', () => {
658
+ render(
659
+ <LookupCellRenderer
660
+ value={2}
661
+ field={{
662
+ name: 'customer',
663
+ type: 'lookup',
664
+ options: [
665
+ { value: 1, label: 'Alice' },
666
+ { value: 2, label: 'Bob' },
667
+ ],
668
+ } as any}
669
+ />
670
+ );
671
+ expect(screen.getByText('Bob')).toBeInTheDocument();
672
+ });
673
+
674
+ it('should resolve string ID to label via field options', () => {
675
+ render(
676
+ <LookupCellRenderer
677
+ value="contact_1"
678
+ field={{
679
+ name: 'customer',
680
+ type: 'lookup',
681
+ options: [{ value: 'contact_1', label: 'Alice Smith' }],
682
+ } as any}
683
+ />
684
+ );
685
+ expect(screen.getByText('Alice Smith')).toBeInTheDocument();
686
+ });
687
+
688
+ it('should render raw primitive when no options available', () => {
689
+ render(
690
+ <LookupCellRenderer
691
+ value={42}
692
+ field={{ name: 'customer', type: 'lookup' } as any}
693
+ />
694
+ );
695
+ expect(screen.getByText('42')).toBeInTheDocument();
696
+ });
697
+
698
+ it('should render object name when value is an object', () => {
699
+ render(
700
+ <LookupCellRenderer
701
+ value={{ name: 'Acme Corp', _id: '123' }}
702
+ field={{ name: 'account', type: 'lookup' } as any}
703
+ />
704
+ );
705
+ expect(screen.getByText('Acme Corp')).toBeInTheDocument();
706
+ });
707
+
708
+ it('should render object label when name is missing', () => {
709
+ render(
710
+ <LookupCellRenderer
711
+ value={{ label: 'Widget Co', _id: '456' }}
712
+ field={{ name: 'account', type: 'lookup' } as any}
713
+ />
714
+ );
715
+ expect(screen.getByText('Widget Co')).toBeInTheDocument();
716
+ });
717
+
718
+ it('should render object _id as fallback', () => {
719
+ render(
720
+ <LookupCellRenderer
721
+ value={{ _id: '789' }}
722
+ field={{ name: 'account', type: 'lookup' } as any}
723
+ />
724
+ );
725
+ expect(screen.getByText('789')).toBeInTheDocument();
726
+ });
727
+
728
+ it('should render tags for array of objects', () => {
729
+ render(
730
+ <LookupCellRenderer
731
+ value={[{ name: 'Alice' }, { name: 'Bob' }]}
732
+ field={{ name: 'contacts', type: 'lookup' } as any}
733
+ />
734
+ );
735
+ expect(screen.getByText('Alice')).toBeInTheDocument();
736
+ expect(screen.getByText('Bob')).toBeInTheDocument();
737
+ });
738
+
739
+ it('should resolve array of primitive IDs via options', () => {
740
+ render(
741
+ <LookupCellRenderer
742
+ value={[1, 3]}
743
+ field={{
744
+ name: 'contacts',
745
+ type: 'lookup',
746
+ options: [
747
+ { value: 1, label: 'Alice' },
748
+ { value: 2, label: 'Bob' },
749
+ { value: 3, label: 'Charlie' },
750
+ ],
751
+ } as any}
752
+ />
753
+ );
754
+ expect(screen.getByText('Alice')).toBeInTheDocument();
755
+ expect(screen.getByText('Charlie')).toBeInTheDocument();
756
+ expect(screen.queryByText('Bob')).not.toBeInTheDocument();
757
+ });
758
+ });
759
+
760
+ // =========================================================================
761
+ // 8. UserCellRenderer
762
+ // =========================================================================
763
+ describe('UserCellRenderer', () => {
764
+ it('should render dash for null value', () => {
765
+ render(
766
+ <UserCellRenderer
767
+ value={null}
768
+ field={{ name: 'owner', type: 'user' } as any}
769
+ />
770
+ );
771
+ expect(screen.getByText('-')).toBeInTheDocument();
772
+ });
773
+
774
+ it('should render text for primitive user ID (number)', () => {
775
+ render(
776
+ <UserCellRenderer
777
+ value={5}
778
+ field={{ name: 'owner', type: 'user' } as any}
779
+ />
780
+ );
781
+ expect(screen.getByText('5')).toBeInTheDocument();
782
+ });
783
+
784
+ it('should render text for primitive user ID (string)', () => {
785
+ render(
786
+ <UserCellRenderer
787
+ value="user_abc"
788
+ field={{ name: 'owner', type: 'user' } as any}
789
+ />
790
+ );
791
+ expect(screen.getByText('user_abc')).toBeInTheDocument();
792
+ });
793
+
794
+ it('should render avatar and name for object value', () => {
795
+ render(
796
+ <UserCellRenderer
797
+ value={{ name: 'John Doe', username: 'jdoe' }}
798
+ field={{ name: 'owner', type: 'user' } as any}
799
+ />
800
+ );
801
+ expect(screen.getByText('John Doe')).toBeInTheDocument();
802
+ });
803
+
804
+ it('should use username when name is missing', () => {
805
+ render(
806
+ <UserCellRenderer
807
+ value={{ username: 'jdoe' }}
808
+ field={{ name: 'owner', type: 'user' } as any}
809
+ />
810
+ );
811
+ expect(screen.getByText('jdoe')).toBeInTheDocument();
812
+ });
813
+
814
+ it('should render multiple avatars for array of user objects', () => {
815
+ render(
816
+ <UserCellRenderer
817
+ value={[{ name: 'Alice' }, { name: 'Bob' }]}
818
+ field={{ name: 'assignees', type: 'user' } as any}
819
+ />
820
+ );
821
+ // Avatar fallbacks contain initials
822
+ expect(screen.getByTitle('Alice')).toBeInTheDocument();
823
+ expect(screen.getByTitle('Bob')).toBeInTheDocument();
824
+ });
825
+ });
826
+
827
+ // =========================================================================
828
+ // 9. humanizeLabel
829
+ // =========================================================================
830
+
831
+ describe('humanizeLabel', () => {
832
+ it('should convert snake_case to Title Case', () => {
833
+ expect(humanizeLabel('in_progress')).toBe('In Progress');
834
+ });
835
+
836
+ it('should convert kebab-case to Title Case', () => {
837
+ expect(humanizeLabel('high-priority')).toBe('High Priority');
838
+ });
839
+
840
+ it('should handle single word', () => {
841
+ expect(humanizeLabel('active')).toBe('Active');
842
+ });
843
+
844
+ it('should handle already Title Case', () => {
845
+ expect(humanizeLabel('Active')).toBe('Active');
846
+ });
847
+
848
+ it('should handle multiple underscores', () => {
849
+ expect(humanizeLabel('not_yet_started')).toBe('Not Yet Started');
850
+ });
851
+
852
+ it('should handle empty string', () => {
853
+ expect(humanizeLabel('')).toBe('');
854
+ });
855
+
856
+ it('should handle mixed separators', () => {
857
+ expect(humanizeLabel('in_progress-now')).toBe('In Progress Now');
858
+ });
859
+ });
860
+
861
+ // =========================================================================
862
+ // 10. SelectCellRenderer humanizeLabel fallback
863
+ // =========================================================================
864
+ describe('SelectCellRenderer humanizeLabel fallback', () => {
865
+ it('should humanize snake_case value when no option label exists', () => {
866
+ render(
867
+ <SelectCellRenderer
868
+ value="in_progress"
869
+ field={{ name: 'status', type: 'select', options: [] } as any}
870
+ />
871
+ );
872
+ expect(screen.getByText('In Progress')).toBeInTheDocument();
873
+ });
874
+
875
+ it('should prefer explicit option.label over humanized fallback', () => {
876
+ render(
877
+ <SelectCellRenderer
878
+ value="in_progress"
879
+ field={{
880
+ name: 'status',
881
+ type: 'select',
882
+ options: [{ value: 'in_progress', label: 'WIP' }],
883
+ } as any}
884
+ />
885
+ );
886
+ expect(screen.getByText('WIP')).toBeInTheDocument();
887
+ expect(screen.queryByText('In Progress')).not.toBeInTheDocument();
888
+ });
889
+
890
+ it('should humanize snake_case values in arrays', () => {
891
+ render(
892
+ <SelectCellRenderer
893
+ value={['not_started', 'in_progress']}
894
+ field={{ name: 'status', type: 'select', options: [] } as any}
895
+ />
896
+ );
897
+ expect(screen.getByText('Not Started')).toBeInTheDocument();
898
+ expect(screen.getByText('In Progress')).toBeInTheDocument();
899
+ });
900
+ });
901
+
902
+ // =========================================================================
903
+ // 11. PercentCellRenderer with progress-type fields
904
+ // =========================================================================
905
+ describe('PercentCellRenderer progress-type fields', () => {
906
+ it('should render progress field value 75 as 75% with correct bar', () => {
907
+ const { container } = render(
908
+ <PercentCellRenderer
909
+ value={75}
910
+ field={{ name: 'progress', type: 'percent' } as any}
911
+ />
912
+ );
913
+ expect(screen.getByText('75%')).toBeInTheDocument();
914
+ const bar = container.querySelector('[role="progressbar"]');
915
+ expect(bar).toHaveAttribute('aria-valuenow', '75');
916
+ });
917
+
918
+ it('should render completion field value 50 as 50% with correct bar', () => {
919
+ const { container } = render(
920
+ <PercentCellRenderer
921
+ value={50}
922
+ field={{ name: 'completion', type: 'percent' } as any}
923
+ />
924
+ );
925
+ expect(screen.getByText('50%')).toBeInTheDocument();
926
+ const bar = container.querySelector('[role="progressbar"]');
927
+ expect(bar).toHaveAttribute('aria-valuenow', '50');
928
+ });
929
+
930
+ it('should render probability field value 0.75 as 75%', () => {
931
+ const { container } = render(
932
+ <PercentCellRenderer
933
+ value={0.75}
934
+ field={{ name: 'probability', type: 'percent' } as any}
935
+ />
936
+ );
937
+ expect(screen.getByText('75%')).toBeInTheDocument();
938
+ const bar = container.querySelector('[role="progressbar"]');
939
+ expect(bar).toHaveAttribute('aria-valuenow', '75');
940
+ });
941
+
942
+ it('should render rate field value 0.5 as 50%', () => {
943
+ const { container } = render(
944
+ <PercentCellRenderer
945
+ value={0.5}
946
+ field={{ name: 'rate', type: 'percent' } as any}
947
+ />
948
+ );
949
+ expect(screen.getByText('50%')).toBeInTheDocument();
950
+ const bar = container.querySelector('[role="progressbar"]');
951
+ expect(bar).toHaveAttribute('aria-valuenow', '50');
952
+ });
953
+ });