datajunction-ui 0.0.44 → 0.0.46

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.
@@ -429,4 +429,197 @@ describe('MetricFlowGraph Node Display', () => {
429
429
  expect(screen.getByText('num_repair_orders')).toBeInTheDocument();
430
430
  expect(screen.getByText('avg_repair_price')).toBeInTheDocument();
431
431
  });
432
+
433
+ describe('Branch Coverage - Edge Cases', () => {
434
+ it('handles grain group with empty grain array', () => {
435
+ const grainGroupsEmptyGrain = [
436
+ {
437
+ parent_name: 'default.orders',
438
+ grain: [], // Empty grain array
439
+ components: [{ name: 'count_orders', expression: 'COUNT(*)' }],
440
+ },
441
+ ];
442
+
443
+ render(
444
+ <MetricFlowGraph
445
+ grainGroups={grainGroupsEmptyGrain}
446
+ metricFormulas={[
447
+ {
448
+ name: 'default.metric',
449
+ short_name: 'metric',
450
+ components: ['count_orders'],
451
+ is_derived: false,
452
+ },
453
+ ]}
454
+ onNodeSelect={jest.fn()}
455
+ />,
456
+ );
457
+
458
+ expect(screen.getByTestId('react-flow')).toBeInTheDocument();
459
+ });
460
+
461
+ it('handles grain group with no components', () => {
462
+ const grainGroupsNoComponents = [
463
+ {
464
+ parent_name: 'default.orders',
465
+ grain: ['date_id'],
466
+ components: [], // Empty components
467
+ },
468
+ ];
469
+
470
+ render(
471
+ <MetricFlowGraph
472
+ grainGroups={grainGroupsNoComponents}
473
+ metricFormulas={[
474
+ {
475
+ name: 'default.metric',
476
+ short_name: 'metric',
477
+ components: [],
478
+ is_derived: false,
479
+ },
480
+ ]}
481
+ onNodeSelect={jest.fn()}
482
+ />,
483
+ );
484
+
485
+ expect(screen.getByTestId('react-flow')).toBeInTheDocument();
486
+ });
487
+
488
+ it('handles grain group with undefined components', () => {
489
+ const grainGroupsUndefinedComponents = [
490
+ {
491
+ parent_name: 'default.orders',
492
+ grain: ['date_id'],
493
+ // components is undefined
494
+ },
495
+ ];
496
+
497
+ render(
498
+ <MetricFlowGraph
499
+ grainGroups={grainGroupsUndefinedComponents}
500
+ metricFormulas={[
501
+ {
502
+ name: 'default.metric',
503
+ short_name: 'metric',
504
+ components: [],
505
+ is_derived: false,
506
+ },
507
+ ]}
508
+ onNodeSelect={jest.fn()}
509
+ />,
510
+ );
511
+
512
+ expect(screen.getByTestId('react-flow')).toBeInTheDocument();
513
+ });
514
+
515
+ it('handles metric with is_derived false', () => {
516
+ render(
517
+ <MetricFlowGraph
518
+ grainGroups={mockGrainGroups}
519
+ metricFormulas={[
520
+ {
521
+ name: 'default.simple_metric',
522
+ short_name: 'simple_metric',
523
+ combiner: 'SUM(count)',
524
+ is_derived: false,
525
+ components: ['count_orders'],
526
+ },
527
+ ]}
528
+ onNodeSelect={jest.fn()}
529
+ />,
530
+ );
531
+
532
+ expect(screen.getByText('simple_metric')).toBeInTheDocument();
533
+ });
534
+
535
+ it('handles metric with is_derived true', () => {
536
+ render(
537
+ <MetricFlowGraph
538
+ grainGroups={mockGrainGroups}
539
+ metricFormulas={[
540
+ {
541
+ name: 'default.derived_metric',
542
+ short_name: 'derived_metric',
543
+ combiner: 'SUM(a) / SUM(b)',
544
+ is_derived: true,
545
+ components: ['sum_revenue', 'count_orders'],
546
+ },
547
+ ]}
548
+ onNodeSelect={jest.fn()}
549
+ />,
550
+ );
551
+
552
+ expect(screen.getByText('derived_metric')).toBeInTheDocument();
553
+ });
554
+
555
+ it('handles selectedNode prop for preagg', () => {
556
+ render(
557
+ <MetricFlowGraph
558
+ grainGroups={mockGrainGroups}
559
+ metricFormulas={mockMetricFormulas}
560
+ onNodeSelect={jest.fn()}
561
+ selectedNode={{ type: 'preagg', index: 0, data: mockGrainGroups[0] }}
562
+ />,
563
+ );
564
+
565
+ expect(screen.getByTestId('react-flow')).toBeInTheDocument();
566
+ });
567
+
568
+ it('handles selectedNode prop for metric', () => {
569
+ render(
570
+ <MetricFlowGraph
571
+ grainGroups={mockGrainGroups}
572
+ metricFormulas={mockMetricFormulas}
573
+ onNodeSelect={jest.fn()}
574
+ selectedNode={{
575
+ type: 'metric',
576
+ index: 0,
577
+ data: mockMetricFormulas[0],
578
+ }}
579
+ />,
580
+ );
581
+
582
+ expect(screen.getByTestId('react-flow')).toBeInTheDocument();
583
+ });
584
+
585
+ it('handles no selectedNode', () => {
586
+ render(
587
+ <MetricFlowGraph
588
+ grainGroups={mockGrainGroups}
589
+ metricFormulas={mockMetricFormulas}
590
+ onNodeSelect={jest.fn()}
591
+ selectedNode={null}
592
+ />,
593
+ );
594
+
595
+ expect(screen.getByTestId('react-flow')).toBeInTheDocument();
596
+ });
597
+
598
+ it('handles grain group with undefined grain', () => {
599
+ const grainGroupsUndefinedGrain = [
600
+ {
601
+ parent_name: 'default.orders',
602
+ // grain is undefined
603
+ components: [{ name: 'count_orders', expression: 'COUNT(*)' }],
604
+ },
605
+ ];
606
+
607
+ render(
608
+ <MetricFlowGraph
609
+ grainGroups={grainGroupsUndefinedGrain}
610
+ metricFormulas={[
611
+ {
612
+ name: 'default.metric',
613
+ short_name: 'metric',
614
+ components: ['count_orders'],
615
+ is_derived: false,
616
+ },
617
+ ]}
618
+ onNodeSelect={jest.fn()}
619
+ />,
620
+ );
621
+
622
+ expect(screen.getByTestId('react-flow')).toBeInTheDocument();
623
+ });
624
+ });
432
625
  });
