Skip to content

Commit f967426

Browse files
committed
test(dashboard): add tests for table rendering, interval computation, and bar charts
Export periodToSeconds and computeOptimalInterval for direct testing. Add API tests covering period parsing, interval selection for different periods/widths, and edge cases. Add formatter tests for right-aligned numeric columns, compact number formatting, cell truncation, column expansion, unit formatting, full-width bar rendering, and stacked multi-series bar charts.
1 parent d370085 commit f967426

File tree

3 files changed

+336
-2
lines changed

3 files changed

+336
-2
lines changed

src/lib/api/dashboards.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ const PERIOD_UNITS: Record<string, number> = {
173173
const PERIOD_RE = /^(\d+)([smhdw])$/;
174174

175175
/** Parse a Sentry period string (e.g., "24h", "7d") into seconds. */
176-
function periodToSeconds(period: string): number | undefined {
176+
export function periodToSeconds(period: string): number | undefined {
177177
const match = PERIOD_RE.exec(period);
178178
if (!match) {
179179
return;
@@ -199,7 +199,7 @@ const VALID_INTERVALS = ["1m", "5m", "15m", "30m", "1h", "4h", "12h", "1d"];
199199
* width in terminal columns. Picks the largest valid Sentry interval that
200200
* produces at least `chartWidth` data points, ensuring barWidth stays at 1.
201201
*/
202-
function computeOptimalInterval(
202+
export function computeOptimalInterval(
203203
statsPeriod: string,
204204
widget: DashboardWidget
205205
): string | undefined {

test/lib/api/dashboards.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* Dashboard API helper tests
3+
*
4+
* Tests for periodToSeconds and computeOptimalInterval from
5+
* src/lib/api/dashboards.ts.
6+
*/
7+
8+
import { describe, expect, test } from "bun:test";
9+
import {
10+
computeOptimalInterval,
11+
periodToSeconds,
12+
} from "../../../src/lib/api/dashboards.js";
13+
import type { DashboardWidget } from "../../../src/types/dashboard.js";
14+
15+
// ---------------------------------------------------------------------------
16+
// periodToSeconds
17+
// ---------------------------------------------------------------------------
18+
19+
describe("periodToSeconds", () => {
20+
test("parses seconds", () => {
21+
expect(periodToSeconds("30s")).toBe(30);
22+
expect(periodToSeconds("1s")).toBe(1);
23+
});
24+
25+
test("parses minutes", () => {
26+
expect(periodToSeconds("1m")).toBe(60);
27+
expect(periodToSeconds("5m")).toBe(300);
28+
expect(periodToSeconds("30m")).toBe(1800);
29+
});
30+
31+
test("parses hours", () => {
32+
expect(periodToSeconds("1h")).toBe(3600);
33+
expect(periodToSeconds("24h")).toBe(86_400);
34+
expect(periodToSeconds("4h")).toBe(14_400);
35+
});
36+
37+
test("parses days", () => {
38+
expect(periodToSeconds("1d")).toBe(86_400);
39+
expect(periodToSeconds("7d")).toBe(604_800);
40+
expect(periodToSeconds("14d")).toBe(1_209_600);
41+
expect(periodToSeconds("90d")).toBe(7_776_000);
42+
});
43+
44+
test("parses weeks", () => {
45+
expect(periodToSeconds("1w")).toBe(604_800);
46+
expect(periodToSeconds("2w")).toBe(1_209_600);
47+
});
48+
49+
test("returns undefined for invalid input", () => {
50+
expect(periodToSeconds("")).toBeUndefined();
51+
expect(periodToSeconds("abc")).toBeUndefined();
52+
expect(periodToSeconds("24")).toBeUndefined();
53+
expect(periodToSeconds("h")).toBeUndefined();
54+
expect(periodToSeconds("24x")).toBeUndefined();
55+
});
56+
});
57+
58+
// ---------------------------------------------------------------------------
59+
// computeOptimalInterval
60+
// ---------------------------------------------------------------------------
61+
62+
/** Build a minimal widget with optional layout width. */
63+
function makeWidget(layoutW?: number, interval?: string): DashboardWidget {
64+
return {
65+
title: "Test Widget",
66+
displayType: "line",
67+
layout:
68+
layoutW !== undefined ? { x: 0, y: 0, w: layoutW, h: 2 } : undefined,
69+
interval,
70+
} as DashboardWidget;
71+
}
72+
73+
describe("computeOptimalInterval", () => {
74+
test("returns a valid Sentry interval string", () => {
75+
const validIntervals = new Set([
76+
"1m",
77+
"5m",
78+
"15m",
79+
"30m",
80+
"1h",
81+
"4h",
82+
"12h",
83+
"1d",
84+
]);
85+
const result = computeOptimalInterval("24h", makeWidget(2));
86+
expect(result).toBeDefined();
87+
expect(validIntervals.has(result!)).toBe(true);
88+
});
89+
90+
test("shorter periods produce finer intervals", () => {
91+
const interval24h = computeOptimalInterval("24h", makeWidget(2));
92+
const interval7d = computeOptimalInterval("7d", makeWidget(2));
93+
const interval90d = computeOptimalInterval("90d", makeWidget(2));
94+
95+
// Convert back to seconds for comparison
96+
const sec24h = periodToSeconds(interval24h!) ?? 0;
97+
const sec7d = periodToSeconds(interval7d!) ?? 0;
98+
const sec90d = periodToSeconds(interval90d!) ?? 0;
99+
100+
expect(sec24h).toBeLessThanOrEqual(sec7d);
101+
expect(sec7d).toBeLessThanOrEqual(sec90d);
102+
});
103+
104+
test("wider widgets produce finer intervals", () => {
105+
const narrow = computeOptimalInterval("24h", makeWidget(1));
106+
const wide = computeOptimalInterval("24h", makeWidget(6));
107+
108+
const secNarrow = periodToSeconds(narrow!) ?? 0;
109+
const secWide = periodToSeconds(wide!) ?? 0;
110+
111+
// Wider widget has more columns → finer interval
112+
expect(secWide).toBeLessThanOrEqual(secNarrow);
113+
});
114+
115+
test("falls back to widget interval for invalid period", () => {
116+
const widget = makeWidget(2, "5m");
117+
expect(computeOptimalInterval("invalid", widget)).toBe("5m");
118+
});
119+
120+
test("falls back to widget interval when no layout", () => {
121+
const widget = makeWidget(undefined, "15m");
122+
// Without layout, uses default GRID_COLS (full width) — still computes
123+
const result = computeOptimalInterval("24h", widget);
124+
expect(result).toBeDefined();
125+
});
126+
127+
test("never returns undefined for valid periods", () => {
128+
const periods = ["1h", "24h", "7d", "14d", "90d"];
129+
for (const period of periods) {
130+
const result = computeOptimalInterval(period, makeWidget(2));
131+
expect(result).toBeDefined();
132+
}
133+
});
134+
135+
test("returns 1m as floor for very short periods", () => {
136+
// 1h / ~40 cols ≈ 90s → should pick "1m" (finest available)
137+
const result = computeOptimalInterval("1h", makeWidget(2));
138+
expect(result).toBe("1m");
139+
});
140+
});

test/lib/formatters/dashboard.test.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,70 @@ describe("formatDashboardWithData", () => {
595595
const output = formatDashboardWithData(data);
596596
expect(output).toContain("(no data)");
597597
});
598+
599+
test("bar chart fills full widget width (no trailing gap)", () => {
600+
// Create enough data points that bars should fill the chart area.
601+
// With a tall widget (h=3), the renderer uses bar mode (not sparkline).
602+
const values = Array.from({ length: 20 }, (_, i) => ({
603+
timestamp: 1_700_000_000 + i * 3600,
604+
value: 10 + (i % 5) * 10,
605+
}));
606+
const data = makeDashboardData({
607+
widgets: [
608+
makeWidget({
609+
displayType: "bar",
610+
layout: { x: 0, y: 0, w: 2, h: 3 },
611+
data: makeTimeseriesData({
612+
series: [{ label: "count()", values }],
613+
}),
614+
}),
615+
],
616+
});
617+
const output = formatDashboardWithData(data);
618+
// The bars should contain block characters
619+
expect(output).toContain("█");
620+
// Should have Y-axis tick marks
621+
expect(output).toContain("┤");
622+
});
623+
624+
test("stacked bar chart renders with multiple series", () => {
625+
const timestamps = Array.from({ length: 10 }, (_, i) => ({
626+
timestamp: 1_700_000_000 + i * 3600,
627+
value: 0,
628+
}));
629+
const data = makeDashboardData({
630+
widgets: [
631+
makeWidget({
632+
displayType: "bar",
633+
layout: { x: 0, y: 0, w: 2, h: 3 },
634+
data: makeTimeseriesData({
635+
series: [
636+
{
637+
label: "series-a",
638+
values: timestamps.map((t, i) => ({
639+
...t,
640+
value: 10 + i * 5,
641+
})),
642+
},
643+
{
644+
label: "series-b",
645+
values: timestamps.map((t, i) => ({
646+
...t,
647+
value: 5 + i * 2,
648+
})),
649+
},
650+
],
651+
}),
652+
}),
653+
],
654+
});
655+
const output = formatDashboardWithData(data);
656+
// Should render bar blocks
657+
expect(output).toContain("█");
658+
// Legend for multi-series
659+
expect(output).toContain("series-a");
660+
expect(output).toContain("series-b");
661+
});
598662
});
599663

600664
describe("bar widget (rendered mode colors)", () => {
@@ -701,6 +765,136 @@ describe("formatDashboardWithData", () => {
701765
const output = formatDashboardWithData(data);
702766
expect(output).toContain("(no data)");
703767
});
768+
769+
test("right-aligns numeric columns", () => {
770+
const data = makeDashboardData({
771+
widgets: [
772+
makeWidget({
773+
displayType: "table",
774+
data: makeTableData({
775+
columns: [
776+
{ name: "endpoint", type: "string" },
777+
{ name: "count", type: "integer" },
778+
],
779+
rows: [
780+
{ endpoint: "/api/a", count: 100 },
781+
{ endpoint: "/api/b", count: 5 },
782+
],
783+
}),
784+
}),
785+
],
786+
});
787+
const output = formatDashboardWithData(data);
788+
// Numeric column header and values should appear
789+
expect(output).toContain("COUNT");
790+
expect(output).toContain("100");
791+
expect(output).toContain("5");
792+
// Separator line with ─ should be present
793+
expect(output).toContain("\u2500");
794+
});
795+
796+
test("uses compact numbers when columns overflow", () => {
797+
const data = makeDashboardData({
798+
widgets: [
799+
makeWidget({
800+
displayType: "table",
801+
// Very narrow widget
802+
layout: { x: 0, y: 0, w: 1, h: 2 },
803+
data: makeTableData({
804+
columns: [
805+
{ name: "very_long_column_name_here", type: "string" },
806+
{ name: "another_long_column_count", type: "integer" },
807+
],
808+
rows: [
809+
{
810+
very_long_column_name_here: "long-value-text-here",
811+
another_long_column_count: 1_500_000,
812+
},
813+
],
814+
}),
815+
}),
816+
],
817+
});
818+
const output = formatDashboardWithData(data);
819+
// Should use compact notation (1.5M instead of 1,500,000)
820+
expect(output).toContain("1.5M");
821+
});
822+
823+
test("truncates long cell values with ellipsis", () => {
824+
const data = makeDashboardData({
825+
widgets: [
826+
makeWidget({
827+
displayType: "table",
828+
layout: { x: 0, y: 0, w: 1, h: 2 },
829+
data: makeTableData({
830+
columns: [
831+
{ name: "name", type: "string" },
832+
{ name: "very_long_description_column", type: "string" },
833+
],
834+
rows: [
835+
{
836+
name: "short",
837+
very_long_description_column:
838+
"this is a very long description that should be truncated",
839+
},
840+
],
841+
}),
842+
}),
843+
],
844+
});
845+
const output = formatDashboardWithData(data);
846+
// Should contain ellipsis from truncation
847+
expect(output).toContain("\u2026");
848+
});
849+
850+
test("expands last column to fill widget width", () => {
851+
const data = makeDashboardData({
852+
widgets: [
853+
makeWidget({
854+
displayType: "table",
855+
// Full-width widget
856+
layout: { x: 0, y: 0, w: 6, h: 2 },
857+
data: makeTableData({
858+
columns: [{ name: "id" }, { name: "val" }],
859+
rows: [{ id: "a", val: 1 }],
860+
}),
861+
}),
862+
],
863+
});
864+
const output = formatDashboardWithData(data);
865+
expect(output).toContain("ID");
866+
expect(output).toContain("VAL");
867+
});
868+
869+
test("formats number units correctly", () => {
870+
const data = makeDashboardData({
871+
widgets: [
872+
makeWidget({
873+
displayType: "table",
874+
data: makeTableData({
875+
columns: [
876+
{ name: "endpoint" },
877+
{ name: "duration", unit: "millisecond" },
878+
{ name: "size", unit: "byte" },
879+
{ name: "latency", unit: "second" },
880+
],
881+
rows: [
882+
{
883+
endpoint: "/api",
884+
duration: 250,
885+
size: 1024,
886+
latency: 3,
887+
},
888+
],
889+
}),
890+
}),
891+
],
892+
});
893+
const output = formatDashboardWithData(data);
894+
expect(output).toContain("250ms");
895+
expect(output).toContain("1,024B");
896+
expect(output).toContain("3s");
897+
});
704898
});
705899

706900
describe("big number widget (plain mode)", () => {

0 commit comments

Comments
 (0)