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..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)] @@ -273,3 +282,194 @@ 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")), "?"); + } + + // ---------- 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")); + // 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 072f04ef..d1fa55f0 100644 --- a/src/core/statusline.rs +++ b/src/core/statusline.rs @@ -525,3 +525,135 @@ pub fn collect_all_segments( results } + +#[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); + } +}