From 09895fa71102f1e3f08f14d87a4e6e3e5d2848b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=95=85=E7=92=83?= Date: Thu, 15 Oct 2026 11:22:15 +0800 Subject: [PATCH] fix(usage): read token-plan quota windows dynamically and flag missing data (#231) --- .../commands/src/commands/usage/token-plan.ts | 98 +++++++++++++------ .../tests/e2e/usage-token-plan.e2e.test.ts | 15 ++- .../commands/tests/token-plan-usage.test.ts | 55 ++++++++++- 3 files changed, 128 insertions(+), 40 deletions(-) diff --git a/packages/commands/src/commands/usage/token-plan.ts b/packages/commands/src/commands/usage/token-plan.ts index 9e308850..411ff0f3 100644 --- a/packages/commands/src/commands/usage/token-plan.ts +++ b/packages/commands/src/commands/usage/token-plan.ts @@ -1,40 +1,82 @@ import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core"; import { emitResult } from "bailian-cli-runtime"; -import { printQuotaBox, readNumber } from "./quota-box.ts"; +import { printQuotaBox, readNumber, type QuotaSection } from "./quota-box.ts"; const TOKEN_PLAN_USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage"; -interface TokenPlanUsage { - per1MonthPercentage?: number; - per1MonthResetTime?: number; +/** Quota window fields look like per5HourPercentage / per1WeekResetTime / per1MonthPercentage. */ +const WINDOW_FIELD_PATTERN = /^per(\d+)(Hour|Day|Week|Month)(Percentage|ResetTime)$/; + +/** Personal plans are metered monthly; render an empty monthly window when the API returns none. */ +const DEFAULT_WINDOW_KEY = "per1Month"; + +const UNIT_HOURS: Record = { Hour: 1, Day: 24, Week: 24 * 7, Month: 24 * 30 }; + +interface TokenPlanWindow { + /** Field prefix, e.g. per1Month. */ + key: string; + count: number; + unit: string; + percentage?: number; + resetTime?: number; } -function readUsage(result: unknown): TokenPlanUsage { - const response = unwrapResponse(result as Record); - const usage: TokenPlanUsage = {}; +/** + * JSON output: keeps the API's per* quota fields as-is; hasQuotaData tells whether any usage + * ratio was read, so consumers can tell "no data" apart from a normal result. + */ +type TokenPlanUsage = Record; - const per1MonthPercentage = readNumber(response.per1MonthPercentage); - if (per1MonthPercentage !== undefined) usage.per1MonthPercentage = per1MonthPercentage; - const per1MonthResetTime = readNumber(response.per1MonthResetTime); - if (per1MonthResetTime !== undefined) usage.per1MonthResetTime = per1MonthResetTime; +/** Discover windows from the per* fields actually present, so metering changes don't break parsing. */ +function readWindows(response: Record): TokenPlanWindow[] { + const windows = new Map(); + for (const [field, value] of Object.entries(response)) { + const match = WINDOW_FIELD_PATTERN.exec(field); + if (!match) continue; + const numberValue = readNumber(value); + if (numberValue === undefined) continue; + + const [, count, unit, kind] = match; + const key = `per${count}${unit}`; + const window = windows.get(key) ?? { key, count: Number(count), unit }; + if (kind === "Percentage") window.percentage = numberValue; + else window.resetTime = numberValue; + windows.set(key, window); + } + return [...windows.values()].sort( + (left, right) => left.count * UNIT_HOURS[left.unit] - right.count * UNIT_HOURS[right.unit], + ); +} +function toJson(windows: TokenPlanWindow[]): TokenPlanUsage { + const usage: TokenPlanUsage = {}; + for (const window of windows) { + if (window.percentage !== undefined) usage[`${window.key}Percentage`] = window.percentage; + if (window.resetTime !== undefined) usage[`${window.key}ResetTime`] = window.resetTime; + } + usage.hasQuotaData = windows.some((window) => window.percentage !== undefined); return usage; } -function printView(usage: TokenPlanUsage, generatedAt: number): void { - printQuotaBox( - "Token Plan Usage", - [ - { - label: "Monthly quota", - emptyMessage: - "The monthly limit may be unlimited; verify in the Bailian Token Plan console.", - percentage: usage.per1MonthPercentage, - resetTime: usage.per1MonthResetTime, - }, - ], - generatedAt, - ); +function windowName(window: Pick): string { + if (window.count === 1 && window.unit === "Month") return "monthly"; + return `${window.count}-${window.unit.toLowerCase()}`; +} + +function toSection(window: TokenPlanWindow): QuotaSection { + const name = windowName(window); + return { + label: `${name.charAt(0).toUpperCase()}${name.slice(1)} quota`, + emptyMessage: `The ${name} limit may be unlimited; verify in the Bailian Token Plan console.`, + percentage: window.percentage, + resetTime: window.resetTime, + }; +} + +function printView(windows: TokenPlanWindow[], generatedAt: number): void { + const visibleWindows = + windows.length > 0 ? windows : [{ key: DEFAULT_WINDOW_KEY, count: 1, unit: "Month" }]; + printQuotaBox("Token Plan Usage", visibleWindows.map(toSection), generatedAt); } export default defineCommand({ @@ -55,13 +97,13 @@ export default defineCommand({ } const result = await ctx.client.console(TOKEN_PLAN_USAGE_API, {}); - const usage = readUsage(result); + const windows = readWindows(unwrapResponse(result as Record)); if (format === "json") { - emitResult(usage, format); + emitResult(toJson(windows), format); return; } - printView(usage, Date.now()); + printView(windows, Date.now()); }, }); diff --git a/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts b/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts index 63fe287c..53f7536c 100644 --- a/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts +++ b/packages/commands/tests/e2e/usage-token-plan.e2e.test.ts @@ -49,21 +49,18 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () = const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--output", "json"]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); - const data = parseStdoutJson<{ - per1MonthPercentage?: number; - per1MonthResetTime?: number; - }>(result.stdout); - const fields = [data.per1MonthPercentage, data.per1MonthResetTime]; - for (const field of fields) { - if (field !== undefined) expect(field).toBeTypeOf("number"); + const data = parseStdoutJson>(result.stdout); + expect(data.hasQuotaData).toBeTypeOf("boolean"); + for (const [field, value] of Object.entries(data)) { + if (field.startsWith("per")) expect(value).toBeTypeOf("number"); } }); - test("usage token-plan 默认渲染生成时间与月额度窗口", async () => { + test("usage token-plan 默认渲染生成时间与额度窗口", async () => { const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan"]); if (isConsoleAuthFailure(result)) return; expect(result.exitCode, result.stderr).toBe(0); expect(result.stdout).toContain("Generated at:"); - expect(result.stdout).toContain("Monthly quota"); + expect(result.stdout).toMatch(/(Monthly|\d+-(hour|day|week|month)) quota/); }); }); diff --git a/packages/commands/tests/token-plan-usage.test.ts b/packages/commands/tests/token-plan-usage.test.ts index 7335067d..c09430fa 100644 --- a/packages/commands/tests/token-plan-usage.test.ts +++ b/packages/commands/tests/token-plan-usage.test.ts @@ -124,6 +124,28 @@ describe("usage token-plan view", () => { "The monthly limit may be unlimited; verify in the Bailian Token Plan console.", ); }); + + test("renders every quota window returned by the API, ordered by duration", async () => { + process.env.NO_COLOR = "1"; + const output = captureStdout(); + + await runTokenPlan( + wrapResponse({ + per1WeekPercentage: 0.13, + per1WeekResetTime: 1_786_417_680_000, + per5HourPercentage: 0.43, + per5HourResetTime: 1_785_868_560_000, + }), + ); + + const renderedOutput = output.join(""); + expect(renderedOutput).toContain("5-hour quota"); + expect(renderedOutput).toContain("1-week quota"); + expect(renderedOutput).not.toContain("Monthly quota"); + expect(renderedOutput.indexOf("5-hour quota")).toBeLessThan( + renderedOutput.indexOf("1-week quota"), + ); + }); }); describe("usage token-plan json", () => { @@ -135,15 +157,39 @@ describe("usage token-plan json", () => { expect(JSON.parse(output.join(""))).toEqual({ per1MonthPercentage: 0.5, per1MonthResetTime: 1_786_000_000_000, + hasQuotaData: true, }); }); - test("returns an empty JSON object when no quota fields are available", async () => { + test("flags the absence of quota data instead of returning a bare empty object", async () => { const output = captureStdout(); await runTokenPlan(makeUsageResponse(), "json"); - expect(output.join("").trim()).toBe("{}"); + expect(JSON.parse(output.join(""))).toEqual({ hasQuotaData: false }); + }); + + test("outputs every per* quota window returned by the API", async () => { + const output = captureStdout(); + + await runTokenPlan( + wrapResponse({ + per5HourPercentage: 0.43, + per5HourResetTime: 1_785_868_560_000, + per1WeekPercentage: 0.13, + per1WeekResetTime: 1_786_417_680_000, + requestId: "ignored", + }), + "json", + ); + + expect(JSON.parse(output.join(""))).toEqual({ + per5HourPercentage: 0.43, + per5HourResetTime: 1_785_868_560_000, + per1WeekPercentage: 0.13, + per1WeekResetTime: 1_786_417_680_000, + hasQuotaData: true, + }); }); test("omits non-numeric quota fields from the JSON output", async () => { @@ -154,6 +200,9 @@ describe("usage token-plan json", () => { "json", ); - expect(JSON.parse(output.join(""))).toEqual({ per1MonthResetTime: 1_786_000_000_000 }); + expect(JSON.parse(output.join(""))).toEqual({ + per1MonthResetTime: 1_786_000_000_000, + hasQuotaData: false, + }); }); });