Files
codex/codex-rs/backend-client/src/client/task_usage_tests.rs
T
Felipe Coury 8f0d2459ac Add consumer Top chats usage analytics (#45768)
## Why

Consumer analytics lacked a per-chat view of allowance usage and balance credit debits.

## What changed

- Add a Top chats panel for up to 100 local chats active in the past 30 days, including discovered descendants and excluding archived roots.
- Show weekly and five-hour usage as percentages of current full limits, plus balance credits including adjustments. Preserve exact credit decimals and distinguish missing values from zero.
- Support cycling sort metrics with `s`, a top-five dashboard summary, and expandable model, reasoning effort, and speed breakdowns.
- Query task usage through `query_v2` with bounded, disjoint descendant groups. Retain partial and unavailable rows, hide titles when usage is unavailable, and report ranking coverage and freshness.

## Testing

Add backend and TUI tests covering decimal precision, request and response validation, paginated archived descendants, unavailable responses, metric selection, title hiding, and top-five rendering.

GitOrigin-RevId: b8f233166576389c8212bb97eceb7c6be7f5f51d
2026-09-15 19:12:44 +00:00

133 lines
4.6 KiB
Rust

//! Exact decimal preservation and sorting for consumer task credits.
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn credits_preserve_precision_and_order_signed_decimal_strings() {
let mut amounts: Vec<TaskCredits> = serde_json::from_value(json!([
"1.000000000000000002",
"-0.00025",
"0",
"1.000000000000000001",
"1e-20",
"-1E+3"
]))
.unwrap();
amounts.sort();
assert_eq!(
amounts.iter().map(TaskCredits::as_str).collect::<Vec<_>>(),
vec![
"-1E+3",
"-0.00025",
"0",
"1e-20",
"1.000000000000000001",
"1.000000000000000002"
]
);
for invalid in ["NaN", "inf", "1..0", "", "1e999999999999"] {
assert!(serde_json::from_value::<TaskCredits>(json!(invalid)).is_err());
}
}
#[tokio::test]
async fn task_queries_validate_request_groups_and_response_ownership() {
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
let server = MockServer::start().await;
let client = Client::new(
server.uri(),
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
);
let query = TaskUsageThread {
thread_id: "root".into(),
created_at: None,
descendant_thread_ids: vec!["child".into()],
};
let task = json!({"thread_id":"root", "data_status":"available", "usage_source":"plan_and_credits",
"five_hour_limit_percent":150.0, "weekly_limit_percent":0.0,
"balance_usage_credits":"-0.000000000000000001", "groups":[{
"product_experience":"codex", "model":"gpt-5.5", "reasoning_effort":"high", "speed":"standard",
"five_hour_limit_percent":150.25, "weekly_limit_percent":0.125, "balance_usage_credits":"0"
}]});
for ids in [vec!["root"], vec!["other"], vec!["root", "root"]] {
server.reset().await;
let response = json!({"data_as_of":null, "threads":ids.iter().map(|id| {
let mut task=task.clone();task["thread_id"]=json!(id);task
}).collect::<Vec<_>>()});
Mock::given(method("POST"))
.and(path("/api/codex/usage/thread_usage/query_v2"))
.respond_with(move |request: &wiremock::Request| {
assert_eq!(
request.body_json::<serde_json::Value>().unwrap(),
json!({"threads":[{
"thread_id":"root", "created_at":null, "descendant_thread_ids":["child"]
}]})
);
ResponseTemplate::new(/*s*/ 200).set_body_json(&response)
})
.expect(/*r*/ 1)
.mount(&server)
.await;
let result = client.get_task_usage(std::slice::from_ref(&query)).await;
if ids == ["root"] {
let response = result.unwrap();
assert_eq!((response.data_as_of, response.threads.len()), (None, 1));
let row = &response.threads[0];
assert_eq!(
(
row.thread_id.as_str(),
row.data_status,
row.amounts.five_hour_limit_percent,
row.amounts.weekly_limit_percent,
row.amounts
.balance_usage_credits
.as_ref()
.map(TaskCredits::as_str)
),
(
"root",
TaskUsageStatus::Available,
Some(150.0),
Some(0.0),
Some("-0.000000000000000001")
)
);
assert_eq!(
(
row.groups[0].amounts.five_hour_limit_percent,
row.groups[0].amounts.weekly_limit_percent
),
(Some(150.25), Some(0.125))
);
} else {
assert!(
result
.unwrap_err()
.to_string()
.contains("Invalid task usage response.")
);
}
}
server.reset().await;
for queries in [
vec![],
vec![query.clone(), query.clone()],
vec![TaskUsageThread {
thread_id: "root".into(),
created_at: None,
descendant_thread_ids: (0..1_000).map(|n| format!("child-{n}")).collect(),
}],
] {
assert!(client.get_task_usage(&queries).await.is_err());
}
assert!(server.received_requests().await.unwrap().is_empty());
}