|
1 | 1 | /* @vitest-environment jsdom */ |
2 | 2 |
|
3 | 3 | import { render } from "lit"; |
4 | | -import { describe, expect, it } from "vitest"; |
5 | | -import { renderSessionsCard, renderUsageInsights } from "./usage-render-overview.ts"; |
6 | | -import type { UsageAggregates, UsageSessionEntry, UsageTotals } from "./usageTypes.ts"; |
| 4 | +import { afterEach, describe, expect, it, vi } from "vitest"; |
| 5 | +import { |
| 6 | + renderDailyChartCompact, |
| 7 | + renderSessionsCard, |
| 8 | + renderUsageInsights, |
| 9 | +} from "./usage-render-overview.ts"; |
| 10 | +import type { |
| 11 | + CostDailyEntry, |
| 12 | + UsageAggregates, |
| 13 | + UsageSessionEntry, |
| 14 | + UsageTotals, |
| 15 | +} from "./usageTypes.ts"; |
7 | 16 |
|
8 | 17 | const totals: UsageTotals = { |
9 | 18 | input: 100, |
@@ -40,6 +49,89 @@ const aggregates = { |
40 | 49 | daily: [], |
41 | 50 | } as unknown as UsageAggregates; |
42 | 51 |
|
| 52 | +function rect(left: number, top: number, width: number, height: number): DOMRect { |
| 53 | + return { |
| 54 | + x: left, |
| 55 | + y: top, |
| 56 | + left, |
| 57 | + top, |
| 58 | + width, |
| 59 | + height, |
| 60 | + right: left + width, |
| 61 | + bottom: top + height, |
| 62 | + toJSON: () => ({}), |
| 63 | + } as DOMRect; |
| 64 | +} |
| 65 | + |
| 66 | +function setViewport(width: number, height: number) { |
| 67 | + Object.defineProperty(window, "innerWidth", { configurable: true, value: width }); |
| 68 | + Object.defineProperty(window, "innerHeight", { configurable: true, value: height }); |
| 69 | +} |
| 70 | + |
| 71 | +function mockTooltipRect(width: number, height: number) { |
| 72 | + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( |
| 73 | + function (this: HTMLElement) { |
| 74 | + if (this.classList.contains("daily-bar-tooltip--floating")) { |
| 75 | + return rect(0, 0, width, height); |
| 76 | + } |
| 77 | + return rect(0, 0, 0, 0); |
| 78 | + }, |
| 79 | + ); |
| 80 | +} |
| 81 | + |
| 82 | +function mockElementRect( |
| 83 | + element: HTMLElement, |
| 84 | + left: number, |
| 85 | + top: number, |
| 86 | + width: number, |
| 87 | + height: number, |
| 88 | +) { |
| 89 | + Object.defineProperty(element, "getBoundingClientRect", { |
| 90 | + configurable: true, |
| 91 | + value: () => rect(left, top, width, height), |
| 92 | + }); |
| 93 | +} |
| 94 | + |
| 95 | +function dailyEntry(date: string, totalTokens: number, totalCost = 0): CostDailyEntry { |
| 96 | + return { |
| 97 | + ...totals, |
| 98 | + date, |
| 99 | + input: totalTokens, |
| 100 | + output: 0, |
| 101 | + cacheRead: 0, |
| 102 | + cacheWrite: 0, |
| 103 | + totalTokens, |
| 104 | + totalCost, |
| 105 | + }; |
| 106 | +} |
| 107 | + |
| 108 | +function renderDailyChart( |
| 109 | + daily: CostDailyEntry[], |
| 110 | + onSelectDay = vi.fn<(day: string, shiftKey: boolean) => void>(), |
| 111 | +) { |
| 112 | + const container = document.createElement("div"); |
| 113 | + document.body.append(container); |
| 114 | + render( |
| 115 | + renderDailyChartCompact(daily, [], "tokens", "total", () => {}, onSelectDay), |
| 116 | + container, |
| 117 | + ); |
| 118 | + return { |
| 119 | + container, |
| 120 | + onSelectDay, |
| 121 | + bars: Array.from(container.querySelectorAll<HTMLElement>(".daily-bar-wrapper")), |
| 122 | + }; |
| 123 | +} |
| 124 | + |
| 125 | +function getFloatingTooltip(): HTMLElement | null { |
| 126 | + return document.body.querySelector(".daily-bar-tooltip--floating"); |
| 127 | +} |
| 128 | + |
| 129 | +afterEach(() => { |
| 130 | + document.body.replaceChildren(); |
| 131 | + window.dispatchEvent(new Event("scroll")); |
| 132 | + vi.restoreAllMocks(); |
| 133 | +}); |
| 134 | + |
43 | 135 | function directText(element: Element | null | undefined): string | undefined { |
44 | 136 | return Array.from(element?.childNodes ?? []) |
45 | 137 | .filter((node) => node.nodeType === Node.TEXT_NODE) |
@@ -92,6 +184,101 @@ describe("renderUsageInsights", () => { |
92 | 184 | }); |
93 | 185 | }); |
94 | 186 |
|
| 187 | +describe("renderDailyChartCompact", () => { |
| 188 | + it("shows one floating tooltip for tall and short daily bars and hides it on mouse leave", () => { |
| 189 | + setViewport(800, 600); |
| 190 | + mockTooltipRect(180, 64); |
| 191 | + const { bars } = renderDailyChart([ |
| 192 | + dailyEntry("2026-05-01", 1_200_000, 3.5), |
| 193 | + dailyEntry("2026-05-02", 4, 0.01), |
| 194 | + ]); |
| 195 | + |
| 196 | + mockElementRect(bars[0], 100, 100, 24, 200); |
| 197 | + bars[0].dispatchEvent(new MouseEvent("mouseenter")); |
| 198 | + |
| 199 | + let tooltip = getFloatingTooltip(); |
| 200 | + expect(tooltip).not.toBeNull(); |
| 201 | + expect(tooltip?.textContent).toContain("1.2M tokens"); |
| 202 | + expect(tooltip?.style.top).toBe("28px"); |
| 203 | + expect(document.body.querySelectorAll(".daily-bar-tooltip--floating")).toHaveLength(1); |
| 204 | + |
| 205 | + bars[0].dispatchEvent(new MouseEvent("mouseleave")); |
| 206 | + expect(getFloatingTooltip()).toBeNull(); |
| 207 | + |
| 208 | + mockElementRect(bars[1], 200, 320, 24, 6); |
| 209 | + bars[1].dispatchEvent(new MouseEvent("mouseenter")); |
| 210 | + |
| 211 | + tooltip = getFloatingTooltip(); |
| 212 | + expect(tooltip).not.toBeNull(); |
| 213 | + expect(tooltip?.textContent).toContain("4 tokens"); |
| 214 | + bars[1].dispatchEvent(new MouseEvent("mouseleave")); |
| 215 | + }); |
| 216 | + |
| 217 | + it("flips below when the bar is near the top and clamps inside a narrow viewport", () => { |
| 218 | + setViewport(120, 140); |
| 219 | + mockTooltipRect(100, 40); |
| 220 | + const { bars } = renderDailyChart([dailyEntry("2026-05-03", 10_000, 1)]); |
| 221 | + |
| 222 | + mockElementRect(bars[0], 110, 12, 20, 20); |
| 223 | + bars[0].dispatchEvent(new MouseEvent("mouseenter")); |
| 224 | + |
| 225 | + const tooltip = getFloatingTooltip(); |
| 226 | + expect(tooltip?.dataset.placement).toBe("below"); |
| 227 | + expect(tooltip?.style.top).toBe("40px"); |
| 228 | + expect(tooltip?.style.left).toBe("12px"); |
| 229 | + bars[0].dispatchEvent(new MouseEvent("mouseleave")); |
| 230 | + }); |
| 231 | + |
| 232 | + it("clears the floating tooltip when the chart DOM is removed", async () => { |
| 233 | + setViewport(800, 600); |
| 234 | + mockTooltipRect(160, 56); |
| 235 | + const { bars, container } = renderDailyChart([dailyEntry("2026-05-04", 500, 0.2)]); |
| 236 | + mockElementRect(bars[0], 300, 220, 24, 80); |
| 237 | + |
| 238 | + bars[0].dispatchEvent(new MouseEvent("mouseenter")); |
| 239 | + expect(getFloatingTooltip()).not.toBeNull(); |
| 240 | + |
| 241 | + container.remove(); |
| 242 | + await Promise.resolve(); |
| 243 | + expect(getFloatingTooltip()).toBeNull(); |
| 244 | + }); |
| 245 | + |
| 246 | + it("shows on keyboard focus, hides on blur, and keeps day selection operable", () => { |
| 247 | + setViewport(800, 600); |
| 248 | + mockTooltipRect(160, 56); |
| 249 | + const { bars, onSelectDay } = renderDailyChart([dailyEntry("2026-05-04", 500, 0.2)]); |
| 250 | + mockElementRect(bars[0], 300, 220, 24, 80); |
| 251 | + |
| 252 | + bars[0].dispatchEvent(new Event("focus")); |
| 253 | + expect(getFloatingTooltip()?.textContent).toContain("500 tokens"); |
| 254 | + |
| 255 | + bars[0].dispatchEvent(new Event("blur")); |
| 256 | + expect(getFloatingTooltip()).toBeNull(); |
| 257 | + |
| 258 | + bars[0].dispatchEvent(new MouseEvent("click", { bubbles: true, shiftKey: true })); |
| 259 | + expect(onSelectDay).toHaveBeenCalledWith("2026-05-04", true); |
| 260 | + |
| 261 | + bars[0].dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })); |
| 262 | + expect(onSelectDay).toHaveBeenCalledWith("2026-05-04", false); |
| 263 | + |
| 264 | + const space = new KeyboardEvent("keydown", { |
| 265 | + bubbles: true, |
| 266 | + cancelable: true, |
| 267 | + key: " ", |
| 268 | + shiftKey: true, |
| 269 | + }); |
| 270 | + bars[0].dispatchEvent(space); |
| 271 | + expect(space.defaultPrevented).toBe(true); |
| 272 | + expect(onSelectDay).toHaveBeenCalledWith("2026-05-04", true); |
| 273 | + |
| 274 | + bars[0].dispatchEvent(new MouseEvent("mouseenter")); |
| 275 | + bars[0].dispatchEvent(new Event("pointerdown", { bubbles: true })); |
| 276 | + bars[0].dispatchEvent(new Event("focus")); |
| 277 | + bars[0].dispatchEvent(new MouseEvent("mouseleave")); |
| 278 | + expect(getFloatingTooltip()).toBeNull(); |
| 279 | + }); |
| 280 | +}); |
| 281 | + |
95 | 282 | describe("renderSessionsCard", () => { |
96 | 283 | const noop = () => {}; |
97 | 284 |
|
|
0 commit comments