|
| 1 | +// @vitest-environment jsdom |
| 2 | +import { act, createElement, type ReactNode } from "react"; |
| 3 | +import { createRoot, type Root } from "react-dom/client"; |
| 4 | +import { renderToStaticMarkup } from "react-dom/server"; |
| 5 | +import { afterEach, describe, expect, it, vi } from "vitest"; |
| 6 | +import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon"; |
| 7 | +import type { AlertDetail } from "~/presenters/v3/AlertDetailPresenter.server"; |
| 8 | +import { OperatingSystemContextProvider } from "~/components/primitives/OperatingSystemProvider"; |
| 9 | +import { ShortcutsProvider } from "~/components/primitives/ShortcutsProvider"; |
| 10 | +import { AlertDetailChart } from "./AlertDetailChart"; |
| 11 | +import { AREA_OPTIONS } from "./AlertsList"; |
| 12 | +import { WARNING_COLOR } from "./ChartLegend"; |
| 13 | +import { AREA_ICON, AREA_LABEL, isAlertArea } from "./domainIcons"; |
| 14 | +import type { MetricSeriesPoint } from "./alertSeriesTypes"; |
| 15 | +import { defaultGroupSelection } from "./queryGroupSelection"; |
| 16 | +import type * as AlertThresholdChartModule from "./AlertThresholdChart"; |
| 17 | + |
| 18 | +const { assignGroupColors, AlertMultiSeriesChart, SERIES_COLORS, thresholdChartLegend } = |
| 19 | + await vi.importActual<typeof AlertThresholdChartModule>("./AlertThresholdChart"); |
| 20 | + |
| 21 | +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; |
| 22 | +globalThis.ResizeObserver ??= class { |
| 23 | + observe() {} |
| 24 | + unobserve() {} |
| 25 | + disconnect() {} |
| 26 | +}; |
| 27 | + |
| 28 | +vi.mock("~/components/primitives/charts/ChartCompound", () => ({ |
| 29 | + Chart: { |
| 30 | + Root: ({ |
| 31 | + children, |
| 32 | + series, |
| 33 | + config, |
| 34 | + }: { |
| 35 | + children: ReactNode; |
| 36 | + series: string[]; |
| 37 | + config: Record<string, { color?: string }>; |
| 38 | + }) => |
| 39 | + createElement( |
| 40 | + "div", |
| 41 | + { |
| 42 | + "data-series": series.join(","), |
| 43 | + "data-colors": Object.values(config) |
| 44 | + .map((c) => c.color) |
| 45 | + .join(","), |
| 46 | + }, |
| 47 | + children |
| 48 | + ), |
| 49 | + Line: ({ warningOverlay }: { warningOverlay?: unknown }) => |
| 50 | + createElement("i", { "data-overlay": JSON.stringify(warningOverlay) }), |
| 51 | + }, |
| 52 | +})); |
| 53 | +vi.mock("~/components/primitives/DateTime", () => ({ useUserTimeZone: () => "UTC" })); |
| 54 | +vi.mock("~/components/runs/v3/SharedFilters", () => ({ TimeFilter: () => null })); |
| 55 | +vi.mock("~/components/primitives/DetailChartBlock", () => ({ |
| 56 | + DetailChartBlock: ({ children }: { children: ReactNode }) => children, |
| 57 | +})); |
| 58 | +vi.mock("./PreviewChartCard", () => ({ |
| 59 | + PreviewChartCard: ({ accessory, children }: { accessory?: ReactNode; children: ReactNode }) => |
| 60 | + createElement("div", null, accessory, children), |
| 61 | +})); |
| 62 | +vi.mock("./ActivityBars", async (importOriginal) => ({ |
| 63 | + ...(await importOriginal<object>()), |
| 64 | + ActivityBars: ({ data }: { data: unknown[] }) => |
| 65 | + createElement("i", { "data-chart": "bars", "data-points": data.length }), |
| 66 | +})); |
| 67 | +vi.mock("./AlertThresholdChart", async (importOriginal) => ({ |
| 68 | + ...(await importOriginal<object>()), |
| 69 | + AlertMultiSeriesChart: ({ seriesMap }: { seriesMap: Map<string, unknown> }) => |
| 70 | + createElement("i", { "data-chart": "multi", "data-keys": [...seriesMap.keys()].join(",") }), |
| 71 | +})); |
| 72 | + |
| 73 | +const HOUR = 60 * 60 * 1000; |
| 74 | +const T0 = Date.UTC(2026, 0, 1); |
| 75 | + |
| 76 | +const alert = { |
| 77 | + kind: "threshold", |
| 78 | + capabilityTitle: "Custom", |
| 79 | + capabilityKey: "custom.query", |
| 80 | + operator: ">", |
| 81 | + thresholdValue: 10, |
| 82 | + scope: { type: "all" }, |
| 83 | + status: { status: "active" }, |
| 84 | + episode: { raisedAt: new Date(T0), outcome: "raised" }, |
| 85 | + events: [], |
| 86 | + currentReading: { status: "unavailable" }, |
| 87 | +} as unknown as AlertDetail; |
| 88 | + |
| 89 | +const points = (...values: number[]): MetricSeriesPoint[] => |
| 90 | + values.map((value, i) => ({ bucket: T0 + i * HOUR, value })); |
| 91 | + |
| 92 | +// g8 and g9 cross the threshold; the others stay below and rise with their index |
| 93 | +const groups = Array.from({ length: 10 }, (_, i) => `g${i}`); |
| 94 | +const seriesMap = new Map(groups.map((g, i) => [g, points(i >= 8 ? 50 + i : i, 1)])); |
| 95 | + |
| 96 | +let root: Root | undefined; |
| 97 | +let container: HTMLElement | undefined; |
| 98 | + |
| 99 | +afterEach(() => { |
| 100 | + act(() => root?.unmount()); |
| 101 | + container?.remove(); |
| 102 | + document.body.innerHTML = ""; |
| 103 | +}); |
| 104 | + |
| 105 | +function mount(props: Record<string, unknown> = {}) { |
| 106 | + container = document.createElement("div"); |
| 107 | + document.body.appendChild(container); |
| 108 | + root = createRoot(container); |
| 109 | + render(props); |
| 110 | + return container; |
| 111 | +} |
| 112 | + |
| 113 | +function render(props: Record<string, unknown> = {}) { |
| 114 | + act(() => |
| 115 | + root!.render( |
| 116 | + createElement( |
| 117 | + OperatingSystemContextProvider, |
| 118 | + { platform: "mac" }, |
| 119 | + createElement( |
| 120 | + ShortcutsProvider, |
| 121 | + null, |
| 122 | + createElement(AlertDetailChart, { |
| 123 | + alert, |
| 124 | + metricSeriesMap: seriesMap, |
| 125 | + healthBands: [], |
| 126 | + window: { since: new Date(T0), until: new Date(T0 + 4 * HOUR) }, |
| 127 | + now: new Date(T0 + 4 * HOUR), |
| 128 | + queryGroups: groups, |
| 129 | + querySeriesMode: "threshold", |
| 130 | + ...props, |
| 131 | + } as never) |
| 132 | + ) |
| 133 | + ) |
| 134 | + ) |
| 135 | + ); |
| 136 | +} |
| 137 | + |
| 138 | +const keys = (c: HTMLElement) => |
| 139 | + c.querySelector("[data-chart=multi]")!.getAttribute("data-keys")!.split(","); |
| 140 | + |
| 141 | +describe("defaultGroupSelection", () => { |
| 142 | + it("puts crossed groups first, then closest to the threshold, capped at 8", () => { |
| 143 | + const picked = defaultGroupSelection({ groups, seriesMap, operator: ">" }); |
| 144 | + expect(picked).toEqual(["g9", "g8", "g7", "g6", "g5", "g4", "g3", "g2"]); |
| 145 | + }); |
| 146 | + |
| 147 | + it("orders toward the threshold for a below-threshold operator", () => { |
| 148 | + const picked = defaultGroupSelection({ |
| 149 | + groups: ["a", "b", "c"], |
| 150 | + seriesMap: new Map([ |
| 151 | + ["a", points(5)], |
| 152 | + ["b", points(1)], |
| 153 | + ["c", points(3)], |
| 154 | + ]), |
| 155 | + operator: "<", |
| 156 | + }); |
| 157 | + expect(picked).toEqual(["b", "c", "a"]); |
| 158 | + }); |
| 159 | +}); |
| 160 | + |
| 161 | +describe("AlertDetailChart query groups", () => { |
| 162 | + it("renders at most 8 series by default, crossed groups first", () => { |
| 163 | + const c = mount(); |
| 164 | + expect(keys(c)).toEqual(["g9", "g8", "g7", "g6", "g5", "g4", "g3", "g2"]); |
| 165 | + expect(c.textContent).toContain("Groups 8/10"); |
| 166 | + }); |
| 167 | + |
| 168 | + it("hides a series when its group is toggled off and searches the list", () => { |
| 169 | + const c = mount(); |
| 170 | + act(() => c.querySelector<HTMLElement>("button")!.click()); |
| 171 | + |
| 172 | + const search = document.querySelector<HTMLInputElement>("input")!; |
| 173 | + expect(search.placeholder).toContain("Up to 8 visible"); |
| 174 | + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; |
| 175 | + const type = (value: string) => |
| 176 | + act(() => { |
| 177 | + setter.call(search, value); |
| 178 | + search.dispatchEvent(new Event("input", { bubbles: true })); |
| 179 | + }); |
| 180 | + const rows = () => [...document.querySelectorAll('[role="option"]')].map((o) => o.textContent); |
| 181 | + const g0 = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find( |
| 182 | + (o) => o.textContent === "g0" |
| 183 | + )!; |
| 184 | + expect(g0.getAttribute("aria-disabled")).toBe("true"); |
| 185 | + type("g1"); |
| 186 | + expect(rows()).toEqual(["g1"]); |
| 187 | + |
| 188 | + type(""); |
| 189 | + const row = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find( |
| 190 | + (o) => o.textContent === "g9" |
| 191 | + )!; |
| 192 | + act(() => row.click()); |
| 193 | + expect(keys(c)).not.toContain("g9"); |
| 194 | + expect(c.textContent).toContain("Groups 7/10"); |
| 195 | + }); |
| 196 | + |
| 197 | + it("shows the picker only with at least two groups", () => { |
| 198 | + expect(mount({ queryGroups: ["g9"] }).textContent).not.toContain("Groups"); |
| 199 | + act(() => root?.unmount()); |
| 200 | + container?.remove(); |
| 201 | + expect(mount({ queryGroups: ["g9", "g8"] }).textContent).toContain("Groups 2/2"); |
| 202 | + act(() => container!.querySelector<HTMLElement>("button")!.click()); |
| 203 | + expect(document.querySelector<HTMLInputElement>("input")!.placeholder).not.toContain("Up to"); |
| 204 | + }); |
| 205 | + |
| 206 | + it("shows the only remaining group even after it was deselected", () => { |
| 207 | + const c = mount({ queryGroups: ["g9", "g8"] }); |
| 208 | + act(() => c.querySelector<HTMLElement>("button")!.click()); |
| 209 | + const g9 = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find( |
| 210 | + (o) => o.textContent === "g9" |
| 211 | + )!; |
| 212 | + act(() => g9.click()); |
| 213 | + expect(keys(c)).toEqual(["g8"]); |
| 214 | + |
| 215 | + render({ queryGroups: ["g9"] }); |
| 216 | + expect(keys(c)).toEqual(["g9"]); |
| 217 | + expect(c.textContent).not.toContain("Groups"); |
| 218 | + }); |
| 219 | + |
| 220 | + it("renders the occurrence bar chart in match mode", () => { |
| 221 | + const c = mount({ |
| 222 | + querySeriesMode: "match", |
| 223 | + metricSeriesMap: new Map([["__agg__", points(2, 3, 4)]]), |
| 224 | + }); |
| 225 | + expect(c.querySelector("[data-chart=bars]")!.getAttribute("data-points")).toBe("3"); |
| 226 | + expect(c.querySelector("[data-chart=multi]")).toBeNull(); |
| 227 | + }); |
| 228 | +}); |
| 229 | + |
| 230 | +describe("Watching area for custom queries", () => { |
| 231 | + it("shows Custom with the Query page icon", () => { |
| 232 | + expect(AREA_LABEL.query).toBe("Custom"); |
| 233 | + expect(AREA_ICON.query).toBe(CodeSquareIcon); |
| 234 | + expect(isAlertArea("query")).toBe(true); |
| 235 | + expect(AREA_OPTIONS).toContainEqual({ value: "query", label: "Custom" }); |
| 236 | + }); |
| 237 | +}); |
| 238 | + |
| 239 | +function hue(hex: string): number { |
| 240 | + const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) as [ |
| 241 | + number, |
| 242 | + number, |
| 243 | + number, |
| 244 | + ]; |
| 245 | + const max = Math.max(r, g, b); |
| 246 | + const d = max - Math.min(r, g, b); |
| 247 | + if (d === 0) return 0; |
| 248 | + const h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4; |
| 249 | + return (h * 60 + 360) % 360; |
| 250 | +} |
| 251 | + |
| 252 | +describe("grouped chart threshold highlight", () => { |
| 253 | + it("keeps yellow and amber out of the series colours", () => { |
| 254 | + expect(SERIES_COLORS).toHaveLength(8); |
| 255 | + for (const color of SERIES_COLORS) { |
| 256 | + const h = hue(color); |
| 257 | + expect(h < 20 || h > 75, color).toBe(true); |
| 258 | + } |
| 259 | + }); |
| 260 | + |
| 261 | + it("applies the warning overlay to the chart holding every group", () => { |
| 262 | + const html = renderToStaticMarkup( |
| 263 | + createElement(AlertMultiSeriesChart, { |
| 264 | + seriesMap: new Map([ |
| 265 | + ["a", points(1, 20, 25)], |
| 266 | + ["b", points(2, 30, 15)], |
| 267 | + ]), |
| 268 | + threshold: 10, |
| 269 | + operator: ">", |
| 270 | + }) |
| 271 | + ); |
| 272 | + expect(html).toContain('data-series="a,b"'); |
| 273 | + expect(html).toContain(""direction":"above""); |
| 274 | + expect(html).toContain("var(--color-warning)"); |
| 275 | + }); |
| 276 | +}); |
| 277 | + |
| 278 | +describe("assignGroupColors", () => { |
| 279 | + const keys = Array.from({ length: 12 }, (_, i) => `g${i}`); |
| 280 | + |
| 281 | + it("gives 8 visible groups 8 distinct colours", () => { |
| 282 | + const colors = assignGroupColors(new Map(), keys.slice(0, 8)); |
| 283 | + expect(new Set(colors.values()).size).toBe(8); |
| 284 | + }); |
| 285 | + |
| 286 | + it("stays distinct for 12 groups with 8 selected", () => { |
| 287 | + const colors = assignGroupColors(new Map(), keys.slice(2, 10)); |
| 288 | + expect(colors.size).toBe(8); |
| 289 | + expect(new Set(colors.values()).size).toBe(8); |
| 290 | + }); |
| 291 | + |
| 292 | + it("keeps untouched colours when one is toggled off and another on", () => { |
| 293 | + const first = assignGroupColors(new Map(), keys.slice(0, 8)); |
| 294 | + const next = assignGroupColors(first, [...keys.slice(1, 8), "g10"]); |
| 295 | + expect(new Set(next.values()).size).toBe(8); |
| 296 | + for (const key of keys.slice(1, 8)) expect(next.get(key)).toBe(first.get(key)); |
| 297 | + expect(next.get("g10")).toBe(first.get("g0")); |
| 298 | + }); |
| 299 | +}); |
| 300 | + |
| 301 | +describe("grouped chart legend", () => { |
| 302 | + const seriesMap = new Map([ |
| 303 | + ["a", points(1, 20)], |
| 304 | + ["b", points(2, 30)], |
| 305 | + ]); |
| 306 | + |
| 307 | + it("lists every colour it draws, warning entry included", () => { |
| 308 | + const input = { seriesMap, label: "x", threshold: 10, operator: ">" as const }; |
| 309 | + const legend = thresholdChartLegend(input); |
| 310 | + expect(legend.map((i) => i.label)).toEqual(["a", "b", "Above threshold"]); |
| 311 | + expect(legend.map((i) => i.color)).toEqual([SERIES_COLORS[0], SERIES_COLORS[1], WARNING_COLOR]); |
| 312 | + }); |
| 313 | + |
| 314 | + it("omits it without a threshold", () => { |
| 315 | + const labels = thresholdChartLegend({ seriesMap, label: "x", threshold: null }).map( |
| 316 | + (i) => i.label |
| 317 | + ); |
| 318 | + expect(labels).toEqual(["a", "b"]); |
| 319 | + }); |
| 320 | + |
| 321 | + it("keeps a lone group's assigned colour in the line and the legend", () => { |
| 322 | + const colors = new Map([["b", SERIES_COLORS[1]!]]); |
| 323 | + const single = new Map([["b", points(2, 30)]]); |
| 324 | + const html = renderToStaticMarkup( |
| 325 | + createElement(AlertMultiSeriesChart, { seriesMap: single, threshold: 10, colors }) |
| 326 | + ); |
| 327 | + expect(html).toContain(`data-colors="${SERIES_COLORS[1]}"`); |
| 328 | + const legend = thresholdChartLegend({ seriesMap: single, label: "x", threshold: null, colors }); |
| 329 | + expect(legend.map((i) => i.color)).toEqual([SERIES_COLORS[1]]); |
| 330 | + }); |
| 331 | +}); |
0 commit comments