From 8e72d715849cb3ae7285dff8783066734e55b6cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=96=BD=E5=B8=83=E5=B8=83?= <2247274521@qq.com> Date: Tue, 16 Jun 2026 15:47:16 +0800 Subject: [PATCH] fix(quota): surface "not in plan" for 0/0 + status=3 models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/output/quota-table.ts | 52 +++++++++++-- test/output/quota-table.test.ts | 128 ++++++++++++++++++++++++++++++-- 2 files changed, 169 insertions(+), 11 deletions(-) diff --git a/src/output/quota-table.ts b/src/output/quota-table.ts index 8241c78..96c7893 100644 --- a/src/output/quota-table.ts +++ b/src/output/quota-table.ts @@ -78,10 +78,29 @@ const COMPACT_BAR_WIDTH = 10; // at 200% to leave headroom and keep the bar/text readable. const MAX_DISPLAY_PCT = 200; -// Weekly quota is unlimited when the server reports `current_weekly_status: 3` -// (per the status enum: 1=normal, 2=exhausted, 3=unlimited). -function isUnweekly(status: number | undefined | null): boolean { - return status === 3; +// Server-side status enum: 1=normal (limited), 2=exhausted, 3=unlimited. +// +// Caveat: when `total_count === 0 && status === 3` the model is not in the +// user's plan at all (the API conflates "no bucket" with "unlimited"). +// Callers must check `isNotInPlan` first; only treat status=3 as truly +// unlimited when there is a real allowance bucket (total > 0). +export function isUnweekly( + status: number | undefined | null, + totalCount: number, +): boolean { + return status === 3 && totalCount > 0; +} + +// Detect models whose status=3 actually means "not in your plan" rather +// than unlimited. The signal is `total_count === 0 && status === 3`: +// the server has no allowance bucket (count is zero) but still reports +// status 3, which would otherwise render as "100% unlimited" — the very +// inconsistency reported in #173. +export function isNotInPlan( + totalCount: number, + status: number | undefined | null, +): boolean { + return totalCount === 0 && status === 3; } function clampPct(value: number): number { @@ -124,6 +143,8 @@ function renderBar(remainingPct: number, color: boolean, barWidth: number = BAR_ const UNLIMITED_SYMBOL = '∞'; const UNLIMITED_LABEL_CN = '无限'; const UNLIMITED_LABEL_EN = 'unlimited'; +const NOT_IN_PLAN_LABEL_CN = '不在套餐中'; +const NOT_IN_PLAN_LABEL_EN = 'not in plan'; function renderMetric( label: string, @@ -134,7 +155,18 @@ function renderMetric( boostPermille?: number | null, unlimited?: boolean, unlimitedLabel?: string, + notInPlan?: boolean, + notInPlanLabel?: string, ): string { + if (notInPlan) { + const nip = notInPlanLabel ?? NOT_IN_PLAN_LABEL_EN; + if (color) { + const bar = `${BG_EMPTY}${' '.repeat(COMPACT_BAR_WIDTH)}${R}`; + return `${D}${label}${R} ${bar} ${D}${nip}${R}`; + } + const bar = `[${'.'.repeat(COMPACT_BAR_WIDTH)}]`; + return `${label} ${bar} ${nip}`; + } if (unlimited) { const ul = unlimitedLabel ?? UNLIMITED_SYMBOL; const ulStr = ul.padStart(4); @@ -169,12 +201,20 @@ export function renderQuotaTable(models: QuotaModelRemain[], config: Config): vo const rows = models.map((m) => { const displayName = displayModelName(m.model_name, config.region); + const notInPlanLabel = config.region === 'cn' ? NOT_IN_PLAN_LABEL_CN : NOT_IN_PLAN_LABEL_EN; + const intervalNotInPlan = isNotInPlan(m.current_interval_total_count, m.current_interval_status); + const weeklyNotInPlan = isNotInPlan(m.current_weekly_total_count, m.current_weekly_status); const current = renderMetric( L.current, m.current_interval_usage_count, m.current_interval_total_count, m.current_interval_remaining_percent, useColor, + undefined, + false, + undefined, + intervalNotInPlan, + notInPlanLabel, ); const weekly = renderMetric( L.weekly, @@ -183,8 +223,10 @@ export function renderQuotaTable(models: QuotaModelRemain[], config: Config): vo m.current_weekly_remaining_percent, useColor, m.weekly_boost_permille, - isUnweekly(m.current_weekly_status), + isUnweekly(m.current_weekly_status, m.current_weekly_total_count), config.region === 'cn' ? UNLIMITED_LABEL_CN : UNLIMITED_LABEL_EN, + weeklyNotInPlan, + notInPlanLabel, ); const reset = `${L.resetsIn} ${formatDuration(m.remains_time, L.now)}`; return { displayName, current, weekly, reset }; diff --git a/test/output/quota-table.test.ts b/test/output/quota-table.test.ts index 5f24a00..bd83945 100644 --- a/test/output/quota-table.test.ts +++ b/test/output/quota-table.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'bun:test'; -import { renderQuotaTable } from '../../src/output/quota-table'; +import { + renderQuotaTable, + isNotInPlan, + isUnweekly, +} from '../../src/output/quota-table'; import type { Config } from '../../src/config/schema'; import type { QuotaModelRemain } from '../../src/types/api'; @@ -189,7 +193,11 @@ describe('renderQuotaTable', () => { expect(output).not.toContain('300%'); }); - it('renders "无限" for weekly when status=3 (CN region)', () => { + // Behavior change for #173: when status=3 but total_count=0, the API is + // signalling "not in your plan" (a 0/0 bucket), not "unlimited". The CLI + // must surface this so users do not see "100% remaining" for a model + // they cannot actually use. + it('renders "不在套餐中" for weekly when status=3 + total_count=0 (CN region)', () => { const lines: string[] = []; const originalLog = console.log; @@ -216,13 +224,13 @@ describe('renderQuotaTable', () => { } const output = lines.join('\n'); - expect(output).toContain('[██████████]'); expect(output).toContain('周剩余'); - expect(output).toContain('无限'); + expect(output).toContain('不在套餐中'); + expect(output).not.toContain('无限'); expect(output).not.toContain('150%'); }); - it('renders "unlimited" for weekly when status=3 (global region)', () => { + it('renders "not in plan" for weekly when status=3 + total_count=0 (global region)', () => { const lines: string[] = []; const originalLog = console.log; @@ -248,9 +256,117 @@ describe('renderQuotaTable', () => { } const output = lines.join('\n'); - expect(output).toContain('[██████████]'); + expect(output).toContain('Wk left'); + expect(output).toContain('not in plan'); + expect(output).not.toContain('unlimited'); + expect(output).not.toContain('100%'); + }); + + it('still renders "unlimited" when status=3 + total_count > 0 (genuine unlimited)', () => { + const lines: string[] = []; + const originalLog = console.log; + + console.log = (message?: unknown) => { + lines.push(String(message ?? '')); + }; + + try { + renderQuotaTable( + [ + { + ...createModel(), + current_weekly_total_count: 5000, + current_weekly_usage_count: 100, + current_weekly_remaining_percent: 98, + current_weekly_status: 3, + }, + ], + { ...createConfig(), noColor: true }, + ); + } finally { + console.log = originalLog; + } + + const output = lines.join('\n'); expect(output).toContain('Wk left'); expect(output).toContain('unlimited'); + expect(output).not.toContain('not in plan'); + }); + + it('renders "not in plan" for interval (current) row when status=3 + total_count=0', () => { + const lines: string[] = []; + const originalLog = console.log; + + console.log = (message?: unknown) => { + lines.push(String(message ?? '')); + }; + + try { + renderQuotaTable( + [ + { + ...createModel(), + model_name: 'video', + current_interval_total_count: 0, + current_interval_usage_count: 0, + current_interval_remaining_percent: 100, + current_interval_status: 3, + current_weekly_total_count: 0, + current_weekly_usage_count: 0, + current_weekly_remaining_percent: 100, + current_weekly_status: 3, + }, + ], + { ...createConfig(), noColor: true }, + ); + } finally { + console.log = originalLog; + } + + const output = lines.join('\n'); + expect(output).toContain('Left'); + expect(output).toContain('not in plan'); + // The bug: previously this rendered "100%" via current_interval_remaining_percent. expect(output).not.toContain('100%'); }); }); + +describe('isNotInPlan', () => { + it('returns true when total_count is 0 and status is 3', () => { + expect(isNotInPlan(0, 3)).toBe(true); + }); + + it('returns false when total_count is positive (real unlimited)', () => { + expect(isNotInPlan(100, 3)).toBe(false); + }); + + it('returns false when status is not 3 (plain zero quota)', () => { + expect(isNotInPlan(0, 1)).toBe(false); + expect(isNotInPlan(0, 2)).toBe(false); + }); + + it('returns false when status is undefined or null', () => { + expect(isNotInPlan(0, undefined)).toBe(false); + expect(isNotInPlan(0, null)).toBe(false); + }); +}); + +describe('isUnweekly', () => { + it('returns true only when status is 3 and total_count is positive', () => { + expect(isUnweekly(3, 100)).toBe(true); + }); + + it('returns false when total_count is 0 (would be not-in-plan)', () => { + expect(isUnweekly(3, 0)).toBe(false); + }); + + it('returns false when status is not 3', () => { + expect(isUnweekly(1, 100)).toBe(false); + expect(isUnweekly(2, 100)).toBe(false); + }); + + it('returns false when status is undefined or null', () => { + expect(isUnweekly(undefined, 100)).toBe(false); + expect(isUnweekly(null, 100)).toBe(false); + }); +});