Skip to content

Commit 8e72d71

Browse files
committed
fix(quota): surface "not in plan" for 0/0 + status=3 models
When `current_*_total_count === 0` and `current_*_status === 3`, the API is signalling "not in your plan" (no allowance bucket), not "unlimited". The CLI previously rendered such rows as "100% unlimited" while `/v1/video_generation` immediately rejected the same request with `(0/0 used)` — the inconsistency reported in #173. Split the status=3 path into two cases: - total_count === 0 && status === 3 -> "not in plan" / "不在套餐中" - total_count > 0 && status === 3 -> unchanged: "unlimited" / "无限" Changes: - New `isNotInPlan(total, status)` helper (exported for testing). - `isUnweekly(status, total)` now requires `total > 0` (exported). - `renderMetric` gains a `notInPlan` branch rendered with an empty bar and the `not in plan` / `不在套餐中` label, applied to both interval and weekly columns. Tests: - Two existing assertions that asserted "unlimited" / "无限" with `total=0` were updated; they encoded the bug behaviour. - Added regression coverage for the genuine unlimited path (status=3 + total > 0) and for the interval column. - Added unit tests for `isNotInPlan` and `isUnweekly`. Closes #173
1 parent 5f13ef5 commit 8e72d71

2 files changed

Lines changed: 169 additions & 11 deletions

File tree

src/output/quota-table.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,29 @@ const COMPACT_BAR_WIDTH = 10;
7878
// at 200% to leave headroom and keep the bar/text readable.
7979
const MAX_DISPLAY_PCT = 200;
8080

