mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-25 17:22:27 +08:00
* feat(usage): restructure the usage page layout - Header: one sync button that shows when it last synced, next to a settings button; the settings sheet now holds data sources, display and pricing. - Scope bar: date first, app chips, and one cascading provider / model menu. - Overview: a selectable metric strip drives the trend chart and heatmap; the four token parts fold into an expandable second row. Hit-rate axis starts near the lowest value; hourly labels are short; new terracotta chart color. - Detail panel: tabs with counts; tables scroll sideways at natural column widths, pin the time / app columns, group columns with wider gaps, and animate rows that arrive on refresh. - Request detail drawer: no per-row dividers, no duplicate session rows. - Empty values show an em dash; dark-mode surfaces use one token per role. * feat(usage): let people pick how many request logs fit on a page The pagination bar gets a rows-per-page menu (10 / 20 / 50, default 20), remembered on this device. It stays visible on a single page as long as there are more rows than the smallest size, so a larger choice can still be undone. Changing the size keeps the first visible row on screen, and the rows it adds are not animated as newly arrived requests. * fix(usage): polish details found in review - New-row highlight keeps a pinned column's edge shadow instead of replacing it. - Pinned cells only follow row hover on rows that can be clicked. - Update a stale comment about the metric strip spacing. * fix(usage): fix issues found in branch review - Heatmap in hit-rate mode: a day with traffic but a 0% hit rate is now shaded instead of looking idle, and the tooltip shows the hit rate. The heatmap reuses the trend chart's hitRateOf instead of its own copy. - Turkish: add the request log column labels and the columns menu title. - Sync button: when auto scan is off, the tooltip says so. - Columns menu: concurrent saves no longer drop later toggles; a failed save reverts only once the last save returns. - Tab counts query only the open tab; other tabs show their cached count. - UsageDashboard uses the shared useElementWidth instead of its own copy. - Note the two-column limit of the pinned-column offset. * fix(usage): keep heatmap tooltip rows on one line The added hit-rate row made the label column wider, so the card near the right edge wrapped values like "7640.5 万" and the label itself. * fix(usage): shorten the auto-scan-off sync tooltip to one line The longer sentence wrapped inside the pill-shaped tooltip and broke mid-word. * fix(usage): open pricing as a sub-page so its editor can be used Pricing lived in a modal Dialog, but the add/edit pricing editor is a FullScreenPanel portaled into #content-area. The dialog's overlay sat above it and Radix set pointer-events: none on body while the dialog was open, so the editor's fields and save button could not be used. Pricing is now a FullScreenPanel too, the same pattern as other nested panels. * fix(usage): show exact metric values in the app tooltip The metric strip used native title attributes for the exact numbers. Use HoverTip instead, anchored to the number and opening to the right, so it does not cover the expanded token row. No tip while the cell shows a placeholder.
176 lines
5.8 KiB
TypeScript
176 lines
5.8 KiB
TypeScript
import { render, screen } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { UsageDisplaySection } from "@/components/usage/UsageDisplaySection";
|
|
|
|
const pickCurrency = async (
|
|
user: ReturnType<typeof userEvent.setup>,
|
|
code: string,
|
|
) => {
|
|
await user.click(
|
|
screen.getByRole("combobox", { name: "usage.display.currency" }),
|
|
);
|
|
await user.click(
|
|
await screen.findByRole("option", { name: new RegExp(`^${code}`) }),
|
|
);
|
|
};
|
|
|
|
describe("UsageDisplaySection", () => {
|
|
// jsdom 没有 scrollIntoView,下拉打开时会调
|
|
beforeAll(() => {
|
|
Element.prototype.scrollIntoView = vi.fn();
|
|
});
|
|
|
|
it("offers every display currency and hides the rate while showing USD", async () => {
|
|
const user = userEvent.setup();
|
|
const onChange = vi.fn();
|
|
render(<UsageDisplaySection value={undefined} onChange={onChange} />);
|
|
|
|
expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
|
|
await user.click(
|
|
screen.getByRole("combobox", { name: "usage.display.currency" }),
|
|
);
|
|
const options = await screen.findAllByRole("option");
|
|
expect(options.map((o) => o.textContent?.slice(0, 3))).toEqual([
|
|
"USD",
|
|
"CNY",
|
|
"EUR",
|
|
"JPY",
|
|
"TWD",
|
|
"TRY",
|
|
]);
|
|
await user.keyboard("{Escape}");
|
|
|
|
await pickCurrency(user, "JPY");
|
|
expect(onChange).toHaveBeenCalledWith({ currency: "JPY" });
|
|
// 预置汇率:用户没填过时从这里起步
|
|
expect(screen.getByRole("textbox")).toHaveValue("158");
|
|
});
|
|
|
|
it("rejects a bad rate and keeps the rates of other currencies", async () => {
|
|
const user = userEvent.setup();
|
|
const onChange = vi.fn();
|
|
render(
|
|
<UsageDisplaySection
|
|
value={{ currency: "CNY", exchangeRates: { CNY: 7, EUR: 0.9 } }}
|
|
onChange={onChange}
|
|
/>,
|
|
);
|
|
const input = screen.getByRole("textbox");
|
|
expect(input).toHaveValue("7");
|
|
|
|
await user.clear(input);
|
|
await user.type(input, "0{Enter}");
|
|
expect(screen.getByText("usage.display.rateInvalid")).toBeInTheDocument();
|
|
expect(input).toHaveAttribute("aria-invalid", "true");
|
|
expect(onChange).not.toHaveBeenCalled();
|
|
|
|
await user.clear(input);
|
|
await user.type(input, "7.25{Enter}");
|
|
expect(onChange).toHaveBeenCalledWith({
|
|
currency: "CNY",
|
|
exchangeRates: { CNY: 7.25, EUR: 0.9 },
|
|
});
|
|
});
|
|
|
|
it("switches the rate box to the rate saved for the picked currency", async () => {
|
|
const user = userEvent.setup();
|
|
const onChange = vi.fn();
|
|
render(
|
|
<UsageDisplaySection
|
|
value={{ currency: "CNY", exchangeRates: { CNY: 7, EUR: 0.9 } }}
|
|
onChange={onChange}
|
|
/>,
|
|
);
|
|
await pickCurrency(user, "EUR");
|
|
expect(screen.getByRole("textbox")).toHaveValue("0.9");
|
|
});
|
|
|
|
it("does not write the preset rate when it was left unchanged", async () => {
|
|
const user = userEvent.setup();
|
|
const onChange = vi.fn();
|
|
render(
|
|
<UsageDisplaySection value={{ currency: "TWD" }} onChange={onChange} />,
|
|
);
|
|
await user.type(screen.getByRole("textbox"), "{Enter}");
|
|
expect(onChange).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("picks up to two items for the sidebar Today label", async () => {
|
|
const user = userEvent.setup();
|
|
const onChange = vi.fn();
|
|
render(
|
|
<UsageDisplaySection value={{ currency: "CNY" }} onChange={onChange} />,
|
|
);
|
|
const trigger = screen.getByRole("button", {
|
|
name: /^usage\.display\.sidebarToday:/,
|
|
});
|
|
expect(trigger).toHaveTextContent("usage.display.sidebarTodayItems.cost");
|
|
|
|
const item = (key: string) =>
|
|
screen.getByRole("menuitemcheckbox", {
|
|
name: `usage.display.sidebarTodayItems.${key}`,
|
|
});
|
|
await user.click(trigger);
|
|
expect(
|
|
await screen.findByText("usage.display.sidebarTodayLimit"),
|
|
).toBeInTheDocument();
|
|
expect(item("cost")).toHaveAttribute("aria-checked", "true");
|
|
|
|
// 勾的先后不影响顺序:按菜单顺序存
|
|
await user.click(item("hitRate"));
|
|
expect(onChange).toHaveBeenLastCalledWith({
|
|
currency: "CNY",
|
|
sidebarTodayItems: ["cost", "hitRate"],
|
|
});
|
|
expect(trigger).toHaveTextContent(
|
|
"usage.display.sidebarTodayItems.cost + usage.display.sidebarTodayItems.hitRate",
|
|
);
|
|
|
|
// 勾满两项:没勾的灰掉,点了也不变
|
|
expect(item("requests")).toHaveAttribute("aria-disabled", "true");
|
|
expect(item("tokens")).toHaveAttribute("aria-disabled", "true");
|
|
await user.click(item("requests"));
|
|
expect(onChange).toHaveBeenCalledTimes(1);
|
|
|
|
// 去掉一项后其余的又能勾;全去掉=不显示,存空列表
|
|
await user.click(item("cost"));
|
|
expect(onChange).toHaveBeenLastCalledWith({
|
|
currency: "CNY",
|
|
sidebarTodayItems: ["hitRate"],
|
|
});
|
|
expect(item("requests")).not.toHaveAttribute("aria-disabled");
|
|
await user.click(item("hitRate"));
|
|
expect(onChange).toHaveBeenLastCalledWith({
|
|
currency: "CNY",
|
|
sidebarTodayItems: [],
|
|
});
|
|
expect(trigger).toHaveTextContent("usage.display.sidebarTodayItems.none");
|
|
});
|
|
|
|
it("picks the trend chart style and keeps the other display settings", async () => {
|
|
const user = userEvent.setup();
|
|
const onChange = vi.fn();
|
|
render(
|
|
<UsageDisplaySection value={{ currency: "CNY" }} onChange={onChange} />,
|
|
);
|
|
const trigger = screen.getByRole("combobox", {
|
|
name: "usage.display.trendChartType",
|
|
});
|
|
// 没设置过按柱形图
|
|
expect(trigger).toHaveTextContent("usage.display.trendChartTypes.bar");
|
|
|
|
await user.click(trigger);
|
|
await user.click(
|
|
await screen.findByRole("option", {
|
|
name: "usage.display.trendChartTypes.area",
|
|
}),
|
|
);
|
|
expect(onChange).toHaveBeenCalledWith({
|
|
currency: "CNY",
|
|
trendChartType: "area",
|
|
});
|
|
expect(trigger).toHaveTextContent("usage.display.trendChartTypes.area");
|
|
});
|
|
});
|