返回 CodeWhale
tests.rs
根目录 / crates / tui / src / palette / tests.rs
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
1043 lines RUST