Files
cc-switch/tests/components/McodeProviderForm.test.tsx
T
Jason b999b07bec feat(ui): rebuild the global pages and finish the v7 visual pass
Pages (phase 4):
- Usage: v7 layout (filter row, four metrics with more on demand,
  switchable trend, request log / provider / model / pricing tabs, data
  sources sheet). Latency becomes output speed: output / (duration -
  first token), shown only for requests with at least 100 output tokens;
  provider totals sum output and generation time instead of averaging.
- Sessions: single-column list grouped by project or time, drilling into
  a reader with resume, find, question jump and copy actions.
- MCP and Skills share one app matrix (neutral checks, retry on failed
  writes, column bulk actions with undo); MCP add/edit moves into a
  drawer with form/JSON modes; Skills merges installed and discover and
  always shows a Pi column.
- Prompts: one page for eight apps including Hermes SOUL.md, enable and
  disable as buttons, delete with undo instead of a confirm, copy to
  other apps, import existing files; app pages show the active prompt.
- Add provider is two steps: pick a preset (always-on search including
  Chinese names and domains, categories inferred for display, sorted by
  name with no promoted entries), then fill the form. Edit pages name
  the provider and warn when it is the one in use.
- App page menu links to that app's usage.

Visual pass (phase 6):
- Secondary pages cover only the content area so the sidebar stays.
- Cmd/Ctrl+K jumps to apps, pages and settings sections.
- Legacy button variants, primary color, dialogs, toasts and sheets
  follow v7 tokens; hard-coded palette classes and 10px text across
  the remaining components are replaced with tokens.
2026-10-03 23:28:58 +08:00

236 lines
7.5 KiB
TypeScript

