From 5e501f802338bed21476321923124ed81e749942 Mon Sep 17 00:00:00 2001 From: Oleg Gushul Date: Wed, 13 May 2026 14:51:38 +0400 Subject: [PATCH 1/2] test: pin existing pure helpers + clippy lints fix (T00) --- src/config/types.rs | 165 +++++++++++++++++++++++++++++++ src/core/segments/usage.rs | 111 +++++++++++++++++++++ src/core/statusline.rs | 132 +++++++++++++++++++++++++ src/ui/main_menu.rs | 6 +- src/utils/claude_code_patcher.rs | 2 +- 5 files changed, 411 insertions(+), 5 deletions(-) diff --git a/src/config/types.rs b/src/config/types.rs index e5a78dc1..c48f8e13 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -414,3 +414,168 @@ pub struct TranscriptEntry { pub parent_uuid: Option, pub summary: Option, } + +#[cfg(test)] +mod tests { + use super::*; + + // ---------- RawUsage::normalize ---------- + + #[test] + fn normalize_empty_returns_zeros() { + let n = RawUsage::default().normalize(); + assert_eq!(n.input_tokens, 0); + assert_eq!(n.output_tokens, 0); + assert_eq!(n.total_tokens, 0); + assert_eq!(n.cache_creation_input_tokens, 0); + assert_eq!(n.cache_read_input_tokens, 0); + assert_eq!(n.context_tokens(), 0); + assert_eq!(n.total_for_cost(), 0); + assert_eq!(n.display_tokens(), 0); + } + + #[test] + fn normalize_anthropic_only() { + let raw = RawUsage { + input_tokens: Some(100), + output_tokens: Some(50), + cache_creation_input_tokens: Some(30), + cache_read_input_tokens: Some(20), + ..Default::default() + }; + let n = raw.normalize(); + assert_eq!(n.input_tokens, 100); + assert_eq!(n.output_tokens, 50); + assert_eq!(n.cache_creation_input_tokens, 30); + assert_eq!(n.cache_read_input_tokens, 20); + assert_eq!(n.context_tokens(), 200); + assert_eq!(n.total_for_cost(), 200); + assert!(n.calculation_source.contains("total_from_components")); + } + + #[test] + fn normalize_openai_total_priority() { + let raw = RawUsage { + prompt_tokens: Some(100), + completion_tokens: Some(50), + total_tokens: Some(999), + ..Default::default() + }; + let n = raw.normalize(); + assert_eq!(n.input_tokens, 100); + assert_eq!(n.output_tokens, 50); + // When total is provided directly, it is preferred over the sum. + assert_eq!(n.total_tokens, 999); + assert!(n.calculation_source.contains("total_tokens_direct")); + assert_eq!(n.total_for_cost(), 999); + } + + #[test] + fn normalize_anthropic_priority_over_openai() { + let raw = RawUsage { + input_tokens: Some(100), // Anthropic + prompt_tokens: Some(999), // OpenAI — should be ignored + output_tokens: Some(50), // Anthropic + completion_tokens: Some(888), // OpenAI — should be ignored + cache_creation_input_tokens: Some(30), + cache_creation_prompt_tokens: Some(777), + cache_read_input_tokens: Some(20), + cache_read_prompt_tokens: Some(666), + ..Default::default() + }; + let n = raw.normalize(); + assert_eq!(n.input_tokens, 100); + assert_eq!(n.output_tokens, 50); + assert_eq!(n.cache_creation_input_tokens, 30); + assert_eq!(n.cache_read_input_tokens, 20); + } + + #[test] + fn normalize_cached_tokens_fallback_chain() { + // Only the deepest fallback — nested prompt_tokens_details.cached_tokens + let raw = RawUsage { + prompt_tokens: Some(100), + completion_tokens: Some(50), + prompt_tokens_details: Some(PromptTokensDetails { + cached_tokens: Some(42), + audio_tokens: None, + }), + ..Default::default() + }; + let n = raw.normalize(); + assert_eq!(n.cache_read_input_tokens, 42); + } + + #[test] + fn normalize_cached_tokens_flat_field_wins_over_nested() { + let raw = RawUsage { + cached_tokens: Some(100), + prompt_tokens_details: Some(PromptTokensDetails { + cached_tokens: Some(42), + audio_tokens: None, + }), + ..Default::default() + }; + let n = raw.normalize(); + // cached_tokens (flat) should beat the nested path. + assert_eq!(n.cache_read_input_tokens, 100); + } + + #[test] + fn normalize_records_available_fields() { + let raw = RawUsage { + input_tokens: Some(10), + output_tokens: Some(5), + ..Default::default() + }; + let n = raw.normalize(); + assert!(n.raw_data_available.contains(&"input_tokens".to_string())); + assert!(n.raw_data_available.contains(&"output_tokens".to_string())); + } + + // ---------- Config::matches_theme ---------- + + #[test] + fn default_config_matches_default_theme() { + let cfg = Config::default(); + assert!(cfg.matches_theme("default")); + assert!(!cfg.is_modified_from_theme()); + } + + #[test] + fn default_config_does_not_match_cometix_theme() { + let cfg = Config::default(); + assert!(!cfg.matches_theme("cometix")); + } + + #[test] + fn config_with_dropped_segment_does_not_match() { + let mut cfg = Config::default(); + cfg.segments.pop(); + assert!(!cfg.matches_theme("default")); + } + + #[test] + fn config_with_mutated_style_mode_does_not_match() { + let mut cfg = Config::default(); + cfg.style.mode = StyleMode::Powerline; + assert!(!cfg.matches_theme("default")); + assert!(cfg.is_modified_from_theme()); + } + + #[test] + fn config_with_mutated_separator_does_not_match() { + let mut cfg = Config::default(); + cfg.style.separator = "###".to_string(); + assert!(!cfg.matches_theme("default")); + } + + #[test] + fn config_with_disabled_segment_does_not_match() { + let mut cfg = Config::default(); + if let Some(first) = cfg.segments.first_mut() { + first.enabled = !first.enabled; + } + assert!(!cfg.matches_theme("default")); + } +} diff --git a/src/core/segments/usage.rs b/src/core/segments/usage.rs index d5dd9bde..90157de0 100644 --- a/src/core/segments/usage.rs +++ b/src/core/segments/usage.rs @@ -273,3 +273,114 @@ impl Segment for UsageSegment { SegmentId::Usage } } + +#[cfg(test)] +mod tests { + use super::*; + + // ---------- get_circle_icon boundaries ---------- + // + // The eight glyphs map to these percent ranges: + // 0..=12 → slice_1 (f0a9e) + // 13..=25 → slice_2 (f0a9f) + // 26..=37 → slice_3 (f0aa0) + // 38..=50 → slice_4 (f0aa1) + // 51..=62 → slice_5 (f0aa2) + // 63..=75 → slice_6 (f0aa3) + // 76..=87 → slice_7 (f0aa4) + // 88.. → slice_8 (f0aa5) + // + // Input is utilization in 0.0..=1.0 (the segment passes `seven_day_util / 100.0`). + + #[track_caller] + fn assert_icon(util_fraction: f64, expected_codepoint: &str) { + let actual = UsageSegment::get_circle_icon(util_fraction); + assert_eq!( + actual, expected_codepoint, + "for utilization {} expected {:?} got {:?}", + util_fraction, expected_codepoint, actual + ); + } + + #[test] + fn circle_icon_slice_1_low_boundary() { + assert_icon(0.0, "\u{f0a9e}"); + assert_icon(0.12, "\u{f0a9e}"); + } + + #[test] + fn circle_icon_slice_2_boundary() { + assert_icon(0.13, "\u{f0a9f}"); + assert_icon(0.25, "\u{f0a9f}"); + } + + #[test] + fn circle_icon_slice_3_boundary() { + assert_icon(0.26, "\u{f0aa0}"); + assert_icon(0.37, "\u{f0aa0}"); + } + + #[test] + fn circle_icon_slice_4_boundary() { + assert_icon(0.38, "\u{f0aa1}"); + assert_icon(0.50, "\u{f0aa1}"); + } + + #[test] + fn circle_icon_slice_5_boundary() { + assert_icon(0.51, "\u{f0aa2}"); + assert_icon(0.62, "\u{f0aa2}"); + } + + #[test] + fn circle_icon_slice_6_boundary() { + assert_icon(0.63, "\u{f0aa3}"); + assert_icon(0.75, "\u{f0aa3}"); + } + + #[test] + fn circle_icon_slice_7_boundary() { + assert_icon(0.76, "\u{f0aa4}"); + assert_icon(0.87, "\u{f0aa4}"); + } + + #[test] + fn circle_icon_slice_8_high_boundary() { + assert_icon(0.88, "\u{f0aa5}"); + assert_icon(1.00, "\u{f0aa5}"); + } + + // ---------- format_reset_time ---------- + // + // Note: the function converts to Local timezone, so any test asserting the + // exact month/day/hour string is environment-dependent. We pin only the + // structural invariants and the None/malformed branches. + + #[test] + fn format_reset_time_none_returns_placeholder() { + assert_eq!(UsageSegment::format_reset_time(None), "?"); + } + + #[test] + fn format_reset_time_malformed_returns_placeholder() { + assert_eq!(UsageSegment::format_reset_time(Some("not a date")), "?"); + assert_eq!(UsageSegment::format_reset_time(Some("")), "?"); + assert_eq!(UsageSegment::format_reset_time(Some("2026-13-99")), "?"); + } + + #[test] + fn format_reset_time_valid_rfc3339_has_month_day_hour_shape() { + let out = UsageSegment::format_reset_time(Some("2026-05-13T15:30:00Z")); + // Format is "{month}-{day}-{hour}" — three dash-separated numeric segments. + let parts: Vec<&str> = out.split('-').collect(); + assert_eq!(parts.len(), 3, "expected 3 parts in {:?}", out); + for p in &parts { + assert!( + p.parse::().is_ok(), + "part {:?} of {:?} is not numeric", + p, + out + ); + } + } +} diff --git a/src/core/statusline.rs b/src/core/statusline.rs index c2724931..4e1c08b5 100644 --- a/src/core/statusline.rs +++ b/src/core/statusline.rs @@ -453,6 +453,138 @@ impl StatusLineGenerator { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{ + ColorConfig, IconConfig, SegmentId, StyleConfig, StyleMode, TextStyleConfig, + }; + use std::collections::HashMap; + + // ---------- visible_width ---------- + + #[test] + fn visible_width_plain_ascii() { + assert_eq!(visible_width(""), 0); + assert_eq!(visible_width("abc"), 3); + assert_eq!(visible_width("hello world"), 11); + } + + #[test] + fn visible_width_strips_ansi_color() { + // \x1b[31mRED\x1b[0m — three visible chars + assert_eq!(visible_width("\x1b[31mRED\x1b[0m"), 3); + } + + #[test] + fn visible_width_multiple_ansi_sequences() { + let s = "\x1b[1;38;2;255;0;0mA\x1b[0m\x1b[32mB\x1b[0mC"; + assert_eq!(visible_width(s), 3); + } + + #[test] + fn visible_width_only_ansi_no_text() { + assert_eq!(visible_width("\x1b[31m\x1b[0m"), 0); + } + + #[test] + fn visible_width_counts_chars_not_bytes() { + // Multi-byte char must count as 1 (function uses .chars().count()). + assert_eq!(visible_width("ё"), 1); + assert_eq!(visible_width("привет"), 6); + } + + // ---------- StatusLineGenerator::generate ---------- + + fn synth_segment(id: SegmentId, primary: &str) -> (SegmentConfig, SegmentData) { + ( + SegmentConfig { + id, + enabled: true, + icon: IconConfig { + plain: "x".to_string(), + nerd_font: "x".to_string(), + }, + colors: ColorConfig { + icon: None, + text: None, + background: None, + }, + styles: TextStyleConfig { text_bold: false }, + options: HashMap::new(), + }, + SegmentData { + primary: primary.to_string(), + secondary: String::new(), + metadata: HashMap::new(), + }, + ) + } + + fn plain_config(separator: &str) -> Config { + Config { + style: StyleConfig { + mode: StyleMode::Plain, + separator: separator.to_string(), + }, + segments: vec![], + theme: "test".to_string(), + } + } + + #[test] + fn generate_empty_input_returns_empty_string() { + let gen = StatusLineGenerator::new(plain_config(" | ")); + assert_eq!(gen.generate(vec![]), ""); + } + + #[test] + fn generate_single_segment_contains_primary() { + let gen = StatusLineGenerator::new(plain_config(" | ")); + let out = gen.generate(vec![synth_segment(SegmentId::Model, "Opus 4")]); + assert!(out.contains("Opus 4"), "output was: {:?}", out); + // No separator with a single segment. + assert!(!out.contains(" | "), "unexpected separator in: {:?}", out); + } + + #[test] + fn generate_two_segments_preserves_order_and_separator() { + let gen = StatusLineGenerator::new(plain_config(" | ")); + let out = gen.generate(vec![ + synth_segment(SegmentId::Model, "Opus 4"), + synth_segment(SegmentId::Directory, "myproj"), + ]); + let i_first = out.find("Opus 4").expect("first primary missing"); + let i_second = out.find("myproj").expect("second primary missing"); + assert!(i_first < i_second, "order broken: {:?}", out); + assert!(out.contains(" | "), "separator missing in: {:?}", out); + // Separator must sit between the two primaries. + let i_sep = out.find(" | ").expect("separator missing"); + assert!( + i_first < i_sep && i_sep < i_second, + "separator not between primaries in: {:?}", + out + ); + } + + #[test] + fn generate_skips_disabled_segments() { + let gen = StatusLineGenerator::new(plain_config(" | ")); + let (mut cfg_a, data_a) = synth_segment(SegmentId::Model, "Opus 4"); + cfg_a.enabled = false; + let visible = synth_segment(SegmentId::Directory, "myproj"); + let out = gen.generate(vec![(cfg_a, data_a), visible]); + assert!( + !out.contains("Opus 4"), + "disabled segment leaked: {:?}", + out + ); + assert!(out.contains("myproj")); + // Only one visible segment → no separator. + assert!(!out.contains(" | "), "spurious separator in: {:?}", out); + } +} + pub fn collect_all_segments( config: &Config, input: &crate::config::InputData, diff --git a/src/ui/main_menu.rs b/src/ui/main_menu.rs index b24258a1..1254573c 100644 --- a/src/ui/main_menu.rs +++ b/src/ui/main_menu.rs @@ -84,10 +84,8 @@ impl MainMenu { KeyCode::Esc | KeyCode::Char('q') => { self.should_quit = true; } - KeyCode::Up => { - if self.selected_item > 0 { - self.selected_item -= 1; - } + KeyCode::Up if self.selected_item > 0 => { + self.selected_item -= 1; } KeyCode::Down => { let menu_items = self.get_menu_items(); diff --git a/src/utils/claude_code_patcher.rs b/src/utils/claude_code_patcher.rs index 79bc77b1..467f90f7 100644 --- a/src/utils/claude_code_patcher.rs +++ b/src/utils/claude_code_patcher.rs @@ -802,7 +802,7 @@ impl ClaudeCodePatcher { } // Sort patches by position descending (apply from end to start to avoid offset issues) - patches.sort_by(|a, b| b.location.start_index.cmp(&a.location.start_index)); + patches.sort_by_key(|p| std::cmp::Reverse(p.location.start_index)); // Apply all patches in one pass for patch in patches { From 34ceb39e2a682423bbeb29e3f71a3ed61ac7bddc Mon Sep 17 00:00:00 2001 From: Oleg Gushul Date: Wed, 13 May 2026 15:13:42 +0400 Subject: [PATCH 2/2] feat(usage): widen response schema with serde flatten extra (T01) --- src/core/segments/usage.rs | 89 +++++++++++++++++++++++++ src/core/statusline.rs | 132 ++++++++++++++++++------------------- 2 files changed, 155 insertions(+), 66 deletions(-) diff --git a/src/core/segments/usage.rs b/src/core/segments/usage.rs index 90157de0..29d4c4fb 100644 --- a/src/core/segments/usage.rs +++ b/src/core/segments/usage.rs @@ -9,12 +9,21 @@ use std::collections::HashMap; struct ApiUsageResponse { five_hour: UsagePeriod, seven_day: UsagePeriod, + // Forward compatibility: capture any unknown top-level fields (e.g. new + // per-model weekly windows that Anthropic may add). Consumed by tests and + // future feature work (T06). + #[serde(flatten)] + #[allow(dead_code)] + extra: HashMap, } #[derive(Debug, Deserialize)] struct UsagePeriod { utilization: f64, resets_at: Option, + #[serde(flatten)] + #[allow(dead_code)] + extra: HashMap, } #[derive(Debug, Serialize, Deserialize)] @@ -368,6 +377,86 @@ mod tests { assert_eq!(UsageSegment::format_reset_time(Some("2026-13-99")), "?"); } + // ---------- ApiUsageResponse / UsagePeriod forward-compat ---------- + // + // T01: the Anthropic usage API may grow new fields (weekly Sonnet, weekly + // Opus, etc.). Our deserializer must accept unknown fields and preserve + // them in `extra` so feature work can introspect them without a schema + // bump. + + #[test] + fn api_usage_response_preserves_unknown_top_level_fields() { + let json = r#"{ + "five_hour": { "utilization": 12.5, "resets_at": "2026-05-13T20:00:00Z" }, + "seven_day": { "utilization": 45.0, "resets_at": "2026-05-20T00:00:00Z" }, + "weekly_opus": { "utilization": 8.0, "resets_at": "2026-05-20T00:00:00Z" }, + "future_field": "anything" + }"#; + + let parsed: ApiUsageResponse = + serde_json::from_str(json).expect("parse should succeed with extras present"); + + assert_eq!(parsed.five_hour.utilization, 12.5); + assert_eq!(parsed.seven_day.utilization, 45.0); + assert!( + parsed.extra.contains_key("weekly_opus"), + "expected weekly_opus in extra, got keys: {:?}", + parsed.extra.keys().collect::>() + ); + assert!(parsed.extra.contains_key("future_field")); + } + + #[test] + fn usage_period_preserves_unknown_fields() { + let json = r#"{ + "utilization": 50.0, + "resets_at": "2026-05-13T20:00:00Z", + "model": "opus", + "remaining_seconds": 3600 + }"#; + + let parsed: UsagePeriod = + serde_json::from_str(json).expect("parse should succeed with extras present"); + + assert_eq!(parsed.utilization, 50.0); + assert!(parsed.extra.contains_key("model")); + assert!(parsed.extra.contains_key("remaining_seconds")); + } + + #[test] + fn api_usage_response_parses_real_world_sample() { + // Real (redacted) response captured 2026-05-13 via examples/probe_usage. + // The API returns more than the historically-known shape — extras live + // in `.extra` thanks to #[serde(flatten)]. + let sample = include_str!("../../../docs/api-samples/usage-response.json"); + let parsed: ApiUsageResponse = + serde_json::from_str(sample).expect("real sample must parse"); + + assert_eq!(parsed.five_hour.utilization, 22.0); + assert_eq!(parsed.seven_day.utilization, 26.0); + + // The known-but-currently-unhandled per-model field is captured. + assert!( + parsed.extra.contains_key("seven_day_sonnet"), + "seven_day_sonnet must land in extra for future T06 work" + ); + // seven_day_opus is present in the schema but `null` in this sample. + assert!(parsed.extra.contains_key("seven_day_opus")); + } + + #[test] + fn api_usage_response_known_fields_still_parse_when_extras_absent() { + // Regression guard: forward-compat must not break the current canonical shape. + let json = r#"{ + "five_hour": { "utilization": 23.0, "resets_at": null }, + "seven_day": { "utilization": 67.0, "resets_at": null } + }"#; + let parsed: ApiUsageResponse = serde_json::from_str(json).expect("parse"); + assert_eq!(parsed.five_hour.utilization, 23.0); + assert_eq!(parsed.seven_day.utilization, 67.0); + assert!(parsed.extra.is_empty()); + } + #[test] fn format_reset_time_valid_rfc3339_has_month_day_hour_shape() { let out = UsageSegment::format_reset_time(Some("2026-05-13T15:30:00Z")); diff --git a/src/core/statusline.rs b/src/core/statusline.rs index 4e1c08b5..268cd0f3 100644 --- a/src/core/statusline.rs +++ b/src/core/statusline.rs @@ -453,6 +453,72 @@ impl StatusLineGenerator { } } +pub fn collect_all_segments( + config: &Config, + input: &crate::config::InputData, +) -> Vec<(SegmentConfig, SegmentData)> { + use crate::core::segments::*; + + let mut results = Vec::new(); + + for segment_config in &config.segments { + // Skip disabled segments to avoid unnecessary API requests + if !segment_config.enabled { + continue; + } + + let segment_data = match segment_config.id { + crate::config::SegmentId::Model => { + let segment = ModelSegment::new(); + segment.collect(input) + } + crate::config::SegmentId::Directory => { + let segment = DirectorySegment::new(); + segment.collect(input) + } + crate::config::SegmentId::Git => { + let show_sha = segment_config + .options + .get("show_sha") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let segment = GitSegment::new().with_sha(show_sha); + segment.collect(input) + } + crate::config::SegmentId::ContextWindow => { + let segment = ContextWindowSegment::new(); + segment.collect(input) + } + crate::config::SegmentId::Usage => { + let segment = UsageSegment::new(); + segment.collect(input) + } + crate::config::SegmentId::Cost => { + let segment = CostSegment::new(); + segment.collect(input) + } + crate::config::SegmentId::Session => { + let segment = SessionSegment::new(); + segment.collect(input) + } + crate::config::SegmentId::OutputStyle => { + let segment = OutputStyleSegment::new(); + segment.collect(input) + } + crate::config::SegmentId::Update => { + let segment = UpdateSegment::new(); + segment.collect(input) + } + }; + + if let Some(data) = segment_data { + results.push((segment_config.clone(), data)); + } + } + + results +} + #[cfg(test)] mod tests { use super::*; @@ -584,69 +650,3 @@ mod tests { assert!(!out.contains(" | "), "spurious separator in: {:?}", out); } } - -pub fn collect_all_segments( - config: &Config, - input: &crate::config::InputData, -) -> Vec<(SegmentConfig, SegmentData)> { - use crate::core::segments::*; - - let mut results = Vec::new(); - - for segment_config in &config.segments { - // Skip disabled segments to avoid unnecessary API requests - if !segment_config.enabled { - continue; - } - - let segment_data = match segment_config.id { - crate::config::SegmentId::Model => { - let segment = ModelSegment::new(); - segment.collect(input) - } - crate::config::SegmentId::Directory => { - let segment = DirectorySegment::new(); - segment.collect(input) - } - crate::config::SegmentId::Git => { - let show_sha = segment_config - .options - .get("show_sha") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let segment = GitSegment::new().with_sha(show_sha); - segment.collect(input) - } - crate::config::SegmentId::ContextWindow => { - let segment = ContextWindowSegment::new(); - segment.collect(input) - } - crate::config::SegmentId::Usage => { - let segment = UsageSegment::new(); - segment.collect(input) - } - crate::config::SegmentId::Cost => { - let segment = CostSegment::new(); - segment.collect(input) - } - crate::config::SegmentId::Session => { - let segment = SessionSegment::new(); - segment.collect(input) - } - crate::config::SegmentId::OutputStyle => { - let segment = OutputStyleSegment::new(); - segment.collect(input) - } - crate::config::SegmentId::Update => { - let segment = UpdateSegment::new(); - segment.collect(input) - } - }; - - if let Some(data) = segment_data { - results.push((segment_config.clone(), data)); - } - } - - results -}