Skip to content

Commit 09895fa

Browse files
committed
fix(usage): read token-plan quota windows dynamically and flag missing data (#231)
1 parent c3d4d4b commit 09895fa

3 files changed

Lines changed: 128 additions & 40 deletions

File tree

Lines changed: 70 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,82 @@
11
import { defineCommand, detectOutputFormat, unwrapResponse } from "bailian-cli-core";
22
import { emitResult } from "bailian-cli-runtime";
3-
import { printQuotaBox, readNumber } from "./quota-box.ts";
3+
import { printQuotaBox, readNumber, type QuotaSection } from "./quota-box.ts";
44

55
const TOKEN_PLAN_USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
66

7-
interface TokenPlanUsage {
8-
per1MonthPercentage?: number;
9-
per1MonthResetTime?: number;
7+
/** Quota window fields look like per5HourPercentage / per1WeekResetTime / per1MonthPercentage. */
8+
const WINDOW_FIELD_PATTERN = /^per(\d+)(Hour|Day|Week|Month)(Percentage|ResetTime)$/;
9+
10+
/** Personal plans are metered monthly; render an empty monthly window when the API returns none. */
11+
const DEFAULT_WINDOW_KEY = "per1Month";
12+
13+
const UNIT_HOURS: Record<string, number> = { Hour: 1, Day: 24, Week: 24 * 7, Month: 24 * 30 };
14+
15+
interface TokenPlanWindow {
16+
/** Field prefix, e.g. per1Month. */
17+
key: string;
18+
count: number;
19+
unit: string;
20+
percentage?: number;
21+
resetTime?: number;
1022
}
1123

12-
function readUsage(result: unknown): TokenPlanUsage {
13-
const response = unwrapResponse(result as Record<string, unknown>);
14-
const usage: TokenPlanUsage = {};
24+
/**
25+
* JSON output: keeps the API's per* quota fields as-is; hasQuotaData tells whether any usage
26+
* ratio was read, so consumers can tell "no data" apart from a normal result.
27+
*/
28+
type TokenPlanUsage = Record<string, number | boolean>;
1529

16-
const per1MonthPercentage = readNumber(response.per1MonthPercentage);
17-
if (per1MonthPercentage !== undefined) usage.per1MonthPercentage = per1MonthPercentage;
18-
const per1MonthResetTime = readNumber(response.per1MonthResetTime);
19-
if (per1MonthResetTime !== undefined) usage.per1MonthResetTime = per1MonthResetTime;
30+
/** Discover windows from the per* fields actually present, so metering changes don't break parsing. */
31+
function readWindows(response: Record<string, unknown>): TokenPlanWindow[] {
32+
const windows = new Map<string, TokenPlanWindow>();
33+
for (const [field, value] of Object.entries(response)) {
34+
const match = WINDOW_FIELD_PATTERN.exec(field);
35+
if (!match) continue;
36+
const numberValue = readNumber(value);
37+
if (numberValue === undefined) continue;
38+
39+
const [, count, unit, kind] = match;
40+
const key = `per${count}${unit}`;
41+
const window = windows.get(key) ?? { key, count: Number(count), unit };
42+
if (kind === "Percentage") window.percentage = numberValue;
43+
else window.resetTime = numberValue;
44+
windows.set(key, window);
45+
}
46+
return [...windows.values()].sort(
47+
(left, right) => left.count * UNIT_HOURS[left.unit] - right.count * UNIT_HOURS[right.unit],
48+
);
49+
}
2050

51+
function toJson(windows: TokenPlanWindow[]): TokenPlanUsage {
52+
const usage: TokenPlanUsage = {};
53+
for (const window of windows) {
54+
if (window.percentage !== undefined) usage[`${window.key}Percentage`] = window.percentage;
55+
if (window.resetTime !== undefined) usage[`${window.key}ResetTime`] = window.resetTime;
56+
}
57+
usage.hasQuotaData = windows.some((window) => window.percentage !== undefined);
2158
return usage;
2259
}
2360

24-
function printView(usage: TokenPlanUsage, generatedAt: number): void {
25-
printQuotaBox(
26-
"Token Plan Usage",
27-
[
28-
{
29-
label: "Monthly quota",
30-
emptyMessage:
31-
"The monthly limit may be unlimited; verify in the Bailian Token Plan console.",
32-
percentage: usage.per1MonthPercentage,
33-
resetTime: usage.per1MonthResetTime,
34-
},
35-
],
36-
generatedAt,
37-
);
61+
function windowName(window: Pick<TokenPlanWindow, "count" | "unit">): string {
62+
if (window.count === 1 && window.unit === "Month") return "monthly";
63+
return `${window.count}-${window.unit.toLowerCase()}`;
64+
}
65+
66+
function toSection(window: TokenPlanWindow): QuotaSection {
67+
const name = windowName(window);
68+
return {
69+
label: `${name.charAt(0).toUpperCase()}${name.slice(1)} quota`,
70+
emptyMessage: `The ${name} limit may be unlimited; verify in the Bailian Token Plan console.`,
71+
percentage: window.percentage,
72+
resetTime: window.resetTime,
73+
};
74+
}
75+
76+
function printView(windows: TokenPlanWindow[], generatedAt: number): void {
77+
const visibleWindows =
78+
windows.length > 0 ? windows : [{ key: DEFAULT_WINDOW_KEY, count: 1, unit: "Month" }];
79+
printQuotaBox("Token Plan Usage", visibleWindows.map(toSection), generatedAt);
3880
}
3981

4082
export default defineCommand({
@@ -55,13 +97,13 @@ export default defineCommand({
5597
}
5698

5799
const result = await ctx.client.console(TOKEN_PLAN_USAGE_API, {});
58-
const usage = readUsage(result);
100+
const windows = readWindows(unwrapResponse(result as Record<string, unknown>));
59101

60102
if (format === "json") {
61-
emitResult(usage, format);
103+
emitResult(toJson(windows), format);
62104
return;
63105
}
64106

65-
printView(usage, Date.now());
107+
printView(windows, Date.now());
66108
},
67109
});

‎packages/commands/tests/e2e/usage-token-plan.e2e.test.ts‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,21 +49,18 @@ describe.skipIf(!isConsoleE2EReady())("e2e: usage token-plan(Console)", () =
4949
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan", "--output", "json"]);
5050
if (isConsoleAuthFailure(result)) return;
5151
expect(result.exitCode, result.stderr).toBe(0);
52-
const data = parseStdoutJson<{
53-
per1MonthPercentage?: number;
54-
per1MonthResetTime?: number;
55-
}>(result.stdout);
56-
const fields = [data.per1MonthPercentage, data.per1MonthResetTime];
57-
for (const field of fields) {
58-
if (field !== undefined) expect(field).toBeTypeOf("number");
52+
const data = parseStdoutJson<Record<string, unknown>>(result.stdout);
53+
expect(data.hasQuotaData).toBeTypeOf("boolean");
54+
for (const [field, value] of Object.entries(data)) {
55+
if (field.startsWith("per")) expect(value).toBeTypeOf("number");
5956
}
6057
});
6158

62-
test("usage token-plan 默认渲染生成时间与月额度窗口", async () => {
59+
test("usage token-plan 默认渲染生成时间与额度窗口", async () => {
6360
const result = await runCommandE2e(USAGE_ROUTES, ["usage", "token-plan"]);
6461
if (isConsoleAuthFailure(result)) return;
6562
expect(result.exitCode, result.stderr).toBe(0);
6663
expect(result.stdout).toContain("Generated at:");
67-
expect(result.stdout).toContain("Monthly quota");
64+
expect(result.stdout).toMatch(/(Monthly|\d+-(hour|day|week|month)) quota/);
6865
});
6966
});

‎packages/commands/tests/token-plan-usage.test.ts‎

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,28 @@ describe("usage token-plan view", () => {
124124
"The monthly limit may be unlimited; verify in the Bailian Token Plan console.",
125125
);
126126
});
127+
128+
test("renders every quota window returned by the API, ordered by duration", async () => {
129+
process.env.NO_COLOR = "1";
130+
const output = captureStdout();
131+
132+
await runTokenPlan(
133+
wrapResponse({
134+
per1WeekPercentage: 0.13,
135+
per1WeekResetTime: 1_786_417_680_000,
136+
per5HourPercentage: 0.43,
137+
per5HourResetTime: 1_785_868_560_000,
138+
}),
139+
);
140+
141+
const renderedOutput = output.join("");
142+
expect(renderedOutput).toContain("5-hour quota");
143+
expect(renderedOutput).toContain("1-week quota");
144+
expect(renderedOutput).not.toContain("Monthly quota");
145+
expect(renderedOutput.indexOf("5-hour quota")).toBeLessThan(
146+
renderedOutput.indexOf("1-week quota"),
147+
);
148+
});
127149
});
128150

129151
describe("usage token-plan json", () => {
@@ -135,15 +157,39 @@ describe("usage token-plan json", () => {
135157
expect(JSON.parse(output.join(""))).toEqual({
136158
per1MonthPercentage: 0.5,
137159
per1MonthResetTime: 1_786_000_000_000,
160+
hasQuotaData: true,
138161
});
139162
});
140163

141-
test("returns an empty JSON object when no quota fields are available", async () => {
164+
test("flags the absence of quota data instead of returning a bare empty object", async () => {
142165
const output = captureStdout();
143166

144167
await runTokenPlan(makeUsageResponse(), "json");
145168

146-
expect(output.join("").trim()).toBe("{}");
169+
expect(JSON.parse(output.join(""))).toEqual({ hasQuotaData: false });
170+
});
171+
172+
test("outputs every per* quota window returned by the API", async () => {
173+
const output = captureStdout();
174+
175+
await runTokenPlan(
176+
wrapResponse({
177+
per5HourPercentage: 0.43,
178+
per5HourResetTime: 1_785_868_560_000,
179+
per1WeekPercentage: 0.13,
180+
per1WeekResetTime: 1_786_417_680_000,
181+
requestId: "ignored",
182+
}),
183+
"json",
184+
);
185+
186+
expect(JSON.parse(output.join(""))).toEqual({
187+
per5HourPercentage: 0.43,
188+
per5HourResetTime: 1_785_868_560_000,
189+
per1WeekPercentage: 0.13,
190+
per1WeekResetTime: 1_786_417_680_000,
191+
hasQuotaData: true,
192+
});
147193
});
148194

149195
test("omits non-numeric quota fields from the JSON output", async () => {
@@ -154,6 +200,9 @@ describe("usage token-plan json", () => {
154200
"json",
155201
);
156202

157-
expect(JSON.parse(output.join(""))).toEqual({ per1MonthResetTime: 1_786_000_000_000 });
203+
expect(JSON.parse(output.join(""))).toEqual({
204+
per1MonthResetTime: 1_786_000_000_000,
205+
hasQuotaData: false,
206+
});
158207
});
159208
});

0 commit comments

Comments
 (0)