model-multi-select.test.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  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 { ModelMultiSelect } from "@/app/[locale]/settings/providers/_components/model-multi-select";
  10. import commonMessages from "../../../../messages/en/common.json";
  11. import errorsMessages from "../../../../messages/en/errors.json";
  12. import formsMessages from "../../../../messages/en/forms.json";
  13. import settingsMessages from "../../../../messages/en/settings";
  14. import uiMessages from "../../../../messages/en/ui.json";
  15. const modelPricesActionMocks = vi.hoisted(() => ({
  16. getAvailableModelCatalog: vi.fn(async () => [
  17. {
  18. modelName: "openai-new",
  19. litellmProvider: "openai",
  20. updatedAt: "2026-04-05T12:00:00.000Z",
  21. },
  22. {
  23. modelName: "anthropic-mid",
  24. litellmProvider: "anthropic",
  25. updatedAt: "2026-04-04T12:00:00.000Z",
  26. },
  27. {
  28. modelName: "openai-old",
  29. litellmProvider: "openai",
  30. updatedAt: "2026-04-01T12:00:00.000Z",
  31. },
  32. ]),
  33. }));
  34. vi.mock("@/actions/model-prices", () => modelPricesActionMocks);
  35. const providerActionMocks = vi.hoisted(() => ({
  36. fetchUpstreamModels: vi.fn(async () => ({ ok: false, error: "upstream unavailable" })),
  37. getUnmaskedProviderKey: vi.fn(async () => ({ ok: false })),
  38. }));
  39. vi.mock("@/actions/providers", () => providerActionMocks);
  40. vi.mock("@/components/ui/popover", async () => {
  41. const React = await import("react");
  42. const PopoverContext = React.createContext<{
  43. open: boolean;
  44. setOpen: (value: boolean) => void;
  45. } | null>(null);
  46. function Popover({
  47. open,
  48. onOpenChange,
  49. children,
  50. }: {
  51. open?: boolean;
  52. onOpenChange?: (open: boolean) => void;
  53. children?: ReactNode;
  54. }) {
  55. const [internalOpen, setInternalOpen] = React.useState(Boolean(open));
  56. const setOpen = (value: boolean) => {
  57. setInternalOpen(value);
  58. onOpenChange?.(value);
  59. };
  60. return (
  61. <PopoverContext.Provider value={{ open: internalOpen, setOpen }}>
  62. {children}
  63. </PopoverContext.Provider>
  64. );
  65. }
  66. function PopoverTrigger({ children, asChild }: { children?: ReactNode; asChild?: boolean }) {
  67. const ctx = React.useContext(PopoverContext);
  68. if (!ctx) return null;
  69. if (!asChild || !React.isValidElement(children)) {
  70. return <button onClick={() => ctx.setOpen(!ctx.open)}>{children}</button>;
  71. }
  72. return React.cloneElement(children, {
  73. onClick: () => ctx.setOpen(!ctx.open),
  74. });
  75. }
  76. function PopoverContent({ children }: { children?: ReactNode }) {
  77. const ctx = React.useContext(PopoverContext);
  78. if (!ctx?.open) return null;
  79. return <div data-testid="mock-popover-content">{children}</div>;
  80. }
  81. return {
  82. Popover,
  83. PopoverTrigger,
  84. PopoverContent,
  85. };
  86. });
  87. vi.mock("@/components/ui/select", () => {
  88. function NativeSelect({
  89. value,
  90. onValueChange,
  91. children,
  92. }: {
  93. value?: string;
  94. onValueChange?: (value: string) => void;
  95. children?: ReactNode;
  96. }) {
  97. return (
  98. <select
  99. data-testid="provider-filter-select"
  100. value={value}
  101. onChange={(event) => onValueChange?.(event.target.value)}
  102. >
  103. {children}
  104. </select>
  105. );
  106. }
  107. return {
  108. Select: NativeSelect,
  109. SelectContent: ({ children }: { children?: ReactNode }) => <>{children}</>,
  110. SelectItem: ({ value, children }: { value: string; children?: ReactNode }) => (
  111. <option value={value}>{children}</option>
  112. ),
  113. SelectTrigger: ({ children }: { children?: ReactNode }) => <>{children}</>,
  114. SelectValue: () => null,
  115. };
  116. });
  117. function loadMessages() {
  118. return {
  119. common: commonMessages,
  120. errors: errorsMessages,
  121. ui: uiMessages,
  122. forms: formsMessages,
  123. settings: settingsMessages,
  124. };
  125. }
  126. function render(node: ReactNode) {
  127. const container = document.createElement("div");
  128. document.body.appendChild(container);
  129. const root = createRoot(container);
  130. act(() => {
  131. root.render(node);
  132. });
  133. return {
  134. unmount: () => {
  135. act(() => root.unmount());
  136. container.remove();
  137. },
  138. };
  139. }
  140. async function flushTicks(times = 4) {
  141. for (let i = 0; i < times; i += 1) {
  142. await act(async () => {
  143. await new Promise((resolve) => setTimeout(resolve, 0));
  144. });
  145. }
  146. }
  147. describe("ModelMultiSelect", () => {
  148. beforeEach(() => {
  149. document.body.innerHTML = "";
  150. vi.clearAllMocks();
  151. });
  152. async function openPicker() {
  153. const trigger = document.querySelector(
  154. "[data-allowed-model-picker-trigger]"
  155. ) as HTMLButtonElement | null;
  156. expect(trigger).toBeTruthy();
  157. await act(async () => {
  158. trigger?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
  159. trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  160. });
  161. await flushTicks(5);
  162. }
  163. test("falls back to local catalog sorted by newest update first and filters by provider", async () => {
  164. const messages = loadMessages();
  165. const onChange = vi.fn();
  166. const { unmount } = render(
  167. <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
  168. <ModelMultiSelect providerType="claude" selectedModels={[]} onChange={onChange} />
  169. </NextIntlClientProvider>
  170. );
  171. expect(modelPricesActionMocks.getAvailableModelCatalog).not.toHaveBeenCalled();
  172. await openPicker();
  173. expect(modelPricesActionMocks.getAvailableModelCatalog).toHaveBeenCalledTimes(1);
  174. const initialItems = Array.from(
  175. document.querySelectorAll('[data-model-group="available"] [data-slot="command-item"]')
  176. ).map((element) => element.textContent?.trim() || "");
  177. expect(initialItems[0]).toContain("openai-new");
  178. expect(initialItems[1]).toContain("anthropic-mid");
  179. expect(initialItems[2]).toContain("openai-old");
  180. const providerFilter = document.querySelector(
  181. '[data-testid="provider-filter-select"]'
  182. ) as HTMLSelectElement | null;
  183. expect(providerFilter).toBeTruthy();
  184. await act(async () => {
  185. if (providerFilter) {
  186. providerFilter.value = "openai";
  187. providerFilter.dispatchEvent(new Event("change", { bubbles: true }));
  188. }
  189. });
  190. await flushTicks(2);
  191. const filteredItems = Array.from(
  192. document.querySelectorAll('[data-model-group="available"] [data-slot="command-item"]')
  193. ).map((element) => element.textContent?.trim() || "");
  194. expect(filteredItems.some((text) => text.includes("anthropic-mid"))).toBe(false);
  195. expect(filteredItems.some((text) => text.includes("openai-new"))).toBe(true);
  196. expect(filteredItems.some((text) => text.includes("openai-old"))).toBe(true);
  197. unmount();
  198. });
  199. test("invert selection only toggles the currently filtered provider result set", async () => {
  200. const messages = loadMessages();
  201. const onChange = vi.fn();
  202. const { unmount } = render(
  203. <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
  204. <ModelMultiSelect
  205. providerType="claude"
  206. selectedModels={["anthropic-mid"]}
  207. onChange={onChange}
  208. />
  209. </NextIntlClientProvider>
  210. );
  211. await openPicker();
  212. const providerFilter = document.querySelector(
  213. '[data-testid="provider-filter-select"]'
  214. ) as HTMLSelectElement | null;
  215. expect(providerFilter).toBeTruthy();
  216. await act(async () => {
  217. if (providerFilter) {
  218. providerFilter.value = "openai";
  219. providerFilter.dispatchEvent(new Event("change", { bubbles: true }));
  220. }
  221. });
  222. await flushTicks(2);
  223. const invertButton = document.querySelector(
  224. "[data-allowed-model-invert]"
  225. ) as HTMLButtonElement | null;
  226. expect(invertButton).toBeTruthy();
  227. await act(async () => {
  228. invertButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  229. });
  230. await flushTicks(2);
  231. expect(onChange).toHaveBeenLastCalledWith(["anthropic-mid", "openai-new", "openai-old"]);
  232. unmount();
  233. });
  234. test("prefers upstream models when available", async () => {
  235. const messages = loadMessages();
  236. providerActionMocks.fetchUpstreamModels.mockResolvedValueOnce({
  237. ok: true,
  238. data: {
  239. models: ["claude-opus-4-1", "claude-sonnet-4-1"],
  240. source: "upstream",
  241. },
  242. });
  243. const { unmount } = render(
  244. <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
  245. <ModelMultiSelect
  246. providerType="claude"
  247. providerUrl="https://api.example.com"
  248. apiKey="sk-test"
  249. selectedModels={[]}
  250. onChange={vi.fn()}
  251. />
  252. </NextIntlClientProvider>
  253. );
  254. await openPicker();
  255. expect(providerActionMocks.fetchUpstreamModels).toHaveBeenCalledTimes(1);
  256. expect(modelPricesActionMocks.getAvailableModelCatalog).not.toHaveBeenCalled();
  257. expect(document.querySelector('[data-testid="provider-filter-select"]')).toBeNull();
  258. const upstreamItems = Array.from(
  259. document.querySelectorAll('[data-model-group="available"] [data-slot="command-item"]')
  260. ).map((element) => element.textContent?.trim() || "");
  261. expect(upstreamItems.some((text) => text.includes("claude-opus-4-1"))).toBe(true);
  262. expect(upstreamItems.some((text) => text.includes("claude-sonnet-4-1"))).toBe(true);
  263. unmount();
  264. });
  265. test("取消一个 mixed-case exact 模型时不会连带移除另一个", async () => {
  266. const messages = loadMessages();
  267. const onChange = vi.fn();
  268. const { unmount } = render(
  269. <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
  270. <ModelMultiSelect
  271. providerType="openai"
  272. selectedModels={["GLM-5", "glm-5"]}
  273. onChange={onChange}
  274. />
  275. </NextIntlClientProvider>
  276. );
  277. await openPicker();
  278. const selectedItems = Array.from(
  279. document.querySelectorAll('[data-model-group="selected"] [data-slot="command-item"]')
  280. );
  281. expect(selectedItems).toHaveLength(2);
  282. expect(selectedItems.map((item) => item.textContent || "")).toEqual(
  283. expect.arrayContaining(["GLM-5", "glm-5"])
  284. );
  285. const upperItem = selectedItems.find((item) => (item.textContent || "").includes("GLM-5"));
  286. expect(upperItem).toBeTruthy();
  287. await act(async () => {
  288. upperItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  289. });
  290. await flushTicks(2);
  291. expect(onChange).toHaveBeenLastCalledWith(["glm-5"]);
  292. unmount();
  293. });
  294. });