| 1 | //! Palette audit tests to prevent color drift. |
| 2 | //! |
| 3 | //! These tests ensure that deprecated colors (like DEEPSEEK_AQUA) are not used |
| 4 | //! directly in user-visible code. The palette should only use DeepSeek brand |
| 5 | //! colors: blue, sky, red (plus neutral shades). |
| 6 | |
| 7 | use std::fs; |
| 8 | use std::path::Path; |
| 9 | |
| 10 | use ratatui::style::Color; |
| 11 | |
| 12 | #[path = "../src/palette.rs"] |
| 13 | #[allow(dead_code)] |
| 14 | mod palette; |
| 15 | |
| 16 | const DEPRECATED_DIRECT_COLORS: &[&str] = &["DEEPSEEK_AQUA"]; |
| 17 | const ALLOWED_PATTERNS: &[&str] = &["pub const DEEPSEEK_AQUA", "DEEPSEEK_AQUA_RGB"]; |
| 18 | |
| 19 | fn color_to_rgb(color: Color) -> (u8, u8, u8) { |
| 20 | match color { |
| 21 | Color::Rgb(r, g, b) => (r, g, b), |
| 22 | Color::Black => (0, 0, 0), |
| 23 | Color::White => (255, 255, 255), |
| 24 | Color::Gray => (128, 128, 128), |
| 25 | Color::DarkGray => (169, 169, 169), |
| 26 | Color::Red => (255, 0, 0), |
| 27 | Color::LightRed => (255, 102, 102), |
| 28 | Color::Green => (0, 255, 0), |
| 29 | Color::LightGreen => (102, 255, 102), |
| 30 | Color::Yellow => (255, 255, 0), |
| 31 | Color::LightYellow => (255, 255, 153), |
| 32 | Color::Blue => (0, 0, 255), |
| 33 | Color::LightBlue => (102, 153, 255), |
| 34 | Color::Magenta => (255, 0, 255), |
| 35 | Color::LightMagenta => (255, 153, 255), |
| 36 | Color::Cyan => (0, 255, 255), |
| 37 | Color::LightCyan => (153, 255, 255), |
| 38 | _ => panic!("unsupported color variant for contrast test: {:?}", color), |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | fn linearize_srgb(component: u8) -> f64 { |
| 43 | let srgb = f64::from(component) / 255.0; |
| 44 | if srgb <= 0.04045 { |
| 45 | srgb / 12.92 |
| 46 | } else { |
| 47 | ((srgb + 0.055) / 1.055).powf(2.4) |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | fn relative_luminance(color: Color) -> f64 { |
| 52 | let (r, g, b) = color_to_rgb(color); |
| 53 | 0.2126 * linearize_srgb(r) + 0.7152 * linearize_srgb(g) + 0.0722 * linearize_srgb(b) |
| 54 | } |
| 55 | |
| 56 | fn contrast_ratio(foreground: Color, background: Color) -> f64 { |
| 57 | let fg = relative_luminance(foreground); |
| 58 | let bg = relative_luminance(background); |
| 59 | if fg >= bg { |
| 60 | (fg + 0.05) / (bg + 0.05) |
| 61 | } else { |
| 62 | (bg + 0.05) / (fg + 0.05) |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | fn assert_min_contrast(label: &str, foreground: Color, background: Color, min_ratio: f64) { |
| 67 | let ratio = contrast_ratio(foreground, background); |
| 68 | assert!( |
| 69 | ratio >= min_ratio, |
| 70 | "{label} contrast {ratio:.2} is below minimum {min_ratio:.2}" |
| 71 | ); |
| 72 | } |
| 73 | |
| 74 | fn audit_file(path: &Path, violations: &mut Vec<String>) { |
| 75 | let content = match fs::read_to_string(path) { |
| 76 | Ok(c) => c, |
| 77 | Err(_) => return, |
| 78 | }; |
| 79 | |
| 80 | for (line_num, line) in content.lines().enumerate() { |
| 81 | for deprecated in DEPRECATED_DIRECT_COLORS { |
| 82 | let pattern = format!("palette::{}", deprecated); |
| 83 | if line.contains(&pattern) { |
| 84 | let is_allowed = ALLOWED_PATTERNS.iter().any(|p| line.contains(p)); |
| 85 | if !is_allowed { |
| 86 | violations.push(format!( |
| 87 | "{}:{}: direct use of {} (use semantic alias instead)", |
| 88 | path.display(), |
| 89 | line_num + 1, |
| 90 | deprecated |
| 91 | )); |
| 92 | } |
| 93 | } |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | fn audit_directory(dir: &Path, violations: &mut Vec<String>) { |
| 99 | let entries = match fs::read_dir(dir) { |
| 100 | Ok(e) => e, |
| 101 | Err(_) => return, |
| 102 | }; |
| 103 | |
| 104 | for entry in entries.flatten() { |
| 105 | let path = entry.path(); |
| 106 | if path.is_dir() { |
| 107 | audit_directory(&path, violations); |
| 108 | } else if path.extension().is_some_and(|e| e == "rs") { |
| 109 | if path.file_name().is_some_and(|n| n == "palette.rs") { |
| 110 | continue; |
| 111 | } |
| 112 | audit_file(&path, violations); |
| 113 | } |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | #[test] |
| 118 | fn audit_no_direct_aqua_usage() { |
| 119 | let manifest_dir = env!("CARGO_MANIFEST_DIR"); |
| 120 | let src_dir = Path::new(manifest_dir).join("src"); |
| 121 | let mut violations = Vec::new(); |
| 122 | |
| 123 | audit_directory(&src_dir, &mut violations); |
| 124 | |
| 125 | if !violations.is_empty() { |
| 126 | let report = violations.join("\n"); |
| 127 | panic!( |
| 128 | "Palette audit failed! Found {} direct uses of deprecated colors:\n{}", |
| 129 | violations.len(), |
| 130 | report |
| 131 | ); |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | #[test] |
| 136 | fn verify_status_success_uses_sky() { |
| 137 | let manifest_dir = env!("CARGO_MANIFEST_DIR"); |
| 138 | let palette_path = Path::new(manifest_dir).join("src/palette.rs"); |
| 139 | let content = fs::read_to_string(&palette_path).expect("Failed to read palette.rs"); |
| 140 | |
| 141 | assert!( |
| 142 | content.contains("pub const STATUS_SUCCESS: Color = DEEPSEEK_SKY;"), |
| 143 | "STATUS_SUCCESS should use DEEPSEEK_SKY, not DEEPSEEK_AQUA" |
| 144 | ); |
| 145 | } |
| 146 | |
| 147 | #[test] |
| 148 | fn verify_brand_colors_defined() { |
| 149 | let manifest_dir = env!("CARGO_MANIFEST_DIR"); |
| 150 | let palette_path = Path::new(manifest_dir).join("src/palette.rs"); |
| 151 | let content = fs::read_to_string(&palette_path).expect("Failed to read palette.rs"); |
| 152 | |
| 153 | assert!( |
| 154 | content.contains("DEEPSEEK_BLUE_RGB: (u8, u8, u8) = (53, 120, 229);"), |
| 155 | "DEEPSEEK_BLUE should be #3578E5" |
| 156 | ); |
| 157 | assert!( |
| 158 | content.contains("DEEPSEEK_SKY_RGB: (u8, u8, u8) = (106, 174, 242);"), |
| 159 | "DEEPSEEK_SKY should be #6AAEF2" |
| 160 | ); |
| 161 | assert!( |
| 162 | content.contains("DEEPSEEK_RED_RGB: (u8, u8, u8) = (226, 80, 96);"), |
| 163 | "DEEPSEEK_RED should be #E25060" |
| 164 | ); |
| 165 | } |
| 166 | |
| 167 | #[test] |
| 168 | fn contrast_guardrails_for_key_ui_pairs() { |
| 169 | let min_readable = 4.5; |
| 170 | |
| 171 | assert_min_contrast( |
| 172 | "TEXT_BODY on DEEPSEEK_INK", |
| 173 | palette::TEXT_BODY, |
| 174 | palette::DEEPSEEK_INK, |
| 175 | min_readable, |
| 176 | ); |
| 177 | assert_min_contrast( |
| 178 | "TEXT_SECONDARY on DEEPSEEK_INK", |
| 179 | palette::TEXT_SECONDARY, |
| 180 | palette::DEEPSEEK_INK, |
| 181 | min_readable, |
| 182 | ); |
| 183 | assert_min_contrast( |
| 184 | "TEXT_HINT on DEEPSEEK_INK", |
| 185 | palette::TEXT_HINT, |
| 186 | palette::DEEPSEEK_INK, |
| 187 | min_readable, |
| 188 | ); |
| 189 | assert_min_contrast( |
| 190 | "STATUS_WARNING on DEEPSEEK_INK", |
| 191 | palette::STATUS_WARNING, |
| 192 | palette::DEEPSEEK_INK, |
| 193 | min_readable, |
| 194 | ); |
| 195 | assert_min_contrast( |
| 196 | "STATUS_ERROR on DEEPSEEK_INK", |
| 197 | palette::STATUS_ERROR, |
| 198 | palette::DEEPSEEK_INK, |
| 199 | min_readable, |
| 200 | ); |
| 201 | } |
| 202 |