mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
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.
243 lines
6.8 KiB
TypeScript
243 lines
6.8 KiB
TypeScript
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const {
|
|
getModelsDevSyncConfig,
|
|
saveModelsDevSyncConfig,
|
|
getModelPricing,
|
|
openAppConfigFolder,
|
|
syncModelsDevPricing,
|
|
} = vi.hoisted(() => ({
|
|
getModelsDevSyncConfig: vi.fn(),
|
|
saveModelsDevSyncConfig: vi.fn(),
|
|
getModelPricing: vi.fn(),
|
|
openAppConfigFolder: vi.fn(),
|
|
syncModelsDevPricing: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("react-i18next", () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string, options?: { count?: number; status?: string }) =>
|
|
options?.count != null
|
|
? `${key}:${options.count}`
|
|
: options?.status != null
|
|
? `${key}:${options.status}`
|
|
: key,
|
|
i18n: { resolvedLanguage: "en" },
|
|
}),
|
|
}));
|
|
|
|
vi.mock("sonner", () => ({
|
|
toast: { success: vi.fn(), error: vi.fn() },
|
|
}));
|
|
|
|
vi.mock("@/lib/api/usage", () => ({
|
|
usageApi: {
|
|
getModelsDevSyncConfig,
|
|
saveModelsDevSyncConfig,
|
|
getModelPricing,
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/lib/api/settings", () => ({
|
|
settingsApi: { openAppConfigFolder },
|
|
}));
|
|
|
|
vi.mock("@/lib/modelsDevAutoSync", () => ({
|
|
MODELS_DEV_SYNC_CONFIG_QUERY_KEY: ["models-dev-sync-config"],
|
|
syncModelsDevPricing,
|
|
}));
|
|
|
|
import { ModelsDevAutoSyncPanel } from "@/components/usage/ModelsDevAutoSyncPanel";
|
|
|
|
const state = {
|
|
configPath: "C:/Users/test/.cc-switch/model-pricing.json",
|
|
config: {
|
|
autoSyncEnabled: false,
|
|
includeCommonModels: true,
|
|
selectedModelKeys: [],
|
|
excludedCommonModelKeys: [],
|
|
lastSyncAt: null,
|
|
lastSyncError: null,
|
|
},
|
|
};
|
|
|
|
function renderPanel() {
|
|
const client = new QueryClient({
|
|
defaultOptions: { queries: { retry: false } },
|
|
});
|
|
return render(
|
|
<QueryClientProvider client={client}>
|
|
<ModelsDevAutoSyncPanel />
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
describe("ModelsDevAutoSyncPanel", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
getModelsDevSyncConfig.mockResolvedValue(state);
|
|
saveModelsDevSyncConfig.mockResolvedValue(undefined);
|
|
getModelPricing.mockResolvedValue([]);
|
|
openAppConfigFolder.mockResolvedValue(undefined);
|
|
syncModelsDevPricing.mockResolvedValue({
|
|
skipped: false,
|
|
selected: 2,
|
|
imported: 2,
|
|
changed: 1,
|
|
syncedAt: Date.now(),
|
|
});
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
openai: {
|
|
name: "OpenAI",
|
|
models: {
|
|
"gpt-5": {
|
|
name: "GPT-5",
|
|
release_date: "2025-08-01",
|
|
cost: { input: 1, output: 2 },
|
|
},
|
|
},
|
|
},
|
|
deepseek: {
|
|
name: "DeepSeek",
|
|
models: {
|
|
"deepseek-chat": {
|
|
name: "DeepSeek Chat",
|
|
release_date: "2025-12-01",
|
|
cost: { input: 0.3, output: 1.2 },
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("loads automatic sync as disabled by default", async () => {
|
|
const user = userEvent.setup();
|
|
renderPanel();
|
|
|
|
expect(
|
|
await screen.findByText(
|
|
"usage.pricing.modelsDevTitle:usage.modelsDevAutoSync.disabled",
|
|
),
|
|
).toBeInTheDocument();
|
|
expect(screen.getByRole("switch")).not.toBeChecked();
|
|
// 本地定价文件路径在 ⋯ 菜单里
|
|
await user.click(
|
|
screen.getByRole("button", { name: "usage.pricing.moreActions" }),
|
|
);
|
|
expect(await screen.findByText(state.configPath)).toBeInTheDocument();
|
|
expect(saveModelsDevSyncConfig).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("persists disabling without showing the overwrite warning", async () => {
|
|
const enabledState = {
|
|
...state,
|
|
config: { ...state.config, autoSyncEnabled: true },
|
|
};
|
|
getModelsDevSyncConfig.mockResolvedValue(enabledState);
|
|
renderPanel();
|
|
|
|
fireEvent.click(await screen.findByRole("switch"));
|
|
await waitFor(() =>
|
|
expect(saveModelsDevSyncConfig).toHaveBeenCalledWith({
|
|
...enabledState.config,
|
|
autoSyncEnabled: false,
|
|
}),
|
|
);
|
|
expect(
|
|
screen.queryByText("usage.modelsDevAutoSync.enableConfirmTitle"),
|
|
).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("warns about price overwrites before enabling automatic sync", async () => {
|
|
renderPanel();
|
|
|
|
fireEvent.click(await screen.findByRole("switch"));
|
|
|
|
expect(saveModelsDevSyncConfig).not.toHaveBeenCalled();
|
|
expect(
|
|
await screen.findByText("usage.modelsDevAutoSync.enableConfirmTitle"),
|
|
).toBeInTheDocument();
|
|
fireEvent.click(
|
|
screen.getByRole("button", {
|
|
name: "usage.modelsDevAutoSync.enableConfirmAction",
|
|
}),
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(saveModelsDevSyncConfig).toHaveBeenCalledWith({
|
|
...state.config,
|
|
autoSyncEnabled: true,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("keeps automatic sync disabled when the overwrite warning is cancelled", async () => {
|
|
renderPanel();
|
|
|
|
fireEvent.click(await screen.findByRole("switch"));
|
|
fireEvent.click(
|
|
await screen.findByRole("button", { name: "common.cancel" }),
|
|
);
|
|
|
|
expect(saveModelsDevSyncConfig).not.toHaveBeenCalled();
|
|
expect(screen.getByRole("switch")).not.toBeChecked();
|
|
});
|
|
|
|
it("reloads the automatic sync config after reading the local pricing file", async () => {
|
|
const initialState = {
|
|
...state,
|
|
config: { ...state.config, autoSyncEnabled: true },
|
|
};
|
|
getModelsDevSyncConfig
|
|
.mockResolvedValueOnce(initialState)
|
|
.mockResolvedValue(state);
|
|
const user = userEvent.setup();
|
|
renderPanel();
|
|
|
|
await user.click(
|
|
await screen.findByRole("button", { name: "usage.pricing.moreActions" }),
|
|
);
|
|
await user.click(
|
|
await screen.findByRole("menuitem", {
|
|
name: "usage.modelsDevAutoSync.reloadLocalFile",
|
|
}),
|
|
);
|
|
|
|
await waitFor(() =>
|
|
expect(getModelsDevSyncConfig).toHaveBeenCalledTimes(2),
|
|
);
|
|
expect(getModelPricing).toHaveBeenCalledTimes(1);
|
|
await waitFor(() => expect(screen.getByRole("switch")).not.toBeChecked());
|
|
});
|
|
|
|
it("opens the searchable multi-select dialog with common models selected", async () => {
|
|
renderPanel();
|
|
fireEvent.click(
|
|
await screen.findByRole("button", {
|
|
name: "usage.modelsDevAutoSync.configure",
|
|
}),
|
|
);
|
|
|
|
expect(
|
|
await screen.findByText("usage.modelsDevAutoSync.configureTitle"),
|
|
).toBeInTheDocument();
|
|
expect(await screen.findByText("GPT-5")).toBeInTheDocument();
|
|
expect(screen.getByText("DeepSeek Chat")).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText("usage.modelsDevAutoSync.selectedCount:2"),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.getAllByText("usage.modelsDevAutoSync.commonBadge"),
|
|
).toHaveLength(2);
|
|
});
|
|
});
|