model-multi-select-custom-models-ui.test.tsx 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  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. getAvailableModelsByProviderType: vi.fn(async () => ["remote-model-1"]),
  17. }));
  18. vi.mock("@/actions/model-prices", () => modelPricesActionMocks);
  19. const providersActionMocks = vi.hoisted(() => ({
  20. fetchUpstreamModels: vi.fn(async () => ({ ok: false })),
  21. getUnmaskedProviderKey: vi.fn(async () => ({ ok: false })),
  22. }));
  23. vi.mock("@/actions/providers", () => providersActionMocks);
  24. function loadMessages() {
  25. return {
  26. common: commonMessages,
  27. errors: errorsMessages,
  28. ui: uiMessages,
  29. forms: formsMessages,
  30. settings: settingsMessages,
  31. };
  32. }
  33. function render(node: ReactNode) {
  34. const container = document.createElement("div");
  35. document.body.appendChild(container);
  36. const root = createRoot(container);
  37. act(() => {
  38. root.render(node);
  39. });
  40. return {
  41. unmount: () => {
  42. act(() => root.unmount());
  43. container.remove();
  44. },
  45. };
  46. }
  47. async function flushTicks(times = 3) {
  48. for (let i = 0; i < times; i++) {
  49. await act(async () => {
  50. await new Promise((r) => setTimeout(r, 0));
  51. });
  52. }
  53. }
  54. describe("ModelMultiSelect: 自定义白名单模型应可在列表中取消选中", () => {
  55. beforeEach(() => {
  56. vi.clearAllMocks();
  57. });
  58. test("已选中但不在 availableModels 的模型应出现在列表中,并可取消选中删除", async () => {
  59. const messages = loadMessages();
  60. const onChange = vi.fn();
  61. const { unmount } = render(
  62. <NextIntlClientProvider locale="en" messages={messages} timeZone="UTC">
  63. <ModelMultiSelect
  64. providerType="claude"
  65. selectedModels={["custom-model-x"]}
  66. onChange={onChange}
  67. />
  68. </NextIntlClientProvider>
  69. );
  70. await flushTicks(5);
  71. expect(modelPricesActionMocks.getAvailableModelsByProviderType).toHaveBeenCalledTimes(1);
  72. const trigger = document.querySelector("button[role='combobox']") as HTMLButtonElement | null;
  73. expect(trigger).toBeTruthy();
  74. await act(async () => {
  75. trigger?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
  76. trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  77. });
  78. await flushTicks(5);
  79. // 回归点:custom-model-x 不在 availableModels 时仍应可见,否则用户无法单个删除
  80. expect(document.body.textContent || "").toContain("custom-model-x");
  81. const items = Array.from(document.querySelectorAll("[data-slot='command-item']"));
  82. const customItem =
  83. items.find((el) => (el.textContent || "").includes("custom-model-x")) ?? null;
  84. expect(customItem).toBeTruthy();
  85. await act(async () => {
  86. customItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
  87. });
  88. expect(onChange).toHaveBeenCalled();
  89. expect(onChange).toHaveBeenLastCalledWith([]);
  90. unmount();
  91. });
  92. });