live-sessions-panel-dynamic-items.test.tsx 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. /**
  2. * @vitest-environment happy-dom
  3. */
  4. import fs from "node:fs";
  5. import path from "node:path";
  6. import type { ReactNode } from "react";
  7. import { act } from "react";
  8. import { createRoot } from "react-dom/client";
  9. import { NextIntlClientProvider } from "next-intl";
  10. import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
  11. import { LiveSessionsPanel } from "@/app/[locale]/dashboard/_components/bento/live-sessions-panel";
  12. import type { ActiveSessionInfo } from "@/types/session";
  13. vi.mock("next/navigation", () => ({
  14. useRouter: () => ({
  15. push: vi.fn(),
  16. }),
  17. }));
  18. const customsMessages = JSON.parse(
  19. fs.readFileSync(path.join(process.cwd(), "messages/en/customs.json"), "utf8")
  20. );
  21. const SESSION_ITEM_HEIGHT = 36;
  22. const HEADER_HEIGHT = 48;
  23. const FOOTER_HEIGHT = 36;
  24. function createMockSession(id: number): ActiveSessionInfo & { lastActivityAt?: number } {
  25. return {
  26. sessionId: `session_${id}`,
  27. userName: `User ${id}`,
  28. keyName: `key_${id}`,
  29. model: "claude-sonnet-4-5-20250929",
  30. providerName: "anthropic",
  31. status: "in_progress",
  32. startTime: Date.now() - 1000,
  33. inputTokens: 100,
  34. outputTokens: 50,
  35. costUsd: 0.01,
  36. lastActivityAt: Date.now(),
  37. };
  38. }
  39. function renderWithIntl(node: ReactNode) {
  40. const container = document.createElement("div");
  41. document.body.appendChild(container);
  42. const root = createRoot(container);
  43. act(() => {
  44. root.render(
  45. <NextIntlClientProvider locale="en" messages={{ customs: customsMessages }} timeZone="UTC">
  46. {node}
  47. </NextIntlClientProvider>
  48. );
  49. });
  50. return {
  51. container,
  52. unmount: () => {
  53. act(() => root.unmount());
  54. container.remove();
  55. },
  56. };
  57. }
  58. describe("LiveSessionsPanel - dynamic maxItems calculation", () => {
  59. let resizeCallback: ResizeObserverCallback | null = null;
  60. let observedElement: Element | null = null;
  61. beforeEach(() => {
  62. resizeCallback = null;
  63. observedElement = null;
  64. vi.stubGlobal(
  65. "ResizeObserver",
  66. class MockResizeObserver {
  67. constructor(callback: ResizeObserverCallback) {
  68. resizeCallback = callback;
  69. }
  70. observe(element: Element) {
  71. observedElement = element;
  72. }
  73. unobserve() {}
  74. disconnect() {}
  75. }
  76. );
  77. });
  78. afterEach(() => {
  79. vi.unstubAllGlobals();
  80. });
  81. test("should calculate maxItems based on container height when maxItems prop is not provided", () => {
  82. const sessions = Array.from({ length: 20 }, (_, i) => createMockSession(i + 1));
  83. const { container, unmount } = renderWithIntl(
  84. <LiveSessionsPanel sessions={sessions} isLoading={false} />
  85. );
  86. const bentoCard = container.querySelector("[class*='flex-col']");
  87. expect(bentoCard).toBeTruthy();
  88. // Simulate container height that can fit 5 items
  89. // Available height = containerHeight - HEADER_HEIGHT - FOOTER_HEIGHT
  90. // Items = floor(availableHeight / SESSION_ITEM_HEIGHT)
  91. // For 5 items: availableHeight = 5 * 36 = 180, containerHeight = 180 + 48 + 36 = 264
  92. const containerHeight = 264;
  93. if (observedElement && resizeCallback) {
  94. Object.defineProperty(observedElement, "clientHeight", {
  95. value: containerHeight,
  96. configurable: true,
  97. });
  98. act(() => {
  99. resizeCallback!([{ target: observedElement } as ResizeObserverEntry], {} as ResizeObserver);
  100. });
  101. }
  102. // Count rendered session items (buttons with session info)
  103. const sessionButtons = container.querySelectorAll("button[class*='flex items-center gap-3']");
  104. expect(sessionButtons.length).toBe(5);
  105. unmount();
  106. });
  107. test("should show all sessions when container is large enough", () => {
  108. const sessions = Array.from({ length: 3 }, (_, i) => createMockSession(i + 1));
  109. const { container, unmount } = renderWithIntl(
  110. <LiveSessionsPanel sessions={sessions} isLoading={false} />
  111. );
  112. // Container height for 10 items (more than we have)
  113. const containerHeight = 10 * SESSION_ITEM_HEIGHT + HEADER_HEIGHT + FOOTER_HEIGHT;
  114. if (observedElement && resizeCallback) {
  115. Object.defineProperty(observedElement, "clientHeight", {
  116. value: containerHeight,
  117. configurable: true,
  118. });
  119. act(() => {
  120. resizeCallback!([{ target: observedElement } as ResizeObserverEntry], {} as ResizeObserver);
  121. });
  122. }
  123. const sessionButtons = container.querySelectorAll("button[class*='flex items-center gap-3']");
  124. expect(sessionButtons.length).toBe(3);
  125. unmount();
  126. });
  127. test("should update displayed items when container resizes", () => {
  128. const sessions = Array.from({ length: 15 }, (_, i) => createMockSession(i + 1));
  129. const { container, unmount } = renderWithIntl(
  130. <LiveSessionsPanel sessions={sessions} isLoading={false} />
  131. );
  132. // Initial: container fits 4 items
  133. let containerHeight = 4 * SESSION_ITEM_HEIGHT + HEADER_HEIGHT + FOOTER_HEIGHT;
  134. if (observedElement && resizeCallback) {
  135. Object.defineProperty(observedElement, "clientHeight", {
  136. value: containerHeight,
  137. configurable: true,
  138. });
  139. act(() => {
  140. resizeCallback!([{ target: observedElement } as ResizeObserverEntry], {} as ResizeObserver);
  141. });
  142. }
  143. let sessionButtons = container.querySelectorAll("button[class*='flex items-center gap-3']");
  144. expect(sessionButtons.length).toBe(4);
  145. // Resize: container now fits 8 items
  146. containerHeight = 8 * SESSION_ITEM_HEIGHT + HEADER_HEIGHT + FOOTER_HEIGHT;
  147. if (observedElement && resizeCallback) {
  148. Object.defineProperty(observedElement, "clientHeight", {
  149. value: containerHeight,
  150. configurable: true,
  151. });
  152. act(() => {
  153. resizeCallback!([{ target: observedElement } as ResizeObserverEntry], {} as ResizeObserver);
  154. });
  155. }
  156. sessionButtons = container.querySelectorAll("button[class*='flex items-center gap-3']");
  157. expect(sessionButtons.length).toBe(8);
  158. unmount();
  159. });
  160. test("should respect maxItems prop as upper limit when provided", () => {
  161. const sessions = Array.from({ length: 20 }, (_, i) => createMockSession(i + 1));
  162. const { container, unmount } = renderWithIntl(
  163. <LiveSessionsPanel sessions={sessions} isLoading={false} maxItems={5} />
  164. );
  165. // Container can fit 10 items, but maxItems is 5
  166. const containerHeight = 10 * SESSION_ITEM_HEIGHT + HEADER_HEIGHT + FOOTER_HEIGHT;
  167. if (observedElement && resizeCallback) {
  168. Object.defineProperty(observedElement, "clientHeight", {
  169. value: containerHeight,
  170. configurable: true,
  171. });
  172. act(() => {
  173. resizeCallback!([{ target: observedElement } as ResizeObserverEntry], {} as ResizeObserver);
  174. });
  175. }
  176. const sessionButtons = container.querySelectorAll("button[class*='flex items-center gap-3']");
  177. expect(sessionButtons.length).toBe(5);
  178. unmount();
  179. });
  180. test("should show View All button with correct count", () => {
  181. const sessions = Array.from({ length: 12 }, (_, i) => createMockSession(i + 1));
  182. const { container, unmount } = renderWithIntl(
  183. <LiveSessionsPanel sessions={sessions} isLoading={false} />
  184. );
  185. // Container fits 6 items
  186. const containerHeight = 6 * SESSION_ITEM_HEIGHT + HEADER_HEIGHT + FOOTER_HEIGHT;
  187. if (observedElement && resizeCallback) {
  188. Object.defineProperty(observedElement, "clientHeight", {
  189. value: containerHeight,
  190. configurable: true,
  191. });
  192. act(() => {
  193. resizeCallback!([{ target: observedElement } as ResizeObserverEntry], {} as ResizeObserver);
  194. });
  195. }
  196. // Footer should show total count
  197. expect(container.textContent).toContain("View All");
  198. expect(container.textContent).toContain("(12)");
  199. unmount();
  200. });
  201. });