Files
cc-switch/tests/components/UsageDisplaySection.test.tsx
mashiro 79ba7982aa feat(usage): restructure the usage page layout (#8711)
* 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.
2026-10-23 10:59:12 +08:00

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");
});
});