dispatch-simulator-dialog.test.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  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 { NextIntlClientProvider } from "next-intl";
  8. import { beforeEach, describe, expect, test, vi } from "vitest";
  9. import { DispatchSimulatorDialog } from "@/app/[locale]/settings/providers/_components/dispatch-simulator-dialog";
  10. import type { ProviderDisplay } from "@/types/provider";
  11. import commonMessages from "../../../../messages/en/common.json";
  12. import errorsMessages from "../../../../messages/en/errors.json";
  13. import formsMessages from "../../../../messages/en/forms.json";
  14. import settingsMessages from "../../../../messages/en/settings";
  15. import uiMessages from "../../../../messages/en/ui.json";
  16. const dispatchActionMocks = vi.hoisted(() => ({
  17. simulateDispatchAction: vi.fn(),
  18. }));
  19. vi.mock("@/actions/dispatch-simulator", () => dispatchActionMocks);
  20. function loadMessages() {
  21. return {
  22. common: commonMessages,
  23. errors: errorsMessages,
  24. ui: uiMessages,
  25. forms: formsMessages,
  26. settings: settingsMessages,
  27. };
  28. }
  29. function render(node: ReactNode) {
  30. const container = document.createElement("div");
  31. document.body.appendChild(container);
  32. const root = createRoot(container);
  33. act(() => root.render(node));
  34. return {
  35. unmount: () => {
  36. act(() => root.unmount());
  37. container.remove();
  38. },
  39. };
  40. }
  41. async function flushTicks(times = 3) {
  42. for (let i = 0; i < times; i += 1) {
  43. await act(async () => {
  44. await new Promise((resolve) => setTimeout(resolve, 0));
  45. });
  46. }
  47. }
  48. function makeProvider(overrides: Partial<ProviderDisplay> = {}): ProviderDisplay {
  49. return {
  50. id: 1,
  51. name: "Provider A",
  52. url: "https://api.example.com",
  53. maskedKey: "sk-***",
  54. isEnabled: true,
  55. weight: 1,
  56. priority: 0,
  57. groupPriorities: null,
  58. costMultiplier: 1,
  59. groupTag: "alpha",
  60. providerType: "claude",
  61. providerVendorId: 1,
  62. preserveClientIp: false,
  63. disableSessionReuse: false,
  64. modelRedirects: null,
  65. activeTimeStart: null,
  66. activeTimeEnd: null,
  67. allowedModels: null,
  68. allowedClients: [],
  69. blockedClients: [],
  70. mcpPassthroughType: "none",
  71. mcpPassthroughUrl: null,
  72. limit5hUsd: null,
  73. limitDailyUsd: null,
  74. dailyResetMode: "fixed",
  75. dailyResetTime: "00:00",
  76. limitWeeklyUsd: null,
  77. limitMonthlyUsd: null,
  78. limitTotalUsd: null,
  79. limitConcurrentSessions: 0,
  80. maxRetryAttempts: null,
  81. circuitBreakerFailureThreshold: 3,
  82. circuitBreakerOpenDuration: 60_000,
  83. circuitBreakerHalfOpenSuccessThreshold: 1,
  84. proxyUrl: null,
  85. proxyFallbackToDirect: false,
  86. firstByteTimeoutStreamingMs: 30_000,
  87. streamingIdleTimeoutMs: 60_000,
  88. requestTimeoutNonStreamingMs: 120_000,
  89. websiteUrl: null,
  90. faviconUrl: null,
  91. cacheTtlPreference: null,
  92. swapCacheTtlBilling: false,
  93. context1mPreference: null,
  94. codexReasoningEffortPreference: null,
  95. codexReasoningSummaryPreference: null,
  96. codexTextVerbosityPreference: null,
  97. codexParallelToolCallsPreference: null,
  98. codexServiceTierPreference: null,
  99. anthropicMaxTokensPreference: null,
  100. anthropicThinkingBudgetPreference: null,
  101. anthropicAdaptiveThinking: null,
  102. geminiGoogleSearchPreference: null,
  103. tpm: null,
  104. rpm: null,
  105. rpd: null,
  106. cc: null,
  107. createdAt: "2026-01-01",
  108. updatedAt: "2026-01-01",
  109. ...overrides,
  110. } as ProviderDisplay;
  111. }
  112. describe("DispatchSimulatorDialog", () => {
  113. beforeEach(() => {
  114. document.body.innerHTML = "";
  115. vi.clearAllMocks();
  116. });
  117. test("submits simulation input and renders the result", async () => {
  118. const messages = loadMessages();
  119. dispatchActionMocks.simulateDispatchAction.mockResolvedValue({
  120. ok: true,
  121. data: {
  122. steps: [
  123. {
  124. stepName: "groupFilter",
  125. stepIndex: 1,
  126. inputCount: 1,
  127. outputCount: 1,
  128. filteredOut: [],
  129. surviving: [
  130. {
  131. id: 1,
  132. name: "Provider A",
  133. providerType: "claude",
  134. groupTag: "alpha",
  135. priority: 0,
  136. effectivePriority: 0,
  137. weight: 1,
  138. },
  139. ],
  140. },
  141. {
  142. stepName: "priorityTiers",
  143. stepIndex: 7,
  144. inputCount: 1,
  145. outputCount: 1,
  146. filteredOut: [],
  147. surviving: [
  148. {
  149. id: 1,
  150. name: "Provider A",
  151. providerType: "claude",
  152. groupTag: "alpha",
  153. priority: 0,
  154. effectivePriority: 0,
  155. weight: 1,
  156. },
  157. ],
  158. },
  159. ],
  160. priorityTiers: [
  161. {
  162. priority: 0,
  163. isSelected: true,
  164. providers: [
  165. {
  166. id: 1,
  167. name: "Provider A",
  168. providerType: "claude",
  169. groupTag: "alpha",
  170. priority: 0,
  171. effectivePriority: 0,
  172. weight: 1,
  173. weightPercent: 100,
  174. redirectedModel: "glm-4.6",
  175. endpointStats: { total: 2, enabled: 2, circuitOpen: 0, available: 2 },
  176. },
  177. ],
  178. },
  179. ],
  180. totalProviders: 1,
  181. finalCandidateCount: 1,
  182. selectedPriority: 0,
  183. },
  184. });
  185. const { unmount } = render(
  186. <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
  187. <DispatchSimulatorDialog providers={[makeProvider()]} />
  188. </NextIntlClientProvider>
  189. );
  190. const trigger = [...document.querySelectorAll("button")].find((button) =>
  191. button.textContent?.includes("Dispatch Test")
  192. ) as HTMLButtonElement | undefined;
  193. expect(trigger).toBeTruthy();
  194. await act(async () => {
  195. trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  196. });
  197. await flushTicks();
  198. const input = [...document.querySelectorAll("input")].find(
  199. (element) => (element as HTMLInputElement).placeholder === "e.g. claude-opus-4-1"
  200. ) as HTMLInputElement | undefined;
  201. expect(input).toBeTruthy();
  202. await act(async () => {
  203. if (input) {
  204. input.value = "claude-opus-4-1";
  205. input.dispatchEvent(new Event("input", { bubbles: true }));
  206. input.dispatchEvent(new Event("change", { bubbles: true }));
  207. }
  208. });
  209. await flushTicks();
  210. const simulateButton = [...document.querySelectorAll("button")].find((button) =>
  211. button.textContent?.includes("Simulate")
  212. ) as HTMLButtonElement | undefined;
  213. expect(simulateButton).toBeTruthy();
  214. await act(async () => {
  215. simulateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  216. });
  217. await flushTicks(4);
  218. expect(dispatchActionMocks.simulateDispatchAction).toHaveBeenCalledWith({
  219. clientFormat: "claude",
  220. modelName: "claude-opus-4-1",
  221. groupTags: ["default"],
  222. });
  223. expect(document.body.textContent || "").toContain("Priority Tiers");
  224. expect(document.body.textContent || "").toContain("Provider A");
  225. expect(document.body.textContent || "").toContain("glm-4.6");
  226. unmount();
  227. });
  228. });