返回 CodeWhale
tests.rs
根目录 / crates / palette / src / 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_ACTION, WHALE_BG, WHALE_ERROR,
26 WHALE_HUMAN, WHALE_LIVE, WHALE_PANEL, WHALE_REASONING_TEXT_RGB, WHALE_REASONING_TINT_RGB,
27 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, Color::Reset);
94 assert_eq!(theme.text_body, LIGHT_TEXT_BODY);
95 }
96
97 #[test]
98 fn whale_pair_flat_shells_are_terminal_native_without_erasing_semantic_surfaces() {
99 for theme in [UI_THEME, LIGHT_UI_THEME] {
100 for shell_surface in [
101 theme.surface_bg,
102 theme.panel_bg,
103 theme.composer_bg,
104 theme.header_bg,
105 theme.footer_bg,
106 ] {
107 assert_eq!(shell_surface, Color::Reset, "{} shell", theme.name);
108 }
109
110 for semantic_surface in [
111 theme.elevated_bg,
112 theme.selection_bg,
113 theme.error_surface,
114 theme.diff_added_bg,
115 theme.diff_deleted_bg,
116 ] {
117 assert_ne!(semantic_surface, Color::Reset, "{} semantics", theme.name);
118 }
119 }
120 }
121
122 #[test]
123 fn ui_theme_selects_grayscale_variant() {
124 let theme = UiTheme::for_mode(PaletteMode::Grayscale);
125 assert_eq!(theme, GRAYSCALE_UI_THEME);
126 assert_eq!(theme.surface_bg, GRAYSCALE_SURFACE);
127 assert_eq!(theme.panel_bg, GRAYSCALE_PANEL);
128 assert_eq!(theme.text_body, GRAYSCALE_TEXT_BODY);
129 }
130
131 #[test]
132 fn ui_theme_selects_solarized_light_variant() {
133 let theme = UiTheme::for_mode(PaletteMode::SolarizedLight);
134 assert_eq!(theme, SOLARIZED_LIGHT_UI_THEME);
135 assert_eq!(theme.surface_bg, SOLARIZED_SURFACE);
136 assert_eq!(theme.panel_bg, SOLARIZED_PANEL);
137 assert_eq!(theme.text_body, SOLARIZED_TEXT_BODY);
138 }
139
140 #[test]
141 fn theme_names_normalize_common_grayscale_aliases() {
142 assert_eq!(normalize_theme_name("system"), Some("system"));
143 assert_eq!(normalize_theme_name("default"), Some("system"));
144 assert_eq!(normalize_theme_name("whale"), Some("dark"));
145 assert_eq!(normalize_theme_name("transparent"), Some("terminal"));
146 assert_eq!(normalize_theme_name("inherit"), Some("terminal"));
147 assert_eq!(normalize_theme_name("black-white"), Some("grayscale"));
148 assert_eq!(normalize_theme_name("mono"), Some("grayscale"));
149 assert_eq!(normalize_theme_name("solarized"), Some("solarized-light"));
150 assert_eq!(theme_label_for_mode(PaletteMode::Grayscale), "grayscale");
151 }
152
153 #[test]
154 fn terminal_theme_resets_surfaces_and_remaps_direct_palette_constants() {
155 assert_eq!(ThemeId::from_name("terminal"), Some(ThemeId::Terminal));
156 assert_eq!(TERMINAL_UI_THEME.surface_bg, Color::Reset);
157 assert_eq!(TERMINAL_UI_THEME.footer_bg, Color::Reset);
158 assert_eq!(TERMINAL_UI_THEME.text_body, Color::Reset);
159
160 assert_eq!(
161 adapt_bg_for_theme(WHALE_BG, ThemeId::Terminal, &TERMINAL_UI_THEME),
162 Color::Reset
163 );
164 assert_eq!(
165 adapt_bg_for_theme(DIFF_ADDED_BG, ThemeId::Terminal, &TERMINAL_UI_THEME),
166 Color::Reset
167 );
168 assert_eq!(
169 adapt_fg_for_theme(TEXT_BODY, ThemeId::Terminal, &TERMINAL_UI_THEME),
170 Color::Reset
171 );
172 assert_eq!(
173 adapt_fg_for_theme(DIFF_ADDED, ThemeId::Terminal, &TERMINAL_UI_THEME),
174 Color::Green
175 );
176 }
177
178 #[test]
179 fn terminal_and_matrix_preserve_agent_plan_and_full_access_mode_slots() {
180 for (theme_id, theme) in [
181 (ThemeId::Terminal, TERMINAL_UI_THEME),
182 (ThemeId::Matrix, MATRIX_UI_THEME),
183 ] {
184 for (source, expected, role) in [
185 (MODE_AGENT, theme.mode_agent, "agent"),
186 (MODE_PLAN, theme.mode_plan, "plan"),
187 (MODE_YOLO, theme.mode_yolo, "full access"),
188 ] {
189 assert_eq!(
190 adapt_fg_for_theme(source, theme_id, &theme),
191 expected,
192 "theme '{}' must map the raw {role} token to its mode slot",
193 theme_id.name(),
194 );
195 }
196 }
197 }
198
199 #[test]
200 fn community_remap_keeps_selection_tool_and_error_background_domains() {
201 let mut theme = TOKYO_NIGHT_UI_THEME;
202 theme.selection_bg = Color::Rgb(1, 2, 3);
203 theme.elevated_bg = Color::Rgb(4, 5, 6);
204 theme.error_surface = Color::Rgb(7, 8, 9);
205 theme.diff_deleted_bg = Color::Rgb(10, 11, 12);
206
207 assert_eq!(
208 adapt_bg_for_theme(SELECTION_BG, ThemeId::TokyoNight, &theme),
209 theme.selection_bg
210 );
211 assert_eq!(
212 adapt_bg_for_theme(SURFACE_TOOL_ACTIVE, ThemeId::TokyoNight, &theme),
213 theme.elevated_bg
214 );
215 assert_eq!(
216 adapt_bg_for_theme(SURFACE_ERROR, ThemeId::TokyoNight, &theme),
217 theme.error_surface
218 );
219 assert_eq!(
220 adapt_bg_for_theme(DIFF_DELETED_BG, ThemeId::TokyoNight, &theme),
221 theme.diff_deleted_bg
222 );
223 }
224
225 #[test]
226 fn light_palette_has_quiet_layer_separation() {
227 assert_eq!(LIGHT_SURFACE, Color::Rgb(244, 247, 251));
228 assert_eq!(LIGHT_PANEL, Color::Rgb(255, 253, 248));
229 assert_eq!(LIGHT_ELEVATED, Color::Rgb(232, 238, 248));
230 assert_eq!(LIGHT_BORDER, Color::Rgb(169, 184, 207));
231 assert_eq!(LIGHT_SELECTION_BG, Color::Rgb(238, 246, 255));
232 assert_ne!(LIGHT_SURFACE, LIGHT_PANEL);
233 assert_ne!(LIGHT_PANEL, LIGHT_ELEVATED);
234 }
235
236 #[test]
237 fn solarized_light_does_not_mutate_whale_light_text() {
238 assert_eq!(
239 LIGHT_TEXT_BODY,
240 Color::Rgb(
241 LIGHT_TEXT_BODY_RGB.0,
242 LIGHT_TEXT_BODY_RGB.1,
243 LIGHT_TEXT_BODY_RGB.2
244 )
245 );
246 assert_ne!(LIGHT_TEXT_BODY, SOLARIZED_TEXT_BODY);
247 }
248
249 #[test]
250 fn dark_palette_uses_soft_body_text_and_warm_reasoning() {
251 assert_eq!(
252 TEXT_BODY,
253 Color::Rgb(
254 WHALE_TEXT_BODY_RGB.0,
255 WHALE_TEXT_BODY_RGB.1,
256 WHALE_TEXT_BODY_RGB.2
257 )
258 );
259 assert_eq!(
260 TEXT_REASONING,
261 Color::Rgb(
262 WHALE_REASONING_TEXT_RGB.0,
263 WHALE_REASONING_TEXT_RGB.1,
264 WHALE_REASONING_TEXT_RGB.2
265 )
266 );
267 assert_eq!(
268 ACCENT_REASONING_LIVE,
269 Color::Rgb(
270 WHALE_REASONING_TEXT_RGB.0,
271 WHALE_REASONING_TEXT_RGB.1,
272 WHALE_REASONING_TEXT_RGB.2
273 )
274 );
275 assert_ne!(TEXT_REASONING, TEXT_TOOL_OUTPUT);
276 assert_ne!(TEXT_BODY, Color::White);
277 }
278
279 #[test]
280 fn ui_theme_applies_custom_background_to_base_surfaces() {
281 let custom = Color::Rgb(26, 27, 38);
282 let theme = UiTheme::for_mode(PaletteMode::Dark).with_background_color(custom);
283
284 assert_eq!(theme.surface_bg, custom);
285 assert_eq!(theme.header_bg, custom);
286 assert_eq!(theme.footer_bg, custom);
287 assert_eq!(
288 theme.composer_bg, UI_THEME.composer_bg,
289 "custom background must not erase panel contrast"
290 );
291 }
292
293 #[test]
294 fn hex_rgb_color_parser_accepts_hashless_and_normalizes() {
295 assert_eq!(parse_hex_rgb_color("#1a1B26"), Some(Color::Rgb(26, 27, 38)));
296 assert_eq!(parse_hex_rgb_color("1a1b26"), Some(Color::Rgb(26, 27, 38)));
297 assert_eq!(
298 normalize_hex_rgb_color("#1A1B26").as_deref(),
299 Some("#1a1b26")
300 );
301 assert_eq!(parse_hex_rgb_color("#123"), None);
302 assert_eq!(parse_hex_rgb_color("#zzzzzz"), None);
303 }
304
305 #[test]
306 fn light_palette_maps_dark_surfaces_and_text() {
307 assert_eq!(
308 adapt_bg_for_palette_mode(WHALE_BG, PaletteMode::Light),
309 LIGHT_SURFACE
310 );
311 assert_eq!(
312 adapt_bg_for_palette_mode(WHALE_PANEL, PaletteMode::Light),
313 LIGHT_PANEL
314 );
315 assert_eq!(
316 adapt_fg_for_palette_mode(Color::White, LIGHT_SURFACE, PaletteMode::Light),
317 LIGHT_TEXT_BODY
318 );
319 assert_eq!(
320 adapt_fg_for_palette_mode(TEXT_HINT, LIGHT_SURFACE, PaletteMode::Light),
321 LIGHT_TEXT_HINT
322 );
323 assert_eq!(
324 adapt_fg_for_palette_mode(WHALE_ACTION, LIGHT_SURFACE, PaletteMode::Light),
325 LIGHT_ACTION
326 );
327 assert_eq!(
328 adapt_fg_for_palette_mode(WHALE_LIVE, LIGHT_SURFACE, PaletteMode::Light),
329 LIGHT_LIVE
330 );
331 assert_eq!(
332 adapt_fg_for_palette_mode(WHALE_HUMAN, LIGHT_SURFACE, PaletteMode::Light),
333 LIGHT_HUMAN
334 );
335 assert_eq!(
336 adapt_fg_for_palette_mode(STATUS_WARNING, LIGHT_SURFACE, PaletteMode::Light),
337 LIGHT_WARNING
338 );
339 assert_eq!(
340 adapt_fg_for_palette_mode(STATUS_ERROR, LIGHT_SURFACE, PaletteMode::Light),
341 LIGHT_DANGER
342 );
343 assert_ne!(LIGHT_LIVE, LIGHT_SUCCESS_FG);
344 }
345
346 #[test]
347 fn solarized_light_palette_maps_dark_surfaces_and_text_to_solarized_roles() {
348 assert_eq!(
349 adapt_bg_for_palette_mode(WHALE_BG, PaletteMode::SolarizedLight),
350 SOLARIZED_SURFACE
351 );
352 assert_eq!(
353 adapt_bg_for_palette_mode(WHALE_PANEL, PaletteMode::SolarizedLight),
354 SOLARIZED_PANEL
355 );
356 assert_eq!(
357 adapt_fg_for_palette_mode(Color::White, SOLARIZED_SURFACE, PaletteMode::SolarizedLight),
358 SOLARIZED_TEXT_BODY
359 );
360 assert_eq!(
361 adapt_fg_for_palette_mode(TEXT_HINT, SOLARIZED_SURFACE, PaletteMode::SolarizedLight),
362 SOLARIZED_TEXT_HINT
363 );
364 }
365
366 #[test]
367 fn grayscale_palette_maps_brand_hues_to_neutral_roles() {
368 assert_eq!(
369 adapt_bg_for_palette_mode(WHALE_BG, PaletteMode::Grayscale),
370 GRAYSCALE_SURFACE
371 );
372 assert_eq!(
373 adapt_bg_for_palette_mode(WHALE_PANEL, PaletteMode::Grayscale),
374 GRAYSCALE_PANEL
375 );
376 assert_eq!(
377 adapt_bg_for_palette_mode(SURFACE_REASONING, PaletteMode::Grayscale),
378 GRAYSCALE_REASONING
379 );
380 assert_eq!(
381 adapt_fg_for_palette_mode(WHALE_ACTION, GRAYSCALE_SURFACE, PaletteMode::Grayscale),
382 GRAYSCALE_TEXT_SOFT
383 );
384 assert_eq!(
385 adapt_fg_for_palette_mode(WHALE_ERROR, GRAYSCALE_SURFACE, PaletteMode::Grayscale),
386 GRAYSCALE_TEXT_BODY
387 );
388 assert_eq!(
389 adapt_fg_for_palette_mode(TEXT_HINT, GRAYSCALE_SURFACE, PaletteMode::Grayscale),
390 GRAYSCALE_TEXT_HINT
391 );
392 }
393
394 #[test]
395 fn grayscale_luma_handles_bright_rgb_without_overflow() {
396 assert_eq!(luma(255, 255, 255), 255);
397 assert_eq!(
398 adapt_fg_for_palette_mode(
399 Color::Rgb(255, 255, 255),
400 GRAYSCALE_SURFACE,
401 PaletteMode::Grayscale
402 ),
403 GRAYSCALE_TEXT_BODY
404 );
405 }
406
407 #[test]
408 fn ui_theme_from_settings_applies_theme_and_background() {
409 let theme = ui_theme_from_settings("grayscale", Some("#111111"));
410 assert_eq!(theme.mode, PaletteMode::Grayscale);
411 assert_eq!(theme.surface_bg, Color::Rgb(17, 17, 17));
412 assert_eq!(theme.header_bg, Color::Rgb(17, 17, 17));
413 assert_eq!(theme.footer_bg, Color::Rgb(17, 17, 17));
414 assert_eq!(theme.panel_bg, GRAYSCALE_PANEL);
415 assert_eq!(theme.elevated_bg, GRAYSCALE_ELEVATED);
416 assert_eq!(theme.border, GRAYSCALE_BORDER);
417 }
418
419 #[test]
420 fn adapt_color_passes_through_truecolor() {
421 let c = Color::Rgb(53, 120, 229);
422 assert_eq!(adapt_color(c, ColorDepth::TrueColor), c);
423 }
424
425 #[test]
426 fn adapt_color_maps_rgb_to_indexed_on_ansi256() {
427 let c = Color::Rgb(53, 120, 229);
428 assert!(matches!(
429 adapt_color(c, ColorDepth::Ansi256),
430 Color::Indexed(_)
431 ));
432 }
433
434 #[test]
435 fn adapt_bg_maps_rgb_to_indexed_on_ansi256() {
436 assert!(matches!(
437 adapt_bg(SURFACE_REASONING, ColorDepth::Ansi256),
438 Color::Indexed(_)
439 ));
440 }
441
442 #[test]
443 fn adapt_color_drops_to_named_on_ansi16() {
444 // Sky: blue-dominant and bright → LightBlue, not terminal cyan.
445 assert_eq!(
446 adapt_color(WHALE_ACTION, ColorDepth::Ansi16),
447 Color::LightBlue
448 );
449 // Rose Red is intentionally bright enough to use the terminal's
450 // bright red slot.
451 assert_eq!(
452 adapt_color(WHALE_ERROR, ColorDepth::Ansi16),
453 Color::LightRed
454 );
455 }
456
457 #[test]
458 fn action_blue_is_not_human_gold() {
459 assert_ne!(WHALE_ACTION, WHALE_HUMAN);
460 }
461
462 #[test]
463 fn stable_dark_and_light_ids_expose_blue_stage_product_names() {
464 assert_eq!(ThemeId::from_name("dark"), Some(ThemeId::Whale));
465 assert_eq!(ThemeId::Whale.display_name(), "Blue Stage");
466 assert_eq!(ThemeId::from_name("light"), Some(ThemeId::WhaleLight));
467 assert_eq!(ThemeId::WhaleLight.display_name(), "Blue Stage Light");
468 }
469
470 #[test]
471 fn community_theme_info_keeps_the_sky_live_role_on_ansi16() {
472 assert_eq!(
473 adapt_fg_for_depth(
474 CATPPUCCIN_MOCHA_UI_THEME.info,
475 CATPPUCCIN_MOCHA_UI_THEME.info,
476 ColorDepth::Ansi16,
477 &CATPPUCCIN_MOCHA_UI_THEME,
478 ),
479 Color::LightCyan,
480 );
481 assert_eq!(
482 adapt_fg_for_depth(
483 CATPPUCCIN_MOCHA_UI_THEME.status_working,
484 CATPPUCCIN_MOCHA_UI_THEME.status_working,
485 ColorDepth::Ansi16,
486 &CATPPUCCIN_MOCHA_UI_THEME,
487 ),
488 Color::LightCyan,
489 );
490 }
491
492 #[test]
493 fn every_selectable_theme_keeps_action_and_working_roles_distinct_on_ansi16() {
494 for theme_id in SELECTABLE_THEMES {
495 // Grayscale deliberately collapses colored semantic lanes to neutral
496 // luminance tiers before terminal-depth adaptation.
497 if *theme_id == ThemeId::Grayscale {
498 continue;
499 }
500 let ui = theme_id.ui_theme();
501 assert_eq!(
502 adapt_fg_for_depth(
503 ui.accent_primary,
504 ui.accent_primary,
505 ColorDepth::Ansi16,
506 &ui,
507 ),
508 Color::LightBlue,
509 "theme '{}' lost the action lane",
510 theme_id.name(),
511 );
512 assert_eq!(
513 adapt_fg_for_depth(
514 ui.status_working,
515 ui.status_working,
516 ColorDepth::Ansi16,
517 &ui,
518 ),
519 Color::LightCyan,
520 "theme '{}' lost the live working lane",
521 theme_id.name(),
522 );
523 }
524 }
525
526 #[test]
527 fn adapt_bg_disables_tints_on_ansi16() {
528 assert_eq!(
529 adapt_bg(SURFACE_REASONING, ColorDepth::Ansi16),
530 Color::Reset
531 );
532 assert_eq!(
533 adapt_bg(SURFACE_REASONING, ColorDepth::TrueColor),
534 SURFACE_REASONING
535 );
536 }
537
538 #[test]
539 fn reasoning_tint_is_none_on_ansi16() {
540 assert!(reasoning_surface_tint(ColorDepth::Ansi16).is_none());
541 assert!(reasoning_surface_tint(ColorDepth::TrueColor).is_some());
542 assert!(matches!(
543 reasoning_surface_tint(ColorDepth::Ansi256),
544 Some(Color::Indexed(_))
545 ));
546 }
547
548 #[test]
549 fn light_palette_maps_reasoning_tint_to_light_surface() {
550 assert_eq!(
551 SURFACE_REASONING_TINT,
552 Color::Rgb(
553 WHALE_REASONING_TINT_RGB.0,
554 WHALE_REASONING_TINT_RGB.1,
555 WHALE_REASONING_TINT_RGB.2
556 )
557 );
558 assert_eq!(
559 adapt_bg_for_palette_mode(SURFACE_REASONING_TINT, PaletteMode::Light),
560 LIGHT_REASONING
561 );
562 assert_eq!(
563 adapt_bg_for_palette_mode(
564 reasoning_surface_tint(ColorDepth::TrueColor).expect("truecolor tint"),
565 PaletteMode::Light,
566 ),
567 LIGHT_REASONING
568 );
569 }
570
571 #[test]
572 fn blend_at_zero_returns_bg_at_one_returns_fg() {
573 let fg = Color::Rgb(200, 100, 50);
574 let bg = Color::Rgb(0, 0, 0);
575 assert_eq!(blend(fg, bg, 0.0), bg);
576 assert_eq!(blend(fg, bg, 1.0), fg);
577 }
578
579 #[test]
580 fn blend_at_half_is_midpoint() {
581 let mid = blend(Color::Rgb(200, 100, 0), Color::Rgb(0, 0, 0), 0.5);
582 assert_eq!(mid, Color::Rgb(100, 50, 0));
583 }
584
585 #[test]
586 fn pulse_brightness_swings_within_envelope() {
587 // The pulse rides between 30%..100% — never below 30% of the source.
588 let src = ACCENT_REASONING_LIVE;
589 let mut min_r = u8::MAX;
590 let mut max_r = 0u8;
591 for ms in (0u64..2000).step_by(50) {
592 if let Color::Rgb(r, _, _) = pulse_brightness(src, ms) {
593 min_r = min_r.min(r);
594 max_r = max_r.max(r);
595 }
596 }
597 let Color::Rgb(src_r, _, _) = src else {
598 panic!("expected RGB");
599 };
600 // Trough should land near 30% of source; crest near source itself.
601 let lower = (f32::from(src_r) * 0.30).round() as u8;
602 assert!(min_r <= lower + 2, "trough too high: {min_r}");
603 assert!(max_r + 2 >= src_r, "crest too low: {max_r}");
604 }
605
606 #[test]
607 fn pulse_passes_named_colors_unchanged() {
608 // Named palette entries don't blend meaningfully — leave them alone.
609 assert_eq!(pulse_brightness(Color::Reset, 0), Color::Reset);
610 assert_eq!(pulse_brightness(Color::Cyan, 1234), Color::Cyan);
611 }
612
613 #[test]
614 fn nearest_ansi16_routes_known_brand_colors() {
615 // Codewhale keeps action, live, human, and danger distinct where ANSI-16 allows it.
616 assert_eq!(nearest_ansi16(106, 174, 242), Color::LightBlue); // Cobalt action
617 assert_eq!(nearest_ansi16(246, 196, 83), Color::LightYellow); // Signal Gold
618 assert_eq!(nearest_ansi16(79, 209, 197), Color::LightCyan); // Seafoam
619 assert_eq!(nearest_ansi16(38, 62, 92), Color::Blue); // Border
620 assert_eq!(nearest_ansi16(54, 187, 212), Color::LightCyan); // Aqua
621 assert_eq!(nearest_ansi16(255, 134, 178), Color::LightRed); // Rose danger
622 assert_eq!(nearest_ansi16(3, 7, 13), Color::Black); // Deep field
623 }
624
625 #[test]
626 fn rgb_to_ansi256_uses_stable_extended_palette() {
627 assert!(rgb_to_ansi256(53, 120, 229) >= 16);
628 assert!(rgb_to_ansi256(11, 21, 38) >= 16);
629 }
630
631 #[test]
632 fn color_depth_detect_is_safe_without_env() {
633 // Don't try to pin the result — env may be anything in CI. Just
634 // exercise the path so a panic would surface.
635 let _ = ColorDepth::detect();
636 let _ = adapt_color(WHALE_BG, ColorDepth::detect());
637 }
638
639 /// no-color.org contract (spec TIDELINE §5d gap): `NO_COLOR` present and
640 /// non-empty suppresses colors even on a truecolor terminal;
641 /// an empty value does not count.
642 #[test]
643 fn no_color_forces_the_mono_depth_even_on_truecolor() {
644 fn read(pairs: &[(&'static str, &'static str)]) -> impl Fn(&str) -> Option<std::ffi::OsString> {
645 move |key: &str| {
646 pairs
647 .iter()
648 .find(|(k, _)| *k == key)
649 .map(|(_, v)| std::ffi::OsString::from(v))
650 }
651 }
652 let depth = ColorDepth::detect_with(read(&[("NO_COLOR", "1"), ("COLORTERM", "truecolor")]));
653 assert_eq!(
654 depth,
655 ColorDepth::Monochrome,
656 "NO_COLOR wins over COLORTERM"
657 );
658 for color in [
659 Color::Reset,
660 Color::Red,
661 Color::Indexed(9),
662 Color::Rgb(103, 184, 214),
663 ] {
664 assert_eq!(adapt_color(color, depth), Color::Reset);
665 assert_eq!(adapt_bg(color, depth), Color::Reset);
666 }
667 assert!(reasoning_surface_tint(depth).is_none());
668 let depth = ColorDepth::detect_with(read(&[("NO_COLOR", ""), ("COLORTERM", "truecolor")]));
669 assert_eq!(
670 depth,
671 ColorDepth::TrueColor,
672 "empty NO_COLOR does not count (no-color.org)"
673 );
674 let depth = ColorDepth::detect_with(read(&[("COLORTERM", "truecolor")]));
675 assert_eq!(depth, ColorDepth::TrueColor, "no NO_COLOR: normal detect");
676 }
677
678 // === #4833: contrast floor ===
679
680 use super::contrast::{
681 AA_BODY_CONTRAST, contrast_ratio, effective_surface, enforce_contrast, meets_contrast,
682 relative_luminance, symbol_needs_text_contrast, theme_contrast_violations,
683 theme_uses_terminal_owned_surfaces,
684 };
685 use super::detect::TerminalBackground;
686 use super::osc11::{ProbeSplit, ProbeStep, parse_osc11_reply};
687 use super::tokens::{
688 MODE_OPERATE, STATUS_SUCCESS, TEXT_MUTED, TEXT_SECONDARY, TEXT_SOFT, USER_BODY,
689 };
690
691 const WHITE: Color = Color::Rgb(0xFF, 0xFF, 0xFF);
692 const BLACK: Color = Color::Rgb(0x00, 0x00, 0x00);
693
694 /// Every dark-palette token that renders *text*. Frame chrome (`BORDER_COLOR`)
695 /// is deliberately absent — see `symbol_needs_text_contrast`.
696 const DARK_TEXT_TOKENS: &[Color] = &[
697 TEXT_BODY,
698 TEXT_SOFT,
699 TEXT_SECONDARY,
700 TEXT_MUTED,
701 TEXT_HINT,
702 TEXT_REASONING,
703 TEXT_TOOL_OUTPUT,
704 USER_BODY,
705 WHALE_ACTION,
706 WHALE_LIVE,
707 WHALE_HUMAN,
708 WHALE_ERROR,
709 MODE_AGENT,
710 MODE_PLAN,
711 MODE_OPERATE,
712 MODE_YOLO,
713 STATUS_ERROR,
714 STATUS_WARNING,
715 STATUS_SUCCESS,
716 DIFF_ADDED,
717 ];
718
719 fn approx(actual: f32, expected: f32, tolerance: f32) {
720 assert!(
721 (actual - expected).abs() <= tolerance,
722 "expected {expected} ± {tolerance}, got {actual}"
723 );
724 }
725
726 #[test]
727 fn relative_luminance_matches_wcag_reference_values() {
728 approx(relative_luminance(WHITE).unwrap(), 1.0, 1e-4);
729 approx(relative_luminance(BLACK).unwrap(), 0.0, 1e-4);
730 // WCAG worked example: #808080 has relative luminance 0.2159.
731 approx(
732 relative_luminance(Color::Rgb(0x80, 0x80, 0x80)).unwrap(),
733 0.2159,
734 1e-3,
735 );
736 // Terminal-defined colors have no knowable RGB, so no luminance.
737 assert_eq!(relative_luminance(Color::Reset), None);
738 assert_eq!(relative_luminance(Color::White), None);
739 assert_eq!(relative_luminance(Color::Indexed(7)), None);
740 // The xterm cube and gray ramp are fixed by spec, so they are knowable.
741 assert!(relative_luminance(Color::Indexed(231)).is_some());
742 }
743
744 #[test]
745 fn contrast_ratio_matches_known_pairs() {
746 approx(contrast_ratio(BLACK, WHITE).unwrap(), 21.0, 1e-3);
747 approx(contrast_ratio(WHITE, WHITE).unwrap(), 1.0, 1e-4);
748 // #767676 on white is the canonical "smallest AA-passing gray".
749 approx(
750 contrast_ratio(Color::Rgb(0x76, 0x76, 0x76), WHITE).unwrap(),
751 4.54,
752 0.01,
753 );
754 // Symmetric in its arguments.
755 assert_eq!(
756 contrast_ratio(TEXT_BODY, WHALE_BG),
757 contrast_ratio(WHALE_BG, TEXT_BODY)
758 );
759 // An unknowable side yields no ratio, and `meets_contrast` refuses to call
760 // that a pass.
761 assert_eq!(contrast_ratio(TEXT_BODY, Color::Reset), None);
762 assert!(!meets_contrast(TEXT_BODY, Color::Reset, AA_BODY_CONTRAST));
763 }
764
765 #[test]
766 fn light_surface_lifts_body_text_that_no_whitelist_adapted() {
767 // The #4833 shape: dark-tuned ivory body text reaching a near-white
768 // terminal with no light adaptation applied, because detection said Dark.
769 let before = contrast_ratio(TEXT_BODY, WHITE).unwrap();
770 assert!(
771 before < AA_BODY_CONTRAST,
772 "precondition: unadapted body text is illegible on white ({before})"
773 );
774
775 let lifted = enforce_contrast(TEXT_BODY, WHITE, AA_BODY_CONTRAST);
776 let after = contrast_ratio(lifted, WHITE).unwrap();
777 assert!(
778 after >= AA_BODY_CONTRAST,
779 "body text must clear AA on a light surface, got {after}"
780 );
781
782 // The same holds for the reporter's paler surface and for the secondary
783 // tiers that collapsed alongside body text.
784 let reported_surface = Color::Rgb(0xF7, 0xF7, 0xF5);
785 for token in [TEXT_BODY, TEXT_SOFT, TEXT_SECONDARY, TEXT_HINT] {
786 let lifted = enforce_contrast(token, reported_surface, AA_BODY_CONTRAST);
787 let ratio = contrast_ratio(lifted, reported_surface).unwrap();
788 assert!(
789 ratio >= AA_BODY_CONTRAST,
790 "{token:?} still below floor on light surface: {ratio}"
791 );
792 }
793 }
794
795 #[test]
796 fn enforce_contrast_lifts_by_the_smallest_amount_that_clears_the_floor() {
797 let lifted = enforce_contrast(TEXT_BODY, WHITE, AA_BODY_CONTRAST);
798 let ratio = contrast_ratio(lifted, WHITE).unwrap();
799 assert!(
800 (AA_BODY_CONTRAST..AA_BODY_CONTRAST + 0.1).contains(&ratio),
801 "expected a minimal lift to ~{AA_BODY_CONTRAST}, got {ratio}"
802 );
803 // Already-compliant colors are returned byte-identical.
804 assert_eq!(
805 enforce_contrast(LIGHT_TEXT_BODY, WHITE, AA_BODY_CONTRAST),
806 LIGHT_TEXT_BODY
807 );
808 }
809
810 #[test]
811 fn dark_surface_leaves_every_text_token_untouched() {
812 // The no-regression guarantee for today's users: on the surfaces a dark
813 // terminal actually presents, no shipped text token is rewritten.
814 for surface in [
815 WHALE_BG,
816 WHALE_PANEL,
817 BLACK,
818 Color::Rgb(0x1E, 0x1E, 0x1E), // VS Code dark
819 Color::Rgb(0x0C, 0x0C, 0x0C), // Windows Terminal default
820 ] {
821 for token in DARK_TEXT_TOKENS {
822 assert_eq!(
823 enforce_contrast(*token, surface, AA_BODY_CONTRAST),
824 *token,
825 "{token:?} was rewritten on dark surface {surface:?}"
826 );
827 }
828 }
829 }
830
831 #[test]
832 fn light_theme_tokens_already_clear_the_floor_on_their_own_surfaces() {
833 for surface in [LIGHT_SURFACE, LIGHT_PANEL, LIGHT_ELEVATED] {
834 for token in [
835 LIGHT_TEXT_BODY,
836 LIGHT_TEXT_HINT,
837 LIGHT_ACTION,
838 LIGHT_LIVE,
839 LIGHT_HUMAN,
840 LIGHT_WARNING,
841 LIGHT_DANGER,
842 LIGHT_SUCCESS_FG,
843 ] {
844 let ratio = contrast_ratio(token, surface).unwrap();
845 assert!(
846 ratio >= AA_BODY_CONTRAST,
847 "{token:?} on {surface:?} is {ratio}, below the floor"
848 );
849 assert_eq!(enforce_contrast(token, surface, AA_BODY_CONTRAST), token);
850 }
851 }
852 }
853
854 #[test]
855 fn enforce_contrast_declines_when_it_cannot_know_the_colors() {
856 // Named/indexed colors are remapped by the user's terminal profile, and
857 // `Reset` is the terminal's own choice. Rewriting either would be a guess.
858 assert_eq!(
859 enforce_contrast(Color::White, WHITE, AA_BODY_CONTRAST),
860 Color::White
861 );
862 assert_eq!(
863 enforce_contrast(Color::Indexed(250), WHITE, AA_BODY_CONTRAST),
864 Color::Indexed(250)
865 );
866 assert_eq!(
867 enforce_contrast(TEXT_BODY, Color::Reset, AA_BODY_CONTRAST),
868 TEXT_BODY
869 );
870 // 4.5:1 is reachable from *every* surface — the worst case is the
871 // luminance where black and white tie, and even there the better pole
872 // clears 4.58:1. So the floor never silently gives up.
873 for gray in (0u8..=255).step_by(5) {
874 let surface = Color::Rgb(gray, gray, gray);
875 let lifted = enforce_contrast(TEXT_BODY, surface, AA_BODY_CONTRAST);
876 let ratio = contrast_ratio(lifted, surface).unwrap();
877 assert!(
878 ratio >= AA_BODY_CONTRAST,
879 "gray {gray:#04x} left body text at {ratio}:1"
880 );
881 }
882
883 // An unreachable floor (AAA on a mid-gray) returns the better pole rather
884 // than pretending it succeeded.
885 let mid = Color::Rgb(0x80, 0x80, 0x80);
886 assert_eq!(enforce_contrast(TEXT_BODY, mid, 7.0), BLACK);
887 }
888
889 #[test]
890 fn effective_surface_prefers_painted_background_then_measurement() {
891 // A painted cell knows its own surface.
892 assert_eq!(
893 effective_surface(WHALE_PANEL, Some(WHITE)),
894 Some(WHALE_PANEL)
895 );
896 // An unpainted cell falls through to what detection measured.
897 assert_eq!(effective_surface(Color::Reset, Some(WHITE)), Some(WHITE));
898 // With no measurement there is no surface — the floor stands down.
899 assert_eq!(effective_surface(Color::Reset, None), None);
900 // A measurement we cannot resolve is not a measurement.
901 assert_eq!(effective_surface(Color::Reset, Some(Color::Reset)), None);
902 }
903
904 #[test]
905 fn text_contrast_floor_applies_to_glyphs_not_frame_chrome() {
906 assert!(symbol_needs_text_contrast("a"));
907 assert!(symbol_needs_text_contrast("字"));
908 assert!(symbol_needs_text_contrast("→"));
909 assert!(!symbol_needs_text_contrast(" "));
910 assert!(!symbol_needs_text_contrast(""));
911 assert!(!symbol_needs_text_contrast("─"));
912 assert!(!symbol_needs_text_contrast("│"));
913 assert!(!symbol_needs_text_contrast("█"));
914 assert!(!symbol_needs_text_contrast("▏"));
915 assert!(!symbol_needs_text_contrast("●"));
916 }
917
918 #[test]
919 fn background_luminance_decides_polarity_without_a_color_list() {
920 assert_eq!(palette_mode_for_background(WHITE), Some(PaletteMode::Light));
921 assert_eq!(palette_mode_for_background(BLACK), Some(PaletteMode::Dark));
922 assert_eq!(
923 palette_mode_for_background(LIGHT_SURFACE),
924 Some(PaletteMode::Light)
925 );
926 assert_eq!(
927 palette_mode_for_background(SOLARIZED_SURFACE),
928 Some(PaletteMode::Light)
929 );
930 assert_eq!(
931 palette_mode_for_background(WHALE_BG),
932 Some(PaletteMode::Dark)
933 );
934 assert_eq!(
935 palette_mode_for_background(Color::Rgb(0x28, 0x2C, 0x34)),
936 Some(PaletteMode::Dark)
937 );
938 assert_eq!(palette_mode_for_background(Color::Reset), None);
939 }
940
941 #[test]
942 fn unknown_background_keeps_the_dark_default_and_offers_no_surface() {
943 let unknown = TerminalBackground::unknown();
944 assert_eq!(unknown.mode(), PaletteMode::Dark);
945 assert_eq!(unknown.color(), None);
946 assert_eq!(unknown.source(), BackgroundSource::Unknown);
947 // This is the #4833 trigger environment: a terminal that sets no
948 // COLORFGBG and is not macOS. Detection still answers Dark — but it says
949 // so with `Unknown` provenance and no color, so nothing downstream
950 // mistakes the guess for a measurement.
951 let resolved = resolve_terminal_background(None, None, None);
952 assert_eq!(resolved, unknown);
953 assert_eq!(effective_surface(Color::Reset, resolved.color()), None);
954 }
955
956 #[test]
957 fn measured_background_outranks_env_hints_and_records_provenance() {
958 // A white terminal that also exports a dark-looking COLORFGBG: the
959 // measurement wins, and it carries the color the floor needs.
960 let measured = resolve_terminal_background(Some((0xFF, 0xFF, 0xFF)), Some("15;0"), None);
961 assert_eq!(measured.mode(), PaletteMode::Light);
962 assert_eq!(measured.color(), Some(WHITE));
963 assert_eq!(measured.source(), BackgroundSource::Osc11);
964
965 // COLORFGBG with a resolvable xterm index yields a real color too.
966 let indexed = resolve_terminal_background(None, Some("0;231"), None);
967 assert_eq!(indexed.mode(), PaletteMode::Light);
968 assert_eq!(indexed.color(), Some(Color::Indexed(231)));
969 assert_eq!(indexed.source(), BackgroundSource::ColorFgBg);
970
971 // Indices 0..=15 are terminal-profile defined: mode only, no color.
972 let ansi = resolve_terminal_background(None, Some("0;15"), None);
973 assert_eq!(ansi.mode(), PaletteMode::Light);
974 assert_eq!(ansi.color(), None);
975 assert_eq!(ansi.source(), BackgroundSource::ColorFgBg);
976
977 // macOS appearance describes the OS, not the terminal — no color.
978 let macos = resolve_terminal_background(None, None, Some(PaletteMode::Light));
979 assert_eq!(macos.mode(), PaletteMode::Light);
980 assert_eq!(macos.color(), None);
981 assert_eq!(macos.source(), BackgroundSource::MacOsAppearance);
982 }
983
984 #[test]
985 fn osc11_replies_parse_across_the_shapes_terminals_emit() {
986 assert_eq!(
987 parse_osc11_reply("\u{1b}]11;rgb:ffff/ffff/ffff"),
988 Some((255, 255, 255))
989 );
990 assert_eq!(
991 parse_osc11_reply("]11;rgb:0000/0000/0000\u{7}"),
992 Some((0, 0, 0))
993 );
994 // 8-bit channels, and a mid value that must scale rather than truncate.
995 assert_eq!(parse_osc11_reply("rgb:1e/1e/1e"), Some((30, 30, 30)));
996 assert_eq!(
997 parse_osc11_reply("rgb:8000/8000/8000"),
998 Some((128, 128, 128))
999 );
1000 assert_eq!(parse_osc11_reply("rgb:f/f/f"), Some((255, 255, 255)));
1001 // Hash forms.
1002 assert_eq!(parse_osc11_reply("]11;#282c34"), Some((0x28, 0x2C, 0x34)));
1003 assert_eq!(parse_osc11_reply("#fff"), Some((255, 255, 255)));
1004 // Anything we cannot read is `None`, never a fabricated color.
1005 assert_eq!(parse_osc11_reply(""), None);
1006 assert_eq!(parse_osc11_reply("\u{1b}]11;"), None);
1007 assert_eq!(parse_osc11_reply("rgb:ff/ff"), None);
1008 assert_eq!(parse_osc11_reply("rgb:ff/ff/ff/ff"), None);
1009 assert_eq!(parse_osc11_reply("rgb:zz/zz/zz"), None);
1010 assert_eq!(parse_osc11_reply("#ff00"), None);
1011 }
1012
1013 /// Drive a whole byte stream through the split the way the reader does,
1014 /// including the one-byte lookahead after an `ESC \` terminator.
1015 fn split_probe_stream(query: &[u8], stream: &[u8], csi: bool) -> (Vec<u8>, Vec<u8>) {
1016 let mut split = ProbeSplit::for_query(query, csi);
1017 let mut index = 0;
1018 while index < stream.len() {
1019 let byte = stream[index];
1020 index += 1;
1021 match split.feed(byte) {
1022 ProbeStep::Continue => {}
1023 ProbeStep::Done | ProbeStep::Overflow => break,
1024 ProbeStep::AwaitStringTerminator => {
1025 if let Some(next) = stream.get(index) {
1026 split.finish_string_terminator(*next);
1027 }
1028 break;
1029 }
1030 }
1031 }
1032 split.finish()
1033 }
1034
1035 #[test]
1036 fn a_probe_reply_never_swallows_the_line_typed_before_it() {
1037 // #5925: `/plugin install …` typed at launch arrived before the terminal
1038 // answered the OSC 11 query. The reply is ours; every other byte is the
1039 // user's and must come back out, in order.
1040 let (reply, carried) = split_probe_stream(
1041 b"\x1b]11;?\x1b\\",
1042 b"/plugin install /tmp/bundle\r\x1b]11;rgb:ffff/ffff/ffff\x1b\\",
1043 false,
1044 );
1045 assert_eq!(
1046 parse_osc11_reply(&String::from_utf8_lossy(&reply)),
1047 Some((255, 255, 255)),
1048 "the reply still parses"
1049 );
1050 assert_eq!(
1051 carried,
1052 b"/plugin install /tmp/bundle\r".to_vec(),
1053 "not one typed byte is consumed or reordered"
1054 );
1055 }
1056
1057 #[test]
1058 fn a_probe_that_is_never_answered_still_hands_back_what_was_typed() {
1059 let (reply, carried) = split_probe_stream(b"\x1b]11;?\x1b\\", b"/plugin list\r", false);
1060 assert!(reply.is_empty(), "no reply arrived");
1061 assert_eq!(carried, b"/plugin list\r".to_vec());
1062 }
1063
1064 #[test]
1065 fn an_escape_that_is_not_the_reply_is_the_users_keystroke() {
1066 // A bare `Esc`, then typing, then the real reply.
1067 let (reply, carried) = split_probe_stream(
1068 b"\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\",
1069 b"\x1bhi\x1b_Gi=31;OK\x1b\\",
1070 false,
1071 );
1072 assert_eq!(reply, b"\x1b_Gi=31;OK".to_vec());
1073 assert_eq!(carried, b"\x1bhi".to_vec());
1074 }
1075
1076 #[test]
1077 fn a_keystroke_after_the_string_terminator_escape_is_kept() {
1078 // The reply ends `ESC \`; if the byte after `ESC` is not `\` it belongs
1079 // to the user and must not vanish with the terminator.
1080 let (reply, carried) = split_probe_stream(b"\x1b]11;?\x1b\\", b"\x1b]11;rgb:0/0/0\x1bx", false);
1081 assert_eq!(reply, b"\x1b]11;rgb:0/0/0".to_vec());
1082 assert_eq!(carried, b"x".to_vec());
1083 }
1084
1085 #[test]
1086 fn a_da_reply_stops_at_its_final_byte_and_keeps_the_rest_of_the_line() {
1087 let (reply, carried) = split_probe_stream(b"\x1b[c", b"\x1b[?62;4c/plugin list\r", true);
1088 assert_eq!(reply, b"\x1b[?62;4c".to_vec());
1089 // Bytes after the DA reply were never read by the split; the reader
1090 // leaves them in the tty for the input pump.
1091 assert!(carried.is_empty(), "{carried:?}");
1092 }
1093
1094 #[test]
1095 fn measured_light_background_selects_the_light_theme_end_to_end() {
1096 // Detection → mode → theme: an OSC 11 answer of white must reach the
1097 // light UiTheme, which is what actually repaints the frame.
1098 let measured = resolve_terminal_background(Some((0xFA, 0xFA, 0xFA)), None, None);
1099 assert_eq!(UiTheme::for_mode(measured.mode()), LIGHT_UI_THEME);
1100 let dark = resolve_terminal_background(Some((0x1E, 0x1E, 0x1E)), None, None);
1101 assert_eq!(UiTheme::for_mode(dark.mode()), UI_THEME);
1102 }
1103
1104 // === #4813: cross-theme contrast audit ===
1105
1106 #[test]
1107 fn grayscale_background_roles_survive_direct_and_token_render_paths() {
1108 let theme = GRAYSCALE_UI_THEME;
1109 for (token, expected) in [
1110 (WHALE_BG, theme.surface_bg),
1111 (WHALE_PANEL, theme.panel_bg),
1112 (SURFACE_TOOL_ACTIVE, theme.elevated_bg),
1113 (SELECTION_BG, theme.selection_bg),
1114 (LIGHT_SELECTION_BG, theme.selection_bg),
1115 (SURFACE_REASONING, GRAYSCALE_REASONING),
1116 (DIFF_ADDED_BG, theme.diff_added_bg),
1117 (DIFF_DELETED_BG, theme.diff_deleted_bg),
1118 ] {
1119 let resolved = adapt_bg_for_palette_mode(token, PaletteMode::Grayscale);
1120 assert_eq!(resolved, expected, "token {token:?}");
1121 assert_eq!(
1122 adapt_bg_for_palette_mode(expected, PaletteMode::Grayscale),
1123 expected,
1124 "direct theme role {expected:?} must not be bucketed twice",
1125 );
1126 }
1127 }
1128
1129 #[test]
1130 fn rendered_workbench_selection_tracks_every_selectable_theme() {
1131 use ratatui::{
1132 buffer::Buffer,
1133 layout::Rect,
1134 style::{Modifier, Style},
1135 widgets::{Block, Paragraph, Widget},
1136 };
1137
1138 // Menus and Config paint legacy semantic tokens; the home/composer use
1139 // direct UiTheme roles. The final cell remap must make those surfaces
1140 // agree, including the selected row's padding, on every picker choice.
1141 for theme_id in SELECTABLE_THEMES {
1142 let theme = theme_id.ui_theme();
1143 for width in [40, 80] {
1144 let area = Rect::new(0, 0, width, 3);
1145 let mut buffer = Buffer::empty(area);
1146 Block::default()
1147 .style(Style::default().bg(WHALE_BG))
1148 .render(area, &mut buffer);
1149 Paragraph::new("Current workspace")
1150 .style(Style::default().fg(TEXT_BODY))
1151 .render(Rect::new(0, 0, width, 1), &mut buffer);
1152 Paragraph::new("> Recent session")
1153 .style(
1154 Style::default()
1155 .fg(super::tokens::SELECTION_TEXT)
1156 .bg(SELECTION_BG)
1157 .add_modifier(Modifier::BOLD),
1158 )
1159 .render(Rect::new(0, 1, width, 1), &mut buffer);
1160
1161 // The same two semantic adaptation stages as ColorCompatBackend,
1162 // before terminal-depth quantization or any contrast correction.
1163 for cell in &mut buffer.content {
1164 cell.fg = adapt_fg_for_theme(cell.fg, *theme_id, &theme);
1165 cell.bg = adapt_bg_for_theme(cell.bg, *theme_id, &theme);
1166 cell.fg = adapt_fg_for_palette_mode(cell.fg, cell.bg, theme.mode);
1167 cell.bg = adapt_bg_for_palette_mode(cell.bg, theme.mode);
1168 }
1169 assert_eq!(buffer[(0, 0)].fg, theme.text_body, "{} body", theme.name);
1170 assert_eq!(buffer[(0, 0)].bg, theme.surface_bg, "{} field", theme.name);
1171 for x in 0..width {
1172 let selected = &buffer[(x, 1)];
1173 assert_eq!(selected.fg, theme.text_body, "{} selected ink", theme.name);
1174 assert_eq!(selected.bg, theme.selection_bg, "{} selection", theme.name);
1175 assert!(selected.modifier.contains(Modifier::BOLD));
1176 }
1177 if let Some(ratio) =
1178 super::contrast::contrast_ratio(buffer[(0, 1)].fg, buffer[(0, 1)].bg)
1179 {
1180 assert!(ratio >= 4.5, "{} selected text: {ratio}", theme.name);
1181 }
1182 }
1183 }
1184 }
1185
1186 #[test]
1187 fn every_selectable_theme_clears_the_text_floor() {
1188 let mut terminal_owned = Vec::new();
1189 for theme_id in SELECTABLE_THEMES {
1190 let theme = theme_id.ui_theme();
1191 if theme_uses_terminal_owned_surfaces(&theme) {
1192 terminal_owned.push(theme_id.name());
1193 continue;
1194 }
1195 let violations = theme_contrast_violations(&theme);
1196 assert!(
1197 violations.is_empty(),
1198 "theme '{}' fails the contrast audit: {violations:?}",
1199 theme_id.name(),
1200 );
1201 }
1202 // System resolves to one member of the built-in Whale pair. Those Flat
1203 // shells and Terminal are intentionally host-owned; community themes stay
1204 // painted and therefore remain fully auditable here.
1205 assert_eq!(terminal_owned, ["system", "terminal", "dark", "light"]);
1206 }
1207
1208 #[test]
1209 fn terminal_native_theme_exemptions_are_explicit() {
1210 // Reset surfaces are terminal-defined, so the audit records zero
1211 // violations for those pairs. That is *not* a pass — unresolvable pairs
1212 // are skipped, never counted as clearing the floor. Deepsea's authored
1213 // RGB ramp is audited separately by the ocean tests.
1214 for theme in [TERMINAL_UI_THEME, UI_THEME, LIGHT_UI_THEME] {
1215 assert!(theme_uses_terminal_owned_surfaces(&theme));
1216 assert_eq!(theme.surface_bg, Color::Reset);
1217 assert!(theme_contrast_violations(&theme).is_empty());
1218 }
1219 // A painted community theme never gets the exemption.
1220 assert!(!theme_uses_terminal_owned_surfaces(&TOKYO_NIGHT_UI_THEME));
1221 }
1222
1223 #[test]
1224 fn high_contrast_grayscale_theme_clears_body_floor_on_every_surface() {
1225 // The picker tagline claims "Color-minimal high contrast" — hold the
1226 // grayscale theme to the full body-text floor on every surface, not the
1227 // 3:1 secondary-chrome floor.
1228 let theme = GRAYSCALE_UI_THEME;
1229 for fg in [theme.text_body, theme.text_soft, theme.text_muted] {
1230 for bg in [
1231 theme.surface_bg,
1232 theme.panel_bg,
1233 theme.composer_bg,
1234 theme.elevated_bg,
1235 ] {
1236 let ratio = contrast_ratio(fg, bg).expect("grayscale colors are all resolvable RGB");
1237 assert!(
1238 ratio >= AA_BODY_CONTRAST,
1239 "grayscale {fg:?} on {bg:?} is {ratio}:1, below the {AA_BODY_CONTRAST}:1 floor",
1240 );
1241 }
1242 }
1243 }
1244
1245 #[test]
1246 fn violation_report_names_pair_and_ratio() {
1247 let mut bad = TOKYO_NIGHT_UI_THEME;
1248 bad.text_muted = bad.surface_bg;
1249 let violations = theme_contrast_violations(&bad);
1250 // The muted-on-surface color fails against all four text surfaces.
1251 let muted_pairs: Vec<_> = violations
1252 .iter()
1253 .filter(|violation| violation.pair.starts_with("text_muted on "))
1254 .collect();
1255 assert_eq!(muted_pairs.len(), 4);
1256 let violation = violations
1257 .iter()
1258 .find(|violation| violation.pair == "text_muted on surface_bg")
1259 .expect("the report must name the failing pair");
1260 assert_eq!(violation.fg, bad.text_muted);
1261 assert_eq!(violation.bg, bad.surface_bg);
1262 // Identical colors sit at 1:1 — far under the floor the report carries.
1263 assert!(violation.ratio < 1.1);
1264 assert!(violation.ratio < violation.floor);
1265 assert_eq!(violation.floor, AA_BODY_CONTRAST);
1266 }
1267
1268 #[test]
1269 fn direct_field_paint_follows_the_terminal_owned_shell() {
1270 // Widgets that paint `bg(WHALE_BG)` directly follow the theme's shell
1271 // instead of laying a navy patch over the terminal's own ground.
1272 assert_eq!(
1273 adapt_bg_for_theme(WHALE_BG, ThemeId::Whale, &UI_THEME),
1274 Color::Reset
1275 );
1276 assert_eq!(
1277 adapt_bg_for_theme(WHALE_BG, ThemeId::WhaleLight, &LIGHT_UI_THEME),
1278 Color::Reset
1279 );
1280 // A user `background_color` override is the field for direct paints too.
1281 let custom = UI_THEME.with_background_color(Color::Rgb(1, 2, 3));
1282 assert_eq!(
1283 adapt_bg_for_theme(WHALE_BG, ThemeId::Whale, &custom),
1284 Color::Rgb(1, 2, 3)
1285 );
1286 // Semantic surfaces keep their paint on the whale pair.
1287 assert_eq!(
1288 adapt_bg_for_theme(SELECTION_BG, ThemeId::Whale, &UI_THEME),
1289 SELECTION_BG
1290 );
1291 }
1292
1292 lines RUST