81-
// Weekly quota is unlimited when the server reports `current_weekly_status: 3`
82-
// (per the status enum: 1=normal, 2=exhausted, 3=unlimited).
83-
function isUnweekly(status: number | undefined | null): boolean {
84-
return status === 3;
81+
// Server-side status enum: 1=normal (limited), 2=exhausted, 3=unlimited.
82+
//
83+
// Caveat: when `total_count === 0 && status === 3` the model is not in the
84+
// user's plan at all (the API conflates "no bucket" with "unlimited").
85+
// Callers must check `isNotInPlan` first; only treat status=3 as truly
86+
// unlimited when there is a real allowance bucket (total > 0).
87+
export function isUnweekly(
88+
status: number | undefined | null,
89+
totalCount: number,
90+
): boolean {
91+
return status === 3 && totalCount > 0;
92+
}
93+
94+
// Detect models whose status=3 actually means "not in your plan" rather
95+
// than unlimited. The signal is `total_count === 0 && status === 3`:
96+
// the server has no allowance bucket (count is zero) but still reports
97+
// status 3, which would otherwise render as "100% unlimited" — the very
98+
// inconsistency reported in #173.
99+
export function isNotInPlan(
100+
totalCount: number,
101+
status: number | undefined | null,
102+
): boolean {
103+
return totalCount === 0 && status === 3;
85104
}
86105

87106
function clampPct(value: number): number {
@@ -124,6 +143,8 @@ function renderBar(remainingPct: number, color: boolean, barWidth: number = BAR_
124143
const UNLIMITED_SYMBOL = '∞';
125144
const UNLIMITED_LABEL_CN = '无限';
126145
const UNLIMITED_LABEL_EN = 'unlimited';
146+
const NOT_IN_PLAN_LABEL_CN = '不在套餐中';
147+
const NOT_IN_PLAN_LABEL_EN = 'not in plan';
127148

128149
function renderMetric(
129150
label: string,
@@ -134,7 +155,18 @@ function renderMetric(
134155
boostPermille?: number | null,
135156
unlimited?: boolean,
136157
unlimitedLabel?: string,
158+
notInPlan?: boolean,
159+
notInPlanLabel?: string,
137160
): string {
161+
if (notInPlan) {
162+
const nip = notInPlanLabel ?? NOT_IN_PLAN_LABEL_EN;
163+
if (color) {
164+
const bar = `${BG_EMPTY}${' '.repeat(COMPACT_BAR_WIDTH)}${R}`;
165+
return `${D}${label}${R} ${bar} ${D}${nip}${R}`;
166+
}
167+
const bar = `[${'.'.repeat(COMPACT_BAR_WIDTH)}]`;
168+
return `${label} ${bar} ${nip}`;
169+
}
138170
if (unlimited) {
139171
const ul = unlimitedLabel ?? UNLIMITED_SYMBOL;
140172
const ulStr = ul.padStart(4);
@@ -169,12 +201,20 @@ export function renderQuotaTable(models: QuotaModelRemain[], config: Config): vo
169201

170202
const rows = models.map((m) => {
171203
const displayName = displayModelName(m.model_name, config.region);
204+
const notInPlanLabel = config.region === 'cn' ? NOT_IN_PLAN_LABEL_CN : NOT_IN_PLAN_LABEL_EN;
205+
const intervalNotInPlan = isNotInPlan(m.current_interval_total_count, m.current_interval_status);
206+
const weeklyNotInPlan = isNotInPlan(m.current_weekly_total_count, m.current_weekly_status);
172207
const current = renderMetric(
173208
L.current,
174209
m.current_interval_usage_count,
175210
m.current_interval_total_count,
176211
m.current_interval_remaining_percent,
177212
useColor,
213+
undefined,
214+
false,
215+
undefined,
216+
intervalNotInPlan,
217+
notInPlanLabel,
178218
);
179219
const weekly = renderMetric(
180220
L.weekly,
@@ -183,8 +223,10 @@ export function renderQuotaTable(models: QuotaModelRemain[], config: Config): vo
183223
m.current_weekly_remaining_percent,
184224
useColor,
185225
m.weekly_boost_permille,
186-
isUnweekly(m.current_weekly_status),
226+
isUnweekly(m.current_weekly_status, m.current_weekly_total_count),
187227
config.region === 'cn' ? UNLIMITED_LABEL_CN : UNLIMITED_LABEL_EN,
228+
weeklyNotInPlan,
229+
notInPlanLabel,
188230
);
189231
const reset = `${L.resetsIn} ${formatDuration(m.remains_time, L.now)}`;
190232
return { displayName, current, weekly, reset };

test/output/quota-table.test.ts

Lines changed: 122 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, it, expect } from 'bun:test';
2-
import { renderQuotaTable } from '../../src/output/quota-table';
2+
import {
3+
renderQuotaTable,
4+
isNotInPlan,
5+
isUnweekly,
6+
} from '../../src/output/quota-table';
37
import type { Config } from '../../src/config/schema';
48
import type { QuotaModelRemain } from '../../src/types/api';
59

@@ -189,7 +193,11 @@ describe('renderQuotaTable', () => {
189193
expect(output).not.toContain('300%');
190194
});
191195

192-
it('renders "无限" for weekly when status=3 (CN region)', () => {
196+
// Behavior change for #173: when status=3 but total_count=0, the API is
197+
// signalling "not in your plan" (a 0/0 bucket), not "unlimited". The CLI
198+
// must surface this so users do not see "100% remaining" for a model
199+
// they cannot actually use.
200+
it('renders "不在套餐中" for weekly when status=3 + total_count=0 (CN region)', () => {
193201
const lines: string[] = [];
194202
const originalLog = console.log;
195203

@@ -216,13 +224,13 @@ describe('renderQuotaTable', () => {
216224
}
217225

218226
const output = lines.join('\n');
219-
expect(output).toContain('[██████████]');
220227
expect(output).toContain('周剩余');
221-
expect(output).toContain('无限');
228+
expect(output).toContain('不在套餐中');
229+
expect(output).not.toContain('无限');
222230
expect(output).not.toContain('150%');
223231
});
224232

225-
it('renders "unlimited" for weekly when status=3 (global region)', () => {
233+
it('renders "not in plan" for weekly when status=3 + total_count=0 (global region)', () => {
226234
const lines: string[] = [];
227235
const originalLog = console.log;
228236

@@ -248,9 +256,117 @@ describe('renderQuotaTable', () => {
248256
}
249257

250258
const output = lines.join('\n');
251-
expect(output).toContain('[██████████]');
259+
expect(output).toContain('Wk left');
260+
expect(output).toContain('not in plan');
261+
expect(output).not.toContain('unlimited');
262+
expect(output).not.toContain('100%');
263+
});
264+
265+
it('still renders "unlimited" when status=3 + total_count > 0 (genuine unlimited)', () => {
266+
const lines: string[] = [];
267+
const originalLog = console.log;
268+
269+
console.log = (message?: unknown) => {
270+
lines.push(String(message ?? ''));
271+
};
272+
273+
try {
274+
renderQuotaTable(
275+
[
276+
{
277+
...createModel(),
278+
current_weekly_total_count: 5000,
279+
current_weekly_usage_count: 100,
280+
current_weekly_remaining_percent: 98,
281+
current_weekly_status: 3,
282+
},
283+
],
284+
{ ...createConfig(), noColor: true },
285+
);
286+
} finally {
287+
console.log = originalLog;
288+
}
289+
290+
const output = lines.join('\n');
252291
expect(output).toContain('Wk left');
253292
expect(output).toContain('unlimited');
293+
expect(output).not.toContain('not in plan');
294+
});
295+
296+
it('renders "not in plan" for interval (current) row when status=3 + total_count=0', () => {
297+
const lines: string[] = [];
298+
const originalLog = console.log;
299+
300+
console.log = (message?: unknown) => {
301+
lines.push(String(message ?? ''));
302+
};
303+
304+
try {
305+
renderQuotaTable(
306+
[
307+
{
308+
...createModel(),
309+
model_name: 'video',
310+
current_interval_total_count: 0,
311+
current_interval_usage_count: 0,
312+
current_interval_remaining_percent: 100,
313+
current_interval_status: 3,
314+
current_weekly_total_count: 0,
315+
current_weekly_usage_count: 0,
316+
current_weekly_remaining_percent: 100,
317+
current_weekly_status: 3,
318+
},
319+
],
320+
{ ...createConfig(), noColor: true },
321+
);
322+
} finally {
323+
console.log = originalLog;
324+
}
325+
326+
const output = lines.join('\n');
327+
expect(output).toContain('Left');
328+
expect(output).toContain('not in plan');
329+
// The bug: previously this rendered "100%" via current_interval_remaining_percent.
254330
expect(output).not.toContain('100%');
255331
});
256332
});
333+
334+
describe('isNotInPlan', () => {
335+
it('returns true when total_count is 0 and status is 3', () => {
336+
expect(isNotInPlan(0, 3)).toBe(true);
337+
});
338+
339+
it('returns false when total_count is positive (real unlimited)', () => {
340+
expect(isNotInPlan(100, 3)).toBe(false);
341+
});
342+
343+
it('returns false when status is not 3 (plain zero quota)', () => {
344+
expect(isNotInPlan(0, 1)).toBe(false);
345+
expect(isNotInPlan(0, 2)).toBe(false);
346+
});
347+
348+
it('returns false when status is undefined or null', () => {
349+
expect(isNotInPlan(0, undefined)).toBe(false);
350+
expect(isNotInPlan(0, null)).toBe(false);
351+
});
352+
});
353+
354+
describe('isUnweekly', () => {
355+
it('returns true only when status is 3 and total_count is positive', () => {
356+
expect(isUnweekly(3, 100)).toBe(true);
357+
});
358+
359+
it('returns false when total_count is 0 (would be not-in-plan)', () => {
360+
expect(isUnweekly(3, 0)).toBe(false);
361+
});
362+
363+
it('returns false when status is not 3', () => {
364+
expect(isUnweekly(1, 100)).toBe(false);
365+
expect(isUnweekly(2, 100)).toBe(false);
366+
});
367+
368+
it('returns false when status is undefined or null', () => {
369+
expect(isUnweekly(undefined, 100)).toBe(false);
370+
expect(isUnweekly(null, 100)).toBe(false);
371+
});
372+
});

0 commit comments

Comments
 (0)