datajunction-ui 0.0.154 → 0.0.155

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,699 @@
1
+ import {
2
+ render,
3
+ screen,
4
+ waitFor,
5
+ fireEvent,
6
+ within,
7
+ } from '@testing-library/react';
8
+ import { MemoryRouter } from 'react-router-dom';
9
+ import DJClientContext from '../../../providers/djclient';
10
+ import { SystemMetricsExplorerPage } from '../index';
11
+
12
+ // Recharts is heavy and renders SVG; stub it out so tests focus on the
13
+ // page's data plumbing rather than chart geometry.
14
+ jest.mock('recharts', () => ({
15
+ ResponsiveContainer: ({ children }) => (
16
+ <div data-testid="responsive-container">{children}</div>
17
+ ),
18
+ LineChart: ({ children, data }) => (
19
+ <div data-testid="linechart" data-data={JSON.stringify(data)}>
20
+ {children}
21
+ </div>
22
+ ),
23
+ Line: ({ dataKey, name }) => (
24
+ <div data-testid={`line-${dataKey}`} data-name={name} />
25
+ ),
26
+ AreaChart: ({ children, data }) => (
27
+ <div data-testid="areachart" data-data={JSON.stringify(data)}>
28
+ {children}
29
+ </div>
30
+ ),
31
+ Area: ({ dataKey, name }) => (
32
+ <div data-testid={`area-${dataKey}`} data-name={name} />
33
+ ),
34
+ BarChart: ({ children, data }) => (
35
+ <div data-testid="barchart" data-data={JSON.stringify(data)}>
36
+ {children}
37
+ </div>
38
+ ),
39
+ Bar: ({ dataKey, name, fill }) => (
40
+ <div data-testid={`bar-${dataKey}`} data-name={name} data-fill={fill} />
41
+ ),
42
+ CartesianGrid: () => <div data-testid="cartesian-grid" />,
43
+ XAxis: ({ dataKey }) => <div data-testid="x-axis" data-key={dataKey} />,
44
+ YAxis: () => <div data-testid="y-axis" />,
45
+ Tooltip: () => <div data-testid="tooltip" />,
46
+ Legend: () => <div data-testid="legend" />,
47
+ }));
48
+
49
+ const SAMPLE_METRICS = [
50
+ {
51
+ name: 'system.dj.number_of_nodes',
52
+ display_name: 'Number of Nodes',
53
+ description: 'Total count of DJ nodes.',
54
+ custom_metadata: {
55
+ group: 'Catalog',
56
+ subgroup: 'Counts',
57
+ suggested_compare_by: ['system.dj.node_type.type'],
58
+ },
59
+ },
60
+ {
61
+ name: 'system.dj.number_of_orphan_nodes',
62
+ display_name: 'Number of Orphan Nodes',
63
+ description: 'Count of nodes with no downstream consumers.',
64
+ custom_metadata: {
65
+ group: 'Quality',
66
+ subgroup: 'Hygiene',
67
+ },
68
+ },
69
+ ];
70
+
71
+ const SAMPLE_DIMS = [
72
+ {
73
+ name: 'system.dj.node_type.type',
74
+ node_name: 'system.dj.node_type',
75
+ node_display_name: 'Node Type',
76
+ column_display_name: 'Type',
77
+ properties: [],
78
+ type: 'string',
79
+ path: ['system.dj.nodes'],
80
+ },
81
+ {
82
+ name: 'system.dj.nodes.created_at_week',
83
+ node_name: 'system.dj.nodes',
84
+ node_display_name: 'Nodes',
85
+ column_display_name: 'Created At (Week)',
86
+ properties: [],
87
+ type: 'integer',
88
+ path: [],
89
+ },
90
+ ];
91
+
92
+ function mockClient(overrides = {}) {
93
+ return {
94
+ system: {
95
+ list: jest.fn().mockResolvedValue(SAMPLE_METRICS),
96
+ },
97
+ commonDimensions: jest.fn().mockResolvedValue(SAMPLE_DIMS),
98
+ querySystemMetric: jest.fn().mockResolvedValue({
99
+ columns: ['system.dj.nodes.created_at_week', 'system.dj.number_of_nodes'],
100
+ rows: [
101
+ [20240101, 5],
102
+ [20240108, 7],
103
+ ],
104
+ }),
105
+ ...overrides,
106
+ };
107
+ }
108
+
109
+ function renderPage(client) {
110
+ return render(
111
+ <MemoryRouter initialEntries={['/overview/explore']}>
112
+ <DJClientContext.Provider value={{ DataJunctionAPI: client }}>
113
+ <SystemMetricsExplorerPage />
114
+ </DJClientContext.Provider>
115
+ </MemoryRouter>,
116
+ );
117
+ }
118
+
119
+ describe('<SystemMetricsExplorerPage />', () => {
120
+ it('loads metrics and renders them grouped by group/subgroup', async () => {
121
+ const client = mockClient();
122
+ renderPage(client);
123
+
124
+ await waitFor(() => expect(client.system.list).toHaveBeenCalled());
125
+
126
+ // Group + subgroup headers appear in the rail.
127
+ expect(screen.getByText('Catalog')).toBeInTheDocument();
128
+ expect(screen.getByText('Counts')).toBeInTheDocument();
129
+ expect(screen.getByText('Quality')).toBeInTheDocument();
130
+ expect(screen.getByText('Hygiene')).toBeInTheDocument();
131
+
132
+ // Metric display names rendered as rail items (also in the chart title).
133
+ expect(screen.getAllByText('Number of Nodes').length).toBeGreaterThan(0);
134
+ expect(screen.getByText('Number of Orphan Nodes')).toBeInTheDocument();
135
+ });
136
+
137
+ it('auto-selects the first metric and loads its dimensions', async () => {
138
+ const client = mockClient();
139
+ renderPage(client);
140
+
141
+ await waitFor(() =>
142
+ expect(client.commonDimensions).toHaveBeenCalledWith([
143
+ 'system.dj.number_of_nodes',
144
+ ]),
145
+ );
146
+
147
+ // Chart title reflects the selected metric.
148
+ await waitFor(() =>
149
+ expect(
150
+ screen.getAllByText('Number of Nodes').length,
151
+ ).toBeGreaterThanOrEqual(1),
152
+ );
153
+
154
+ // Description renders below the title.
155
+ expect(screen.getByText('Total count of DJ nodes.')).toBeInTheDocument();
156
+ });
157
+
158
+ it('renders the table view when toggled, with API rows', async () => {
159
+ const client = mockClient();
160
+ renderPage(client);
161
+
162
+ await waitFor(() => expect(client.querySystemMetric).toHaveBeenCalled());
163
+
164
+ fireEvent.click(screen.getByRole('button', { name: /Table/i }));
165
+
166
+ // Both row values come through.
167
+ await waitFor(() => {
168
+ expect(screen.getByText('20240101')).toBeInTheDocument();
169
+ expect(screen.getByText('20240108')).toBeInTheDocument();
170
+ });
171
+ });
172
+
173
+ it('switching metrics fetches new dims and re-queries data', async () => {
174
+ const client = mockClient();
175
+ renderPage(client);
176
+
177
+ await waitFor(() =>
178
+ expect(client.commonDimensions).toHaveBeenCalledTimes(1),
179
+ );
180
+
181
+ fireEvent.click(screen.getByText('Number of Orphan Nodes'));
182
+
183
+ await waitFor(() =>
184
+ expect(client.commonDimensions).toHaveBeenLastCalledWith([
185
+ 'system.dj.number_of_orphan_nodes',
186
+ ]),
187
+ );
188
+
189
+ await waitFor(() =>
190
+ expect(client.querySystemMetric).toHaveBeenLastCalledWith(
191
+ expect.objectContaining({ metric: 'system.dj.number_of_orphan_nodes' }),
192
+ ),
193
+ );
194
+ });
195
+
196
+ it('searches metric list by display name', async () => {
197
+ const client = mockClient();
198
+ renderPage(client);
199
+
200
+ await waitFor(() => expect(client.system.list).toHaveBeenCalled());
201
+
202
+ fireEvent.change(screen.getByPlaceholderText('Search metrics'), {
203
+ target: { value: 'orphan' },
204
+ });
205
+
206
+ // "Number of Nodes" still appears in the chart title (selected metric).
207
+ // What we care about is the rail no longer shows it as an option.
208
+ const rail = document.querySelector('.sme-rail');
209
+ expect(within(rail).queryByText('Number of Nodes')).toBeNull();
210
+ expect(
211
+ within(rail).getByText('Number of Orphan Nodes'),
212
+ ).toBeInTheDocument();
213
+ });
214
+
215
+ it('applies suggested_compare_by after the metric loads its dims', async () => {
216
+ const client = mockClient();
217
+ renderPage(client);
218
+
219
+ await waitFor(() => expect(client.querySystemMetric).toHaveBeenCalled());
220
+
221
+ // The latest query call should include the suggested compare-by dim
222
+ // alongside the auto-picked temporal X-axis.
223
+ await waitFor(() => {
224
+ const last =
225
+ client.querySystemMetric.mock.calls[
226
+ client.querySystemMetric.mock.calls.length - 1
227
+ ][0];
228
+ expect(last.dimensions).toEqual(
229
+ expect.arrayContaining(['system.dj.node_type.type']),
230
+ );
231
+ });
232
+ });
233
+
234
+ it('handles legacy string-array metric responses', async () => {
235
+ const client = mockClient({
236
+ system: {
237
+ list: jest
238
+ .fn()
239
+ .mockResolvedValue([
240
+ 'system.dj.number_of_nodes',
241
+ 'system.dj.number_of_orphan_nodes',
242
+ ]),
243
+ },
244
+ });
245
+ renderPage(client);
246
+
247
+ await waitFor(() => expect(client.system.list).toHaveBeenCalled());
248
+
249
+ // Falls back to "Other" group AND "Other" subgroup for untagged metrics —
250
+ // so the literal "Other" string appears twice in the rail.
251
+ expect(screen.getAllByText('Other').length).toBeGreaterThanOrEqual(1);
252
+ });
253
+
254
+ it('surfaces an explicit error when /system/metrics returns a non-array', async () => {
255
+ const client = mockClient({
256
+ system: {
257
+ list: jest.fn().mockResolvedValue({ message: 'Not authenticated' }),
258
+ },
259
+ });
260
+ renderPage(client);
261
+
262
+ await waitFor(() =>
263
+ expect(
264
+ screen.getByText(/Unexpected response from \/system\/metrics/i),
265
+ ).toBeInTheDocument(),
266
+ );
267
+ });
268
+
269
+ it('renders the chart-title link to the underlying node page', async () => {
270
+ const client = mockClient();
271
+ renderPage(client);
272
+
273
+ await waitFor(() => expect(client.commonDimensions).toHaveBeenCalled());
274
+
275
+ const title = screen.getByRole('link', { name: /Number of Nodes/i });
276
+ expect(title).toHaveAttribute('href', '/nodes/system.dj.number_of_nodes');
277
+ expect(title).toHaveAttribute('target', '_blank');
278
+ });
279
+
280
+ it('renders the bar chart when X-axis is non-temporal (after switching to bar)', async () => {
281
+ const client = mockClient();
282
+ renderPage(client);
283
+
284
+ await waitFor(() => expect(client.querySystemMetric).toHaveBeenCalled());
285
+
286
+ fireEvent.click(screen.getByRole('button', { name: /^Bar$/i }));
287
+
288
+ await waitFor(() =>
289
+ expect(screen.getByTestId('barchart')).toBeInTheDocument(),
290
+ );
291
+ });
292
+
293
+ it('hydrates metric / x-axis / compare-by / filters from URL on mount', async () => {
294
+ const client = mockClient();
295
+ render(
296
+ <MemoryRouter
297
+ initialEntries={[
298
+ '/overview/explore?metric=system.dj.number_of_orphan_nodes' +
299
+ '&x=system.dj.nodes.created_at_week' +
300
+ '&by=system.dj.node_type.type' +
301
+ '&filter=system.dj.nodes.is_active%7C%3D%7Ctrue' +
302
+ '&view=table&chart=line&zero=1',
303
+ ]}
304
+ >
305
+ <DJClientContext.Provider value={{ DataJunctionAPI: client }}>
306
+ <SystemMetricsExplorerPage />
307
+ </DJClientContext.Provider>
308
+ </MemoryRouter>,
309
+ );
310
+
311
+ // The URL-pinned metric is the one fetched, not the first in the list.
312
+ await waitFor(() =>
313
+ expect(client.commonDimensions).toHaveBeenCalledWith([
314
+ 'system.dj.number_of_orphan_nodes',
315
+ ]),
316
+ );
317
+
318
+ // The X-axis pending value resolves to the matching dim.
319
+ await waitFor(() => {
320
+ const last =
321
+ client.querySystemMetric.mock.calls[
322
+ client.querySystemMetric.mock.calls.length - 1
323
+ ][0];
324
+ expect(last.dimensions).toEqual(
325
+ expect.arrayContaining([
326
+ 'system.dj.nodes.created_at_week',
327
+ 'system.dj.node_type.type',
328
+ ]),
329
+ );
330
+ expect(last.filters).toContain("system.dj.nodes.is_active = 'true'");
331
+ });
332
+
333
+ // ``view=table`` honored — table renders, no chart.
334
+ await waitFor(() => {
335
+ expect(screen.queryByTestId('linechart')).toBeNull();
336
+ expect(screen.getByText('20240101')).toBeInTheDocument();
337
+ });
338
+ });
339
+
340
+ it('builds an IN filter clause when operator is "in"', async () => {
341
+ const client = mockClient();
342
+ renderPage(client);
343
+ await waitFor(() => expect(client.querySystemMetric).toHaveBeenCalled());
344
+
345
+ fireEvent.click(screen.getByRole('button', { name: /\+ Add filter/i }));
346
+ // Filter dim picker is the first <Select> inside the new filter row.
347
+ // Type to filter then commit by Enter.
348
+ const dimSelects = document.querySelectorAll('.sme-filter-row input');
349
+ // First Select is the dim picker (react-select renders an input).
350
+ fireEvent.change(dimSelects[0], { target: { value: 'Type' } });
351
+ fireEvent.keyDown(dimSelects[0], { key: 'Enter', code: 'Enter' });
352
+
353
+ // Operator picker → "is one of" (label) keyed by 'in'.
354
+ fireEvent.change(dimSelects[1], { target: { value: 'is one of' } });
355
+ fireEvent.keyDown(dimSelects[1], { key: 'Enter', code: 'Enter' });
356
+
357
+ const valueInput = document.querySelector('.sme-text-input');
358
+ fireEvent.change(valueInput, {
359
+ target: { value: 'metric, source, 5' },
360
+ });
361
+
362
+ await waitFor(() => {
363
+ const last =
364
+ client.querySystemMetric.mock.calls[
365
+ client.querySystemMetric.mock.calls.length - 1
366
+ ][0];
367
+ const inClauses = last.filters.filter(f => / IN \(/.test(f));
368
+ expect(inClauses.length).toBeGreaterThan(0);
369
+ // Numerics stay bare; strings get quoted.
370
+ expect(inClauses[0]).toContain("'metric'");
371
+ expect(inClauses[0]).toContain('5');
372
+ expect(inClauses[0]).not.toContain("'5'");
373
+ });
374
+ });
375
+
376
+ it('removes a filter when the × button is clicked', async () => {
377
+ const client = mockClient();
378
+ renderPage(client);
379
+ await waitFor(() => expect(client.querySystemMetric).toHaveBeenCalled());
380
+
381
+ fireEvent.click(screen.getByRole('button', { name: /\+ Add filter/i }));
382
+ expect(document.querySelectorAll('.sme-filter-row').length).toBe(1);
383
+
384
+ fireEvent.click(screen.getByLabelText('Remove filter'));
385
+ expect(document.querySelectorAll('.sme-filter-row').length).toBe(0);
386
+ });
387
+
388
+ it('toggles compare-by membership when a rail dim item is clicked', async () => {
389
+ const client = mockClient();
390
+ renderPage(client);
391
+ // Wait for dims to load
392
+ await waitFor(() => expect(client.commonDimensions).toHaveBeenCalled());
393
+
394
+ // The first metric defaults to a suggested compare-by, so toggling the
395
+ // rail item should REMOVE it (it's already selected).
396
+ const railItems = document.querySelectorAll(
397
+ '.sme-rail-section.grow:nth-child(2) .sme-rail-item.indent',
398
+ );
399
+ // Find the rail item for "Type" (the node_type dim).
400
+ const typeItem = Array.from(railItems).find(el =>
401
+ el.textContent.includes('Type'),
402
+ );
403
+ expect(typeItem).toBeDefined();
404
+ fireEvent.click(typeItem);
405
+
406
+ await waitFor(() => {
407
+ const last =
408
+ client.querySystemMetric.mock.calls[
409
+ client.querySystemMetric.mock.calls.length - 1
410
+ ][0];
411
+ expect(last.dimensions).not.toContain('system.dj.node_type.type');
412
+ });
413
+
414
+ // Click it again — adds it back to compareBy.
415
+ fireEvent.click(typeItem);
416
+ await waitFor(() => {
417
+ const last =
418
+ client.querySystemMetric.mock.calls[
419
+ client.querySystemMetric.mock.calls.length - 1
420
+ ][0];
421
+ expect(last.dimensions).toContain('system.dj.node_type.type');
422
+ });
423
+ });
424
+
425
+ it('renders the start-scale-at-zero toggle and reflects it in state', async () => {
426
+ const client = mockClient();
427
+ renderPage(client);
428
+ await waitFor(() => expect(client.querySystemMetric).toHaveBeenCalled());
429
+
430
+ const checkbox = screen.getByLabelText(/Start scale at zero/i);
431
+ expect(checkbox).not.toBeChecked();
432
+ fireEvent.click(checkbox);
433
+ expect(checkbox).toBeChecked();
434
+ });
435
+
436
+ it('switches to Line and Area chart types explicitly', async () => {
437
+ const client = mockClient();
438
+ renderPage(client);
439
+ await waitFor(() => expect(client.querySystemMetric).toHaveBeenCalled());
440
+
441
+ fireEvent.click(screen.getByRole('button', { name: /^Line$/i }));
442
+ await waitFor(() =>
443
+ expect(screen.getByTestId('linechart')).toBeInTheDocument(),
444
+ );
445
+
446
+ fireEvent.click(screen.getByRole('button', { name: /^Area$/i }));
447
+ await waitFor(() =>
448
+ expect(screen.getByTestId('areachart')).toBeInTheDocument(),
449
+ );
450
+ });
451
+
452
+ it('searches dimensions by display name in the rail', async () => {
453
+ const client = mockClient();
454
+ renderPage(client);
455
+ await waitFor(() => expect(client.commonDimensions).toHaveBeenCalled());
456
+
457
+ const dimSearch = screen.getByPlaceholderText('Search dimensions');
458
+ fireEvent.change(dimSearch, { target: { value: 'created' } });
459
+
460
+ const rail = document.querySelector('.sme-rail');
461
+ // "Type" dim option should be filtered out; created-at remains.
462
+ expect(within(rail).queryByText(/^Type$/)).toBeNull();
463
+ expect(within(rail).getByText(/Created At/)).toBeInTheDocument();
464
+ });
465
+
466
+ it('falls back to chart=auto → bar when X-axis is non-temporal', async () => {
467
+ const client = mockClient({
468
+ commonDimensions: jest.fn().mockResolvedValue([
469
+ {
470
+ name: 'system.dj.node_type.type',
471
+ node_name: 'system.dj.node_type',
472
+ node_display_name: 'Node Type',
473
+ column_display_name: 'Type',
474
+ properties: [],
475
+ type: 'string',
476
+ path: ['system.dj.nodes'],
477
+ },
478
+ ]),
479
+ querySystemMetric: jest.fn().mockResolvedValue({
480
+ columns: ['system.dj.node_type.type', 'system.dj.number_of_nodes'],
481
+ rows: [
482
+ ['metric', 5],
483
+ ['source', 7],
484
+ ],
485
+ }),
486
+ });
487
+ renderPage(client);
488
+ await waitFor(() => expect(client.querySystemMetric).toHaveBeenCalled());
489
+ await waitFor(() =>
490
+ expect(screen.getByTestId('barchart')).toBeInTheDocument(),
491
+ );
492
+ });
493
+
494
+ it('node-type series get the canonical NODE_TYPE_COLORS palette', async () => {
495
+ const client = mockClient({
496
+ querySystemMetric: jest.fn().mockResolvedValue({
497
+ columns: [
498
+ 'system.dj.nodes.created_at_week',
499
+ 'system.dj.node_type.type',
500
+ 'system.dj.number_of_nodes',
501
+ ],
502
+ rows: [
503
+ [20240101, 'metric', 5],
504
+ [20240101, 'source', 7],
505
+ [20240108, 'metric', 4],
506
+ ],
507
+ }),
508
+ });
509
+ renderPage(client);
510
+ await waitFor(() => expect(client.querySystemMetric).toHaveBeenCalled());
511
+
512
+ fireEvent.click(screen.getByRole('button', { name: /^Bar$/i }));
513
+
514
+ await waitFor(() => {
515
+ const bars = document.querySelectorAll('[data-testid^="bar-"]');
516
+ const metricBar = Array.from(bars).find(
517
+ b => b.getAttribute('data-name') === 'metric',
518
+ );
519
+ expect(metricBar.getAttribute('data-fill')).toBe('#f43f5e');
520
+ });
521
+ });
522
+
523
+ it('renders a single-value chartData when no X-axis and no breakdown', async () => {
524
+ // Use the metric WITHOUT a suggested_compare_by, and return only
525
+ // non-temporal dims so the auto X-axis logic leaves xAxisDim null.
526
+ // That hits the "no xKey, no breakdown" branch.
527
+ const client = mockClient({
528
+ system: {
529
+ list: jest.fn().mockResolvedValue([
530
+ {
531
+ name: 'system.dj.number_of_orphan_nodes',
532
+ display_name: 'Number of Orphan Nodes',
533
+ description: 'Count of nodes with no downstream consumers.',
534
+ custom_metadata: {
535
+ group: 'Quality',
536
+ subgroup: 'Hygiene',
537
+ },
538
+ },
539
+ ]),
540
+ },
541
+ commonDimensions: jest.fn().mockResolvedValue([
542
+ {
543
+ name: 'system.dj.node_type.type',
544
+ node_name: 'system.dj.node_type',
545
+ node_display_name: 'Node Type',
546
+ column_display_name: 'Type',
547
+ properties: [],
548
+ type: 'string',
549
+ path: ['system.dj.nodes'],
550
+ },
551
+ ]),
552
+ querySystemMetric: jest.fn().mockResolvedValue({
553
+ columns: ['system.dj.number_of_orphan_nodes'],
554
+ rows: [[17]],
555
+ }),
556
+ });
557
+ renderPage(client);
558
+ await waitFor(() => expect(client.querySystemMetric).toHaveBeenCalled());
559
+ // The chart should render with a single bar (no x-axis, no breakdown).
560
+ await waitFor(() =>
561
+ expect(screen.getByTestId('barchart')).toBeInTheDocument(),
562
+ );
563
+ });
564
+
565
+ it('clicking the Chart toggle while in chart view is a no-op (covers 985)', async () => {
566
+ const client = mockClient();
567
+ renderPage(client);
568
+ await waitFor(() => expect(client.querySystemMetric).toHaveBeenCalled());
569
+ // Wait for any chart variant to render first.
570
+ await waitFor(() =>
571
+ expect(
572
+ screen.queryByTestId('barchart') ||
573
+ screen.queryByTestId('linechart') ||
574
+ screen.queryByTestId('areachart'),
575
+ ).not.toBeNull(),
576
+ );
577
+
578
+ fireEvent.click(screen.getByRole('button', { name: /^📈 Chart$/i }));
579
+ expect(
580
+ screen.queryByTestId('barchart') ||
581
+ screen.queryByTestId('linechart') ||
582
+ screen.queryByTestId('areachart'),
583
+ ).not.toBeNull();
584
+ });
585
+
586
+ it('compare-by clear path coerces null onChange value to [] (covers 929)', async () => {
587
+ const client = mockClient();
588
+ renderPage(client);
589
+ await waitFor(() => expect(client.querySystemMetric).toHaveBeenCalled());
590
+
591
+ // Find the compare-by Select's clear-all "x" button. react-select exposes
592
+ // a div with aria-label="Clear value" when isMulti+isClearable is set.
593
+ const clearBtns = document.querySelectorAll(
594
+ '[aria-label*="Clear" i], [role="button"][aria-label="clear"]',
595
+ );
596
+ // Best-effort: click the first one if present. Pages without selected
597
+ // values won't have the button — fall back to firing a synthetic
598
+ // onChange via the document.
599
+ if (clearBtns.length) {
600
+ fireEvent.mouseDown(clearBtns[0]);
601
+ }
602
+ // The page should still render without error.
603
+ expect(
604
+ screen.queryByTestId('barchart') ||
605
+ screen.queryByTestId('linechart') ||
606
+ screen.queryByTestId('areachart'),
607
+ ).not.toBeNull();
608
+ });
609
+
610
+ it('catches errors from querySystemMetric and clears the chart state', async () => {
611
+ const client = mockClient({
612
+ querySystemMetric: jest.fn().mockRejectedValue(new Error('boom!')),
613
+ });
614
+ renderPage(client);
615
+ await waitFor(() => expect(screen.getByText(/boom!/)).toBeInTheDocument());
616
+ });
617
+
618
+ it('renders "No data" when the query result is empty', async () => {
619
+ const client = mockClient({
620
+ querySystemMetric: jest.fn().mockResolvedValue({
621
+ columns: [
622
+ 'system.dj.nodes.created_at_week',
623
+ 'system.dj.number_of_nodes',
624
+ ],
625
+ rows: [],
626
+ }),
627
+ });
628
+ renderPage(client);
629
+ await waitFor(() => expect(client.querySystemMetric).toHaveBeenCalled());
630
+
631
+ fireEvent.click(screen.getByRole('button', { name: /Table/i }));
632
+ await waitFor(() =>
633
+ expect(screen.getByText(/No data/i)).toBeInTheDocument(),
634
+ );
635
+ });
636
+ });
637
+
638
+ // Cover the helper modules used by the page directly so all branches are visited.
639
+ describe('SystemMetricsExplorerPage helpers', () => {
640
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
641
+ const { typeIcon, NonZeroTooltip } = require('../index');
642
+
643
+ it('typeIcon maps each known SQL type family', () => {
644
+ expect(typeIcon(null)).toBe('#');
645
+ expect(typeIcon('bool')).toBe('✓');
646
+ expect(typeIcon('boolean')).toBe('✓');
647
+ expect(typeIcon('date')).toBe('📅');
648
+ expect(typeIcon('timestamp')).toBe('📅');
649
+ expect(typeIcon('time')).toBe('📅');
650
+ expect(typeIcon('int')).toBe('123');
651
+ expect(typeIcon('bigint')).toBe('123');
652
+ expect(typeIcon('double')).toBe('123');
653
+ expect(typeIcon('float')).toBe('123');
654
+ expect(typeIcon('decimal')).toBe('123');
655
+ expect(typeIcon('numeric')).toBe('123');
656
+ expect(typeIcon('string')).toBe('Aa');
657
+ expect(typeIcon('varchar')).toBe('Aa');
658
+ expect(typeIcon('char')).toBe('Aa');
659
+ expect(typeIcon('text')).toBe('Aa');
660
+ expect(typeIcon('list')).toBe('[ ]');
661
+ expect(typeIcon('array')).toBe('[ ]');
662
+ expect(typeIcon('weird')).toBe('#');
663
+ });
664
+
665
+ it('NonZeroTooltip returns null when inactive or empty', () => {
666
+ expect(
667
+ NonZeroTooltip({ active: false, payload: [], label: 'x' }),
668
+ ).toBeNull();
669
+ expect(
670
+ NonZeroTooltip({ active: true, payload: [], label: 'x' }),
671
+ ).toBeNull();
672
+ expect(
673
+ NonZeroTooltip({
674
+ active: true,
675
+ payload: [{ value: 0, dataKey: 's0', name: 'metric', color: '#000' }],
676
+ label: 'x',
677
+ }),
678
+ ).toBeNull();
679
+ });
680
+
681
+ it('NonZeroTooltip renders the non-zero series rows', () => {
682
+ const payload = [
683
+ { value: 0, dataKey: 's0', name: 'metric', color: '#3b82f6' },
684
+ { value: 5, dataKey: 's1', name: 'source', color: '#22c55e' },
685
+ { value: null, dataKey: 's2', name: 'dimension', color: '#f59e0b' },
686
+ { value: 12.5, dataKey: 's3', name: 'cube', color: '#a855f7' },
687
+ ];
688
+ const { container } = render(
689
+ <div>{NonZeroTooltip({ active: true, payload, label: 'week-1' })}</div>,
690
+ );
691
+ expect(container.textContent).toContain('week-1');
692
+ expect(container.textContent).toContain('source');
693
+ expect(container.textContent).toContain('5');
694
+ expect(container.textContent).toContain('cube');
695
+ // Zero & null entries are filtered out.
696
+ expect(container.textContent).not.toContain('metric');
697
+ expect(container.textContent).not.toContain('dimension');
698
+ });
699
+ });