latency-chart.test.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. /**
  2. * @vitest-environment happy-dom
  3. */
  4. import type { ReactNode } from "react";
  5. import { act } from "react";
  6. import { createRoot } from "react-dom/client";
  7. import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
  8. // Mock next-intl
  9. vi.mock("next-intl", () => ({
  10. useTranslations: () => (key: string) => key,
  11. useTimeZone: () => "UTC",
  12. }));
  13. // Mock recharts to expose color props via data-* attributes
  14. vi.mock("recharts", async (importOriginal) => {
  15. const React = await import("react");
  16. const actual = await importOriginal<typeof import("recharts")>();
  17. return {
  18. ...actual,
  19. ResponsiveContainer: ({ children }: { children: ReactNode }) =>
  20. React.createElement("div", { "data-testid": "recharts-responsive" }, children),
  21. AreaChart: ({ children, data }: { children: ReactNode; data: unknown[] }) =>
  22. React.createElement(
  23. "div",
  24. { "data-testid": "recharts-areachart", "data-points": data?.length || 0 },
  25. children
  26. ),
  27. Area: ({ stroke, fill, dataKey }: { stroke: string; fill: string; dataKey: string }) =>
  28. React.createElement("div", {
  29. "data-testid": `recharts-area-${dataKey}`,
  30. "data-stroke": stroke,
  31. "data-fill": fill,
  32. }),
  33. CartesianGrid: () => null,
  34. XAxis: () => null,
  35. YAxis: () => null,
  36. };
  37. });
  38. // Mock chart.tsx to expose ChartStyle for testing
  39. vi.mock("@/components/ui/chart", async () => {
  40. const React = await import("react");
  41. const actual =
  42. await vi.importActual<typeof import("@/components/ui/chart")>("@/components/ui/chart");
  43. return {
  44. ...actual,
  45. ChartContainer: ({
  46. children,
  47. config,
  48. className,
  49. }: {
  50. children: ReactNode;
  51. config: Record<string, { color?: string; label?: string }>;
  52. className?: string;
  53. }) =>
  54. React.createElement(
  55. "div",
  56. {
  57. "data-testid": "chart-container",
  58. "data-config": JSON.stringify(config),
  59. className,
  60. },
  61. children
  62. ),
  63. ChartTooltip: () => null,
  64. };
  65. });
  66. import { LatencyChart } from "@/app/[locale]/dashboard/availability/_components/provider/latency-chart";
  67. import type { ProviderAvailabilitySummary } from "@/lib/availability";
  68. const mockProviders: ProviderAvailabilitySummary[] = [
  69. {
  70. provider: "test-provider",
  71. uptime: 99.5,
  72. totalRequests: 100,
  73. successRequests: 99,
  74. failedRequests: 1,
  75. avgLatencyMs: 150,
  76. p50LatencyMs: 120,
  77. p95LatencyMs: 200,
  78. p99LatencyMs: 350,
  79. timeBuckets: [
  80. {
  81. bucketStart: "2024-01-01T10:00:00Z",
  82. totalRequests: 50,
  83. successRequests: 49,
  84. failedRequests: 1,
  85. avgLatencyMs: 140,
  86. p50LatencyMs: 110,
  87. p95LatencyMs: 190,
  88. p99LatencyMs: 340,
  89. },
  90. {
  91. bucketStart: "2024-01-01T11:00:00Z",
  92. totalRequests: 50,
  93. successRequests: 50,
  94. failedRequests: 0,
  95. avgLatencyMs: 160,
  96. p50LatencyMs: 130,
  97. p95LatencyMs: 210,
  98. p99LatencyMs: 360,
  99. },
  100. ],
  101. },
  102. ];
  103. function renderComponent(providers: ProviderAvailabilitySummary[]) {
  104. const container = document.createElement("div");
  105. document.body.appendChild(container);
  106. const root = createRoot(container);
  107. act(() => {
  108. root.render(<LatencyChart providers={providers} />);
  109. });
  110. return {
  111. container,
  112. unmount: () => {
  113. act(() => root.unmount());
  114. container.remove();
  115. },
  116. };
  117. }
  118. describe("LatencyChart color bindings", () => {
  119. beforeEach(() => {
  120. document.body.innerHTML = "";
  121. });
  122. afterEach(() => {
  123. vi.clearAllMocks();
  124. });
  125. test("ChartConfig uses var(--chart-*) without hsl wrapper", () => {
  126. const { container, unmount } = renderComponent(mockProviders);
  127. const chartContainer = container.querySelector('[data-testid="chart-container"]');
  128. expect(chartContainer).toBeTruthy();
  129. const configStr = chartContainer?.getAttribute("data-config");
  130. expect(configStr).toBeTruthy();
  131. const config = JSON.parse(configStr!);
  132. // Colors should use var(--chart-*) directly, NOT hsl(var(--chart-*))
  133. expect(config.p50.color).toBe("var(--chart-2)");
  134. expect(config.p95.color).toBe("var(--chart-4)");
  135. expect(config.p99.color).toBe("var(--chart-1)");
  136. // Ensure no hsl wrapper
  137. expect(config.p50.color).not.toMatch(/^hsl\(/);
  138. expect(config.p95.color).not.toMatch(/^hsl\(/);
  139. expect(config.p99.color).not.toMatch(/^hsl\(/);
  140. unmount();
  141. });
  142. test("Area stroke uses var(--color-<key>) CSS variable", () => {
  143. const { container, unmount } = renderComponent(mockProviders);
  144. const areaP50 = container.querySelector('[data-testid="recharts-area-p50"]');
  145. const areaP95 = container.querySelector('[data-testid="recharts-area-p95"]');
  146. const areaP99 = container.querySelector('[data-testid="recharts-area-p99"]');
  147. expect(areaP50).toBeTruthy();
  148. expect(areaP95).toBeTruthy();
  149. expect(areaP99).toBeTruthy();
  150. // Stroke should use var(--color-<key>) injected by ChartContainer
  151. expect(areaP50?.getAttribute("data-stroke")).toBe("var(--color-p50)");
  152. expect(areaP95?.getAttribute("data-stroke")).toBe("var(--color-p95)");
  153. expect(areaP99?.getAttribute("data-stroke")).toBe("var(--color-p99)");
  154. unmount();
  155. });
  156. test("Area fill references gradient with correct ID pattern", () => {
  157. const { container, unmount } = renderComponent(mockProviders);
  158. const areaP50 = container.querySelector('[data-testid="recharts-area-p50"]');
  159. const areaP95 = container.querySelector('[data-testid="recharts-area-p95"]');
  160. const areaP99 = container.querySelector('[data-testid="recharts-area-p99"]');
  161. // Fill should reference gradient URL
  162. expect(areaP50?.getAttribute("data-fill")).toMatch(/url\(#fillP50\)/);
  163. expect(areaP95?.getAttribute("data-fill")).toMatch(/url\(#fillP95\)/);
  164. expect(areaP99?.getAttribute("data-fill")).toMatch(/url\(#fillP99\)/);
  165. unmount();
  166. });
  167. test("renders no data message when providers have no time buckets with requests", () => {
  168. const emptyProviders: ProviderAvailabilitySummary[] = [
  169. {
  170. provider: "empty-provider",
  171. uptime: 0,
  172. totalRequests: 0,
  173. successRequests: 0,
  174. failedRequests: 0,
  175. avgLatencyMs: 0,
  176. p50LatencyMs: 0,
  177. p95LatencyMs: 0,
  178. p99LatencyMs: 0,
  179. timeBuckets: [],
  180. },
  181. ];
  182. const { container, unmount } = renderComponent(emptyProviders);
  183. // Should show no data message
  184. expect(container.textContent).toContain("noData");
  185. // Should not render chart
  186. expect(container.querySelector('[data-testid="chart-container"]')).toBeNull();
  187. unmount();
  188. });
  189. });