From e44207901cac017513a8d92958d91e3fb1f50861 Mon Sep 17 00:00:00 2001 From: shangzy Date: Fri, 8 May 2026 17:31:17 +0800 Subject: [PATCH] feat: add token breakdown helpers --- lib/detail-sort.test.ts | 12 +++++++ lib/detail-sort.ts | 6 ++-- lib/token-breakdown.test.ts | 66 +++++++++++++++++++++++++++++++++++++ lib/token-breakdown.ts | 35 ++++++++++++++++++++ 4 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 lib/token-breakdown.test.ts create mode 100644 lib/token-breakdown.ts diff --git a/lib/detail-sort.test.ts b/lib/detail-sort.test.ts index 5f23dca..2b17e65 100644 --- a/lib/detail-sort.test.ts +++ b/lib/detail-sort.test.ts @@ -20,4 +20,16 @@ describe("sortDetailBreakdown", () => { expect(sorted.map((row) => row.name)).toEqual(["gamma", "alpha", "beta"]); }); + + test("preserves subtype fields while sorting breakdown rows", () => { + const rows = [ + { name: "beta", calls: 2, total_tokens: 100, quota: 500, models: [{ name: "m2", calls: 1, total_tokens: 20, quota: 10 }] }, + { name: "alpha", calls: 5, total_tokens: 200, quota: 300, models: [{ name: "m1", calls: 1, total_tokens: 30, quota: 20 }] }, + ]; + + const sorted = sortDetailBreakdown(rows, "total_tokens", false); + + expect(sorted[0].models[0].name).toBe("m1"); + expect(rows[0].models[0].name).toBe("m2"); + }); }); diff --git a/lib/detail-sort.ts b/lib/detail-sort.ts index 36fd0f9..eb6a701 100644 --- a/lib/detail-sort.ts +++ b/lib/detail-sort.ts @@ -7,11 +7,11 @@ export interface DetailBreakdownItem { export type DetailBreakdownSortKey = "calls" | "total_tokens" | "quota"; -export function sortDetailBreakdown( - items: DetailBreakdownItem[], +export function sortDetailBreakdown( + items: T[], sortKey: DetailBreakdownSortKey, sortAsc: boolean -): DetailBreakdownItem[] { +): T[] { return [...items].sort((a, b) => { const diff = a[sortKey] - b[sortKey]; if (diff !== 0) return sortAsc ? diff : -diff; diff --git a/lib/token-breakdown.test.ts b/lib/token-breakdown.test.ts new file mode 100644 index 0000000..ffa99e7 --- /dev/null +++ b/lib/token-breakdown.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { + getPrimaryModelNames, + getSharePercent, + getTokenDisplayName, + getTokenRowKey, + shouldShowTokenTab, + sortTokenBreakdown, +} from "./token-breakdown"; + +describe("token breakdown helpers", () => { + const rows = [ + { + name: "beta", + calls: 2, + total_tokens: 100, + quota: 500, + models: [{ name: "gpt-4o", calls: 1, total_tokens: 70, quota: 300 }], + }, + { + name: "alpha", + calls: 5, + total_tokens: 300, + quota: 300, + models: [{ name: "claude-sonnet-4", calls: 1, total_tokens: 200, quota: 200 }], + }, + ]; + + test("sorts token rows without mutating nested model data", () => { + const sorted = sortTokenBreakdown(rows, "total_tokens", false); + + expect(sorted.map((row) => row.name)).toEqual(["alpha", "beta"]); + expect(sorted[0].models[0].name).toBe("claude-sonnet-4"); + expect(rows.map((row) => row.name)).toEqual(["beta", "alpha"]); + }); + + test("uses a localized label for unnamed tokens", () => { + expect(getTokenDisplayName("", "未命名令牌")).toBe("未命名令牌"); + expect(getTokenDisplayName("prod-key", "未命名令牌")).toBe("prod-key"); + }); + + test("keeps unnamed token row keys stable", () => { + expect(getTokenRowKey("")).toBe("__unnamed_token__"); + expect(getTokenRowKey("prod-key")).toBe("prod-key"); + }); + + test("shows the token tab only for user details", () => { + expect(shouldShowTokenTab("user")).toBe(true); + expect(shouldShowTokenTab("model")).toBe(false); + expect(shouldShowTokenTab("channel")).toBe(false); + }); + + test("calculates share percentages and handles empty totals", () => { + expect(getSharePercent(25, 100)).toBe(25); + expect(getSharePercent(25, 0)).toBe(0); + }); + + test("formats a compact primary model preview", () => { + expect(getPrimaryModelNames([ + { name: "a", calls: 1, total_tokens: 1, quota: 1 }, + { name: "b", calls: 1, total_tokens: 1, quota: 1 }, + { name: "c", calls: 1, total_tokens: 1, quota: 1 }, + { name: "d", calls: 1, total_tokens: 1, quota: 1 }, + ])).toBe("a, b, c"); + }); +}); diff --git a/lib/token-breakdown.ts b/lib/token-breakdown.ts new file mode 100644 index 0000000..4e9081a --- /dev/null +++ b/lib/token-breakdown.ts @@ -0,0 +1,35 @@ +import { sortDetailBreakdown, type DetailBreakdownItem, type DetailBreakdownSortKey } from "./detail-sort"; + +export interface TokenBreakdownItem extends DetailBreakdownItem { + models: DetailBreakdownItem[]; +} + +export type TokenBreakdownSortKey = DetailBreakdownSortKey; + +export function sortTokenBreakdown( + items: TokenBreakdownItem[], + sortKey: TokenBreakdownSortKey, + sortAsc: boolean +): TokenBreakdownItem[] { + return sortDetailBreakdown(items, sortKey, sortAsc); +} + +export function getTokenDisplayName(name: string, unnamedLabel: string): string { + return name === "" ? unnamedLabel : name; +} + +export function getTokenRowKey(name: string): string { + return name === "" ? "__unnamed_token__" : name; +} + +export function shouldShowTokenTab(detailType: string): boolean { + return detailType === "user"; +} + +export function getSharePercent(part: number, total: number): number { + return total > 0 ? (part / total) * 100 : 0; +} + +export function getPrimaryModelNames(models: DetailBreakdownItem[], limit = 3): string { + return models.slice(0, limit).map((model) => model.name || "(unknown)").join(", "); +}