@@ -0,0 +1,388 @@
1
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
2
+ import { ResultsView } from '../ResultsView';
3
+
4
+ // Mock react-syntax-highlighter
5
+ jest.mock('react-syntax-highlighter', () => {
6
+ const MockLight = ({ children }) => (
7
+ <pre data-testid="syntax-highlighter">{children}</pre>
8
+ );
9
+ MockLight.registerLanguage = jest.fn();
10
+ return { Light: MockLight };
11
+ });
12
+ jest.mock('react-syntax-highlighter/src/styles/hljs', () => ({
13
+ foundation: {},
14
+ }));
15
+ jest.mock('react-syntax-highlighter/dist/esm/languages/hljs/sql', () => ({}));
16
+
17
+ // Mock clipboard API
18
+ const mockWriteText = jest.fn();
19
+ Object.assign(navigator, {
20
+ clipboard: {
21
+ writeText: mockWriteText,
22
+ },
23
+ });
24
+
25
+ describe('ResultsView', () => {
26
+ const defaultProps = {
27
+ sql: 'SELECT * FROM table',
28
+ results: {
29
+ results: [
30
+ {
31
+ columns: [
32
+ { name: 'id', type: 'INT' },
33
+ { name: 'name', type: 'STRING' },
34
+ ],
35
+ rows: [
36
+ [1, 'Alice'],
37
+ [2, 'Bob'],
38
+ ],
39
+ },
40
+ ],
41
+ },
42
+ loading: false,
43
+ error: null,
44
+ elapsedTime: 1.5,
45
+ onBackToPlan: jest.fn(),
46
+ selectedMetrics: ['metric1', 'metric2'],
47
+ selectedDimensions: ['dim1'],
48
+ filters: [],
49
+ dialect: 'SPARK',
50
+ cubeName: null,
51
+ availability: null,
52
+ };
53
+
54
+ beforeEach(() => {
55
+ jest.clearAllMocks();
56
+ });
57
+
58
+ describe('Header', () => {
59
+ it('renders back to plan button', () => {
60
+ render(<ResultsView {...defaultProps} />);
61
+ expect(screen.getByText('Back to Plan')).toBeInTheDocument();
62
+ });
63
+
64
+ it('calls onBackToPlan when back button is clicked', () => {
65
+ const onBackToPlan = jest.fn();
66
+ render(<ResultsView {...defaultProps} onBackToPlan={onBackToPlan} />);
67
+
68
+ fireEvent.click(screen.getByText('Back to Plan'));
69
+ expect(onBackToPlan).toHaveBeenCalled();
70
+ });
71
+
72
+ it('shows row count and elapsed time', () => {
73
+ render(<ResultsView {...defaultProps} />);
74
+ // Row count appears in both header and table section
75
+ expect(screen.getAllByText('2 rows').length).toBeGreaterThanOrEqual(1);
76
+ expect(screen.getByText('1.50s')).toBeInTheDocument();
77
+ });
78
+
79
+ it('shows loading state', () => {
80
+ render(<ResultsView {...defaultProps} loading={true} />);
81
+ expect(screen.getByText('Running query...')).toBeInTheDocument();
82
+ });
83
+
84
+ it('shows error state in header', () => {
85
+ render(<ResultsView {...defaultProps} error="Query timeout" />);
86
+ expect(screen.getByText('Query failed')).toBeInTheDocument();
87
+ });
88
+
89
+ it('handles null elapsedTime', () => {
90
+ render(<ResultsView {...defaultProps} elapsedTime={null} />);
91
+ expect(screen.getAllByText('2 rows').length).toBeGreaterThanOrEqual(1);
92
+ // Should not show elapsed time when null
93
+ expect(screen.queryByText(/^\d+\.\d+s$/)).not.toBeInTheDocument();
94
+ });
95
+ });
96
+
97
+ describe('SQL Pane', () => {
98
+ it('displays SQL query', () => {
99
+ render(<ResultsView {...defaultProps} />);
100
+ expect(screen.getByText('SELECT * FROM table')).toBeInTheDocument();
101
+ });
102
+
103
+ it('shows SQL Query title', () => {
104
+ render(<ResultsView {...defaultProps} />);
105
+ expect(screen.getByText('SQL Query')).toBeInTheDocument();
106
+ });
107
+
108
+ it('shows generating message when no SQL', () => {
109
+ render(<ResultsView {...defaultProps} sql={null} />);
110
+ expect(screen.getByText('Generating SQL...')).toBeInTheDocument();
111
+ });
112
+
113
+ it('renders copy button', () => {
114
+ render(<ResultsView {...defaultProps} />);
115
+ expect(screen.getByText('Copy')).toBeInTheDocument();
116
+ });
117
+
118
+ it('copies SQL to clipboard when copy button clicked', async () => {
119
+ render(<ResultsView {...defaultProps} />);
120
+
121
+ fireEvent.click(screen.getByText('Copy'));
122
+
123
+ expect(mockWriteText).toHaveBeenCalledWith('SELECT * FROM table');
124
+ expect(screen.getByText('✓ Copied')).toBeInTheDocument();
125
+ });
126
+
127
+ it('disables copy button when no SQL', () => {
128
+ render(<ResultsView {...defaultProps} sql={null} />);
129
+ expect(screen.getByText('Copy')).toBeDisabled();
130
+ });
131
+
132
+ it('shows materialization info when cubeName is provided', () => {
133
+ render(
134
+ <ResultsView
135
+ {...defaultProps}
136
+ cubeName="test_cube"
137
+ availability={{
138
+ catalog: 'catalog',
139
+ schema_: 'schema',
140
+ table: 'table',
141
+ validThroughTs: 1705363200000, // Jan 16, 2024
142
+ }}
143
+ />,
144
+ );
145
+
146
+ expect(screen.getByText(/Using materialized cube/)).toBeInTheDocument();
147
+ expect(screen.getByText(/Valid thru/)).toBeInTheDocument();
148
+ });
149
+
150
+ it('shows materialization info without availability date', () => {
151
+ render(<ResultsView {...defaultProps} cubeName="test_cube" />);
152
+
153
+ expect(screen.getByText(/Using materialized cube/)).toBeInTheDocument();
154
+ expect(screen.queryByText(/Valid thru/)).not.toBeInTheDocument();
155
+ });
156
+ });
157
+
158
+ describe('Loading State', () => {
159
+ it('shows loading spinner and message', () => {
160
+ render(<ResultsView {...defaultProps} loading={true} />);
161
+
162
+ expect(screen.getByText('Executing query...')).toBeInTheDocument();
163
+ expect(
164
+ screen.getByText(/Querying 2 metric\(s\) with 1 dimension\(s\)/),
165
+ ).toBeInTheDocument();
166
+ });
167
+ });
168
+
169
+ describe('Error State', () => {
170
+ it('shows error message', () => {
171
+ render(<ResultsView {...defaultProps} error="Connection failed" />);
172
+
173
+ expect(screen.getByText('Query Failed')).toBeInTheDocument();
174
+ expect(screen.getByText('Connection failed')).toBeInTheDocument();
175
+ });
176
+
177
+ it('shows back to plan button in error state', () => {
178
+ const onBackToPlan = jest.fn();
179
+ render(
180
+ <ResultsView
181
+ {...defaultProps}
182
+ error="Error"
183
+ onBackToPlan={onBackToPlan}
184
+ />,
185
+ );
186
+
187
+ // There are two back buttons - one in header, one in error state
188
+ const backButtons = screen.getAllByText('Back to Plan');
189
+ expect(backButtons.length).toBe(2);
190
+
191
+ fireEvent.click(backButtons[1]);
192
+ expect(onBackToPlan).toHaveBeenCalled();
193
+ });
194
+ });
195
+
196
+ describe('Results Table', () => {
197
+ it('renders table with columns and rows', () => {
198
+ render(<ResultsView {...defaultProps} />);
199
+
200
+ expect(screen.getByText('id')).toBeInTheDocument();
201
+ expect(screen.getByText('name')).toBeInTheDocument();
202
+ expect(screen.getByText('Alice')).toBeInTheDocument();
203
+ expect(screen.getByText('Bob')).toBeInTheDocument();
204
+ });
205
+
206
+ it('shows column types', () => {
207
+ render(<ResultsView {...defaultProps} />);
208
+
209
+ expect(screen.getByText('INT')).toBeInTheDocument();
210
+ expect(screen.getByText('STRING')).toBeInTheDocument();
211
+ });
212
+
213
+ it('shows row count in table header', () => {
214
+ render(<ResultsView {...defaultProps} />);
215
+
216
+ // Row count appears in both header and table section
217
+ const rowCounts = screen.getAllByText('2 rows');
218
+ expect(rowCounts.length).toBeGreaterThanOrEqual(1);
219
+ });
220
+
221
+ it('shows empty state when no results', () => {
222
+ render(
223
+ <ResultsView
224
+ {...defaultProps}
225
+ results={{ results: [{ columns: [], rows: [] }] }}
226
+ />,
227
+ );
228
+
229
+ expect(screen.getByText('No results returned')).toBeInTheDocument();
230
+ });
231
+
232
+ it('handles null values in cells', () => {
233
+ render(
234
+ <ResultsView
235
+ {...defaultProps}
236
+ results={{
237
+ results: [
238
+ {
239
+ columns: [{ name: 'value', type: 'STRING' }],
240
+ rows: [[null], ['data']],
241
+ },
242
+ ],
243
+ }}
244
+ />,
245
+ );
246
+
247
+ expect(screen.getByText('NULL')).toBeInTheDocument();
248
+ expect(screen.getByText('data')).toBeInTheDocument();
249
+ });
250
+
251
+ it('displays filters as chips', () => {
252
+ render(
253
+ <ResultsView
254
+ {...defaultProps}
255
+ filters={["date >= '2024-01-01'", "status = 'active'"]}
256
+ />,
257
+ );
258
+
259
+ expect(screen.getByText("date >= '2024-01-01'")).toBeInTheDocument();
260
+ expect(screen.getByText("status = 'active'")).toBeInTheDocument();
261
+ });
262
+ });
263
+
264
+ describe('Sorting', () => {
265
+ it('sorts by column when header is clicked', () => {
266
+ render(<ResultsView {...defaultProps} />);
267
+
268
+ // Click on 'name' column header to sort
269
+ const nameHeader = screen.getByText('name').closest('th');
270
+ fireEvent.click(nameHeader);
271
+
272
+ // First row should now be Alice (ascending)
273
+ const rows = screen.getAllByRole('row');
274
+ expect(rows[1]).toHaveTextContent('Alice');
275
+ });
276
+
277
+ it('toggles sort direction on second click', () => {
278
+ render(<ResultsView {...defaultProps} />);
279
+
280
+ const nameHeader = screen.getByText('name').closest('th');
281
+
282
+ // First click - ascending
283
+ fireEvent.click(nameHeader);
284
+ let rows = screen.getAllByRole('row');
285
+ expect(rows[1]).toHaveTextContent('Alice');
286
+
287
+ // Second click - descending
288
+ fireEvent.click(nameHeader);
289
+ rows = screen.getAllByRole('row');
290
+ expect(rows[1]).toHaveTextContent('Bob');
291
+ });
292
+
293
+ it('shows active sort indicator', () => {
294
+ render(<ResultsView {...defaultProps} />);
295
+
296
+ const nameHeader = screen.getByText('name').closest('th');
297
+ fireEvent.click(nameHeader);
298
+
299
+ expect(nameHeader).toHaveClass('sorted');
300
+ });
301
+
302
+ it('sorts numeric columns correctly', () => {
303
+ render(
304
+ <ResultsView
305
+ {...defaultProps}
306
+ results={{
307
+ results: [
308
+ {
309
+ columns: [{ name: 'count', type: 'INT' }],
310
+ rows: [[10], [2], [100]],
311
+ },
312
+ ],
313
+ }}
314
+ />,
315
+ );
316
+
317
+ const countHeader = screen.getByText('count').closest('th');
318
+ fireEvent.click(countHeader);
319
+
320
+ const cells = screen.getAllByRole('cell');
321
+ expect(cells[0]).toHaveTextContent('2');
322
+ expect(cells[1]).toHaveTextContent('10');
323
+ expect(cells[2]).toHaveTextContent('100');
324
+ });
325
+
326
+ it('handles null values in sorting - nulls go last', () => {
327
+ render(
328
+ <ResultsView
329
+ {...defaultProps}
330
+ results={{
331
+ results: [
332
+ {
333
+ columns: [{ name: 'value', type: 'STRING' }],
334
+ rows: [[null], ['b'], ['a']],
335
+ },
336
+ ],
337
+ }}
338
+ />,
339
+ );
340
+
341
+ const valueHeader = screen.getByText('value').closest('th');
342
+ fireEvent.click(valueHeader);
343
+
344
+ const cells = screen.getAllByRole('cell');
345
+ expect(cells[0]).toHaveTextContent('a');
346
+ expect(cells[1]).toHaveTextContent('b');
347
+ expect(cells[2]).toHaveTextContent('NULL');
348
+ });
349
+ });
350
+
351
+ describe('Edge Cases', () => {
352
+ it('handles missing results gracefully', () => {
353
+ render(<ResultsView {...defaultProps} results={null} />);
354
+
355
+ // Row count appears in both header and table section
356
+ expect(screen.getAllByText('0 rows').length).toBeGreaterThanOrEqual(1);
357
+ });
358
+
359
+ it('handles empty results object', () => {
360
+ render(<ResultsView {...defaultProps} results={{}} />);
361
+
362
+ // Row count appears in both header and table section
363
+ expect(screen.getAllByText('0 rows').length).toBeGreaterThanOrEqual(1);
364
+ });
365
+
366
+ it('formats large row counts with locale', () => {
367
+ const manyRows = Array.from({ length: 1000 }, (_, i) => [i]);
368
+ render(
369
+ <ResultsView
370
+ {...defaultProps}
371
+ results={{
372
+ results: [
373
+ {
374
+ columns: [{ name: 'id', type: 'INT' }],
375
+ rows: manyRows,
376
+ },
377
+ ],
378
+ }}
379
+ />,
380
+ );
381
+
382
+ // Should show "1,000 rows" with locale formatting (appears in both header and table)
383
+ expect(screen.getAllByText('1,000 rows').length).toBeGreaterThanOrEqual(
384
+ 1,
385
+ );
386
+ });
387
+ });
388
+ });