git.lucas.co / cce-status-interface
status bar
git clone https://git.lucas.co/cce-status-interface.git

commit08dd5b1b4706fbe214f37a14a0473b2ca7a76287
parent5ec9d759d8
authorLucas Galante <[email protected]>
date2026-07-13 15:42
test: characterization tests for parsing, config lookup, and module sides (proposal phase 0)

Pin down current behavior before the refactors in PROPOSAL.md:
parse_viewport_text, json_find_key (incl. snake_case splitting and
precedence), module_side_from_json, parse_ccectl_windows, and which
config colors are gamma-corrected.

Two testability seams extracted, no behavior change: get_module_side
now delegates to a pure module_side_from_json, and the ccectl windows
line parsing moved out of trigger_switcher into parse_ccectl_windows.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/main.rs | 363 ++++++++++++++++++++++++++++++++++++++++++++++++++++--------
 1 file changed, 317 insertions(+), 46 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index d06d71e..a4e9e5f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -841,49 +841,11 @@ impl StatusApp {
                 .arg("windows")
                 .output();
 
-            let mut windows = Vec::new();
-            if let Ok(out) = output {
-                let stdout_str = String::from_utf8_lossy(&out.stdout);
-                for line in stdout_str.lines() {
-                    let app_id = if let Some(idx) = line.find("app_id=") {
-                        let rest = &line[idx + 7..];
-                        let end = rest.find(' ').unwrap_or(rest.len());
-                        rest[..end].to_string()
-                    } else {
-                        continue;
-                    };
-
-                    if app_id == "cce-status" || app_id == "cce-status-interface" || app_id == "cce-cloud" {
-                        continue;
-                    }
-
-                    let title = if let Some(idx) = line.find("title=\"") {
-                        let rest = &line[idx + 7..];
-                        let end = rest.find('"').unwrap_or(rest.len());
-                        rest[..end].to_string()
-                    } else {
-                        "".to_string()
-                    };
-
-                    let focused = if let Some(idx) = line.find("focused=") {
-                        let rest = &line[idx + 8..];
-                        let end = rest.find(' ').unwrap_or(rest.len());
-                        rest[..end].trim() == "true"
-                    } else {
-                        false
-                    };
-
-                    let id = if let Some(idx) = line.find("window id=") {
-                        let rest = &line[idx + 10..];
-                        let end = rest.find(' ').unwrap_or(rest.len());
-                        rest[..end].to_string()
-                    } else {
-                        continue;
-                    };
-
-                    windows.push((id, app_id, title, focused));
-                }
-            }
+            let windows = if let Ok(out) = output {
+                parse_ccectl_windows(&String::from_utf8_lossy(&out.stdout))
+            } else {
+                Vec::new()
+            };
 
             if windows.is_empty() {
                 // If there are no windows, don't open a switcher and clear state
@@ -1016,10 +978,13 @@ fn get_active_viewport_from_camera(viewport_json: &str) -> u32 {
 fn get_module_side(name: &str) -> Side {
     let content = std::fs::read_to_string(cce_ui::config::get_config_path()).unwrap_or_default();
     let val = parse_json(&content);
-    
+    module_side_from_json(&val, name)
+}
+
+fn module_side_from_json(val: &serde_json::Value, name: &str) -> Side {
     if name == "light_source" {
         let mut light_pos = 2.356194490192345_f32; // Default 135 deg in rad
-        if let Some(wm_obj) = json_find_key(&val, "window_manager") {
+        if let Some(wm_obj) = json_find_key(val, "window_manager") {
             if let Some(pos_val) = json_find_key(&wm_obj, "light_source_position") {
                 if let Some(f) = pos_val.as_f64() {
                     light_pos = f as f32;
@@ -1049,7 +1014,7 @@ fn get_module_side(name: &str) -> Side {
         }
     }
 
-    if let Some(side_val) = json_find_key(&val, name) {
+    if let Some(side_val) = json_find_key(val, name) {
         if let Some(side_str) = side_val.as_str() {
             match side_str.to_lowercase().as_str() {
                 "left" | "top-left" | "bottom-left" | "top-center" | "bottom-center" => return Side::Left,
@@ -1076,6 +1041,58 @@ fn parse_selected_module_from_args() -> Option<(String, Side)> {
     None
 }
 
+/// One window from `ccectl windows` output: (id, app_id, title, focused).
+pub(crate) type CcectlWindow = (String, String, String, bool);
+
+/// Parse one line of `ccectl windows` output. `window id=` and `app_id=` are
+/// required; `title="…"` (truncated at the first inner quote — the wire format
+/// does not escape) and `focused=` are optional.
+pub(crate) fn parse_ccectl_window_line(line: &str) -> Option<CcectlWindow> {
+    let app_id = {
+        let idx = line.find("app_id=")?;
+        let rest = &line[idx + 7..];
+        let end = rest.find(' ').unwrap_or(rest.len());
+        rest[..end].to_string()
+    };
+
+    let title = if let Some(idx) = line.find("title=\"") {
+        let rest = &line[idx + 7..];
+        let end = rest.find('"').unwrap_or(rest.len());
+        rest[..end].to_string()
+    } else {
+        "".to_string()
+    };
+
+    let focused = if let Some(idx) = line.find("focused=") {
+        let rest = &line[idx + 8..];
+        let end = rest.find(' ').unwrap_or(rest.len());
+        rest[..end].trim() == "true"
+    } else {
+        false
+    };
+
+    let id = {
+        let idx = line.find("window id=")?;
+        let rest = &line[idx + 10..];
+        let end = rest.find(' ').unwrap_or(rest.len());
+        rest[..end].to_string()
+    };
+
+    Some((id, app_id, title, focused))
+}
+
+/// Parse `ccectl windows` output, dropping this app's own surfaces and cce-cloud
+/// popups (they should never appear in the window picker).
+pub(crate) fn parse_ccectl_windows(output: &str) -> Vec<CcectlWindow> {
+    output
+        .lines()
+        .filter_map(parse_ccectl_window_line)
+        .filter(|(_, app_id, _, _)| {
+            app_id != "cce-status" && app_id != "cce-status-interface" && app_id != "cce-cloud"
+        })
+        .collect()
+}
+
 /// The frame's text as prim data: (text, size, x, y, color_u8, font, bounds, box-layout).
 pub(crate) type TextPrim = (String, f32, f32, f32, [u8; 3], Option<String>, Option<[f32; 4]>, Option<cce_ui::scene::paint::TextLayout>);
 
@@ -3378,6 +3395,260 @@ style {
         assert!(font_val.is_some());
         assert_eq!(font_val.unwrap().as_str().unwrap(), "Berkeley Mono 14");
     }
+
+    // ------------------------------------------------------------------
+    // Characterization tests (phase 0): these pin down current behavior
+    // before the refactors in PROPOSAL.md. Where the behavior is odd, the
+    // test documents it rather than fixing it.
+    // ------------------------------------------------------------------
+
+    fn assert_rgba_close(actual: [f32; 4], expected: [f32; 4]) {
+        for i in 0..4 {
+            assert!(
+                (actual[i] - expected[i]).abs() < 1e-3,
+                "channel {} differs: actual {:?} vs expected {:?}",
+                i,
+                actual,
+                expected
+            );
+        }
+    }
+
+    // --- parse_viewport_text ---
+
+    #[test]
+    fn viewport_text_single_span() {
+        let out = parse_viewport_text("<span color='#ff0000'>1</span>");
+        assert_eq!(out.len(), 1);
+        assert_eq!(out[0].1, "1");
+        assert_rgba_close(out[0].0, [1.0, 0.0, 0.0, 1.0]);
+    }
+
+    #[test]
+    fn viewport_text_multiple_spans() {
+        let out = parse_viewport_text(
+            "<span color='#ff0000'>1</span><span color='#00ff00'>2</span>",
+        );
+        assert_eq!(out.len(), 2);
+        assert_eq!(out[0].1, "1");
+        assert_eq!(out[1].1, "2");
+        assert_rgba_close(out[1].0, [0.0, 1.0, 0.0, 1.0]);
+    }
+
+    #[test]
+    fn viewport_text_json_wrapped() {
+        let out = parse_viewport_text(r##"{"text": "<span color='#0000ff'>3</span>"}"##);
+        assert_eq!(out.len(), 1);
+        assert_eq!(out[0].1, "3");
+        assert_rgba_close(out[0].0, [0.0, 0.0, 1.0, 1.0]);
+    }
+
+    #[test]
+    fn viewport_text_plain_text_falls_back_to_default_color() {
+        let out = parse_viewport_text("hello");
+        assert_eq!(out.len(), 1);
+        assert_eq!(out[0].1, "hello");
+        assert_rgba_close(out[0].0, [0.8, 0.8, 0.8, 1.0]);
+    }
+
+    #[test]
+    fn viewport_text_unterminated_span_falls_back_to_raw_input() {
+        // A span with no closing tag aborts markup parsing; the whole raw
+        // input (markup included) is emitted with the default color.
+        let input = "<span color='#ff0000'>abc";
+        let out = parse_viewport_text(input);
+        assert_eq!(out.len(), 1);
+        assert_eq!(out[0].1, input);
+        assert_rgba_close(out[0].0, [0.8, 0.8, 0.8, 1.0]);
+    }
+
+    #[test]
+    fn viewport_text_bad_hex_gets_default_color() {
+        let out = parse_viewport_text("<span color='zzz'>x</span>");
+        assert_eq!(out.len(), 1);
+        assert_eq!(out[0].1, "x");
+        assert_rgba_close(out[0].0, [0.8, 0.8, 0.8, 1.0]);
+    }
+
+    #[test]
+    fn viewport_text_empty_input_is_empty() {
+        assert!(parse_viewport_text("").is_empty());
+    }
+
+    // --- json_find_key ---
+
+    #[test]
+    fn find_key_exact_match_at_top_level() {
+        let val = serde_json::json!({"bar_height": 30.0});
+        assert_eq!(json_find_key(&val, "bar_height").and_then(|v| v.as_f64()), Some(30.0));
+    }
+
+    #[test]
+    fn find_key_splits_snake_case_across_nesting() {
+        // "status_background_color" matches status { background_color }.
+        let val = serde_json::json!({"style": {"status": {"background_color": "#101010"}}});
+        assert_eq!(
+            json_find_key(&val, "status_background_color").and_then(|v| v.as_str()),
+            Some("#101010")
+        );
+    }
+
+    #[test]
+    fn find_key_exact_match_wins_over_split() {
+        // An exact "status_font" key beats descending into status { font }.
+        let val = serde_json::json!({
+            "status_font": "Exact Font",
+            "status": {"font": "Split Font"}
+        });
+        assert_eq!(
+            json_find_key(&val, "status_font").and_then(|v| v.as_str()),
+            Some("Exact Font")
+        );
+    }
+
+    #[test]
+    fn find_key_recurses_into_unrelated_parents() {
+        // The key is found even under a parent the key name never mentions.
+        let val = serde_json::json!({"unrelated": {"deeply": {"bar_height": 42.0}}});
+        assert_eq!(json_find_key(&val, "bar_height").and_then(|v| v.as_f64()), Some(42.0));
+    }
+
+    #[test]
+    fn find_key_miss_is_none() {
+        let val = serde_json::json!({"style": {"status": {}}});
+        assert!(json_find_key(&val, "nonexistent_key").is_none());
+    }
+
+    // --- module_side_from_json ---
+
+    #[test]
+    fn module_side_explicit_values() {
+        let val = serde_json::json!({"clock": "left", "window": "right"});
+        assert_eq!(module_side_from_json(&val, "clock"), Side::Left);
+        assert_eq!(module_side_from_json(&val, "window"), Side::Right);
+    }
+
+    #[test]
+    fn module_side_snap_aliases() {
+        for (snap, side) in [
+            ("top-left", Side::Left),
+            ("bottom-left", Side::Left),
+            ("top-center", Side::Left),
+            ("bottom-center", Side::Left),
+            ("top-right", Side::Right),
+            ("bottom-right", Side::Right),
+            ("TOP-LEFT", Side::Left), // case-insensitive
+        ] {
+            let val = serde_json::json!({"cpu": snap});
+            assert_eq!(module_side_from_json(&val, "cpu"), side, "snap {}", snap);
+        }
+    }
+
+    #[test]
+    fn module_side_defaults() {
+        let val = serde_json::json!({});
+        assert_eq!(module_side_from_json(&val, "window"), Side::Left);
+        assert_eq!(module_side_from_json(&val, "clock"), Side::Right);
+        assert_eq!(module_side_from_json(&val, "tray"), Side::Right);
+    }
+
+    #[test]
+    fn module_side_unknown_value_falls_through_to_default() {
+        let val = serde_json::json!({"window": "sideways", "clock": "sideways"});
+        assert_eq!(module_side_from_json(&val, "window"), Side::Left);
+        assert_eq!(module_side_from_json(&val, "clock"), Side::Right);
+    }
+
+    #[test]
+    fn module_side_light_source_from_angle() {
+        // Left iff angle (rad) in [5π/8, 11π/8); default 135° is Left.
+        let mk = |v: serde_json::Value| serde_json::json!({"window_manager": {"light_source_position": v}});
+        assert_eq!(module_side_from_json(&serde_json::json!({}), "light_source"), Side::Left);
+        // Float values are radians.
+        assert_eq!(
+            module_side_from_json(&mk(serde_json::json!(std::f64::consts::PI)), "light_source"),
+            Side::Left
+        );
+        assert_eq!(module_side_from_json(&mk(serde_json::json!(0.0)), "light_source"), Side::Right);
+        // Integers > 2π are degrees, otherwise radians.
+        assert_eq!(module_side_from_json(&mk(serde_json::json!(180)), "light_source"), Side::Left);
+        assert_eq!(module_side_from_json(&mk(serde_json::json!(3)), "light_source"), Side::Left);
+        assert_eq!(module_side_from_json(&mk(serde_json::json!(0)), "light_source"), Side::Right);
+    }
+
+    // --- parse_ccectl_windows ---
+
+    #[test]
+    fn ccectl_windows_full_line() {
+        let out = parse_ccectl_windows(
+            "window id=3 app_id=firefox title=\"Mozilla Firefox\" focused=true\n\
+             window id=7 app_id=kitty title=\"~\" focused=false",
+        );
+        assert_eq!(out.len(), 2);
+        assert_eq!(out[0], ("3".into(), "firefox".into(), "Mozilla Firefox".into(), true));
+        assert_eq!(out[1], ("7".into(), "kitty".into(), "~".into(), false));
+    }
+
+    #[test]
+    fn ccectl_windows_missing_required_fields_skips_line() {
+        // No app_id → skipped; no window id → skipped.
+        assert!(parse_ccectl_windows("window id=3 title=\"x\"").is_empty());
+        assert!(parse_ccectl_windows("app_id=firefox title=\"x\"").is_empty());
+    }
+
+    #[test]
+    fn ccectl_windows_optional_fields_default() {
+        let out = parse_ccectl_windows("window id=3 app_id=firefox");
+        assert_eq!(out.len(), 1);
+        assert_eq!(out[0], ("3".into(), "firefox".into(), "".into(), false));
+    }
+
+    #[test]
+    fn ccectl_windows_filters_own_surfaces() {
+        let out = parse_ccectl_windows(
+            "window id=1 app_id=cce-status\n\
+             window id=2 app_id=cce-status-interface\n\
+             window id=3 app_id=cce-cloud\n\
+             window id=4 app_id=firefox",
+        );
+        assert_eq!(out.len(), 1);
+        assert_eq!(out[0].1, "firefox");
+    }
+
+    #[test]
+    fn ccectl_windows_title_truncates_at_inner_quote() {
+        // Known wire-format limitation: titles are not escaped, so an inner
+        // quote truncates the title. Documented here, to be fixed by the
+        // --json output in PROPOSAL.md phase 3.
+        let out = parse_ccectl_windows("window id=3 app_id=x title=\"say \"hi\"\" focused=false");
+        assert_eq!(out.len(), 1);
+        assert_eq!(out[0].2, "say ");
+    }
+
+    // --- color parsing gamma (spec for PROPOSAL.md phase 2) ---
+
+    #[test]
+    fn color_from_key_is_gamma_corrected_but_srgb_variant_is_not() {
+        // parse_color_from_key / parse_rgba_color_from_key apply ^2.2 to RGB
+        // (alpha stays raw); parse_srgb_color_from_key returns raw sRGB.
+        // #808080 = 128/255 ≈ 0.50196 per channel; 0.50196^2.2 ≈ 0.21952.
+        let content = r##"
+style {
+    status normal_color=(color)"#808080" background_color=(color)"#80808080"
+}
+"##;
+        let raw = 128.0f32 / 255.0;
+        let linearized = raw.powf(2.2);
+
+        let srgb = parse_srgb_color_from_key(content, "status_normal_color").unwrap();
+        assert_rgba_close(srgb, [raw, raw, raw, 1.0]);
+
+        let gamma = parse_color_from_key(content, "status_background_color").unwrap();
+        assert_rgba_close(gamma, [linearized, linearized, linearized, raw]);
+
+        let gamma_rgba = parse_rgba_color_from_key(content, "status_background_color").unwrap();
+        assert_rgba_close(gamma_rgba, [linearized, linearized, linearized, raw]);
+    }
 }
 
 fn get_cce_cmd() -> String {