mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
feat(tray): show cached provider usage in the system tray menu (#2184)
* feat: add Rust-side write-through usage cache Introduce an in-memory UsageCache on AppState that the existing usage query commands populate on success. The cache is read-only to the rest of the app today; the next commit consumes it from the tray menu. - New services::usage_cache module with split maps: subscription keyed by AppType, script keyed by (AppType, provider_id). - AppType gains Eq + Hash so it can be used as a HashMap key. - commands::subscription::get_subscription_quota now takes State<AppState> and writes through on success (signature change is invisible to the frontend — Tauri injects State automatically). - commands::provider::queryProviderUsage body extracted into an inner async fn; the public command wraps it with write-through, covering Copilot, coding-plan, balance, and generic script paths uniformly. Cache is in-memory only; auto-query interval and the upcoming tray refresh action rebuild it after restarts. * feat(tray): surface cached usage in the system tray menu Read UsageCache populated by the previous commit and render it in three places, scoped to whatever TRAY_SECTIONS covers (Claude/Codex/Gemini): 1. Inline suffix on each provider submenu item "AnyProvider · 🟢 5h 18% / 7d 23%" 2. Disabled summary row per visible app under "Show Main" "Claude · Anthropic Official · 🟢 5h 18% / 7d 23%" 3. "Refresh all usage" menu item that triggers get_subscription_quota + queryProviderUsage for every applicable provider, then rebuilds the tray menu via the existing refresh_tray_menu path. Color encoding uses emoji (🟢 <70% / 🟠 70-89% / 🔴 ≥90%) since Tauri 2 tray labels are plain text. Missing cache entry leaves the label unchanged — tray never issues network requests when opened. Three new i18n-ready strings live in TrayTexts (en/zh/ja), following the existing pattern for tray text. Closes #2178. * feat(usage): bridge tray UsageCache writes to frontend React Query Why: tray hover triggers backend-only refresh that wrote to UsageCache but never notified the frontend, leaving main UI stale while tray showed fresh numbers. Emit a payload-carrying event after each cache write so React Query can setQueryData directly, keeping both views in sync without duplicate fetches. * fix(tray): skip hidden apps on hover refresh and drop stale disabled-script cache Address P2 findings from automated review on #2184: 1. refresh_all_usage_in_tray now filters TRAY_SECTIONS by settings.visible_apps before scheduling subscription/script queries, matching create_tray_menu and preventing wasted external API calls (and rate-limit/auth-error log noise) for apps the user has hidden. 2. format_usage_suffix only trusts the script cache when provider.meta.usage_script is still enabled; when a script is disabled/removed the cached suffix is now invalidated so the tray label no longer shows stale data indefinitely. * refactor: consolidate codex provider helpers and fix test semantics - Add Provider::is_codex_oauth() and Provider::codex_fast_mode_enabled() to eliminate duplicated meta extraction in claude.rs and stream_check.rs - Fix non-codex-oauth tests to pass codex_fast_mode=false (was true, harmless but semantically misleading) - Remove redundant is_dir() guard after resolve_skill_source_dir already guarantees the returned path is a directory * style: apply cargo fmt * fix(tray): reflect failed refreshes in cache and support Gemini flash-lite Follow-up to the tray usage-display feature addressing review feedback: - Write snapshots for both Ok(success:false) and Err paths in queryProviderUsage / get_subscription_quota so stale success data no longer persists across failed refreshes; the original Err is still returned to the frontend onError handler. - Include gemini_flash_lite tier in the tray summary with label "l". Matches the frontend SubscriptionQuotaFooter and keeps the worst emoji correct when lite is the highest utilization. - Add TIER_GEMINI_PRO / _FLASH / _FLASH_LITE constants in services/subscription.rs and reuse them in classify_gemini_model and sort_order. - Extract Provider::has_usage_script_enabled() to remove the duplicated meta.usage_script chain at two call sites. - Use db.get_provider_by_id in refresh_all_usage_in_tray instead of materialising the full provider map, and parallelise subscription and script futures via futures::future::join. - Narrow refresh_all_usage_in_tray to each section's effective current provider (script if enabled, else subscription when the provider is official). Hover refreshes now issue at most TRAY_SECTIONS.len() outbound requests. - Add 10 unit tests in tray::tests covering Claude/Codex h/w dispatch, Gemini p/f/l dispatch (including lite-only and lite-worst cases), and success/failure guards. --------- Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
+462
-18
@@ -2,13 +2,20 @@
|
||||
//!
|
||||
//! 负责系统托盘图标和菜单的创建、更新和事件处理。
|
||||
|
||||
use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem, SubmenuBuilder};
|
||||
use once_cell::sync::Lazy;
|
||||
use tauri::menu::{CheckMenuItem, Menu, MenuBuilder, MenuItem, Submenu, SubmenuBuilder};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
use crate::app_config::AppType;
|
||||
use crate::error::AppError;
|
||||
use crate::store::AppState;
|
||||
|
||||
/// 每个 app 分区的子菜单句柄,用于 usage 更新时就地改 label 而非整菜单重建。
|
||||
/// `create_tray_menu` 每次重建都会整表覆盖写入,保证句柄始终指向当前活跃菜单。
|
||||
static TRAY_SECTION_SUBMENUS: Lazy<
|
||||
std::sync::Mutex<std::collections::HashMap<AppType, Submenu<tauri::Wry>>>,
|
||||
> = Lazy::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
|
||||
|
||||
/// 托盘菜单文本(国际化)
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct TrayTexts {
|
||||
@@ -84,6 +91,140 @@ pub const TRAY_SECTIONS: [TrayAppSection; 3] = [
|
||||
},
|
||||
];
|
||||
|
||||
/// 配色阈值(与前端 `utilizationColor` 语义一致)。
|
||||
const UTIL_WARN_PCT: f64 = 70.0;
|
||||
const UTIL_DANGER_PCT: f64 = 90.0;
|
||||
|
||||
fn emoji_for_utilization(pct: f64) -> &'static str {
|
||||
if pct >= UTIL_DANGER_PCT {
|
||||
"\u{1F534}" // 🔴
|
||||
} else if pct >= UTIL_WARN_PCT {
|
||||
"\u{1F7E0}" // 🟠
|
||||
} else {
|
||||
"\u{1F7E2}" // 🟢
|
||||
}
|
||||
}
|
||||
|
||||
fn format_subscription_summary(
|
||||
quota: &crate::services::subscription::SubscriptionQuota,
|
||||
) -> Option<String> {
|
||||
use crate::services::subscription::{
|
||||
TIER_FIVE_HOUR, TIER_GEMINI_FLASH, TIER_GEMINI_FLASH_LITE, TIER_GEMINI_PRO, TIER_SEVEN_DAY,
|
||||
};
|
||||
if !quota.success {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 按 tool 选取主卡槽 tier 并映射到短 label:
|
||||
// Claude / Codex 沿用时间窗口(h=5 小时,w=7 天);
|
||||
// Gemini 用模型维度(p=pro,f=flash,l=flash-lite)——Gemini 后端 tier
|
||||
// 命名是 gemini_pro / gemini_flash / gemini_flash_lite,与时间窗口不同命名空间。
|
||||
// flash_lite 必须纳入:否则 lite 利用率最高时色标偏低,与前端 footer 行为不一致。
|
||||
let parts: Vec<(&'static str, f64)> = match quota.tool.as_str() {
|
||||
"gemini" => {
|
||||
let mut v = Vec::new();
|
||||
if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_GEMINI_PRO) {
|
||||
v.push(("p", t.utilization));
|
||||
}
|
||||
if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_GEMINI_FLASH) {
|
||||
v.push(("f", t.utilization));
|
||||
}
|
||||
if let Some(t) = quota
|
||||
.tiers
|
||||
.iter()
|
||||
.find(|t| t.name == TIER_GEMINI_FLASH_LITE)
|
||||
{
|
||||
v.push(("l", t.utilization));
|
||||
}
|
||||
v
|
||||
}
|
||||
_ => {
|
||||
let mut v = Vec::new();
|
||||
if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_FIVE_HOUR) {
|
||||
v.push(("h", t.utilization));
|
||||
}
|
||||
if let Some(t) = quota.tiers.iter().find(|t| t.name == TIER_SEVEN_DAY) {
|
||||
v.push(("w", t.utilization));
|
||||
}
|
||||
v
|
||||
}
|
||||
};
|
||||
|
||||
if parts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 色标取所有已选 tier 里最高的利用率——用户更关心"离上限多近"。
|
||||
let worst = parts
|
||||
.iter()
|
||||
.map(|(_, u)| *u)
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
if !worst.is_finite() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let emoji = emoji_for_utilization(worst);
|
||||
let body = parts
|
||||
.iter()
|
||||
.map(|(label, u)| format!("{label}{}%", u.round() as i64))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
Some(format!("{emoji} {body}"))
|
||||
}
|
||||
|
||||
fn format_script_summary(result: &crate::provider::UsageResult) -> Option<String> {
|
||||
if !result.success {
|
||||
return None;
|
||||
}
|
||||
let data = result.data.as_ref()?.first()?;
|
||||
let pct = match (data.used, data.total) {
|
||||
(Some(used), Some(total)) if total > 0.0 => used / total * 100.0,
|
||||
_ => return None,
|
||||
};
|
||||
let emoji = emoji_for_utilization(pct);
|
||||
let plan = data.plan_name.as_deref().unwrap_or("");
|
||||
let rounded = pct.round() as i64;
|
||||
if plan.is_empty() {
|
||||
Some(format!("{} {}%", emoji, rounded))
|
||||
} else {
|
||||
Some(format!("{} {} {}%", emoji, plan, rounded))
|
||||
}
|
||||
}
|
||||
|
||||
fn format_usage_suffix(
|
||||
app_state: &AppState,
|
||||
app_type: &AppType,
|
||||
provider: &crate::provider::Provider,
|
||||
provider_id: &str,
|
||||
) -> Option<String> {
|
||||
// 当前脚本是否启用:禁用/删除时不再沿用旧 UsageCache 结果,
|
||||
// 并顺手 invalidate,防止后续重建继续命中过期数据。
|
||||
if provider.has_usage_script_enabled() {
|
||||
// 脚本缓存优先(覆盖 Copilot/coding_plan/balance/自定义脚本),借用访问避免克隆整条 UsageResult。
|
||||
if let Some(Some(s)) =
|
||||
app_state
|
||||
.usage_cache
|
||||
.with_script(app_type, provider_id, format_script_summary)
|
||||
{
|
||||
return Some(format!(" · {s}"));
|
||||
}
|
||||
} else {
|
||||
app_state
|
||||
.usage_cache
|
||||
.invalidate_script(app_type, provider_id);
|
||||
}
|
||||
|
||||
if provider.category.as_deref() == Some("official") {
|
||||
if let Some(Some(s)) = app_state
|
||||
.usage_cache
|
||||
.with_subscription(app_type, format_subscription_summary)
|
||||
{
|
||||
return Some(format!(" · {s}"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 对供应商列表排序:sort_index → created_at → name
|
||||
fn sort_providers(
|
||||
providers: &indexmap::IndexMap<String, crate::provider::Provider>,
|
||||
@@ -291,6 +432,8 @@ pub fn create_tray_menu(
|
||||
let visible_apps = app_settings.visible_apps.unwrap_or_default();
|
||||
|
||||
let mut menu_builder = MenuBuilder::new(app);
|
||||
let mut section_handles: std::collections::HashMap<AppType, Submenu<tauri::Wry>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
// 顶部:打开主界面
|
||||
let show_main_item =
|
||||
@@ -323,10 +466,13 @@ pub fn create_tray_menu(
|
||||
})?;
|
||||
menu_builder = menu_builder.item(&empty_item);
|
||||
} else {
|
||||
// 有供应商:构建子菜单
|
||||
let current_name = providers.get(¤t_id).map(|p| p.name.as_str());
|
||||
let submenu_label = match current_name {
|
||||
Some(name) => format!("{} · {}", section.header_label, name),
|
||||
let current_provider = providers.get(¤t_id);
|
||||
let submenu_label = match current_provider {
|
||||
Some(p) => {
|
||||
let suffix = format_usage_suffix(app_state, §ion.app_type, p, ¤t_id)
|
||||
.unwrap_or_default();
|
||||
format!("{} · {}{}", section.header_label, p.name, suffix)
|
||||
}
|
||||
None => section.header_label.to_string(),
|
||||
};
|
||||
let submenu_id = format!("submenu_{}", app_type_str);
|
||||
@@ -369,6 +515,7 @@ pub fn create_tray_menu(
|
||||
let submenu = submenu_builder.build().map_err(|e| {
|
||||
AppError::Message(format!("构建{}子菜单失败: {e}", section.log_name))
|
||||
})?;
|
||||
section_handles.insert(section.app_type.clone(), submenu.clone());
|
||||
menu_builder = menu_builder.item(&submenu);
|
||||
}
|
||||
|
||||
@@ -393,9 +540,51 @@ pub fn create_tray_menu(
|
||||
|
||||
menu_builder = menu_builder.item(&quit_item);
|
||||
|
||||
menu_builder
|
||||
let menu = menu_builder
|
||||
.build()
|
||||
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
|
||||
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))?;
|
||||
|
||||
*TRAY_SECTION_SUBMENUS
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner()) = section_handles;
|
||||
|
||||
Ok(menu)
|
||||
}
|
||||
|
||||
/// 就地更新各 app 分区子菜单的标题(usage 后缀变化时走这条),
|
||||
/// 避免 `set_menu` 导致用户打开中的菜单被关闭。
|
||||
/// 句柄由上一次 `create_tray_menu` 填充;为空(从未构建过菜单)时无事发生。
|
||||
fn update_tray_usage_labels(app: &tauri::AppHandle) {
|
||||
let Some(app_state) = app.try_state::<AppState>() else {
|
||||
return;
|
||||
};
|
||||
let handles = match TRAY_SECTION_SUBMENUS.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
|
||||
for section in TRAY_SECTIONS.iter() {
|
||||
let Some(submenu) = handles.get(§ion.app_type) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(providers) = app_state.db.get_all_providers(section.app_type.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(Some(current_id)) =
|
||||
crate::settings::get_effective_current_provider(&app_state.db, §ion.app_type)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(provider) = providers.get(¤t_id) else {
|
||||
continue;
|
||||
};
|
||||
let suffix = format_usage_suffix(&app_state, §ion.app_type, provider, ¤t_id)
|
||||
.unwrap_or_default();
|
||||
let new_label = format!("{} · {}{}", section.header_label, provider.name, suffix);
|
||||
if let Err(e) = submenu.set_text(&new_label) {
|
||||
log::debug!("[Tray] 更新{}子菜单标题失败: {e}", section.log_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_tray_menu(app: &tauri::AppHandle) {
|
||||
@@ -412,17 +601,6 @@ pub fn refresh_tray_menu(app: &tauri::AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::TRAY_ID;
|
||||
|
||||
#[test]
|
||||
fn tray_id_is_unique_to_app() {
|
||||
assert_eq!(TRAY_ID, "cc-switch");
|
||||
assert_ne!(TRAY_ID, "main");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
|
||||
use tauri::ActivationPolicy;
|
||||
@@ -491,3 +669,269 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static LAST_TRAY_USAGE_REFRESH: std::sync::Mutex<Option<std::time::Instant>> =
|
||||
std::sync::Mutex::new(None);
|
||||
const MIN_TRAY_USAGE_REFRESH_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// 合并多次快速触发的"usage 标题软更新":批量刷新期间多个 usage 命令
|
||||
/// 同时成功时,只会产生一次就地 `set_text` 批量调用。走软更新而不是
|
||||
/// `refresh_tray_menu` 整建,避免用户打开中的菜单被 macOS 系统关闭。
|
||||
static TRAY_REBUILD_SCHEDULED: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
pub fn schedule_tray_refresh(app: &tauri::AppHandle) {
|
||||
use std::sync::atomic::Ordering;
|
||||
if TRAY_REBUILD_SCHEDULED.swap(true, Ordering::AcqRel) {
|
||||
return;
|
||||
}
|
||||
let app = app.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
// 50ms 合窗:让同一轮 React Query / 托盘批量刷新触发的多个写入
|
||||
// 共享一次标题更新。
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
TRAY_REBUILD_SCHEDULED.store(false, Ordering::Release);
|
||||
update_tray_usage_labels(&app);
|
||||
});
|
||||
}
|
||||
|
||||
/// 并行刷新每个可见 app "当前 provider" 的用量;成功 / 失败结果都通过各
|
||||
/// command 的 write-through 逻辑写入 `UsageCache`,单次重建菜单由
|
||||
/// `schedule_tray_refresh` 做合并。内部 10 秒节流防止鼠标悬停反复进出时
|
||||
/// 雪崩请求;互斥锁被毒化时以上次状态为准继续推进,不会永久阻塞。
|
||||
///
|
||||
/// 刷新面与 `format_usage_suffix` 的展示面严格对齐 —— 每次悬停最多发
|
||||
/// `TRAY_SECTIONS.len()` 次外部请求,script 优先(覆盖 coding_plan / balance /
|
||||
/// Copilot / 自定义脚本),否则当前 provider 必须是 `official` 才查订阅。
|
||||
pub(crate) async fn refresh_all_usage_in_tray(app: &tauri::AppHandle) {
|
||||
use crate::commands::CopilotAuthState;
|
||||
use futures::future::join_all;
|
||||
|
||||
{
|
||||
let mut guard = LAST_TRAY_USAGE_REFRESH
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let now = std::time::Instant::now();
|
||||
if let Some(last) = *guard {
|
||||
if now.duration_since(last) < MIN_TRAY_USAGE_REFRESH_INTERVAL {
|
||||
return;
|
||||
}
|
||||
}
|
||||
*guard = Some(now);
|
||||
}
|
||||
|
||||
let Some(app_state) = app.try_state::<AppState>() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// 与 `create_tray_menu` 保持一致:用户隐藏的 app 不参与外部 API 查询,
|
||||
// 避免在未使用的 app 上浪费请求、撞 rate limit 或反复触发鉴权失败日志。
|
||||
let visible_apps = crate::settings::get_settings()
|
||||
.visible_apps
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut subscription_futures = Vec::new();
|
||||
let mut script_futures = Vec::new();
|
||||
|
||||
for section in TRAY_SECTIONS.iter() {
|
||||
if !visible_apps.is_visible(§ion.app_type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let app_type_str = section.app_type.as_str();
|
||||
let log_name = section.log_name;
|
||||
|
||||
// 解析 effective current provider;未设置 / 出错都静默跳过,
|
||||
// 与 create_tray_menu 的行为保持一致。
|
||||
let current_id =
|
||||
match crate::settings::get_effective_current_provider(&app_state.db, §ion.app_type)
|
||||
{
|
||||
Ok(Some(id)) => id,
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
log::warn!("[Tray] 读取{log_name}当前供应商失败: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// 只需当前 provider —— by-id 查询避免把整个 app 的 provider 列表加载
|
||||
// 进内存(每次悬停 × 3 sections 的热路径)。
|
||||
let current = match app_state.db.get_provider_by_id(¤t_id, app_type_str) {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
log::warn!("[Tray] 读取{log_name}当前供应商失败: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// 与 format_usage_suffix 同一优先级:脚本启用 → 查脚本;
|
||||
// 否则当前 provider 是 official → 查订阅;其它情况不发请求。
|
||||
if current.has_usage_script_enabled() {
|
||||
let app_clone = app.clone();
|
||||
let state = app.state::<AppState>();
|
||||
let copilot_state = app.state::<CopilotAuthState>();
|
||||
let provider_id = current_id.clone();
|
||||
let app_str = app_type_str.to_string();
|
||||
script_futures.push(async move {
|
||||
if let Err(e) = crate::commands::queryProviderUsage(
|
||||
app_clone,
|
||||
state,
|
||||
copilot_state,
|
||||
provider_id.clone(),
|
||||
app_str,
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::debug!("[Tray] 刷新{log_name}供应商 {provider_id} 用量失败: {e}");
|
||||
}
|
||||
});
|
||||
} else if current.category.as_deref() == Some("official") {
|
||||
let app_clone = app.clone();
|
||||
let state = app.state::<AppState>();
|
||||
let tool = app_type_str.to_string();
|
||||
subscription_futures.push(async move {
|
||||
if let Err(e) =
|
||||
crate::commands::get_subscription_quota(app_clone, state, tool).await
|
||||
{
|
||||
log::debug!("[Tray] 刷新{log_name}订阅用量失败(可能未登录): {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 两组并行启动,整体等待 —— 订阅/脚本互不依赖,没必要串行。
|
||||
futures::future::join(join_all(subscription_futures), join_all(script_futures)).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{format_subscription_summary, TRAY_ID};
|
||||
use crate::services::subscription::{CredentialStatus, QuotaTier, SubscriptionQuota};
|
||||
|
||||
#[test]
|
||||
fn tray_id_is_unique_to_app() {
|
||||
assert_eq!(TRAY_ID, "cc-switch");
|
||||
assert_ne!(TRAY_ID, "main");
|
||||
}
|
||||
|
||||
fn make_quota(tool: &str, success: bool, tiers: Vec<QuotaTier>) -> SubscriptionQuota {
|
||||
SubscriptionQuota {
|
||||
tool: tool.to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: None,
|
||||
success,
|
||||
tiers,
|
||||
extra_usage: None,
|
||||
error: None,
|
||||
queried_at: Some(0),
|
||||
}
|
||||
}
|
||||
|
||||
fn tier(name: &str, utilization: f64) -> QuotaTier {
|
||||
QuotaTier {
|
||||
name: name.to_string(),
|
||||
utilization,
|
||||
resets_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_summary_uses_h_and_w_labels() {
|
||||
let quota = make_quota(
|
||||
"claude",
|
||||
true,
|
||||
vec![tier("five_hour", 9.0), tier("seven_day", 27.0)],
|
||||
);
|
||||
let s = format_subscription_summary("a).expect("should format");
|
||||
assert!(s.contains("h9%"), "expected h9% in {s}");
|
||||
assert!(s.contains("w27%"), "expected w27% in {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_summary_uses_p_and_f_labels() {
|
||||
let quota = make_quota(
|
||||
"gemini",
|
||||
true,
|
||||
vec![tier("gemini_pro", 15.0), tier("gemini_flash", 42.0)],
|
||||
);
|
||||
let s = format_subscription_summary("a).expect("should format");
|
||||
assert!(s.contains("p15%"), "expected p15% in {s}");
|
||||
assert!(s.contains("f42%"), "expected f42% in {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_summary_includes_all_three_tiers() {
|
||||
let quota = make_quota(
|
||||
"gemini",
|
||||
true,
|
||||
vec![
|
||||
tier("gemini_pro", 5.0),
|
||||
tier("gemini_flash", 42.0),
|
||||
tier("gemini_flash_lite", 80.0),
|
||||
],
|
||||
);
|
||||
let s = format_subscription_summary("a).expect("should format");
|
||||
assert!(s.contains("p5%"), "expected p5% in {s}");
|
||||
assert!(s.contains("f42%"), "expected f42% in {s}");
|
||||
assert!(s.contains("l80%"), "expected l80% in {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_summary_lite_only_still_renders() {
|
||||
// flash_lite 如果是 API 返回的唯一 tier,仍应显示(避免前端 footer 能看到、
|
||||
// 托盘空白的不对称)。
|
||||
let quota = make_quota("gemini", true, vec![tier("gemini_flash_lite", 80.0)]);
|
||||
let s = format_subscription_summary("a).expect("should format");
|
||||
assert!(s.contains("l80%"), "expected l80% in {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_summary_emoji_reflects_highest_tier_including_lite() {
|
||||
// lite 是利用率最高的那条 → emoji 必须是红色,不能被 pro/flash 掩盖。
|
||||
let quota = make_quota(
|
||||
"gemini",
|
||||
true,
|
||||
vec![
|
||||
tier("gemini_pro", 10.0),
|
||||
tier("gemini_flash", 20.0),
|
||||
tier("gemini_flash_lite", 95.0),
|
||||
],
|
||||
);
|
||||
let s = format_subscription_summary("a).unwrap();
|
||||
assert!(
|
||||
s.starts_with("\u{1F534}"),
|
||||
"expected red emoji (lite worst) in {s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worst_emoji_reflects_highest_utilization() {
|
||||
// 🔴 = \u{1F534}; 任一 tier ≥ 90% 时预期显示红色。
|
||||
let quota = make_quota(
|
||||
"claude",
|
||||
true,
|
||||
vec![tier("five_hour", 10.0), tier("seven_day", 95.0)],
|
||||
);
|
||||
let s = format_subscription_summary("a).unwrap();
|
||||
assert!(s.starts_with("\u{1F534}"), "expected red emoji in {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_quota_returns_none() {
|
||||
let quota = make_quota("claude", false, vec![tier("five_hour", 50.0)]);
|
||||
assert!(format_subscription_summary("a).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tiers_return_none() {
|
||||
let quota = make_quota("claude", true, vec![tier("one_hour", 80.0)]);
|
||||
assert!(format_subscription_summary("a).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gemini_without_any_known_tiers_returns_none() {
|
||||
// 完全没有 pro/flash/flash_lite 三种 tier 的退化响应 → None。
|
||||
let quota = make_quota("gemini", true, vec![tier("some_future_tier", 80.0)]);
|
||||
assert!(format_subscription_summary("a).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user