/**
* @vitest-environment happy-dom
*/
import type { ReactNode } from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { NextIntlClientProvider } from "next-intl";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ModelMultiSelect } from "@/app/[locale]/settings/providers/_components/model-multi-select";
import commonMessages from "../../../../messages/en/common.json";
import errorsMessages from "../../../../messages/en/errors.json";
import formsMessages from "../../../../messages/en/forms.json";
import settingsMessages from "../../../../messages/en/settings";
import uiMessages from "../../../../messages/en/ui.json";
const modelPricesActionMocks = vi.hoisted(() => ({
getAvailableModelCatalog: vi.fn(async () => [
{
modelName: "openai-new",
litellmProvider: "openai",
updatedAt: "2026-04-05T12:00:00.000Z",
},
{
modelName: "anthropic-mid",
litellmProvider: "anthropic",
updatedAt: "2026-04-04T12:00:00.000Z",
},
{
modelName: "openai-old",
litellmProvider: "openai",
updatedAt: "2026-04-01T12:00:00.000Z",
},
]),
}));
vi.mock("@/actions/model-prices", () => modelPricesActionMocks);
const providerActionMocks = vi.hoisted(() => ({
fetchUpstreamModels: vi.fn(async () => ({ ok: false, error: "upstream unavailable" })),
getUnmaskedProviderKey: vi.fn(async () => ({ ok: false })),
}));
vi.mock("@/actions/providers", () => providerActionMocks);
vi.mock("@/components/ui/popover", async () => {
const React = await import("react");
const PopoverContext = React.createContext<{
open: boolean;
setOpen: (value: boolean) => void;
} | null>(null);
function Popover({
open,
onOpenChange,
children,
}: {
open?: boolean;
onOpenChange?: (open: boolean) => void;
children?: ReactNode;
}) {
const [internalOpen, setInternalOpen] = React.useState(Boolean(open));
const setOpen = (value: boolean) => {
setInternalOpen(value);
onOpenChange?.(value);
};
return (
{children}
);
}
function PopoverTrigger({ children, asChild }: { children?: ReactNode; asChild?: boolean }) {
const ctx = React.useContext(PopoverContext);
if (!ctx) return null;
if (!asChild || !React.isValidElement(children)) {
return ;
}
return React.cloneElement(children, {
onClick: () => ctx.setOpen(!ctx.open),
});
}
function PopoverContent({ children }: { children?: ReactNode }) {
const ctx = React.useContext(PopoverContext);
if (!ctx?.open) return null;
return
{children}
;
}
return {
Popover,
PopoverTrigger,
PopoverContent,
};
});
vi.mock("@/components/ui/select", () => {
function NativeSelect({
value,
onValueChange,
children,
}: {
value?: string;
onValueChange?: (value: string) => void;
children?: ReactNode;
}) {
return (
);
}
return {
Select: NativeSelect,
SelectContent: ({ children }: { children?: ReactNode }) => <>{children}>,
SelectItem: ({ value, children }: { value: string; children?: ReactNode }) => (
),
SelectTrigger: ({ children }: { children?: ReactNode }) => <>{children}>,
SelectValue: () => null,
};
});
function loadMessages() {
return {
common: commonMessages,
errors: errorsMessages,
ui: uiMessages,
forms: formsMessages,
settings: settingsMessages,
};
}
function render(node: ReactNode) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(node);
});
return {
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
async function flushTicks(times = 4) {
for (let i = 0; i < times; i += 1) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
}
describe("ModelMultiSelect", () => {
beforeEach(() => {
document.body.innerHTML = "";
vi.clearAllMocks();
});
async function openPicker() {
const trigger = document.querySelector(
"[data-allowed-model-picker-trigger]"
) as HTMLButtonElement | null;
expect(trigger).toBeTruthy();
await act(async () => {
trigger?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushTicks(5);
}
test("falls back to local catalog sorted by newest update first and filters by provider", async () => {
const messages = loadMessages();
const onChange = vi.fn();
const { unmount } = render(
);
expect(modelPricesActionMocks.getAvailableModelCatalog).not.toHaveBeenCalled();
await openPicker();
expect(modelPricesActionMocks.getAvailableModelCatalog).toHaveBeenCalledTimes(1);
const initialItems = Array.from(
document.querySelectorAll('[data-model-group="available"] [data-slot="command-item"]')
).map((element) => element.textContent?.trim() || "");
expect(initialItems[0]).toContain("openai-new");
expect(initialItems[1]).toContain("anthropic-mid");
expect(initialItems[2]).toContain("openai-old");
const providerFilter = document.querySelector(
'[data-testid="provider-filter-select"]'
) as HTMLSelectElement | null;
expect(providerFilter).toBeTruthy();
await act(async () => {
if (providerFilter) {
providerFilter.value = "openai";
providerFilter.dispatchEvent(new Event("change", { bubbles: true }));
}
});
await flushTicks(2);
const filteredItems = Array.from(
document.querySelectorAll('[data-model-group="available"] [data-slot="command-item"]')
).map((element) => element.textContent?.trim() || "");
expect(filteredItems.some((text) => text.includes("anthropic-mid"))).toBe(false);
expect(filteredItems.some((text) => text.includes("openai-new"))).toBe(true);
expect(filteredItems.some((text) => text.includes("openai-old"))).toBe(true);
unmount();
});
test("invert selection only toggles the currently filtered provider result set", async () => {
const messages = loadMessages();
const onChange = vi.fn();
const { unmount } = render(
);
await openPicker();
const providerFilter = document.querySelector(
'[data-testid="provider-filter-select"]'
) as HTMLSelectElement | null;
expect(providerFilter).toBeTruthy();
await act(async () => {
if (providerFilter) {
providerFilter.value = "openai";
providerFilter.dispatchEvent(new Event("change", { bubbles: true }));
}
});
await flushTicks(2);
const invertButton = document.querySelector(
"[data-allowed-model-invert]"
) as HTMLButtonElement | null;
expect(invertButton).toBeTruthy();
await act(async () => {
invertButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushTicks(2);
expect(onChange).toHaveBeenLastCalledWith(["anthropic-mid", "openai-new", "openai-old"]);
unmount();
});
test("prefers upstream models when available", async () => {
const messages = loadMessages();
providerActionMocks.fetchUpstreamModels.mockResolvedValueOnce({
ok: true,
data: {
models: ["claude-opus-4-1", "claude-sonnet-4-1"],
source: "upstream",
},
});
const { unmount } = render(
);
await openPicker();
expect(providerActionMocks.fetchUpstreamModels).toHaveBeenCalledTimes(1);
expect(modelPricesActionMocks.getAvailableModelCatalog).not.toHaveBeenCalled();
expect(document.querySelector('[data-testid="provider-filter-select"]')).toBeNull();
const upstreamItems = Array.from(
document.querySelectorAll('[data-model-group="available"] [data-slot="command-item"]')
).map((element) => element.textContent?.trim() || "");
expect(upstreamItems.some((text) => text.includes("claude-opus-4-1"))).toBe(true);
expect(upstreamItems.some((text) => text.includes("claude-sonnet-4-1"))).toBe(true);
unmount();
});
test("取消一个 mixed-case exact 模型时不会连带移除另一个", async () => {
const messages = loadMessages();
const onChange = vi.fn();
const { unmount } = render(
);
await openPicker();
const selectedItems = Array.from(
document.querySelectorAll('[data-model-group="selected"] [data-slot="command-item"]')
);
expect(selectedItems).toHaveLength(2);
expect(selectedItems.map((item) => item.textContent || "")).toEqual(
expect.arrayContaining(["GLM-5", "glm-5"])
);
const upperItem = selectedItems.find((item) => (item.textContent || "").includes("GLM-5"));
expect(upperItem).toBeTruthy();
await act(async () => {
upperItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushTicks(2);
expect(onChange).toHaveBeenLastCalledWith(["glm-5"]);
unmount();
});
});