mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
feat(agy): show the signed-in quota on the official card
- Ask agy's own /usage command (agy -p /usage --output-format json) instead of reading credentials; it makes no model call and opens no conversation. - Keep the Gemini group and the Claude/GPT group, each with its 5-hour and weekly window, as four tiers; unknown buckets are skipped. - On the card each group shows only its window with the least left, since four tiers do not fit; the expanded view lists all four. - A missing agy is treated as no login (nothing shown); a timeout propagates so the last good value is kept; other failures do not echo agy's output.
This commit is contained in:
1 parent
dfe100813f
commit
1b4cd547c4
13 files changed
+365
-15
No files matched your search
@@ -30,6 +30,7 @@ pub mod speedtest;
|
||||
pub mod sql_helpers;
|
||||
pub mod stream_check;
|
||||
pub mod subscription;
|
||||
pub mod subscription_agy;
|
||||
pub mod subscription_grok;
|
||||
pub mod sync_protocol;
|
||||
pub mod usage_cache;
|
||||
|
||||
@@ -1645,6 +1645,7 @@ pub async fn get_subscription_quota(tool: &str) -> Result<SubscriptionQuota, Str
|
||||
}
|
||||
}
|
||||
"grokbuild" => crate::services::subscription_grok::get_grok_subscription_quota().await,
|
||||
"agy" => crate::services::subscription_agy::get_agy_subscription_quota().await,
|
||||
_ => Ok(SubscriptionQuota::not_found(tool)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Antigravity CLI 官方登录的剩余额度。
|
||||
//!
|
||||
//! 不读登录凭据:凭据在系统钥匙串里,归 agy 自己管。额度直接问 agy 的 `/usage` 命令
|
||||
//! (`agy -p /usage --output-format json`),这条命令不调模型、不新建会话。
|
||||
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::subscription::{CredentialStatus, QuotaTier, SubscriptionQuota};
|
||||
|
||||
const TOOL: &str = "agy";
|
||||
const TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
pub const TIER_AGY_GEMINI_FIVE_HOUR: &str = "agy_gemini_five_hour";
|
||||
pub const TIER_AGY_GEMINI_WEEKLY: &str = "agy_gemini_weekly";
|
||||
pub const TIER_AGY_THIRD_PARTY_FIVE_HOUR: &str = "agy_third_party_five_hour";
|
||||
pub const TIER_AGY_THIRD_PARTY_WEEKLY: &str = "agy_third_party_weekly";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UsageOutput {
|
||||
status: Option<String>,
|
||||
command: Option<UsageCommand>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UsageCommand {
|
||||
data: Option<UsageData>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UsageData {
|
||||
#[serde(default)]
|
||||
groups: Vec<UsageGroup>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UsageGroup {
|
||||
#[serde(default)]
|
||||
buckets: Vec<UsageBucket>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UsageBucket {
|
||||
id: Option<String>,
|
||||
remaining_fraction: Option<f64>,
|
||||
reset_time: Option<String>,
|
||||
}
|
||||
|
||||
/// agy 的额度分组:Gemini 一组,Claude 和 GPT 共用一组,每组各有 5 小时和每周两档。
|
||||
/// 不认识的桶(以后新增的组)跳过,免得显示成没有文案的档。
|
||||
fn tier_name(bucket_id: &str) -> Option<&'static str> {
|
||||
match bucket_id {
|
||||
"gemini-5h" => Some(TIER_AGY_GEMINI_FIVE_HOUR),
|
||||
"gemini-weekly" => Some(TIER_AGY_GEMINI_WEEKLY),
|
||||
"3p-5h" => Some(TIER_AGY_THIRD_PARTY_FIVE_HOUR),
|
||||
"3p-weekly" => Some(TIER_AGY_THIRD_PARTY_WEEKLY),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_order(name: &str) -> usize {
|
||||
match name {
|
||||
TIER_AGY_GEMINI_FIVE_HOUR => 0,
|
||||
TIER_AGY_GEMINI_WEEKLY => 1,
|
||||
TIER_AGY_THIRD_PARTY_FIVE_HOUR => 2,
|
||||
_ => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_usage(stdout: &[u8]) -> Option<Vec<QuotaTier>> {
|
||||
let output: UsageOutput = serde_json::from_slice(stdout).ok()?;
|
||||
if output.status.as_deref() != Some("SUCCESS") {
|
||||
return None;
|
||||
}
|
||||
let groups = output.command?.data?.groups;
|
||||
let mut tiers: Vec<QuotaTier> = groups
|
||||
.into_iter()
|
||||
.flat_map(|group| group.buckets)
|
||||
.filter_map(|bucket| {
|
||||
let name = tier_name(bucket.id.as_deref()?)?;
|
||||
let remaining = bucket.remaining_fraction?.clamp(0.0, 1.0);
|
||||
Some(QuotaTier {
|
||||
name: name.to_string(),
|
||||
utilization: (1.0 - remaining) * 100.0,
|
||||
resets_at: bucket.reset_time,
|
||||
used_value_usd: None,
|
||||
max_value_usd: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if tiers.is_empty() {
|
||||
return None;
|
||||
}
|
||||
tiers.sort_by_key(|tier| sort_order(&tier.name));
|
||||
Some(tiers)
|
||||
}
|
||||
|
||||
/// 由 `subscription::get_subscription_quota("agy")` 分发。没装 agy 时按「没有凭据」处理(卡片不显示);
|
||||
/// 超时、没登录或输出看不懂都写成查询失败,错误里不带 agy 的原始输出。
|
||||
pub(crate) async fn get_agy_subscription_quota() -> Result<SubscriptionQuota, String> {
|
||||
let Some(bin) = tokio::task::spawn_blocking(crate::agy_config::real_agy_bin)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
else {
|
||||
return Ok(SubscriptionQuota::not_found(TOOL));
|
||||
};
|
||||
let mut command = tokio::process::Command::new(bin);
|
||||
command
|
||||
.args(["--print", "/usage", "--output-format", "json"])
|
||||
// 只问官方登录:GUI 进程里残留的 Key 会让 agy 改走 Key 模式。
|
||||
.env_remove("GEMINI_API_KEY")
|
||||
.env_remove("GOOGLE_GEMINI_BASE_URL")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
let output = match tokio::time::timeout(TIMEOUT, command.output()).await {
|
||||
Ok(Ok(output)) => output,
|
||||
Ok(Err(_)) => return Ok(SubscriptionQuota::not_found(TOOL)),
|
||||
Err(_) => return Err("Antigravity quota query timed out".to_string()),
|
||||
};
|
||||
let tiers = output
|
||||
.status
|
||||
.success()
|
||||
.then(|| parse_usage(&output.stdout))
|
||||
.flatten();
|
||||
let Some(tiers) = tiers else {
|
||||
return Ok(SubscriptionQuota::error(
|
||||
TOOL,
|
||||
CredentialStatus::Valid,
|
||||
"Antigravity did not return quota. Sign in with agy and try again.".to_string(),
|
||||
));
|
||||
};
|
||||
Ok(SubscriptionQuota {
|
||||
tool: TOOL.to_string(),
|
||||
credential_status: CredentialStatus::Valid,
|
||||
credential_message: None,
|
||||
success: true,
|
||||
tiers,
|
||||
extra_usage: None,
|
||||
reset_credits: None,
|
||||
credits_balance: None,
|
||||
error: None,
|
||||
queried_at: Some(now_millis()),
|
||||
})
|
||||
}
|
||||
|
||||
fn now_millis() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// agy 1.4.3 `/usage --output-format json` 的真实结构(数值改过)
|
||||
const SAMPLE: &str = r#"{
|
||||
"conversation_id": "", "status": "SUCCESS", "response": "...",
|
||||
"usage": {"input_tokens": 0, "output_tokens": 0},
|
||||
"command": {"name": "usage", "data": {"description": "...", "groups": [
|
||||
{"name": "Gemini Models", "buckets": [
|
||||
{"id": "gemini-weekly", "window": "weekly", "remaining_fraction": 0.75, "reset_time": "2026-10-27T15:13:36Z"},
|
||||
{"id": "gemini-5h", "window": "5h", "remaining_fraction": 1, "reset_time": "2026-10-21T04:16:37Z"}
|
||||
]},
|
||||
{"name": "Claude and GPT models", "buckets": [
|
||||
{"id": "3p-weekly", "window": "weekly", "remaining_fraction": 0.1, "reset_time": "2026-10-27T23:16:37Z"},
|
||||
{"id": "3p-5h", "window": "5h", "remaining_fraction": 0, "reset_time": "2026-10-21T04:16:37Z"},
|
||||
{"id": "future-bucket", "remaining_fraction": 0.5}
|
||||
]}
|
||||
]}}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn keeps_each_group_and_window_as_its_own_tier() {
|
||||
let tiers = parse_usage(SAMPLE.as_bytes()).unwrap();
|
||||
let names: Vec<_> = tiers.iter().map(|t| t.name.as_str()).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
TIER_AGY_GEMINI_FIVE_HOUR,
|
||||
TIER_AGY_GEMINI_WEEKLY,
|
||||
TIER_AGY_THIRD_PARTY_FIVE_HOUR,
|
||||
TIER_AGY_THIRD_PARTY_WEEKLY,
|
||||
]
|
||||
);
|
||||
let used: Vec<_> = tiers.iter().map(|t| t.utilization.round()).collect();
|
||||
assert_eq!(used, [0.0, 25.0, 100.0, 90.0]);
|
||||
assert_eq!(tiers[1].resets_at.as_deref(), Some("2026-10-27T15:13:36Z"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_or_empty_output_is_not_quota() {
|
||||
assert!(parse_usage(b"not json").is_none());
|
||||
assert!(parse_usage(br#"{"status":"ERROR","command":null}"#).is_none());
|
||||
assert!(parse_usage(br#"{"status":"SUCCESS","command":{"data":{"groups":[]}}}"#).is_none());
|
||||
// 缺比例的桶不当成满额
|
||||
assert!(parse_usage(
|
||||
br#"{"status":"SUCCESS","command":{"data":{"groups":[{"buckets":[{"id":"gemini-5h"}]}]}}}"#
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,11 @@ export const TIER_I18N_KEYS: Record<string, string> = {
|
||||
gemini_pro: "subscription.geminiPro",
|
||||
gemini_flash: "subscription.geminiFlash",
|
||||
gemini_flash_lite: "subscription.geminiFlashLite",
|
||||
// Antigravity:Gemini 一组、Claude 和 GPT 一组,各有 5 小时和每周两档
|
||||
agy_gemini_five_hour: "subscription.agyGeminiFiveHour",
|
||||
agy_gemini_weekly: "subscription.agyGeminiWeekly",
|
||||
agy_third_party_five_hour: "subscription.agyThirdPartyFiveHour",
|
||||
agy_third_party_weekly: "subscription.agyThirdPartyWeekly",
|
||||
// Token Plan(five_hour 已在上方官方映射中)
|
||||
weekly_limit: "subscription.sevenDay",
|
||||
// 火山方舟 Agent Plan / Coding Plan 的月窗口
|
||||
@@ -63,6 +68,31 @@ export const TIER_I18N_KEYS: Record<string, string> = {
|
||||
/** 卡片上不显示的档(展开时仍列出) */
|
||||
const HIDDEN_INLINE_TIERS = new Set(["seven_day_sonnet"]);
|
||||
|
||||
/**
|
||||
* 卡片上按组只留剩余最少的一档、标签写组名(展开时仍逐档列出):
|
||||
* Antigravity 的 Gemini、Claude 和 GPT 两组各有 5 小时和每周两档,四档在卡片上放不下
|
||||
*/
|
||||
const INLINE_TIER_GROUPS: Record<string, string> = {
|
||||
agy_gemini_five_hour: "subscription.agyGemini",
|
||||
agy_gemini_weekly: "subscription.agyGemini",
|
||||
agy_third_party_five_hour: "subscription.agyThirdParty",
|
||||
agy_third_party_weekly: "subscription.agyThirdParty",
|
||||
};
|
||||
|
||||
function collapseInlineGroups(tiers: QuotaTier[]): QuotaTier[] {
|
||||
const kept = new Map<string, QuotaTier>();
|
||||
for (const tier of tiers) {
|
||||
const group = INLINE_TIER_GROUPS[tier.name] ?? tier.name;
|
||||
const current = kept.get(group);
|
||||
if (!current || tier.utilization > current.utilization) {
|
||||
kept.set(group, tier);
|
||||
}
|
||||
}
|
||||
return tiers.filter((tier) =>
|
||||
[...kept.values()].some((keptTier) => keptTier === tier),
|
||||
);
|
||||
}
|
||||
|
||||
export function tierLabel(t: TFunction, name: string): string {
|
||||
return TIER_I18N_KEYS[name] ? t(TIER_I18N_KEYS[name]) : name;
|
||||
}
|
||||
@@ -82,19 +112,20 @@ export function tierLines(
|
||||
display = "left",
|
||||
}: { inline?: boolean; display?: QuotaDisplay } = {},
|
||||
): { label: string; line: QuotaLine }[] {
|
||||
return tiers
|
||||
const known = tiers
|
||||
.filter((tier) => tier.name in TIER_I18N_KEYS)
|
||||
.filter((tier) => !inline || !HIDDEN_INLINE_TIERS.has(tier.name))
|
||||
.map((tier) => {
|
||||
const label = tierLabel(t, tier.name);
|
||||
return {
|
||||
label,
|
||||
line: tierLine(t, tier, label, {
|
||||
short: tierShortLabel(t, tier.name),
|
||||
display,
|
||||
}),
|
||||
};
|
||||
});
|
||||
.filter((tier) => !inline || !HIDDEN_INLINE_TIERS.has(tier.name));
|
||||
return (inline ? collapseInlineGroups(known) : known).map((tier) => {
|
||||
const group = inline ? INLINE_TIER_GROUPS[tier.name] : undefined;
|
||||
const label = group ? t(group) : tierLabel(t, tier.name);
|
||||
return {
|
||||
label,
|
||||
line: tierLine(t, tier, label, {
|
||||
short: group ? label : tierShortLabel(t, tier.name),
|
||||
display,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -248,7 +248,8 @@ export function ProviderCard({
|
||||
provider.meta?.usage_script?.enabled ?? isBoundCodexOfficial;
|
||||
const isOfficial = isOfficialProvider(provider, appId);
|
||||
const supportsOfficialSubscription =
|
||||
isOfficial && ["claude", "codex", "gemini", "grokbuild"].includes(appId);
|
||||
isOfficial &&
|
||||
["claude", "codex", "gemini", "grokbuild", "agy"].includes(appId);
|
||||
const isOfficialSubscriptionUsage =
|
||||
provider.meta?.usage_script?.templateType ===
|
||||
TEMPLATE_TYPES.OFFICIAL_SUBSCRIPTION;
|
||||
|
||||
@@ -95,6 +95,8 @@ export function countdownStr(
|
||||
/** 档位窗口的长短:卡片第一行写最短的那档;不认识的档排最后 */
|
||||
const TIER_WINDOW_ORDER: Record<string, number> = {
|
||||
five_hour: 0,
|
||||
agy_gemini_five_hour: 0,
|
||||
agy_third_party_five_hour: 0,
|
||||
gemini_pro: 1,
|
||||
gemini_flash: 1,
|
||||
gemini_flash_lite: 1,
|
||||
|
||||
@@ -4104,6 +4104,10 @@
|
||||
"geminiPro": "Pro",
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Lite",
|
||||
"agyGeminiFiveHour": "Gemini 5h",
|
||||
"agyGeminiWeekly": "Gemini Wk",
|
||||
"agyThirdPartyFiveHour": "Claude 5h",
|
||||
"agyThirdPartyWeekly": "Claude Wk",
|
||||
"monthly": "Mo",
|
||||
"credits": "Credits",
|
||||
"copilotPremium": "Premium"
|
||||
@@ -4117,6 +4121,12 @@
|
||||
"geminiPro": "Pro",
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"agyGemini": "Gemini",
|
||||
"agyThirdParty": "Claude/GPT",
|
||||
"agyGeminiFiveHour": "Gemini 5-hour",
|
||||
"agyGeminiWeekly": "Gemini weekly",
|
||||
"agyThirdPartyFiveHour": "Claude/GPT 5-hour",
|
||||
"agyThirdPartyWeekly": "Claude/GPT weekly",
|
||||
"weeklyLimit": "Weekly",
|
||||
"monthly": "Monthly",
|
||||
"credits": "Credits",
|
||||
|
||||
@@ -4104,6 +4104,10 @@
|
||||
"geminiPro": "Pro",
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Lite",
|
||||
"agyGeminiFiveHour": "Gemini 5h",
|
||||
"agyGeminiWeekly": "Gemini 週",
|
||||
"agyThirdPartyFiveHour": "Claude 5h",
|
||||
"agyThirdPartyWeekly": "Claude 週",
|
||||
"monthly": "月",
|
||||
"credits": "クレジット",
|
||||
"copilotPremium": "プレミアム"
|
||||
@@ -4117,6 +4121,12 @@
|
||||
"geminiPro": "Pro",
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"agyGemini": "Gemini",
|
||||
"agyThirdParty": "Claude/GPT",
|
||||
"agyGeminiFiveHour": "Gemini 5時間",
|
||||
"agyGeminiWeekly": "Gemini 週間",
|
||||
"agyThirdPartyFiveHour": "Claude/GPT 5時間",
|
||||
"agyThirdPartyWeekly": "Claude/GPT 週間",
|
||||
"weeklyLimit": "週間",
|
||||
"monthly": "月間",
|
||||
"credits": "クレジット",
|
||||
|
||||
@@ -4105,6 +4105,10 @@
|
||||
"geminiPro": "Pro",
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Lite",
|
||||
"agyGeminiFiveHour": "Gemini 5 小時",
|
||||
"agyGeminiWeekly": "Gemini 每週",
|
||||
"agyThirdPartyFiveHour": "Claude 5 小時",
|
||||
"agyThirdPartyWeekly": "Claude 每週",
|
||||
"monthly": "每月",
|
||||
"credits": "額度",
|
||||
"copilotPremium": "進階請求"
|
||||
@@ -4118,6 +4122,12 @@
|
||||
"geminiPro": "Pro",
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"agyGemini": "Gemini",
|
||||
"agyThirdParty": "Claude/GPT",
|
||||
"agyGeminiFiveHour": "Gemini 5 小時",
|
||||
"agyGeminiWeekly": "Gemini 每週",
|
||||
"agyThirdPartyFiveHour": "Claude/GPT 5 小時",
|
||||
"agyThirdPartyWeekly": "Claude/GPT 每週",
|
||||
"weeklyLimit": "每週",
|
||||
"monthly": "每月",
|
||||
"credits": "額度",
|
||||
|
||||
@@ -4104,6 +4104,10 @@
|
||||
"geminiPro": "Pro",
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Lite",
|
||||
"agyGeminiFiveHour": "Gemini 5 小时",
|
||||
"agyGeminiWeekly": "Gemini 每周",
|
||||
"agyThirdPartyFiveHour": "Claude 5 小时",
|
||||
"agyThirdPartyWeekly": "Claude 每周",
|
||||
"monthly": "每月",
|
||||
"credits": "额度",
|
||||
"copilotPremium": "高级请求"
|
||||
@@ -4117,6 +4121,12 @@
|
||||
"geminiPro": "Pro",
|
||||
"geminiFlash": "Flash",
|
||||
"geminiFlashLite": "Flash Lite",
|
||||
"agyGemini": "Gemini",
|
||||
"agyThirdParty": "Claude/GPT",
|
||||
"agyGeminiFiveHour": "Gemini 5 小时",
|
||||
"agyGeminiWeekly": "Gemini 每周",
|
||||
"agyThirdPartyFiveHour": "Claude/GPT 5 小时",
|
||||
"agyThirdPartyWeekly": "Claude/GPT 每周",
|
||||
"weeklyLimit": "每周",
|
||||
"monthly": "每月",
|
||||
"credits": "额度",
|
||||
|
||||
@@ -91,7 +91,8 @@ export function useSubscriptionQuota(
|
||||
queryKey: subscriptionKeys.quota(appId),
|
||||
queryFn: () => subscriptionApi.getQuota(appId),
|
||||
enabled:
|
||||
enabled && ["claude", "codex", "gemini", "grokbuild"].includes(appId),
|
||||
enabled &&
|
||||
["claude", "codex", "gemini", "grokbuild", "agy"].includes(appId),
|
||||
refetchInterval,
|
||||
refetchIntervalInBackground: Boolean(refetchInterval),
|
||||
refetchOnWindowFocus: Boolean(refetchInterval),
|
||||
|
||||
@@ -11,7 +11,9 @@ vi.mock("@/components/providers/ProviderCardActions", () => ({
|
||||
vi.mock("@/components/ProviderIcon", () => ({ ProviderIcon: () => null }));
|
||||
vi.mock("@/components/UsageFooter", () => ({ default: () => null }));
|
||||
vi.mock("@/components/SubscriptionQuotaFooter", () => ({
|
||||
default: () => null,
|
||||
default: ({ appId }: { appId: string }) => (
|
||||
<span data-testid="subscription-quota">{appId}</span>
|
||||
),
|
||||
}));
|
||||
vi.mock("@/components/CopilotQuotaFooter", () => ({ default: () => null }));
|
||||
vi.mock("@/components/CodexOauthQuotaFooter", () => ({ default: () => null }));
|
||||
@@ -75,3 +77,35 @@ describe("ProviderCard Antigravity model", () => {
|
||||
expect(screen.queryByText("Gemini 3.1 Pro (High)")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderCard Antigravity official quota", () => {
|
||||
const official: Provider = {
|
||||
id: "official",
|
||||
name: "Antigravity",
|
||||
category: "official",
|
||||
settingsConfig: { config: {} },
|
||||
meta: {
|
||||
usage_script: {
|
||||
enabled: true,
|
||||
language: "javascript",
|
||||
code: "",
|
||||
templateType: "official_subscription",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("shows the signed-in quota once official usage is turned on", () => {
|
||||
renderCard(official);
|
||||
expect(screen.getByTestId("subscription-quota")).toHaveTextContent("agy");
|
||||
});
|
||||
|
||||
it("stays hidden while usage is off", () => {
|
||||
renderCard({
|
||||
...official,
|
||||
meta: {
|
||||
usage_script: { ...official.meta!.usage_script!, enabled: false },
|
||||
},
|
||||
});
|
||||
expect(screen.queryByTestId("subscription-quota")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -194,6 +194,41 @@ describe("Claude Fable subscription quota", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Antigravity quota groups", () => {
|
||||
const agyTiers: QuotaTier[] = [
|
||||
{ name: "agy_gemini_five_hour", utilization: 10, resetsAt: null },
|
||||
{
|
||||
name: "agy_gemini_weekly",
|
||||
utilization: 40,
|
||||
resetsAt: "2026-09-12T00:00:00Z",
|
||||
},
|
||||
{ name: "agy_third_party_five_hour", utilization: 95, resetsAt: null },
|
||||
{ name: "agy_third_party_weekly", utilization: 20, resetsAt: null },
|
||||
];
|
||||
|
||||
it("keeps one line per group on the card: the window with the least left", () => {
|
||||
renderQuota(agyTiers);
|
||||
const lines = screen.getByRole("button").children;
|
||||
expect(lines).toHaveLength(2);
|
||||
expect(lines[0]).toHaveTextContent("Gemini 剩余 60%");
|
||||
expect(lines[1]).toHaveTextContent("Claude/GPT 剩余 5%");
|
||||
// 倒计时跟着留下的那档(Gemini 每周)
|
||||
expect(screen.getByText("2d12h")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists every group and window when expanded", () => {
|
||||
renderQuota(agyTiers, false);
|
||||
for (const name of [
|
||||
"Gemini 5 小时: 剩余 90%",
|
||||
"Gemini 每周: 剩余 60%",
|
||||
"Claude/GPT 5 小时: 剩余 5%",
|
||||
"Claude/GPT 每周: 剩余 80%",
|
||||
]) {
|
||||
expect(screen.getByRole("meter", { name })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("credential failures", () => {
|
||||
const failed = (credentialStatus: SubscriptionQuota["credentialStatus"]) =>
|
||||
renderQuota([], true, {
|
||||
|
||||
Reference in new issue
Block a user