| 1 | use super::adapt::{ |
| 2 | ColorDepth, adapt_bg, adapt_bg_for_palette_mode, adapt_bg_for_theme, adapt_color, |
| 3 | adapt_fg_for_depth, adapt_fg_for_palette_mode, adapt_fg_for_theme, blend, luma, nearest_ansi16, |
| 4 | pulse_brightness, reasoning_surface_tint, rgb_to_ansi256, |
| 5 | }; |
| 6 | use super::detect::{ |
| 7 | BackgroundSource, PaletteMode, palette_mode_for_background, |
| 8 | palette_mode_from_apple_interface_style, resolve_terminal_background, |
| 9 | }; |
| 10 | use super::themes::{ |
| 11 | CATPPUCCIN_MOCHA_UI_THEME, GRAYSCALE_UI_THEME, LIGHT_UI_THEME, MATRIX_UI_THEME, |
| 12 | SELECTABLE_THEMES, SOLARIZED_LIGHT_UI_THEME, TERMINAL_UI_THEME, TOKYO_NIGHT_UI_THEME, ThemeId, |
| 13 | UI_THEME, UiTheme, normalize_hex_rgb_color, normalize_theme_name, parse_hex_rgb_color, |
| 14 | theme_label_for_mode, ui_theme_from_settings, |
| 15 | }; |
| 16 | use super::tokens::{ |
| 17 | ACCENT_REASONING_LIVE, DIFF_ADDED, DIFF_ADDED_BG, DIFF_DELETED_BG, GRAYSCALE_BORDER, |
| 18 | GRAYSCALE_ELEVATED, GRAYSCALE_PANEL, GRAYSCALE_REASONING, GRAYSCALE_SURFACE, |
| 19 | GRAYSCALE_TEXT_BODY, GRAYSCALE_TEXT_HINT, GRAYSCALE_TEXT_SOFT, LIGHT_ACTION, LIGHT_BORDER, |
| 20 | LIGHT_DANGER, LIGHT_ELEVATED, LIGHT_HUMAN, LIGHT_LIVE, LIGHT_PANEL, LIGHT_REASONING, |
| 21 | LIGHT_SELECTION_BG, LIGHT_SUCCESS_FG, LIGHT_SURFACE, LIGHT_TEXT_BODY, LIGHT_TEXT_BODY_RGB, |
| 22 | LIGHT_TEXT_HINT, LIGHT_WARNING, MODE_AGENT, MODE_PLAN, MODE_YOLO, SELECTION_BG, |
| 23 | SOLARIZED_PANEL, SOLARIZED_SURFACE, SOLARIZED_TEXT_BODY, SOLARIZED_TEXT_HINT, STATUS_ERROR, |
| 24 | STATUS_WARNING, SURFACE_ERROR, SURFACE_REASONING, SURFACE_REASONING_TINT, SURFACE_TOOL_ACTIVE, |
| 25 | TEXT_BODY, TEXT_HINT, TEXT_REASONING, TEXT_TOOL_OUTPUT, WHALE_ACCENT_PRIMARY, WHALE_ACTION, |
| 26 | WHALE_BG, WHALE_ERROR, WHALE_HUMAN, WHALE_INFO, WHALE_LIVE, WHALE_PANEL, |
| 27 | WHALE_REASONING_TEXT_RGB, WHALE_REASONING_TINT_RGB, WHALE_TEXT_BODY_RGB, |
| 28 | }; |
| 29 | use ratatui::style::Color; |
| 30 | |
| 31 | #[test] |
| 32 | fn palette_mode_parses_colorfgbg_background_slot() { |
| 33 | assert_eq!( |
| 34 | PaletteMode::from_colorfgbg("0;15"), |
| 35 | Some(PaletteMode::Light) |
| 36 | ); |
| 37 | assert_eq!(PaletteMode::from_colorfgbg("15;0"), Some(PaletteMode::Dark)); |
| 38 | assert_eq!( |
| 39 | PaletteMode::from_colorfgbg("7;default;15"), |
| 40 | Some(PaletteMode::Light) |
| 41 | ); |
| 42 | assert_eq!(PaletteMode::from_colorfgbg("not-a-color"), None); |
| 43 | } |
| 44 | |
| 45 | #[test] |
| 46 | fn palette_mode_detect_prefers_colorfgbg_over_macos_fallback() { |
| 47 | assert_eq!( |
| 48 | resolve_terminal_background(None, Some("0;15"), Some(PaletteMode::Dark)).mode(), |
| 49 | PaletteMode::Light |
| 50 | ); |
| 51 | assert_eq!( |
| 52 | resolve_terminal_background(None, Some("15;0"), Some(PaletteMode::Light)).mode(), |
| 53 | PaletteMode::Dark |
| 54 | ); |
| 55 | } |
| 56 | |
| 57 | #[test] |
| 58 | fn palette_mode_detect_uses_macos_fallback_when_colorfgbg_missing_or_invalid() { |
| 59 | assert_eq!( |
| 60 | resolve_terminal_background(None, None, Some(PaletteMode::Light)).mode(), |
| 61 | PaletteMode::Light |
| 62 | ); |
| 63 | assert_eq!( |
| 64 | resolve_terminal_background(None, Some("not-a-color"), Some(PaletteMode::Light)).mode(), |
| 65 | PaletteMode::Light |
| 66 | ); |
| 67 | assert_eq!( |
| 68 | resolve_terminal_background(None, None, None).mode(), |
| 69 | PaletteMode::Dark |
| 70 | ); |
| 71 | } |
| 72 | |
| 73 | #[test] |
| 74 | fn apple_interface_style_maps_dark_and_missing_key_to_expected_modes() { |
| 75 | assert_eq!( |
| 76 | palette_mode_from_apple_interface_style("Dark\n"), |
| 77 | PaletteMode::Dark |
| 78 | ); |
| 79 | assert_eq!( |
| 80 | palette_mode_from_apple_interface_style("Light\n"), |
| 81 | PaletteMode::Light |
| 82 | ); |
| 83 | assert_eq!( |
| 84 | palette_mode_from_apple_interface_style(""), |
| 85 | PaletteMode::Light |
| 86 | ); |
| 87 | } |
| 88 | |
| 89 | #[test] |
| 90 | fn ui_theme_selects_light_variant() { |
| 91 | let theme = UiTheme::for_mode(PaletteMode::Light); |
| 92 | assert_eq!(theme, LIGHT_UI_THEME); |
| 93 | assert_eq!(theme.surface_bg, LIGHT_SURFACE); |
| 94 | assert_eq!(theme.text_body, LIGHT_TEXT_BODY); |
| 95 | } |
| 96 | |
| 97 | #[test] |
| 98 | fn ui_theme_selects_grayscale_variant() { |
| 99 | let theme = UiTheme::for_mode(PaletteMode::Grayscale); |
| 100 | assert_eq!(theme, GRAYSCALE_UI_THEME); |
| 101 | assert_eq!(theme.surface_bg, GRAYSCALE_SURFACE); |
| 102 | assert_eq!(theme.panel_bg, GRAYSCALE_PANEL); |
| 103 | assert_eq!(theme.text_body, GRAYSCALE_TEXT_BODY); |
| 104 | } |
| 105 | |
| 106 | #[test] |
| 107 | fn ui_theme_selects_solarized_light_variant() { |
| 108 | let theme = UiTheme::for_mode(PaletteMode::SolarizedLight); |
| 109 | assert_eq!(theme, SOLARIZED_LIGHT_UI_THEME); |
| 110 | assert_eq!(theme.surface_bg, SOLARIZED_SURFACE); |
| 111 | assert_eq!(theme.panel_bg, SOLARIZED_PANEL); |
| 112 | assert_eq!(theme.text_body, SOLARIZED_TEXT_BODY); |
| 113 | } |
| 114 | |
| 115 | #[test] |
| 116 | fn theme_names_normalize_common_grayscale_aliases() { |
| 117 | assert_eq!(normalize_theme_name("system"), Some("system")); |
| 118 | assert_eq!(normalize_theme_name("default"), Some("system")); |
| 119 | assert_eq!(normalize_theme_name("whale"), Some("dark")); |
| 120 | assert_eq!(normalize_theme_name("transparent"), Some("terminal")); |
| 121 | assert_eq!(normalize_theme_name("inherit"), Some("terminal")); |
| 122 | assert_eq!(normalize_theme_name("black-white"), Some("grayscale")); |
| 123 | assert_eq!(normalize_theme_name("mono"), Some("grayscale")); |
| 124 | assert_eq!(normalize_theme_name("solarized"), Some("solarized-light")); |
| 125 | assert_eq!(theme_label_for_mode(PaletteMode::Grayscale), "grayscale"); |
| 126 | } |
| 127 | |
| 128 | #[test] |
| 129 | fn terminal_theme_resets_surfaces_and_remaps_direct_palette_constants() { |
| 130 | assert_eq!(ThemeId::from_name("terminal"), Some(ThemeId::Terminal)); |
| 131 | assert_eq!(TERMINAL_UI_THEME.surface_bg, Color::Reset); |
| 132 | assert_eq!(TERMINAL_UI_THEME.footer_bg, Color::Reset); |
| 133 | assert_eq!(TERMINAL_UI_THEME.text_body, Color::Reset); |
| 134 | |
| 135 | assert_eq!( |
| 136 | adapt_bg_for_theme(WHALE_BG, ThemeId::Terminal, &TERMINAL_UI_THEME), |
| 137 | Color::Reset |
| 138 | ); |
| 139 | assert_eq!( |
| 140 | adapt_bg_for_theme(DIFF_ADDED_BG, ThemeId::Terminal, &TERMINAL_UI_THEME), |
| 141 | Color::Reset |
| 142 | ); |
| 143 | assert_eq!( |
| 144 | adapt_fg_for_theme(TEXT_BODY, ThemeId::Terminal, &TERMINAL_UI_THEME), |
| 145 | Color::Reset |
| 146 | ); |
| 147 | assert_eq!( |
| 148 | adapt_fg_for_theme(DIFF_ADDED, ThemeId::Terminal, &TERMINAL_UI_THEME), |
| 149 | Color::Green |
| 150 | ); |
| 151 | } |
| 152 | |
| 153 | #[test] |
| 154 | fn terminal_and_matrix_preserve_agent_plan_and_full_access_mode_slots() { |
| 155 | for (theme_id, theme) in [ |
| 156 | (ThemeId::Terminal, TERMINAL_UI_THEME), |
| 157 | (ThemeId::Matrix, MATRIX_UI_THEME), |
| 158 | ] { |
| 159 | for (source, expected, role) in [ |
| 160 | (MODE_AGENT, theme.mode_agent, "agent"), |
| 161 | (MODE_PLAN, theme.mode_plan, "plan"), |
| 162 | (MODE_YOLO, theme.mode_yolo, "full access"), |
| 163 | ] { |
| 164 | assert_eq!( |
| 165 | adapt_fg_for_theme(source, theme_id, &theme), |
| 166 | expected, |
| 167 | "theme '{}' must map the raw {role} token to its mode slot", |
| 168 | theme_id.name(), |
| 169 | ); |
| 170 | } |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | #[test] |
| 175 | fn community_remap_keeps_selection_tool_and_error_background_domains() { |
| 176 | let mut theme = TOKYO_NIGHT_UI_THEME; |
| 177 | theme.selection_bg = Color::Rgb(1, 2, 3); |
| 178 | theme.elevated_bg = Color::Rgb(4, 5, 6); |
| 179 | theme.error_surface = Color::Rgb(7, 8, 9); |
| 180 | theme.diff_deleted_bg = Color::Rgb(10, 11, 12); |
| 181 | |
| 182 | assert_eq!( |
| 183 | adapt_bg_for_theme(SELECTION_BG, ThemeId::TokyoNight, &theme), |
| 184 | theme.selection_bg |
| 185 | ); |
| 186 | assert_eq!( |
| 187 | adapt_bg_for_theme(SURFACE_TOOL_ACTIVE, ThemeId::TokyoNight, &theme), |
| 188 | theme.elevated_bg |
| 189 | ); |
| 190 | assert_eq!( |
| 191 | adapt_bg_for_theme(SURFACE_ERROR, ThemeId::TokyoNight, &theme), |
| 192 | theme.error_surface |
| 193 | ); |
| 194 | assert_eq!( |
| 195 | adapt_bg_for_theme(DIFF_DELETED_BG, ThemeId::TokyoNight, &theme), |
| 196 | theme.diff_deleted_bg |
| 197 | ); |
| 198 | } |
| 199 | |
| 200 | #[test] |
| 201 | fn light_palette_has_quiet_layer_separation() { |
| 202 | assert_eq!(LIGHT_SURFACE, Color::Rgb(244, 247, 251)); |
| 203 | assert_eq!(LIGHT_PANEL, Color::Rgb(255, 253, 248)); |
| 204 | assert_eq!(LIGHT_ELEVATED, Color::Rgb(232, 238, 248)); |
| 205 | assert_eq!(LIGHT_BORDER, Color::Rgb(169, 184, 207)); |
| 206 | assert_eq!(LIGHT_SELECTION_BG, Color::Rgb(238, 246, 255)); |
| 207 | assert_ne!(LIGHT_SURFACE, LIGHT_PANEL); |
| 208 | assert_ne!(LIGHT_PANEL, LIGHT_ELEVATED); |
| 209 | } |
| 210 | |
| 211 | #[test] |
| 212 | fn solarized_light_does_not_mutate_whale_light_text() { |
| 213 | assert_eq!( |
| 214 | LIGHT_TEXT_BODY, |
| 215 | Color::Rgb( |
| 216 | LIGHT_TEXT_BODY_RGB.0, |
| 217 | LIGHT_TEXT_BODY_RGB.1, |
| 218 | LIGHT_TEXT_BODY_RGB.2 |
| 219 | ) |
| 220 | ); |
| 221 | assert_ne!(LIGHT_TEXT_BODY, SOLARIZED_TEXT_BODY); |
| 222 | } |
| 223 | |
| 224 | #[test] |
| 225 | fn dark_palette_uses_soft_body_text_and_warm_reasoning() { |
| 226 | assert_eq!( |
| 227 | TEXT_BODY, |
| 228 | Color::Rgb( |
| 229 | WHALE_TEXT_BODY_RGB.0, |
| 230 | WHALE_TEXT_BODY_RGB.1, |
| 231 | WHALE_TEXT_BODY_RGB.2 |
| 232 | ) |
| 233 | ); |
| 234 | assert_eq!( |
| 235 | TEXT_REASONING, |
| 236 | Color::Rgb( |
| 237 | WHALE_REASONING_TEXT_RGB.0, |
| 238 | WHALE_REASONING_TEXT_RGB.1, |
| 239 | WHALE_REASONING_TEXT_RGB.2 |
| 240 | ) |
| 241 | ); |
| 242 | assert_eq!( |
| 243 | ACCENT_REASONING_LIVE, |
| 244 | Color::Rgb( |
| 245 | WHALE_REASONING_TEXT_RGB.0, |
| 246 | WHALE_REASONING_TEXT_RGB.1, |
| 247 | WHALE_REASONING_TEXT_RGB.2 |
| 248 | ) |
| 249 | ); |
| 250 | assert_ne!(TEXT_REASONING, TEXT_TOOL_OUTPUT); |
| 251 | assert_ne!(TEXT_BODY, Color::White); |
| 252 | } |
| 253 | |
| 254 | #[test] |
| 255 | fn ui_theme_applies_custom_background_to_base_surfaces() { |
| 256 | let custom = Color::Rgb(26, 27, 38); |
| 257 | let theme = UiTheme::for_mode(PaletteMode::Dark).with_background_color(custom); |
| 258 | |
| 259 | assert_eq!(theme.surface_bg, custom); |
| 260 | assert_eq!(theme.header_bg, custom); |
| 261 | assert_eq!(theme.footer_bg, custom); |
| 262 | assert_eq!( |
| 263 | theme.composer_bg, UI_THEME.composer_bg, |
| 264 | "custom background must not erase panel contrast" |
| 265 | ); |
| 266 | } |
| 267 | |
| 268 | #[test] |
| 269 | fn hex_rgb_color_parser_accepts_hashless_and_normalizes() { |
| 270 | assert_eq!(parse_hex_rgb_color("#1a1B26"), Some(Color::Rgb(26, 27, 38))); |
| 271 | assert_eq!(parse_hex_rgb_color("1a1b26"), Some(Color::Rgb(26, 27, 38))); |
| 272 | assert_eq!( |
| 273 | normalize_hex_rgb_color("#1A1B26").as_deref(), |
| 274 | Some("#1a1b26") |
| 275 | ); |
| 276 | assert_eq!(parse_hex_rgb_color("#123"), None); |
| 277 | assert_eq!(parse_hex_rgb_color("#zzzzzz"), None); |
| 278 | } |
| 279 | |
| 280 | #[test] |
| 281 | fn light_palette_maps_dark_surfaces_and_text() { |
| 282 | assert_eq!( |
| 283 | adapt_bg_for_palette_mode(WHALE_BG, PaletteMode::Light), |
| 284 | LIGHT_SURFACE |
| 285 | ); |
| 286 | assert_eq!( |
| 287 | adapt_bg_for_palette_mode(WHALE_PANEL, PaletteMode::Light), |
| 288 | LIGHT_PANEL |
| 289 | ); |
| 290 | assert_eq!( |
| 291 | adapt_fg_for_palette_mode(Color::White, LIGHT_SURFACE, PaletteMode::Light), |
| 292 | LIGHT_TEXT_BODY |
| 293 | ); |
| 294 | assert_eq!( |
| 295 | adapt_fg_for_palette_mode(TEXT_HINT, LIGHT_SURFACE, PaletteMode::Light), |
| 296 | LIGHT_TEXT_HINT |
| 297 | ); |
| 298 | assert_eq!( |
| 299 | adapt_fg_for_palette_mode(WHALE_ACTION, LIGHT_SURFACE, PaletteMode::Light), |
| 300 | LIGHT_ACTION |
| 301 | ); |
| 302 | assert_eq!( |
| 303 | adapt_fg_for_palette_mode(WHALE_LIVE, LIGHT_SURFACE, PaletteMode::Light), |
| 304 | LIGHT_LIVE |
| 305 | ); |
| 306 | assert_eq!( |
| 307 | adapt_fg_for_palette_mode(WHALE_HUMAN, LIGHT_SURFACE, PaletteMode::Light), |
| 308 | LIGHT_HUMAN |
| 309 | ); |
| 310 | assert_eq!( |
| 311 | adapt_fg_for_palette_mode(STATUS_WARNING, LIGHT_SURFACE, PaletteMode::Light), |
| 312 | LIGHT_WARNING |
| 313 | ); |
| 314 | assert_eq!( |
| 315 | adapt_fg_for_palette_mode(STATUS_ERROR, LIGHT_SURFACE, PaletteMode::Light), |
| 316 | LIGHT_DANGER |
| 317 | ); |
| 318 | assert_ne!(LIGHT_LIVE, LIGHT_SUCCESS_FG); |
| 319 | } |
| 320 | |
| 321 | #[test] |
| 322 | fn solarized_light_palette_maps_dark_surfaces_and_text_to_solarized_roles() { |
| 323 | assert_eq!( |
| 324 | adapt_bg_for_palette_mode(WHALE_BG, PaletteMode::SolarizedLight), |
| 325 | SOLARIZED_SURFACE |
| 326 | ); |
| 327 | assert_eq!( |
| 328 | adapt_bg_for_palette_mode(WHALE_PANEL, PaletteMode::SolarizedLight), |
| 329 | SOLARIZED_PANEL |
| 330 | ); |
| 331 | assert_eq!( |
| 332 | adapt_fg_for_palette_mode(Color::White, SOLARIZED_SURFACE, PaletteMode::SolarizedLight), |
| 333 | SOLARIZED_TEXT_BODY |
| 334 | ); |
| 335 | assert_eq!( |
| 336 | adapt_fg_for_palette_mode(TEXT_HINT, SOLARIZED_SURFACE, PaletteMode::SolarizedLight), |
| 337 | SOLARIZED_TEXT_HINT |
| 338 | ); |
| 339 | } |
| 340 | |
| 341 | #[test] |
| 342 | fn grayscale_palette_maps_brand_hues_to_neutral_roles() { |
| 343 | assert_eq!( |
| 344 | adapt_bg_for_palette_mode(WHALE_BG, PaletteMode::Grayscale), |
| 345 | GRAYSCALE_SURFACE |
| 346 | ); |
| 347 | assert_eq!( |
| 348 | adapt_bg_for_palette_mode(WHALE_PANEL, PaletteMode::Grayscale), |
| 349 | GRAYSCALE_PANEL |
| 350 | ); |
| 351 | assert_eq!( |
| 352 | adapt_bg_for_palette_mode(SURFACE_REASONING, PaletteMode::Grayscale), |
| 353 | GRAYSCALE_REASONING |
| 354 | ); |
| 355 | assert_eq!( |
| 356 | adapt_fg_for_palette_mode(WHALE_INFO, GRAYSCALE_SURFACE, PaletteMode::Grayscale), |
| 357 | GRAYSCALE_TEXT_SOFT |
| 358 | ); |
| 359 | assert_eq!( |
| 360 | adapt_fg_for_palette_mode(WHALE_ERROR, GRAYSCALE_SURFACE, PaletteMode::Grayscale), |
| 361 | GRAYSCALE_TEXT_BODY |
| 362 | ); |
| 363 | assert_eq!( |
| 364 | adapt_fg_for_palette_mode(TEXT_HINT, GRAYSCALE_SURFACE, PaletteMode::Grayscale), |
| 365 | GRAYSCALE_TEXT_HINT |
| 366 | ); |
| 367 | } |
| 368 | |
| 369 | #[test] |
| 370 | fn grayscale_luma_handles_bright_rgb_without_overflow() { |
| 371 | assert_eq!(luma(255, 255, 255), 255); |
| 372 | assert_eq!( |
| 373 | adapt_fg_for_palette_mode( |
| 374 | Color::Rgb(255, 255, 255), |
| 375 | GRAYSCALE_SURFACE, |
| 376 | PaletteMode::Grayscale |
| 377 | ), |
| 378 | GRAYSCALE_TEXT_BODY |
| 379 | ); |
| 380 | } |
| 381 | |
| 382 | #[test] |
| 383 | fn ui_theme_from_settings_applies_theme_and_background() { |
| 384 | let theme = ui_theme_from_settings("grayscale", Some("#111111")); |
| 385 | assert_eq!(theme.mode, PaletteMode::Grayscale); |
| 386 | assert_eq!(theme.surface_bg, Color::Rgb(17, 17, 17)); |
| 387 | assert_eq!(theme.header_bg, Color::Rgb(17, 17, 17)); |
| 388 | assert_eq!(theme.footer_bg, Color::Rgb(17, 17, 17)); |
| 389 | assert_eq!(theme.panel_bg, GRAYSCALE_PANEL); |
| 390 | assert_eq!(theme.elevated_bg, GRAYSCALE_ELEVATED); |
| 391 | assert_eq!(theme.border, GRAYSCALE_BORDER); |
| 392 | } |
| 393 | |
| 394 | #[test] |
| 395 | fn adapt_color_passes_through_truecolor() { |
| 396 | let c = Color::Rgb(53, 120, 229); |
| 397 | assert_eq!(adapt_color(c, ColorDepth::TrueColor), c); |
| 398 | } |
| 399 | |
| 400 | #[test] |
| 401 | fn adapt_color_maps_rgb_to_indexed_on_ansi256() { |
| 402 | let c = Color::Rgb(53, 120, 229); |
| 403 | assert!(matches!( |
| 404 | adapt_color(c, ColorDepth::Ansi256), |
| 405 | Color::Indexed(_) |
| 406 | )); |
| 407 | } |
| 408 | |
| 409 | #[test] |
| 410 | fn adapt_bg_maps_rgb_to_indexed_on_ansi256() { |
| 411 | assert!(matches!( |
| 412 | adapt_bg(SURFACE_REASONING, ColorDepth::Ansi256), |
| 413 | Color::Indexed(_) |
| 414 | )); |
| 415 | } |
| 416 | |
| 417 | #[test] |
| 418 | fn adapt_color_drops_to_named_on_ansi16() { |
| 419 | // Sky: blue-dominant and bright → LightBlue, not terminal cyan. |
| 420 | assert_eq!( |
| 421 | adapt_color(WHALE_INFO, ColorDepth::Ansi16), |
| 422 | Color::LightBlue |
| 423 | ); |
| 424 | // Rose Red is intentionally bright enough to use the terminal's |
| 425 | // bright red slot. |
| 426 | assert_eq!( |
| 427 | adapt_color(WHALE_ERROR, ColorDepth::Ansi16), |
| 428 | Color::LightRed |
| 429 | ); |
| 430 | } |
| 431 | |
| 432 | #[test] |
| 433 | fn semantic_tokens_align_primary_accent_with_action_not_human_gold() { |
| 434 | assert_eq!(WHALE_ACCENT_PRIMARY, WHALE_ACTION); |
| 435 | assert_eq!(WHALE_ACTION, WHALE_INFO); |
| 436 | assert_ne!(WHALE_ACTION, WHALE_HUMAN); |
| 437 | } |
| 438 | |
| 439 | #[test] |
| 440 | fn stable_dark_and_light_ids_expose_blue_stage_product_names() { |
| 441 | assert_eq!(ThemeId::from_name("dark"), Some(ThemeId::Whale)); |
| 442 | assert_eq!(ThemeId::Whale.display_name(), "Blue Stage"); |
| 443 | assert_eq!(ThemeId::from_name("light"), Some(ThemeId::WhaleLight)); |
| 444 | assert_eq!(ThemeId::WhaleLight.display_name(), "Blue Stage Light"); |
| 445 | } |
| 446 | |
| 447 | #[test] |
| 448 | fn community_theme_info_keeps_the_sky_live_role_on_ansi16() { |
| 449 | assert_eq!( |
| 450 | adapt_fg_for_depth( |
| 451 | CATPPUCCIN_MOCHA_UI_THEME.info, |
| 452 | CATPPUCCIN_MOCHA_UI_THEME.info, |
| 453 | ColorDepth::Ansi16, |
| 454 | &CATPPUCCIN_MOCHA_UI_THEME, |
| 455 | ), |
| 456 | Color::LightCyan, |
| 457 | ); |
| 458 | assert_eq!( |
| 459 | adapt_fg_for_depth( |
| 460 | CATPPUCCIN_MOCHA_UI_THEME.status_working, |
| 461 | CATPPUCCIN_MOCHA_UI_THEME.status_working, |
| 462 | ColorDepth::Ansi16, |
| 463 | &CATPPUCCIN_MOCHA_UI_THEME, |
| 464 | ), |
| 465 | Color::LightCyan, |
| 466 | ); |
| 467 | } |
| 468 | |
| 469 | #[test] |
| 470 | fn every_selectable_theme_keeps_action_and_working_roles_distinct_on_ansi16() { |
| 471 | for theme_id in SELECTABLE_THEMES { |
| 472 | // Grayscale deliberately collapses colored semantic lanes to neutral |
| 473 | // luminance tiers before terminal-depth adaptation. |
| 474 | if *theme_id == ThemeId::Grayscale { |
| 475 | continue; |
| 476 | } |
| 477 | let ui = theme_id.ui_theme(); |
| 478 | assert_eq!( |
| 479 | adapt_fg_for_depth( |
| 480 | ui.accent_primary, |
| 481 | ui.accent_primary, |
| 482 | ColorDepth::Ansi16, |
| 483 | &ui, |
| 484 | ), |
| 485 | Color::LightBlue, |
| 486 | "theme '{}' lost the action lane", |
| 487 | theme_id.name(), |
| 488 | ); |
| 489 | assert_eq!( |
| 490 | adapt_fg_for_depth( |
| 491 | ui.status_working, |
| 492 | ui.status_working, |
| 493 | ColorDepth::Ansi16, |
| 494 | &ui, |
| 495 | ), |
| 496 | Color::LightCyan, |
| 497 | "theme '{}' lost the live working lane", |
| 498 | theme_id.name(), |
| 499 | ); |
| 500 | } |
| 501 | } |
| 502 | |
| 503 | #[test] |
| 504 | fn adapt_bg_disables_tints_on_ansi16() { |
| 505 | assert_eq!( |
| 506 | adapt_bg(SURFACE_REASONING, ColorDepth::Ansi16), |
| 507 | Color::Reset |
| 508 | ); |
| 509 | assert_eq!( |
| 510 | adapt_bg(SURFACE_REASONING, ColorDepth::TrueColor), |
| 511 | SURFACE_REASONING |
| 512 | ); |
| 513 | } |
| 514 | |
| 515 | #[test] |
| 516 | fn reasoning_tint_is_none_on_ansi16() { |
| 517 | assert!(reasoning_surface_tint(ColorDepth::Ansi16).is_none()); |
| 518 | assert!(reasoning_surface_tint(ColorDepth::TrueColor).is_some()); |
| 519 | assert!(matches!( |
| 520 | reasoning_surface_tint(ColorDepth::Ansi256), |
| 521 | Some(Color::Indexed(_)) |
| 522 | )); |
| 523 | } |
| 524 | |
| 525 | #[test] |
| 526 | fn light_palette_maps_reasoning_tint_to_light_surface() { |
| 527 | assert_eq!( |
| 528 | SURFACE_REASONING_TINT, |
| 529 | Color::Rgb( |
| 530 | WHALE_REASONING_TINT_RGB.0, |
| 531 | WHALE_REASONING_TINT_RGB.1, |
| 532 | WHALE_REASONING_TINT_RGB.2 |
| 533 | ) |
| 534 | ); |
| 535 | assert_eq!( |
| 536 | adapt_bg_for_palette_mode(SURFACE_REASONING_TINT, PaletteMode::Light), |
| 537 | LIGHT_REASONING |
| 538 | ); |
| 539 | assert_eq!( |
| 540 | adapt_bg_for_palette_mode( |
| 541 | reasoning_surface_tint(ColorDepth::TrueColor).expect("truecolor tint"), |
| 542 | PaletteMode::Light, |
| 543 | ), |
| 544 | LIGHT_REASONING |
| 545 | ); |
| 546 | } |
| 547 | |
| 548 | #[test] |
| 549 | fn blend_at_zero_returns_bg_at_one_returns_fg() { |
| 550 | let fg = Color::Rgb(200, 100, 50); |
| 551 | let bg = Color::Rgb(0, 0, 0); |
| 552 | assert_eq!(blend(fg, bg, 0.0), bg); |
| 553 | assert_eq!(blend(fg, bg, 1.0), fg); |
| 554 | } |
| 555 | |
| 556 | #[test] |
| 557 | fn blend_at_half_is_midpoint() { |
| 558 | let mid = blend(Color::Rgb(200, 100, 0), Color::Rgb(0, 0, 0), 0.5); |
| 559 | assert_eq!(mid, Color::Rgb(100, 50, 0)); |
| 560 | } |
| 561 | |
| 562 | #[test] |
| 563 | fn pulse_brightness_swings_within_envelope() { |
| 564 | // The pulse rides between 30%..100% — never below 30% of the source. |
| 565 | let src = ACCENT_REASONING_LIVE; |
| 566 | let mut min_r = u8::MAX; |
| 567 | let mut max_r = 0u8; |
| 568 | for ms in (0u64..2000).step_by(50) { |
| 569 | if let Color::Rgb(r, _, _) = pulse_brightness(src, ms) { |
| 570 | min_r = min_r.min(r); |
| 571 | max_r = max_r.max(r); |
| 572 | } |
| 573 | } |
| 574 | let Color::Rgb(src_r, _, _) = src else { |
| 575 | panic!("expected RGB"); |
| 576 | }; |
| 577 | // Trough should land near 30% of source; crest near source itself. |
| 578 | let lower = (f32::from(src_r) * 0.30).round() as u8; |
| 579 | assert!(min_r <= lower + 2, "trough too high: {min_r}"); |
| 580 | assert!(max_r + 2 >= src_r, "crest too low: {max_r}"); |
| 581 | } |
| 582 | |
| 583 | #[test] |
| 584 | fn pulse_passes_named_colors_unchanged() { |
| 585 | // Named palette entries don't blend meaningfully — leave them alone. |
| 586 | assert_eq!(pulse_brightness(Color::Reset, 0), Color::Reset); |
| 587 | assert_eq!(pulse_brightness(Color::Cyan, 1234), Color::Cyan); |
| 588 | } |
| 589 | |
| 590 | #[test] |
| 591 | fn nearest_ansi16_routes_known_brand_colors() { |
| 592 | // Codewhale keeps action, live, human, and danger distinct where ANSI-16 allows it. |
| 593 | assert_eq!(nearest_ansi16(106, 174, 242), Color::LightBlue); // Cobalt action |
| 594 | assert_eq!(nearest_ansi16(246, 196, 83), Color::LightYellow); // Signal Gold |
| 595 | assert_eq!(nearest_ansi16(79, 209, 197), Color::LightCyan); // Seafoam |
| 596 | assert_eq!(nearest_ansi16(38, 62, 92), Color::Blue); // Border |
| 597 | assert_eq!(nearest_ansi16(54, 187, 212), Color::LightCyan); // Aqua |
| 598 | assert_eq!(nearest_ansi16(255, 134, 178), Color::LightRed); // Rose danger |
| 599 | assert_eq!(nearest_ansi16(3, 7, 13), Color::Black); // Deep field |
| 600 | } |
| 601 | |
| 602 | #[test] |
| 603 | fn rgb_to_ansi256_uses_stable_extended_palette() { |
| 604 | assert!(rgb_to_ansi256(53, 120, 229) >= 16); |
| 605 | assert!(rgb_to_ansi256(11, 21, 38) >= 16); |
| 606 | } |
| 607 | |
| 608 | #[test] |
| 609 | fn color_depth_detect_is_safe_without_env() { |
| 610 | // Don't try to pin the result — env may be anything in CI. Just |
| 611 | // exercise the path so a panic would surface. |
| 612 | let _ = ColorDepth::detect(); |
| 613 | let _ = adapt_color(WHALE_BG, ColorDepth::detect()); |
| 614 | } |
| 615 | |
| 616 | // === #4833: contrast floor === |
| 617 | |
| 618 | use super::contrast::{ |
| 619 | AA_BODY_CONTRAST, contrast_ratio, effective_surface, enforce_contrast, meets_contrast, |
| 620 | relative_luminance, symbol_needs_text_contrast, theme_contrast_violations, |
| 621 | theme_uses_terminal_owned_surfaces, |
| 622 | }; |
| 623 | use super::detect::TerminalBackground; |
| 624 | use super::osc11::parse_osc11_reply; |
| 625 | use super::tokens::{ |
| 626 | MODE_OPERATE, STATUS_SUCCESS, TEXT_MUTED, TEXT_SECONDARY, TEXT_SOFT, USER_BODY, |
| 627 | }; |
| 628 | |
| 629 | const WHITE: Color = Color::Rgb(0xFF, 0xFF, 0xFF); |
| 630 | const BLACK: Color = Color::Rgb(0x00, 0x00, 0x00); |
| 631 | |
| 632 | /// Every dark-palette token that renders *text*. Frame chrome (`BORDER_COLOR`) |
| 633 | /// is deliberately absent — see `symbol_needs_text_contrast`. |
| 634 | const DARK_TEXT_TOKENS: &[Color] = &[ |
| 635 | TEXT_BODY, |
| 636 | TEXT_SOFT, |
| 637 | TEXT_SECONDARY, |
| 638 | TEXT_MUTED, |
| 639 | TEXT_HINT, |
| 640 | TEXT_REASONING, |
| 641 | TEXT_TOOL_OUTPUT, |
| 642 | USER_BODY, |
| 643 | WHALE_ACTION, |
| 644 | WHALE_INFO, |
| 645 | WHALE_LIVE, |
| 646 | WHALE_HUMAN, |
| 647 | WHALE_ERROR, |
| 648 | WHALE_ACCENT_PRIMARY, |
| 649 | MODE_AGENT, |
| 650 | MODE_PLAN, |
| 651 | MODE_OPERATE, |
| 652 | MODE_YOLO, |
| 653 | STATUS_ERROR, |
| 654 | STATUS_WARNING, |
| 655 | STATUS_SUCCESS, |
| 656 | DIFF_ADDED, |
| 657 | ]; |
| 658 | |
| 659 | fn approx(actual: f32, expected: f32, tolerance: f32) { |
| 660 | assert!( |
| 661 | (actual - expected).abs() <= tolerance, |
| 662 | "expected {expected} ± {tolerance}, got {actual}" |
| 663 | ); |
| 664 | } |
| 665 | |
| 666 | #[test] |
| 667 | fn relative_luminance_matches_wcag_reference_values() { |
| 668 | approx(relative_luminance(WHITE).unwrap(), 1.0, 1e-4); |
| 669 | approx(relative_luminance(BLACK).unwrap(), 0.0, 1e-4); |
| 670 | // WCAG worked example: #808080 has relative luminance 0.2159. |
| 671 | approx( |
| 672 | relative_luminance(Color::Rgb(0x80, 0x80, 0x80)).unwrap(), |
| 673 | 0.2159, |
| 674 | 1e-3, |
| 675 | ); |
| 676 | // Terminal-defined colors have no knowable RGB, so no luminance. |
| 677 | assert_eq!(relative_luminance(Color::Reset), None); |
| 678 | assert_eq!(relative_luminance(Color::White), None); |
| 679 | assert_eq!(relative_luminance(Color::Indexed(7)), None); |
| 680 | // The xterm cube and gray ramp are fixed by spec, so they are knowable. |
| 681 | assert!(relative_luminance(Color::Indexed(231)).is_some()); |
| 682 | } |
| 683 | |
| 684 | #[test] |
| 685 | fn contrast_ratio_matches_known_pairs() { |
| 686 | approx(contrast_ratio(BLACK, WHITE).unwrap(), 21.0, 1e-3); |
| 687 | approx(contrast_ratio(WHITE, WHITE).unwrap(), 1.0, 1e-4); |
| 688 | // #767676 on white is the canonical "smallest AA-passing gray". |
| 689 | approx( |
| 690 | contrast_ratio(Color::Rgb(0x76, 0x76, 0x76), WHITE).unwrap(), |
| 691 | 4.54, |
| 692 | 0.01, |
| 693 | ); |
| 694 | // Symmetric in its arguments. |
| 695 | assert_eq!( |
| 696 | contrast_ratio(TEXT_BODY, WHALE_BG), |
| 697 | contrast_ratio(WHALE_BG, TEXT_BODY) |
| 698 | ); |
| 699 | // An unknowable side yields no ratio, and `meets_contrast` refuses to call |
| 700 | // that a pass. |
| 701 | assert_eq!(contrast_ratio(TEXT_BODY, Color::Reset), None); |
| 702 | assert!(!meets_contrast(TEXT_BODY, Color::Reset, AA_BODY_CONTRAST)); |
| 703 | } |
| 704 | |
| 705 | #[test] |
| 706 | fn light_surface_lifts_body_text_that_no_whitelist_adapted() { |
| 707 | // The #4833 shape: dark-tuned ivory body text reaching a near-white |
| 708 | // terminal with no light adaptation applied, because detection said Dark. |
| 709 | let before = contrast_ratio(TEXT_BODY, WHITE).unwrap(); |
| 710 | assert!( |
| 711 | before < AA_BODY_CONTRAST, |
| 712 | "precondition: unadapted body text is illegible on white ({before})" |
| 713 | ); |
| 714 | |
| 715 | let lifted = enforce_contrast(TEXT_BODY, WHITE, AA_BODY_CONTRAST); |
| 716 | let after = contrast_ratio(lifted, WHITE).unwrap(); |
| 717 | assert!( |
| 718 | after >= AA_BODY_CONTRAST, |
| 719 | "body text must clear AA on a light surface, got {after}" |
| 720 | ); |
| 721 | |
| 722 | // The same holds for the reporter's paler surface and for the secondary |
| 723 | // tiers that collapsed alongside body text. |
| 724 | let reported_surface = Color::Rgb(0xF7, 0xF7, 0xF5); |
| 725 | for token in [TEXT_BODY, TEXT_SOFT, TEXT_SECONDARY, TEXT_HINT] { |
| 726 | let lifted = enforce_contrast(token, reported_surface, AA_BODY_CONTRAST); |
| 727 | let ratio = contrast_ratio(lifted, reported_surface).unwrap(); |
| 728 | assert!( |
| 729 | ratio >= AA_BODY_CONTRAST, |
| 730 | "{token:?} still below floor on light surface: {ratio}" |
| 731 | ); |
| 732 | } |
| 733 | } |
| 734 | |
| 735 | #[test] |
| 736 | fn enforce_contrast_lifts_by_the_smallest_amount_that_clears_the_floor() { |
| 737 | let lifted = enforce_contrast(TEXT_BODY, WHITE, AA_BODY_CONTRAST); |
| 738 | let ratio = contrast_ratio(lifted, WHITE).unwrap(); |
| 739 | assert!( |
| 740 | (AA_BODY_CONTRAST..AA_BODY_CONTRAST + 0.1).contains(&ratio), |
| 741 | "expected a minimal lift to ~{AA_BODY_CONTRAST}, got {ratio}" |
| 742 | ); |
| 743 | // Already-compliant colors are returned byte-identical. |
| 744 | assert_eq!( |
| 745 | enforce_contrast(LIGHT_TEXT_BODY, WHITE, AA_BODY_CONTRAST), |
| 746 | LIGHT_TEXT_BODY |
| 747 | ); |
| 748 | } |
| 749 | |
| 750 | #[test] |
| 751 | fn dark_surface_leaves_every_text_token_untouched() { |
| 752 | // The no-regression guarantee for today's users: on the surfaces a dark |
| 753 | // terminal actually presents, no shipped text token is rewritten. |
| 754 | for surface in [ |
| 755 | WHALE_BG, |
| 756 | WHALE_PANEL, |
| 757 | BLACK, |
| 758 | Color::Rgb(0x1E, 0x1E, 0x1E), // VS Code dark |
| 759 | Color::Rgb(0x0C, 0x0C, 0x0C), // Windows Terminal default |
| 760 | ] { |
| 761 | for token in DARK_TEXT_TOKENS { |
| 762 | assert_eq!( |
| 763 | enforce_contrast(*token, surface, AA_BODY_CONTRAST), |
| 764 | *token, |
| 765 | "{token:?} was rewritten on dark surface {surface:?}" |
| 766 | ); |
| 767 | } |
| 768 | } |
| 769 | } |
| 770 | |
| 771 | #[test] |
| 772 | fn light_theme_tokens_already_clear_the_floor_on_their_own_surfaces() { |
| 773 | for surface in [LIGHT_SURFACE, LIGHT_PANEL, LIGHT_ELEVATED] { |
| 774 | for token in [ |
| 775 | LIGHT_TEXT_BODY, |
| 776 | LIGHT_TEXT_HINT, |
| 777 | LIGHT_ACTION, |
| 778 | LIGHT_LIVE, |
| 779 | LIGHT_HUMAN, |
| 780 | LIGHT_WARNING, |
| 781 | LIGHT_DANGER, |
| 782 | LIGHT_SUCCESS_FG, |
| 783 | ] { |
| 784 | let ratio = contrast_ratio(token, surface).unwrap(); |
| 785 | assert!( |
| 786 | ratio >= AA_BODY_CONTRAST, |
| 787 | "{token:?} on {surface:?} is {ratio}, below the floor" |
| 788 | ); |
| 789 | assert_eq!(enforce_contrast(token, surface, AA_BODY_CONTRAST), token); |
| 790 | } |
| 791 | } |
| 792 | } |
| 793 | |
| 794 | #[test] |
| 795 | fn enforce_contrast_declines_when_it_cannot_know_the_colors() { |
| 796 | // Named/indexed colors are remapped by the user's terminal profile, and |
| 797 | // `Reset` is the terminal's own choice. Rewriting either would be a guess. |
| 798 | assert_eq!( |
| 799 | enforce_contrast(Color::White, WHITE, AA_BODY_CONTRAST), |
| 800 | Color::White |
| 801 | ); |
| 802 | assert_eq!( |
| 803 | enforce_contrast(Color::Indexed(250), WHITE, AA_BODY_CONTRAST), |
| 804 | Color::Indexed(250) |
| 805 | ); |
| 806 | assert_eq!( |
| 807 | enforce_contrast(TEXT_BODY, Color::Reset, AA_BODY_CONTRAST), |
| 808 | TEXT_BODY |
| 809 | ); |
| 810 | // 4.5:1 is reachable from *every* surface — the worst case is the |
| 811 | // luminance where black and white tie, and even there the better pole |
| 812 | // clears 4.58:1. So the floor never silently gives up. |
| 813 | for gray in (0u8..=255).step_by(5) { |
| 814 | let surface = Color::Rgb(gray, gray, gray); |
| 815 | let lifted = enforce_contrast(TEXT_BODY, surface, AA_BODY_CONTRAST); |
| 816 | let ratio = contrast_ratio(lifted, surface).unwrap(); |
| 817 | assert!( |
| 818 | ratio >= AA_BODY_CONTRAST, |
| 819 | "gray {gray:#04x} left body text at {ratio}:1" |
| 820 | ); |
| 821 | } |
| 822 | |
| 823 | // An unreachable floor (AAA on a mid-gray) returns the better pole rather |
| 824 | // than pretending it succeeded. |
| 825 | let mid = Color::Rgb(0x80, 0x80, 0x80); |
| 826 | assert_eq!(enforce_contrast(TEXT_BODY, mid, 7.0), BLACK); |
| 827 | } |
| 828 | |
| 829 | #[test] |
| 830 | fn effective_surface_prefers_painted_background_then_measurement() { |
| 831 | // A painted cell knows its own surface. |
| 832 | assert_eq!( |
| 833 | effective_surface(WHALE_PANEL, Some(WHITE)), |
| 834 | Some(WHALE_PANEL) |
| 835 | ); |
| 836 | // An unpainted cell falls through to what detection measured. |
| 837 | assert_eq!(effective_surface(Color::Reset, Some(WHITE)), Some(WHITE)); |
| 838 | // With no measurement there is no surface — the floor stands down. |
| 839 | assert_eq!(effective_surface(Color::Reset, None), None); |
| 840 | // A measurement we cannot resolve is not a measurement. |
| 841 | assert_eq!(effective_surface(Color::Reset, Some(Color::Reset)), None); |
| 842 | } |
| 843 | |
| 844 | #[test] |
| 845 | fn text_contrast_floor_applies_to_glyphs_not_frame_chrome() { |
| 846 | assert!(symbol_needs_text_contrast("a")); |
| 847 | assert!(symbol_needs_text_contrast("字")); |
| 848 | assert!(symbol_needs_text_contrast("→")); |
| 849 | assert!(!symbol_needs_text_contrast(" ")); |
| 850 | assert!(!symbol_needs_text_contrast("")); |
| 851 | assert!(!symbol_needs_text_contrast("─")); |
| 852 | assert!(!symbol_needs_text_contrast("│")); |
| 853 | assert!(!symbol_needs_text_contrast("█")); |
| 854 | assert!(!symbol_needs_text_contrast("▏")); |
| 855 | assert!(!symbol_needs_text_contrast("●")); |
| 856 | } |
| 857 | |
| 858 | #[test] |
| 859 | fn background_luminance_decides_polarity_without_a_color_list() { |
| 860 | assert_eq!(palette_mode_for_background(WHITE), Some(PaletteMode::Light)); |
| 861 | assert_eq!(palette_mode_for_background(BLACK), Some(PaletteMode::Dark)); |
| 862 | assert_eq!( |
| 863 | palette_mode_for_background(LIGHT_SURFACE), |
| 864 | Some(PaletteMode::Light) |
| 865 | ); |
| 866 | assert_eq!( |
| 867 | palette_mode_for_background(SOLARIZED_SURFACE), |
| 868 | Some(PaletteMode::Light) |
| 869 | ); |
| 870 | assert_eq!( |
| 871 | palette_mode_for_background(WHALE_BG), |
| 872 | Some(PaletteMode::Dark) |
| 873 | ); |
| 874 | assert_eq!( |
| 875 | palette_mode_for_background(Color::Rgb(0x28, 0x2C, 0x34)), |
| 876 | Some(PaletteMode::Dark) |
| 877 | ); |
| 878 | assert_eq!(palette_mode_for_background(Color::Reset), None); |
| 879 | } |
| 880 | |
| 881 | #[test] |
| 882 | fn unknown_background_keeps_the_dark_default_and_offers_no_surface() { |
| 883 | let unknown = TerminalBackground::unknown(); |
| 884 | assert_eq!(unknown.mode(), PaletteMode::Dark); |
| 885 | assert_eq!(unknown.color(), None); |
| 886 | assert_eq!(unknown.source(), BackgroundSource::Unknown); |
| 887 | // This is the #4833 trigger environment: a terminal that sets no |
| 888 | // COLORFGBG and is not macOS. Detection still answers Dark — but it says |
| 889 | // so with `Unknown` provenance and no color, so nothing downstream |
| 890 | // mistakes the guess for a measurement. |
| 891 | let resolved = resolve_terminal_background(None, None, None); |
| 892 | assert_eq!(resolved, unknown); |
| 893 | assert_eq!(effective_surface(Color::Reset, resolved.color()), None); |
| 894 | } |
| 895 | |
| 896 | #[test] |
| 897 | fn measured_background_outranks_env_hints_and_records_provenance() { |
| 898 | // A white terminal that also exports a dark-looking COLORFGBG: the |
| 899 | // measurement wins, and it carries the color the floor needs. |
| 900 | let measured = resolve_terminal_background(Some((0xFF, 0xFF, 0xFF)), Some("15;0"), None); |
| 901 | assert_eq!(measured.mode(), PaletteMode::Light); |
| 902 | assert_eq!(measured.color(), Some(WHITE)); |
| 903 | assert_eq!(measured.source(), BackgroundSource::Osc11); |
| 904 | |
| 905 | // COLORFGBG with a resolvable xterm index yields a real color too. |
| 906 | let indexed = resolve_terminal_background(None, Some("0;231"), None); |
| 907 | assert_eq!(indexed.mode(), PaletteMode::Light); |
| 908 | assert_eq!(indexed.color(), Some(Color::Indexed(231))); |
| 909 | assert_eq!(indexed.source(), BackgroundSource::ColorFgBg); |
| 910 | |
| 911 | // Indices 0..=15 are terminal-profile defined: mode only, no color. |
| 912 | let ansi = resolve_terminal_background(None, Some("0;15"), None); |
| 913 | assert_eq!(ansi.mode(), PaletteMode::Light); |
| 914 | assert_eq!(ansi.color(), None); |
| 915 | assert_eq!(ansi.source(), BackgroundSource::ColorFgBg); |
| 916 | |
| 917 | // macOS appearance describes the OS, not the terminal — no color. |
| 918 | let macos = resolve_terminal_background(None, None, Some(PaletteMode::Light)); |
| 919 | assert_eq!(macos.mode(), PaletteMode::Light); |
| 920 | assert_eq!(macos.color(), None); |
| 921 | assert_eq!(macos.source(), BackgroundSource::MacOsAppearance); |
| 922 | } |
| 923 | |
| 924 | #[test] |
| 925 | fn osc11_replies_parse_across_the_shapes_terminals_emit() { |
| 926 | assert_eq!( |
| 927 | parse_osc11_reply("\u{1b}]11;rgb:ffff/ffff/ffff"), |
| 928 | Some((255, 255, 255)) |
| 929 | ); |
| 930 | assert_eq!( |
| 931 | parse_osc11_reply("]11;rgb:0000/0000/0000\u{7}"), |
| 932 | Some((0, 0, 0)) |
| 933 | ); |
| 934 | // 8-bit channels, and a mid value that must scale rather than truncate. |
| 935 | assert_eq!(parse_osc11_reply("rgb:1e/1e/1e"), Some((30, 30, 30))); |
| 936 | assert_eq!( |
| 937 | parse_osc11_reply("rgb:8000/8000/8000"), |
| 938 | Some((128, 128, 128)) |
| 939 | ); |
| 940 | assert_eq!(parse_osc11_reply("rgb:f/f/f"), Some((255, 255, 255))); |
| 941 | // Hash forms. |
| 942 | assert_eq!(parse_osc11_reply("]11;#282c34"), Some((0x28, 0x2C, 0x34))); |
| 943 | assert_eq!(parse_osc11_reply("#fff"), Some((255, 255, 255))); |
| 944 | // Anything we cannot read is `None`, never a fabricated color. |
| 945 | assert_eq!(parse_osc11_reply(""), None); |
| 946 | assert_eq!(parse_osc11_reply("\u{1b}]11;"), None); |
| 947 | assert_eq!(parse_osc11_reply("rgb:ff/ff"), None); |
| 948 | assert_eq!(parse_osc11_reply("rgb:ff/ff/ff/ff"), None); |
| 949 | assert_eq!(parse_osc11_reply("rgb:zz/zz/zz"), None); |
| 950 | assert_eq!(parse_osc11_reply("#ff00"), None); |
| 951 | } |
| 952 | |
| 953 | #[test] |
| 954 | fn measured_light_background_selects_the_light_theme_end_to_end() { |
| 955 | // Detection → mode → theme: an OSC 11 answer of white must reach the |
| 956 | // light UiTheme, which is what actually repaints the frame. |
| 957 | let measured = resolve_terminal_background(Some((0xFA, 0xFA, 0xFA)), None, None); |
| 958 | assert_eq!(UiTheme::for_mode(measured.mode()), LIGHT_UI_THEME); |
| 959 | let dark = resolve_terminal_background(Some((0x1E, 0x1E, 0x1E)), None, None); |
| 960 | assert_eq!(UiTheme::for_mode(dark.mode()), UI_THEME); |
| 961 | } |
| 962 | |
| 963 | // === #4813: cross-theme contrast audit === |
| 964 | |
| 965 | #[test] |
| 966 | fn every_selectable_theme_clears_the_text_floor() { |
| 967 | let mut terminal_owned = Vec::new(); |
| 968 | for theme_id in SELECTABLE_THEMES { |
| 969 | let theme = theme_id.ui_theme(); |
| 970 | if theme_uses_terminal_owned_surfaces(&theme) { |
| 971 | terminal_owned.push(theme_id.name()); |
| 972 | continue; |
| 973 | } |
| 974 | let violations = theme_contrast_violations(&theme); |
| 975 | assert!( |
| 976 | violations.is_empty(), |
| 977 | "theme '{}' fails the contrast audit: {violations:?}", |
| 978 | theme_id.name(), |
| 979 | ); |
| 980 | } |
| 981 | // The transparent-terminal exemption is exactly one theme, by design. |
| 982 | assert_eq!(terminal_owned, ["terminal"]); |
| 983 | } |
| 984 | |
| 985 | #[test] |
| 986 | fn transparent_terminal_theme_is_explicitly_terminal_owned() { |
| 987 | // The Terminal theme paints `Color::Reset` surfaces and named-ANSI text: |
| 988 | // every audited pair is terminal-defined, so the audit records zero |
| 989 | // violations. That is *not* a pass — unresolvable pairs are skipped, |
| 990 | // never counted as clearing the floor. `theme_uses_terminal_owned_surfaces` |
| 991 | // is what keeps the exemption explicit instead of silent. |
| 992 | assert!(theme_uses_terminal_owned_surfaces(&TERMINAL_UI_THEME)); |
| 993 | assert_eq!(TERMINAL_UI_THEME.surface_bg, Color::Reset); |
| 994 | assert!(theme_contrast_violations(&TERMINAL_UI_THEME).is_empty()); |
| 995 | // A theme with resolvable RGB surfaces never gets the exemption. |
| 996 | assert!(!theme_uses_terminal_owned_surfaces(&UI_THEME)); |
| 997 | } |
| 998 | |
| 999 | #[test] |
| 1000 | fn high_contrast_grayscale_theme_clears_body_floor_on_every_surface() { |
| 1001 | // The picker tagline claims "Color-minimal high contrast" — hold the |
| 1002 | // grayscale theme to the full body-text floor on every surface, not the |
| 1003 | // 3:1 secondary-chrome floor. |
| 1004 | let theme = GRAYSCALE_UI_THEME; |
| 1005 | for fg in [theme.text_body, theme.text_soft, theme.text_muted] { |
| 1006 | for bg in [ |
| 1007 | theme.surface_bg, |
| 1008 | theme.panel_bg, |
| 1009 | theme.composer_bg, |
| 1010 | theme.elevated_bg, |
| 1011 | ] { |
| 1012 | let ratio = contrast_ratio(fg, bg).expect("grayscale colors are all resolvable RGB"); |
| 1013 | assert!( |
| 1014 | ratio >= AA_BODY_CONTRAST, |
| 1015 | "grayscale {fg:?} on {bg:?} is {ratio}:1, below the {AA_BODY_CONTRAST}:1 floor", |
| 1016 | ); |
| 1017 | } |
| 1018 | } |
| 1019 | } |
| 1020 | |
| 1021 | #[test] |
| 1022 | fn violation_report_names_pair_and_ratio() { |
| 1023 | let mut bad = UI_THEME; |
| 1024 | bad.text_muted = bad.surface_bg; |
| 1025 | let violations = theme_contrast_violations(&bad); |
| 1026 | // The muted-on-surface color fails against all four text surfaces. |
| 1027 | let muted_pairs: Vec<_> = violations |
| 1028 | .iter() |
| 1029 | .filter(|violation| violation.pair.starts_with("text_muted on ")) |
| 1030 | .collect(); |
| 1031 | assert_eq!(muted_pairs.len(), 4); |
| 1032 | let violation = violations |
| 1033 | .iter() |
| 1034 | .find(|violation| violation.pair == "text_muted on surface_bg") |
| 1035 | .expect("the report must name the failing pair"); |
| 1036 | assert_eq!(violation.fg, bad.text_muted); |
| 1037 | assert_eq!(violation.bg, bad.surface_bg); |
| 1038 | // Identical colors sit at 1:1 — far under the floor the report carries. |
| 1039 | assert!(violation.ratio < 1.1); |
| 1040 | assert!(violation.ratio < violation.floor); |
| 1041 | assert_eq!(violation.floor, AA_BODY_CONTRAST); |
| 1042 | } |
| 1043 |