From 179f72da93ef612760fcbc1ee57a7ba398c95309 Mon Sep 17 00:00:00 2001 From: SaladDay Date: Thu, 2 Jul 2026 06:27:40 +0000 Subject: [PATCH] fix(tray): render Codex free-plan 30-day window in tray menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src-tauri/src/services/subscription.rs | 22 ++++++++++++++++------ src-tauri/src/tray.rs | 21 ++++++++++++++++++--- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/services/subscription.rs b/src-tauri/src/services/subscription.rs index 5d6cbf58d..dfe8d0a0e 100644 --- a/src-tauri/src/services/subscription.rs +++ b/src-tauri/src/services/subscription.rs @@ -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"); diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index ea614838a..0b159ae67 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -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("a).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 掩盖。