// @vitest-environment jsdom import { act, createElement, type ReactNode } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { conversationReasoningIndex, conversationReasoningLevels, } from "@/lib/conversation-settings"; const h = vi.hoisted(() => ({ providers: [] as Array<{ id: string; label: string }>, defaultProvider: "openrouter", modelLocked: false, comboboxRendered: false, providerStep: null as null | { providers: Array<{ id: string; label: string }>; current: string | null; onSelect: (provider: string, model: string) => void; lockedProviders?: string[]; }, state: { conversationProvider: null as string | null, conversationModel: null as string | null, conversationReasoningLevel: "high" as const, conversationConfigError: null as string | null, }, update: vi.fn(), sliderProps: null as null | { value: number[]; min: number; max: number; onValueChange: (value: number[]) => void; }, })); vi.mock("next-intl", () => ({ useTranslations: () => (key: string, values?: Record) => key === "fieldValueAria" && values ? `${values.field}: ${values.value}` : key, })); vi.mock("@/lib/assistant-chat-context", () => ({ useAssistantChatContext: () => ({ state: h.state, updateConversationConfig: h.update, }), })); vi.mock("@/lib/use-agent-models-query", () => ({ useAgentModelsQuery: () => ({ provider: "openrouter", defaultProvider: h.defaultProvider, modelLocked: h.modelLocked, providers: h.providers, defaultModel: "z-ai/glm-5.3-flash", defaultReasoning: "medium", models: [{ id: "z-ai/glm-5.3-flash", reasoning: { efforts: ["low"], mandatory: true }, }], }), useReasoningLevelsFor: () => ["low"], })); vi.mock("@/components/agent/model-combobox", () => ({ ModelCombobox: (props: { providerStep?: unknown }) => { h.comboboxRendered = true; h.providerStep = props.providerStep as typeof h.providerStep; return null; }, })); vi.mock("@/components/minddy-logo", () => ({ MinddyLogo: () => null })); vi.mock("@/components/model-logo", () => ({ ModelLogo: () => null, ProviderLogo: () => null, })); vi.mock("@/components/ui/tooltip", async () => { const { createElement } = await import("react"); const Wrapper = ({ children }: { children?: ReactNode }) => createElement("div", null, children); return { Tooltip: Wrapper, TooltipContent: Wrapper, TooltipTrigger: Wrapper, }; }); // Stubs for the UI primitives the component under test renders. const uiStubs = vi.hoisted(() => { let value: Promise> | undefined; return () => (value ??= (async () => { const { createElement } = await import("react"); const Wrapper = ({ children }: { children?: ReactNode }) => createElement("div", null, children); return { Button: ({ children, ...props }: { children?: ReactNode }) => createElement("button", props, children), Popover: Wrapper, PopoverContent: Wrapper, PopoverTrigger: Wrapper, Slider: (props: NonNullable) => { h.sliderProps = props; return createElement("div", { "aria-label": "slider" }); }, cn: (...values: Array) => values.filter(Boolean).join(" "), }; })()); }); vi.mock("@/components/ui/button", async (importOriginal) => { const stubs = await uiStubs(); return { ...(await importOriginal>()), Button: stubs.Button }; }); vi.mock("@/components/ui/popover", async (importOriginal) => { const stubs = await uiStubs(); return { ...(await importOriginal>()), Popover: stubs.Popover, PopoverContent: stubs.PopoverContent, PopoverTrigger: stubs.PopoverTrigger }; }); vi.mock("@/components/ui/slider", async (importOriginal) => { const stubs = await uiStubs(); return { ...(await importOriginal>()), Slider: stubs.Slider }; }); import { ConversationSettings } from "@/components/assistant/conversation-settings"; let root: Root; let host: HTMLDivElement; beforeEach(() => { vi.clearAllMocks(); Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); h.sliderProps = null; h.providers = []; h.defaultProvider = "openrouter"; h.modelLocked = false; h.comboboxRendered = false; h.providerStep = null; h.state.conversationProvider = null; host = document.createElement("div"); document.body.append(host); root = createRoot(host); }); afterEach(() => { act(() => root.unmount()); host.remove(); }); describe("conversation reasoning stops", () => { it("always starts at no reasoning and follows the canonical effort order", () => { expect(conversationReasoningLevels(["high", "minimal", "low"])).toEqual([ "off", "minimal", "low", "high", ]); expect(conversationReasoningIndex("medium", ["low", "high"])).toBe(1); }); }); describe("ConversationSettings", () => { it("combines the effective model and reasoning, and resets both overrides", async () => { await act(async () => root.render(createElement(ConversationSettings))); // The composer trigger shows the logo only (MIN-827); the model and its // reasoning level are named by its tooltip. // Its accessible name carries them too (WCAG 2.5.3). const trigger = host.querySelector('button[aria-label="sectionTitle: GLM 5.3 Flash · reasoningLow"]'); expect(trigger).not.toBeNull(); expect(trigger?.textContent).not.toContain("GLM 5.3 Flash"); expect(trigger?.getAttribute("tooltip")).toBe("GLM 5.3 Flash · reasoningLow"); expect(host.textContent).not.toContain("modelDefault"); expect(h.sliderProps).toMatchObject({ value: [1], min: 0, max: 1 }); expect(h.update).toHaveBeenCalledWith({ reasoningLevel: "low" }); const reset = host.querySelector( 'button[aria-label="modelSettingsReset"]', ); await act(async () => reset?.click()); expect(h.update).toHaveBeenCalledWith({ provider: null, model: null, reasoningLevel: null }); await act(async () => h.sliderProps?.onValueChange([0])); expect(h.update).toHaveBeenCalledWith({ reasoningLevel: "off" }); }); it("hands the providers to the model list and saves the provider with its model (MIN-826)", async () => { h.providers = [ { id: "openrouter", label: "OpenRouter" }, { id: "anthropic", label: "Anthropic" }, ]; await act(async () => root.render(createElement(ConversationSettings))); expect(h.providerStep).toMatchObject({ providers: h.providers, current: "openrouter" }); await act(async () => h.providerStep!.onSelect("anthropic", "claude-opus-5-5")); expect(h.update).toHaveBeenCalledWith({ provider: "anthropic", model: "claude-opus-5-5" }); // Another provider's default model stores no model override. await act(async () => h.providerStep!.onSelect("anthropic", "")); expect(h.update).toHaveBeenLastCalledWith({ provider: "anthropic", model: null }); // A model of the current provider keeps the provider and places the reasoning. h.update.mockClear(); await act(async () => h.providerStep!.onSelect("openrouter", "z-ai/glm-5.3-flash")); expect(h.update).toHaveBeenCalledWith(expect.objectContaining({ model: "z-ai/glm-5.3-flash" })); expect(h.update.mock.calls[0]![0]).not.toHaveProperty("provider"); // Back to the default provider stores no provider override. h.state.conversationProvider = "anthropic"; await act(async () => root.render(createElement(ConversationSettings, { key: "anthropic" }))); await act(async () => h.providerStep!.onSelect("openrouter", "")); expect(h.update).toHaveBeenLastCalledWith({ provider: null, model: null }); }); it("offers no model choice on minddy Cloud without a personal key (MIN-954)", async () => { h.defaultProvider = "minddy"; h.modelLocked = true; h.providers = [{ id: "minddy", label: "minddy Cloud" }]; await act(async () => root.render(createElement(ConversationSettings))); expect(h.comboboxRendered).toBe(false); expect(h.sliderProps).toBeNull(); expect(host.querySelector("button")).toBeNull(); // A level stored before MIN-954 is ignored, never "repaired" into a write. expect(h.update).not.toHaveBeenCalled(); }); it("only switches provider on minddy Cloud when a personal key exists (MIN-954)", async () => { h.defaultProvider = "minddy"; h.modelLocked = true; h.providers = [ { id: "minddy", label: "minddy Cloud" }, { id: "anthropic", label: "Anthropic" }, ]; await act(async () => root.render(createElement(ConversationSettings))); expect(h.sliderProps).toBeNull(); expect(h.providerStep).toMatchObject({ current: "minddy", lockedProviders: ["minddy"] }); // Leaving the instance's provider drops the level it ignored. await act(async () => h.providerStep!.onSelect("anthropic", "claude-opus-5-5")); expect(h.update).toHaveBeenLastCalledWith({ provider: "anthropic", model: "claude-opus-5-5", reasoningLevel: null }); }); it("clears the model and level when a personal conversation moves to minddy Cloud", async () => { h.defaultProvider = "minddy"; h.providers = [ { id: "minddy", label: "minddy Cloud" }, { id: "anthropic", label: "Anthropic" }, ]; h.state.conversationProvider = "anthropic"; await act(async () => root.render(createElement(ConversationSettings))); await act(async () => h.providerStep!.onSelect("minddy", "")); expect(h.update).toHaveBeenLastCalledWith({ provider: null, model: null, reasoningLevel: null }); }); });