import type { ReactElement } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import {
fireEvent,
render as rtlRender,
screen,
waitFor,
} from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { McodeProviderForm } from "@/components/providers/forms/McodeProviderForm";
import { mcodeProviderPresets } from "@/config/mcodeProviderPresets";
import { piProviderPresets } from "@/config/piProviderPresets";
import { setProviders } from "../msw/state";
import { createTestQueryClient } from "../utils/testQueryClient";
vi.mock("@/components/JsonEditor", () => ({
default: ({
id,
value,
onChange,
}: {
id: string;
value: string;
onChange: (value: string) => void;
}) => (
<textarea
id={id}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
),
}));
const render = (ui: ReactElement) =>
rtlRender(
<QueryClientProvider client={createTestQueryClient()}>
{ui}
</QueryClientProvider>,
);
const original = {
kind: "custom",
api: "anthropic-messages",
enabled: true,
options: {
baseURL: "https://api.example.com/anthropic",
apiKey: "local-test-key",
headers: { "X-Test": "keep" },
},
models: {
model: {
name: "Model",
limit: { context: 200000 },
thinking: { effort: "high" },
},
},
futureSetting: { keep: true },
};
describe("McodeProviderForm", () => {
it.each([true, false])(
"edits native fields while preserving model options and unknown settings (explicit API: %s)",
async (explicitApi) => {
const submit = vi.fn();
const settings: Record<string, unknown> = { ...original };
if (!explicitApi) delete settings.api;
render(
<McodeProviderForm
appId="mcode"
providerId="existing"
initialData={{ name: "Existing", settingsConfig: settings }}
submitLabel="Save"
onSubmit={submit}
onCancel={() => {}}
/>,
);
fireEvent.change(screen.getByLabelText("provider.name"), {
target: { value: "Renamed" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
const saved = JSON.parse(submit.mock.calls[0][0].settingsConfig);
expect(saved).toEqual({ ...settings, name: "Renamed" });
expect(submit.mock.calls[0][0].providerKey).toBe("existing");
expect(screen.getByLabelText("API Key")).toHaveAttribute(
"type",
"password",
);
},
);
it("keeps a rejected save open and displays the failure", async () => {
render(
<McodeProviderForm
appId="mcode"
initialData={{ name: "Existing", settingsConfig: original }}
submitLabel="Save"
onSubmit={async () => {
throw new Error("configuration busy");
}}
onCancel={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"configuration busy",
);
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
});
it("uses the shared preset and model editors and saves native API fields", async () => {
const submit = vi.fn();
render(
<McodeProviderForm
appId="mcode"
submitLabel="Save"
onSubmit={submit}
onCancel={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "MiniMax" }));
fireEvent.change(screen.getByLabelText("API Key"), {
target: { value: "test-key" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
const saved = JSON.parse(submit.mock.calls[0][0].settingsConfig);
expect(saved.api).toBe("openai-completions");
expect(saved.options.apiKey).toBe("test-key");
expect(saved).not.toHaveProperty("npm");
expect(saved.models).toHaveProperty("MiniMax-M3");
expect(submit.mock.calls[0][0].providerKey).toBe("cc-switch-mini-max");
});
it("requires a well-formed, unused provider key for new providers", async () => {
setProviders("mcode", {
taken: { id: "taken", name: "Taken", settingsConfig: original },
});
const submit = vi.fn();
render(
<McodeProviderForm
appId="mcode"
submitLabel="Save"
onSubmit={submit}
onCancel={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "MiniMax" }));
fireEvent.change(screen.getByLabelText("API Key"), {
target: { value: "test-key" },
});
const key = screen.getByLabelText(/opencode\.providerKey/);
const save = screen.getByRole("button", { name: "Save" });
expect(key).toHaveValue("cc-switch-mini-max");
expect(save).toBeEnabled();
fireEvent.change(key, { target: { value: "" } });
expect(save).toBeDisabled();
fireEvent.change(key, { target: { value: "-bad-" } });
expect(screen.getByText("opencode.providerKeyInvalid")).toBeInTheDocument();
expect(save).toBeDisabled();
fireEvent.change(key, { target: { value: "taken" } });
expect(
await screen.findByText("opencode.providerKeyDuplicate"),
).toBeInTheDocument();
expect(save).toBeDisabled();
fireEvent.change(key, { target: { value: "My Key!" } });
expect(key).toHaveValue("mykey");
fireEvent.click(save);
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
expect(submit.mock.calls[0][0].providerKey).toBe("mykey");
});
it("locks the provider key when editing", () => {
render(
<McodeProviderForm
appId="mcode"
providerId="existing"
initialData={{ name: "Existing", settingsConfig: original }}
submitLabel="Save"
onSubmit={vi.fn()}
onCancel={() => {}}
/>,
);
const key = screen.getByLabelText(/opencode\.providerKey/);
expect(key).toHaveValue("existing");
expect(key).toBeDisabled();
expect(
screen.getByText("opencode.providerKeyLockedHint"),
).toBeInTheDocument();
});
it("keeps invalid JSON drafts from breaking the structured fields", () => {
render(
<McodeProviderForm
appId="mcode"
initialData={{ name: "Existing", settingsConfig: original }}
submitLabel="Save"
onSubmit={vi.fn()}
onCancel={() => {}}
/>,
);
fireEvent.change(screen.getByLabelText("provider.configJson"), {
target: { value: '{"options":{"baseURL":42}}' },
});
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
fireEvent.change(screen.getByLabelText("provider.configJson"), {
target: { value: JSON.stringify(original) },
});
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
});
it("borrows the MiniMax preset without changing Pi or copying Pi compatibility fields", () => {
const pi = piProviderPresets.find((preset) => preset.name === "MiniMax")!;
const mcode = mcodeProviderPresets.find(
(preset) => preset.name === "MiniMax",
)!;
expect(mcode.settingsConfig.options.baseURL).toBe(
pi.settingsConfig.baseUrl,
);
expect(Object.keys(mcode.settingsConfig.models)).toContain("MiniMax-M3");
expect(mcode.settingsConfig).not.toHaveProperty("compat");
expect(pi.settingsConfig.models).toBeInstanceOf(Array);
expect(
mcodeProviderPresets.every((preset) =>
[
"anthropic-messages",
"openai-completions",
"openai-responses",
].includes(preset.settingsConfig.api),
),
).toBe(true);
});
});