fix(tray): render Codex free-plan 30-day window in tray menu

The tray keeps its own tier-name whitelist (TIER_LABEL_GROUPS) independent
of the frontend TIER_I18N_KEYS. Its month group only listed "monthly", so a
free Codex account whose only tier is "30_day" produced empty labeled parts
→ format_subscription_summary returned None → the tray showed no quota, even
though the footer now does. That breaks the invariant the existing
gemini_summary_lite_only_still_renders test guards: any tier visible in the
footer must not leave the tray blank.

- Add TIER_THIRTY_DAY ("30_day") constant so the string is single-sourced
  across backend mapping, tray grouping, and the frontend whitelist
- Map 2_592_000s explicitly to it in window_seconds_to_tier_name
- Add TIER_THIRTY_DAY to the tray month ("m") group
- Add codex_summary_thirty_day_only_still_renders regression test

Follow-up to the review on #3651 / PR for the 30-day footer fix.
This commit is contained in:
SaladDay
2026-07-02 06:27:40 +00:00
parent 3023dc14e0
commit 179f72da93
2 changed files with 34 additions and 9 deletions
+16 -6
View File
@@ -312,6 +312,12 @@ pub const TIER_WEEKLY_LIMIT: &str = "weekly_limit";
/// 映射到 `subscription.monthly`。
pub const TIER_MONTHLY: &str = "monthly";
/// Codex 免费方案的 30 天(月)滚动窗口 tier 名。付费方案的次要窗口是 7 天
/// (`seven_day`),免费方案则是 30 天。由 `window_seconds_to_tier_name` 产出、
/// tray 的月分组渲染、前端 `TIER_I18N_KEYS` 映射到 `subscription.thirtyDay`
/// 三处共用同一标识。见 #3651。
pub const TIER_THIRTY_DAY: &str = "30_day";
/// Gemini 用量分组名称(按模型而非时间窗口)。`classify_gemini_model` 输出。
pub const TIER_GEMINI_PRO: &str = "gemini_pro";
pub const TIER_GEMINI_FLASH: &str = "gemini_flash";
@@ -632,8 +638,12 @@ struct CodexUsageResponse {
/// 根据窗口秒数映射到 tier 名称(与 Claude 的命名兼容以复用前端 i18n)
fn window_seconds_to_tier_name(secs: i64) -> String {
match secs {
18000 => "five_hour".to_string(),
604800 => "seven_day".to_string(),
18000 => TIER_FIVE_HOUR.to_string(),
604800 => TIER_SEVEN_DAY.to_string(),
// Codex 免费方案的 30 天窗口。显式映射到常量,与 tray 月分组、前端
// TIER_I18N_KEYS 保持同一标识(否则动态回退虽也得到 "30_day",但字符串
// 分散在多处、易和托盘/前端白名单脱节)。见 #3651。
2_592_000 => TIER_THIRTY_DAY.to_string(),
s => {
let hours = s / 3600;
if hours >= 24 {
@@ -1348,11 +1358,11 @@ mod tests {
#[test]
fn window_seconds_map_to_expected_tier_names() {
// 官方特例窗口
assert_eq!(window_seconds_to_tier_name(18000), "five_hour");
assert_eq!(window_seconds_to_tier_name(604800), "seven_day");
assert_eq!(window_seconds_to_tier_name(18000), TIER_FIVE_HOUR);
assert_eq!(window_seconds_to_tier_name(604800), TIER_SEVEN_DAY);
// Codex 免费方案的次要窗口是 30 天(30 * 24 * 3600 = 2_592_000 秒)。
// 前端 TIER_I18N_KEYS 需要有对应的 "30_day" 才能展示,见 #3651。
assert_eq!(window_seconds_to_tier_name(2_592_000), "30_day");
// 前端 TIER_I18N_KEYS 与 tray 月分组都需要认得 "30_day",见 #3651。
assert_eq!(window_seconds_to_tier_name(2_592_000), TIER_THIRTY_DAY);
// 其他窗口按小时/天回退命名
assert_eq!(window_seconds_to_tier_name(3600), "1_hour");
assert_eq!(window_seconds_to_tier_name(86400), "1_day");
+18 -3
View File
@@ -19,8 +19,13 @@ const W_TIER_NAMES: &[&str] = &[
crate::services::subscription::TIER_SEVEN_DAY_OPUS,
crate::services::subscription::TIER_SEVEN_DAY_SONNET,
];
// 火山方舟 Agent/Coding Plan 的月窗口(5h/周/月 三档)
const M_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_MONTHLY];
// 月窗口分组:火山方舟 Agent/Coding Plan 的月窗口(5h/周/月 三档)
// 以及 Codex 免费方案的 30 天窗口(#3651)——两者都归入 "m" 档,避免免费
// Codex 账号在托盘里空白(前端 footer 能看到、托盘却不显示的不对称)。
const M_TIER_NAMES: &[&str] = &[
crate::services::subscription::TIER_MONTHLY,
crate::services::subscription::TIER_THIRTY_DAY,
];
const GEMINI_PRO_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_PRO];
const GEMINI_FLASH_TIER_NAMES: &[&str] = &[crate::services::subscription::TIER_GEMINI_FLASH];
const GEMINI_FLASH_LITE_TIER_NAMES: &[&str] =
@@ -882,7 +887,7 @@ mod tests {
use crate::services::subscription::{
CredentialStatus, QuotaTier, SubscriptionQuota, TIER_FIVE_HOUR, TIER_GEMINI_FLASH,
TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_MONTHLY, TIER_SEVEN_DAY, TIER_SEVEN_DAY_OPUS,
TIER_SEVEN_DAY_SONNET, TIER_WEEKLY_LIMIT,
TIER_SEVEN_DAY_SONNET, TIER_THIRTY_DAY, TIER_WEEKLY_LIMIT,
};
#[test]
@@ -964,6 +969,16 @@ mod tests {
assert!(s.contains("l80%"), "expected l80% in {s}");
}
#[test]
fn codex_summary_thirty_day_only_still_renders() {
// Codex 免费方案的唯一 tier 是 30 天窗口。前端 footer 已能显示(TIER_I18N_KEYS
// 有 "30_day"),托盘也必须能显示——否则就是这条不变量要防的非对称:footer
// 能看到、托盘却空白。30_day 归入 "m" 月分组。见 #3651。
let quota = make_quota("codex", true, vec![tier(TIER_THIRTY_DAY, 85.0)]);
let s = format_subscription_summary(&quota).expect("should format");
assert!(s.contains("m85%"), "expected m85% in {s}");
}
#[test]
fn gemini_summary_emoji_reflects_highest_tier_including_lite() {
// lite 是利用率最高的那条 → emoji 必须是红色,不能被 pro/flash 掩盖。