mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +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.
239 lines
8.3 KiB
TypeScript
239 lines
8.3 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
||
import {
|
||
buildUsageTrendChartData,
|
||
createUsageTrendTokenTickFormatter,
|
||
formatUsageTrendTokenTickLabel,
|
||
formatUsageTrendTickLabel,
|
||
hitRateAxisFloor,
|
||
} from "@/components/usage/UsageTrendChart";
|
||
|
||
const day = (isoDate: string) =>
|
||
({
|
||
date: `${isoDate}T12:00:00.000Z`,
|
||
totalInputTokens: 100,
|
||
totalOutputTokens: 50,
|
||
totalCacheCreationTokens: 0,
|
||
totalCacheReadTokens: 0,
|
||
totalCost: "0.01",
|
||
}) as const;
|
||
|
||
describe("buildUsageTrendChartData (#6302)", () => {
|
||
it("keeps unique x-axis keys when the same MM/DD appears in multiple years", () => {
|
||
// 2025-04-27 and 2026-04-27 share the same MM/DD tick text in single-year
|
||
// formatting. Using that text as the Recharts category key made activeDots
|
||
// jump to the earlier year's point while the tooltip followed the cursor.
|
||
const startDate = Math.floor(Date.parse("2025-01-01T00:00:00Z") / 1000);
|
||
const endDate = Math.floor(Date.parse("2026-08-10T00:00:00Z") / 1000);
|
||
|
||
const points = buildUsageTrendChartData(
|
||
[day("2025-04-27"), day("2026-04-27")],
|
||
{
|
||
isHourly: false,
|
||
dateLocale: "en-US",
|
||
startDate,
|
||
endDate,
|
||
},
|
||
);
|
||
|
||
expect(points).toHaveLength(2);
|
||
expect(points[0].xKey).not.toBe(points[1].xKey);
|
||
expect(points[0].xKey).toContain("2025-04-27");
|
||
expect(points[1].xKey).toContain("2026-04-27");
|
||
// Tooltip always carries a year so the user can tell which April it is.
|
||
expect(points[0].tooltipLabel).toMatch(/2025/);
|
||
expect(points[1].tooltipLabel).toMatch(/2026/);
|
||
});
|
||
|
||
it("includes a year in the axis tick when the selected range spans years", () => {
|
||
const startDate = Math.floor(Date.parse("2025-01-01T00:00:00Z") / 1000);
|
||
const endDate = Math.floor(Date.parse("2026-08-10T00:00:00Z") / 1000);
|
||
|
||
const points = buildUsageTrendChartData([day("2026-04-27")], {
|
||
isHourly: false,
|
||
dateLocale: "en-US",
|
||
startDate,
|
||
endDate,
|
||
});
|
||
|
||
expect(points[0].label).toMatch(/26|2026/);
|
||
});
|
||
|
||
it("keeps short MM/DD ticks for single-year ranges", () => {
|
||
const startDate = Math.floor(Date.parse("2026-01-01T00:00:00Z") / 1000);
|
||
const endDate = Math.floor(Date.parse("2026-08-10T00:00:00Z") / 1000);
|
||
|
||
const points = buildUsageTrendChartData([day("2026-04-27")], {
|
||
isHourly: false,
|
||
dateLocale: "en-US",
|
||
startDate,
|
||
endDate,
|
||
});
|
||
|
||
// en-US 2-digit month/day — should not need a year prefix inside one year.
|
||
expect(points[0].label).not.toMatch(/2026/);
|
||
expect(points[0].tooltipLabel).toMatch(/2026/);
|
||
});
|
||
});
|
||
|
||
describe("hourly tick labels", () => {
|
||
const hour = (iso: string) => ({
|
||
date: iso,
|
||
totalInputTokens: 1,
|
||
totalOutputTokens: 1,
|
||
totalCacheCreationTokens: 0,
|
||
totalCacheReadTokens: 0,
|
||
totalCost: "0",
|
||
});
|
||
|
||
it("shows only the hour within one day, and the date where a new day starts", () => {
|
||
const at = (h: number, d = 22) => new Date(2026, 9, d, h).toISOString();
|
||
const sec = (date: Date) => Math.floor(date.getTime() / 1000);
|
||
|
||
const sameDay = buildUsageTrendChartData([hour(at(1)), hour(at(13))], {
|
||
isHourly: true,
|
||
dateLocale: "en-US",
|
||
startDate: sec(new Date(2026, 9, 22, 0)),
|
||
endDate: sec(new Date(2026, 9, 22, 23, 59)),
|
||
});
|
||
expect(sameDay.map((p) => p.label)).toEqual(["01:00", "13:00"]);
|
||
// 悬停提示仍然写全日期
|
||
expect(sameDay[0].tooltipLabel).toContain("10/22/2026");
|
||
|
||
const acrossMidnight = buildUsageTrendChartData(
|
||
[hour(at(23, 21)), hour(at(0)), hour(at(1))],
|
||
{
|
||
isHourly: true,
|
||
dateLocale: "en-US",
|
||
startDate: sec(new Date(2026, 9, 21, 20)),
|
||
endDate: sec(new Date(2026, 9, 22, 20)),
|
||
},
|
||
);
|
||
expect(acrossMidnight.map((p) => p.label)).toEqual([
|
||
"23:00",
|
||
"10/22",
|
||
"01:00",
|
||
]);
|
||
});
|
||
});
|
||
|
||
describe("formatUsageTrendTickLabel", () => {
|
||
it("resolves labels by xKey even when the tick index is thinned", () => {
|
||
const startDate = Math.floor(Date.parse("2025-01-01T00:00:00Z") / 1000);
|
||
const endDate = Math.floor(Date.parse("2026-08-10T00:00:00Z") / 1000);
|
||
const points = buildUsageTrendChartData(
|
||
[
|
||
{
|
||
date: "2025-01-01T12:00:00.000Z",
|
||
totalInputTokens: 1,
|
||
totalOutputTokens: 1,
|
||
totalCacheCreationTokens: 0,
|
||
totalCacheReadTokens: 0,
|
||
totalCost: "0",
|
||
},
|
||
{
|
||
date: "2025-04-27T12:00:00.000Z",
|
||
totalInputTokens: 1,
|
||
totalOutputTokens: 1,
|
||
totalCacheCreationTokens: 0,
|
||
totalCacheReadTokens: 0,
|
||
totalCost: "0",
|
||
},
|
||
{
|
||
date: "2026-04-27T12:00:00.000Z",
|
||
totalInputTokens: 1,
|
||
totalOutputTokens: 1,
|
||
totalCacheCreationTokens: 0,
|
||
totalCacheReadTokens: 0,
|
||
totalCost: "0",
|
||
},
|
||
],
|
||
{ isHourly: false, dateLocale: "en-US", startDate, endDate },
|
||
);
|
||
|
||
// Simulate Recharts passing a later category as the only visible tick
|
||
// (filtered index 0 would wrongly map to the first chart row).
|
||
const last = points[2];
|
||
expect(formatUsageTrendTickLabel(last.xKey, points)).toBe(last.label);
|
||
expect(formatUsageTrendTickLabel(last.xKey, points)).not.toBe(
|
||
points[0].label,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe("formatUsageTrendTokenTickLabel", () => {
|
||
it("uses localized compact units for large token axis ticks", () => {
|
||
const zhFormatter = createUsageTrendTokenTickFormatter("zh-CN");
|
||
const zhTwFormatter = createUsageTrendTokenTickFormatter("zh-TW");
|
||
const enFormatter = createUsageTrendTokenTickFormatter("en-US");
|
||
|
||
expect(formatUsageTrendTokenTickLabel(600_000_000, zhFormatter)).toBe(
|
||
"6亿",
|
||
);
|
||
expect(formatUsageTrendTokenTickLabel(1_950_000_000, zhFormatter)).toBe(
|
||
"19.5亿",
|
||
);
|
||
expect(formatUsageTrendTokenTickLabel(65_000_000, zhTwFormatter)).toBe(
|
||
"6.5千萬",
|
||
);
|
||
expect(formatUsageTrendTokenTickLabel(600_000_000, enFormatter)).toBe(
|
||
"600M",
|
||
);
|
||
});
|
||
|
||
it("writes four-digit wan / yi ticks with a thousand unit in CJK", () => {
|
||
const zh = createUsageTrendTokenTickFormatter("zh-CN");
|
||
const zhTw = createUsageTrendTokenTickFormatter("zh-TW");
|
||
const ja = createUsageTrendTokenTickFormatter("ja-JP");
|
||
const en = createUsageTrendTokenTickFormatter("en-US");
|
||
|
||
expect(formatUsageTrendTokenTickLabel(50_000_000, zh)).toBe("5千万");
|
||
expect(formatUsageTrendTokenTickLabel(15_000_000, zh)).toBe("1.5千万");
|
||
expect(formatUsageTrendTokenTickLabel(200_000_000_000, zh)).toBe("2千亿");
|
||
expect(formatUsageTrendTokenTickLabel(50_000_000, zhTw)).toBe("5千萬");
|
||
expect(formatUsageTrendTokenTickLabel(50_000_000, ja)).toBe("5千万");
|
||
expect(formatUsageTrendTokenTickLabel(200_000_000_000, ja)).toBe("2千億");
|
||
// 三位以内照旧,进位到 10 的交回原格式
|
||
expect(formatUsageTrendTokenTickLabel(5_000_000, zh)).toBe("500万");
|
||
expect(formatUsageTrendTokenTickLabel(100_000_000, zh)).toBe("1亿");
|
||
expect(formatUsageTrendTokenTickLabel(99_600_000, zh)).toBe("9960万");
|
||
expect(formatUsageTrendTokenTickLabel(50_000_000, en)).toBe("50M");
|
||
});
|
||
|
||
it("keeps zero and small-thousand token ticks readable", () => {
|
||
expect(
|
||
formatUsageTrendTokenTickLabel(
|
||
0,
|
||
createUsageTrendTokenTickFormatter("zh-CN"),
|
||
),
|
||
).toBe("0");
|
||
expect(
|
||
formatUsageTrendTokenTickLabel(
|
||
1500,
|
||
createUsageTrendTokenTickFormatter("en-US"),
|
||
),
|
||
).toBe("1.5K");
|
||
});
|
||
});
|
||
|
||
describe("hitRateAxisFloor", () => {
|
||
it("starts the axis a little below the lowest hit rate, on a 5% step", () => {
|
||
// 最低 96%:余量 2 个百分点 → 94% → 取整到 90%
|
||
expect(hitRateAxisFloor([0.99, 0.96, null, 0.985])).toBe(0.9);
|
||
// 最低 98.5%:98.5 − 2 = 96.5 → 95%
|
||
expect(hitRateAxisFloor([0.985, 0.99])).toBe(0.95);
|
||
// 最低 97%:97 − 2 = 95,正好落在档上,不多掉一档
|
||
expect(hitRateAxisFloor([0.97])).toBe(0.95);
|
||
// 最低 80%:余量 10 个百分点 → 70%
|
||
expect(hitRateAxisFloor([0.8, 0.95])).toBe(0.7);
|
||
});
|
||
|
||
it("falls back to 0 for low rates or no data", () => {
|
||
// 最低 37%:算出来 5%,低于 50% 就不截断
|
||
expect(hitRateAxisFloor([0.37, 0.99])).toBe(0);
|
||
// 最低 70%:余量 15 个百分点 → 55%
|
||
expect(hitRateAxisFloor([0.7])).toBe(0.55);
|
||
expect(hitRateAxisFloor([null, null])).toBe(0);
|
||
expect(hitRateAxisFloor([])).toBe(0);
|
||
});
|
||
});
|