Skip to main content

ftui_style/
color.rs

1//! Color types, profiles, and downgrade utilities.
2
3use std::collections::HashMap;
4
5use ftui_render::cell::PackedRgba;
6
7/// Terminal color profile used for downgrade decisions.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9pub enum ColorProfile {
10    /// No color output.
11    Mono,
12    /// Standard 16 ANSI colors.
13    Ansi16,
14    /// Extended 256-color palette.
15    Ansi256,
16    /// Full 24-bit RGB color.
17    TrueColor,
18}
19
20impl ColorProfile {
21    /// Auto-detect the best available color profile from environment variables.
22    ///
23    /// Detection priority:
24    /// 1. `NO_COLOR` set → [`Mono`](ColorProfile::Mono)
25    /// 2. `COLORTERM=truecolor` or `COLORTERM=24bit` → [`TrueColor`](ColorProfile::TrueColor)
26    /// 3. `TERM` contains "256" → [`Ansi256`](ColorProfile::Ansi256)
27    /// 4. Otherwise → [`Ansi16`](ColorProfile::Ansi16)
28    ///
29    /// # Example
30    /// ```
31    /// use ftui_style::ColorProfile;
32    ///
33    /// let profile = ColorProfile::detect();
34    /// if profile.supports_true_color() {
35    ///     println!("Full 24-bit color available!");
36    /// }
37    /// ```
38    #[must_use]
39    pub fn detect() -> Self {
40        Self::detect_from_env(
41            std::env::var("NO_COLOR").ok().as_deref(),
42            std::env::var("COLORTERM").ok().as_deref(),
43            std::env::var("TERM").ok().as_deref(),
44        )
45    }
46
47    /// Detect color profile from provided environment values (for testing).
48    ///
49    /// Pass `Some("")` for empty env vars or `None` for unset.
50    #[must_use]
51    pub fn detect_from_env(
52        no_color: Option<&str>,
53        colorterm: Option<&str>,
54        term: Option<&str>,
55    ) -> Self {
56        // NO_COLOR takes precedence (presence, not value, matters)
57        if no_color.is_some() {
58            return Self::Mono;
59        }
60
61        // COLORTERM=truecolor or 24bit indicates true color
62        if let Some(ct) = colorterm
63            && (ct == "truecolor" || ct == "24bit")
64        {
65            return Self::TrueColor;
66        }
67
68        // TERM containing "256" indicates 256-color
69        if let Some(t) = term
70            && t.contains("256")
71        {
72            return Self::Ansi256;
73        }
74
75        Self::Ansi16
76    }
77
78    /// Choose the best available profile from detection flags.
79    ///
80    /// `no_color` should reflect explicit user intent (e.g. NO_COLOR).
81    #[must_use]
82    pub const fn from_flags(true_color: bool, colors_256: bool, no_color: bool) -> Self {
83        if no_color {
84            Self::Mono
85        } else if true_color {
86            Self::TrueColor
87        } else if colors_256 {
88            Self::Ansi256
89        } else {
90            Self::Ansi16
91        }
92    }
93
94    /// Check if this profile supports 24-bit true color.
95    #[must_use]
96    pub const fn supports_true_color(self) -> bool {
97        matches!(self, Self::TrueColor)
98    }
99
100    /// Check if this profile supports 256 colors or more.
101    #[must_use]
102    pub const fn supports_256_colors(self) -> bool {
103        matches!(self, Self::TrueColor | Self::Ansi256)
104    }
105
106    /// Check if this profile supports any color (not monochrome).
107    #[must_use]
108    pub const fn supports_color(self) -> bool {
109        !matches!(self, Self::Mono)
110    }
111}
112
113// =============================================================================
114// WCAG Contrast Validation
115// =============================================================================
116
117/// WCAG 2.0 AA contrast ratio for normal text (4.5:1).
118pub const WCAG_AA_NORMAL_TEXT: f64 = 4.5;
119
120/// WCAG 2.0 AA contrast ratio for large text (3.0:1).
121pub const WCAG_AA_LARGE_TEXT: f64 = 3.0;
122
123/// WCAG 2.0 AAA contrast ratio for normal text (7.0:1).
124pub const WCAG_AAA_NORMAL_TEXT: f64 = 7.0;
125
126/// WCAG 2.0 AAA contrast ratio for large text (4.5:1).
127pub const WCAG_AAA_LARGE_TEXT: f64 = 4.5;
128
129/// Convert sRGB channel value to linear RGB.
130///
131/// Per WCAG 2.0 specification for relative luminance calculation.
132#[inline]
133fn srgb_to_linear(c: f64) -> f64 {
134    if c <= 0.04045 {
135        c / 12.92
136    } else {
137        ((c + 0.055) / 1.055).powf(2.4)
138    }
139}
140
141/// Compute relative luminance of a color per WCAG 2.0.
142///
143/// Returns a value in the range [0, 1] where:
144/// - 0 = pure black
145/// - 1 = pure white
146///
147/// Formula: <https://www.w3.org/TR/WCAG20/#relativeluminancedef>
148#[must_use]
149pub fn relative_luminance(rgb: Rgb) -> f64 {
150    let r = srgb_to_linear(rgb.r as f64 / 255.0);
151    let g = srgb_to_linear(rgb.g as f64 / 255.0);
152    let b = srgb_to_linear(rgb.b as f64 / 255.0);
153    0.2126 * r + 0.7152 * g + 0.0722 * b
154}
155
156/// Compute relative luminance of a `PackedRgba` color per WCAG 2.0.
157#[must_use]
158pub fn relative_luminance_packed(color: PackedRgba) -> f64 {
159    relative_luminance(Rgb::from(color))
160}
161
162/// Compute WCAG 2.0 contrast ratio between two colors.
163///
164/// Returns a value in the range [1.0, 21.0] where:
165/// - 1.0 = identical colors (no contrast)
166/// - 21.0 = black on white (maximum contrast)
167///
168/// Formula: <https://www.w3.org/TR/WCAG20/#contrast-ratiodef>
169#[must_use]
170pub fn contrast_ratio(fg: Rgb, bg: Rgb) -> f64 {
171    let lum_fg = relative_luminance(fg);
172    let lum_bg = relative_luminance(bg);
173    let lighter = lum_fg.max(lum_bg);
174    let darker = lum_fg.min(lum_bg);
175    (lighter + 0.05) / (darker + 0.05)
176}
177
178/// Compute WCAG 2.0 contrast ratio between two `PackedRgba` colors.
179#[must_use]
180pub fn contrast_ratio_packed(fg: PackedRgba, bg: PackedRgba) -> f64 {
181    contrast_ratio(Rgb::from(fg), Rgb::from(bg))
182}
183
184/// Check if a color combination meets WCAG 2.0 AA for normal text (4.5:1).
185#[must_use]
186pub fn meets_wcag_aa(fg: Rgb, bg: Rgb) -> bool {
187    contrast_ratio(fg, bg) >= WCAG_AA_NORMAL_TEXT
188}
189
190/// Check if a color combination meets WCAG 2.0 AA for normal text (4.5:1).
191#[must_use]
192pub fn meets_wcag_aa_packed(fg: PackedRgba, bg: PackedRgba) -> bool {
193    contrast_ratio_packed(fg, bg) >= WCAG_AA_NORMAL_TEXT
194}
195
196/// Check if a color combination meets WCAG 2.0 AA for large text (3.0:1).
197#[must_use]
198pub fn meets_wcag_aa_large_text(fg: Rgb, bg: Rgb) -> bool {
199    contrast_ratio(fg, bg) >= WCAG_AA_LARGE_TEXT
200}
201
202/// Check if a color combination meets WCAG 2.0 AAA for normal text (7.0:1).
203#[must_use]
204pub fn meets_wcag_aaa(fg: Rgb, bg: Rgb) -> bool {
205    contrast_ratio(fg, bg) >= WCAG_AAA_NORMAL_TEXT
206}
207
208/// Select the best text color from candidates based on contrast ratio.
209///
210/// Returns the candidate with the highest contrast ratio against the background.
211#[must_use]
212pub fn best_text_color(bg: Rgb, candidates: &[Rgb]) -> Rgb {
213    assert!(!candidates.is_empty(), "candidates must not be empty");
214
215    let mut best = candidates[0];
216    let mut best_ratio = contrast_ratio(best, bg);
217
218    for &candidate in candidates.iter().skip(1) {
219        let ratio = contrast_ratio(candidate, bg);
220        if ratio > best_ratio {
221            best = candidate;
222            best_ratio = ratio;
223        }
224    }
225
226    best
227}
228
229/// Select the best text color from candidates based on contrast ratio.
230#[must_use]
231pub fn best_text_color_packed(bg: PackedRgba, candidates: &[PackedRgba]) -> PackedRgba {
232    assert!(!candidates.is_empty(), "candidates must not be empty");
233
234    let mut best = candidates[0];
235    let mut best_ratio = contrast_ratio_packed(best, bg);
236
237    for &candidate in candidates.iter().skip(1) {
238        let ratio = contrast_ratio_packed(candidate, bg);
239        if ratio > best_ratio {
240            best = candidate;
241            best_ratio = ratio;
242        }
243    }
244
245    best
246}
247
248// =============================================================================
249// RGB Color Type
250// =============================================================================
251
252/// RGB color (opaque).
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
254pub struct Rgb {
255    /// Red channel (0–255).
256    pub r: u8,
257    /// Green channel (0–255).
258    pub g: u8,
259    /// Blue channel (0–255).
260    pub b: u8,
261}
262
263impl Rgb {
264    /// Create a new RGB color.
265    #[must_use]
266    pub const fn new(r: u8, g: u8, b: u8) -> Self {
267        Self { r, g, b }
268    }
269
270    /// Pack into a `u32` key for use in hash maps.
271    #[must_use]
272    pub const fn as_key(self) -> u32 {
273        ((self.r as u32) << 16) | ((self.g as u32) << 8) | (self.b as u32)
274    }
275
276    /// Compute perceived luminance (BT.709) as a `u8` (0 = black, 255 = white).
277    #[must_use]
278    pub fn luminance_u8(self) -> u8 {
279        // ITU-R BT.709 luma: 0.2126 R + 0.7152 G + 0.0722 B
280        let r = self.r as u32;
281        let g = self.g as u32;
282        let b = self.b as u32;
283        let luma = 2126 * r + 7152 * g + 722 * b;
284        ((luma + 5000) / 10_000) as u8
285    }
286}
287
288impl From<PackedRgba> for Rgb {
289    fn from(color: PackedRgba) -> Self {
290        Self::new(color.r(), color.g(), color.b())
291    }
292}
293
294/// ANSI 16-color indices (0-15).
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
296#[repr(u8)]
297pub enum Ansi16 {
298    /// Black (index 0).
299    Black = 0,
300    /// Red (index 1).
301    Red = 1,
302    /// Green (index 2).
303    Green = 2,
304    /// Yellow (index 3).
305    Yellow = 3,
306    /// Blue (index 4).
307    Blue = 4,
308    /// Magenta (index 5).
309    Magenta = 5,
310    /// Cyan (index 6).
311    Cyan = 6,
312    /// White (index 7).
313    White = 7,
314    /// Bright black (index 8).
315    BrightBlack = 8,
316    /// Bright red (index 9).
317    BrightRed = 9,
318    /// Bright green (index 10).
319    BrightGreen = 10,
320    /// Bright yellow (index 11).
321    BrightYellow = 11,
322    /// Bright blue (index 12).
323    BrightBlue = 12,
324    /// Bright magenta (index 13).
325    BrightMagenta = 13,
326    /// Bright cyan (index 14).
327    BrightCyan = 14,
328    /// Bright white (index 15).
329    BrightWhite = 15,
330}
331
332impl Ansi16 {
333    /// Return the raw ANSI index (0–15).
334    #[must_use]
335    pub const fn as_u8(self) -> u8 {
336        self as u8
337    }
338
339    /// Convert a `u8` index to an `Ansi16` variant, returning `None` if out of range.
340    #[must_use]
341    pub const fn from_u8(value: u8) -> Option<Self> {
342        match value {
343            0 => Some(Self::Black),
344            1 => Some(Self::Red),
345            2 => Some(Self::Green),
346            3 => Some(Self::Yellow),
347            4 => Some(Self::Blue),
348            5 => Some(Self::Magenta),
349            6 => Some(Self::Cyan),
350            7 => Some(Self::White),
351            8 => Some(Self::BrightBlack),
352            9 => Some(Self::BrightRed),
353            10 => Some(Self::BrightGreen),
354            11 => Some(Self::BrightYellow),
355            12 => Some(Self::BrightBlue),
356            13 => Some(Self::BrightMagenta),
357            14 => Some(Self::BrightCyan),
358            15 => Some(Self::BrightWhite),
359            _ => None,
360        }
361    }
362}
363
364/// Monochrome output selection.
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
366pub enum MonoColor {
367    /// Black (dark).
368    Black,
369    /// White (light).
370    White,
371}
372
373/// A color value at varying fidelity levels.
374#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
375pub enum Color {
376    /// True-color RGB value.
377    Rgb(Rgb),
378    /// 256-color palette index.
379    Ansi256(u8),
380    /// Standard 16-color ANSI value.
381    Ansi16(Ansi16),
382    /// Monochrome (black or white).
383    Mono(MonoColor),
384}
385
386impl Color {
387    /// Create a true-color RGB value.
388    #[must_use]
389    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
390        Self::Rgb(Rgb::new(r, g, b))
391    }
392
393    /// Convert this color to an RGB triplet regardless of its fidelity level.
394    #[must_use]
395    pub fn to_rgb(self) -> Rgb {
396        match self {
397            Self::Rgb(rgb) => rgb,
398            Self::Ansi256(idx) => ansi256_to_rgb(idx),
399            Self::Ansi16(color) => ansi16_to_rgb(color),
400            Self::Mono(MonoColor::Black) => Rgb::new(0, 0, 0),
401            Self::Mono(MonoColor::White) => Rgb::new(255, 255, 255),
402        }
403    }
404
405    /// Downgrade this color to fit the given color profile.
406    #[must_use]
407    pub fn downgrade(self, profile: ColorProfile) -> Self {
408        match profile {
409            ColorProfile::TrueColor => self,
410            ColorProfile::Ansi256 => match self {
411                Self::Rgb(rgb) => Self::Ansi256(rgb_to_256(rgb.r, rgb.g, rgb.b)),
412                _ => self,
413            },
414            ColorProfile::Ansi16 => match self {
415                Self::Rgb(rgb) => Self::Ansi16(rgb_to_ansi16(rgb.r, rgb.g, rgb.b)),
416                Self::Ansi256(idx) => Self::Ansi16(rgb_to_ansi16_from_ansi256(idx)),
417                _ => self,
418            },
419            ColorProfile::Mono => match self {
420                Self::Rgb(rgb) => Self::Mono(rgb_to_mono(rgb.r, rgb.g, rgb.b)),
421                Self::Ansi256(idx) => {
422                    let rgb = ansi256_to_rgb(idx);
423                    Self::Mono(rgb_to_mono(rgb.r, rgb.g, rgb.b))
424                }
425                Self::Ansi16(color) => {
426                    let rgb = ansi16_to_rgb(color);
427                    Self::Mono(rgb_to_mono(rgb.r, rgb.g, rgb.b))
428                }
429                Self::Mono(_) => self,
430            },
431        }
432    }
433}
434
435impl From<PackedRgba> for Color {
436    fn from(color: PackedRgba) -> Self {
437        Self::Rgb(Rgb::from(color))
438    }
439}
440
441/// Statistics for a [`ColorCache`].
442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
443pub struct CacheStats {
444    /// Number of cache hits.
445    pub hits: u64,
446    /// Number of cache misses.
447    pub misses: u64,
448    /// Current number of entries.
449    pub size: usize,
450    /// Maximum number of entries before eviction.
451    pub capacity: usize,
452}
453
454/// Simple hash cache for downgrade results (bounded; clears on overflow).
455#[derive(Debug)]
456pub struct ColorCache {
457    profile: ColorProfile,
458    max_entries: usize,
459    map: HashMap<u32, Color>,
460    hits: u64,
461    misses: u64,
462}
463
464impl ColorCache {
465    /// Create a new cache with default capacity (4096 entries).
466    #[must_use]
467    pub fn new(profile: ColorProfile) -> Self {
468        Self::with_capacity(profile, 4096)
469    }
470
471    /// Create a new cache with the given maximum entry count.
472    #[must_use]
473    pub fn with_capacity(profile: ColorProfile, max_entries: usize) -> Self {
474        let max_entries = max_entries.max(1);
475        Self {
476            profile,
477            max_entries,
478            map: HashMap::with_capacity(max_entries.min(2048)),
479            hits: 0,
480            misses: 0,
481        }
482    }
483
484    /// Downgrade an RGB color through the cache, returning the cached result.
485    #[must_use]
486    pub fn downgrade_rgb(&mut self, rgb: Rgb) -> Color {
487        let key = rgb.as_key();
488        if let Some(cached) = self.map.get(&key) {
489            self.hits += 1;
490            return *cached;
491        }
492        self.misses += 1;
493        let downgraded = Color::Rgb(rgb).downgrade(self.profile);
494        if self.map.len() >= self.max_entries {
495            self.map.clear();
496        }
497        self.map.insert(key, downgraded);
498        downgraded
499    }
500
501    /// Downgrade a [`PackedRgba`] color through the cache.
502    #[must_use]
503    pub fn downgrade_packed(&mut self, color: PackedRgba) -> Color {
504        self.downgrade_rgb(Rgb::from(color))
505    }
506
507    /// Return current cache statistics.
508    #[must_use]
509    pub fn stats(&self) -> CacheStats {
510        CacheStats {
511            hits: self.hits,
512            misses: self.misses,
513            size: self.map.len(),
514            capacity: self.max_entries,
515        }
516    }
517}
518
519const ANSI16_PALETTE: [Rgb; 16] = [
520    Rgb::new(0, 0, 0),       // Black
521    Rgb::new(205, 0, 0),     // Red
522    Rgb::new(0, 205, 0),     // Green
523    Rgb::new(205, 205, 0),   // Yellow
524    Rgb::new(0, 0, 238),     // Blue
525    Rgb::new(205, 0, 205),   // Magenta
526    Rgb::new(0, 205, 205),   // Cyan
527    Rgb::new(229, 229, 229), // White
528    Rgb::new(127, 127, 127), // Bright Black
529    Rgb::new(255, 0, 0),     // Bright Red
530    Rgb::new(0, 255, 0),     // Bright Green
531    Rgb::new(255, 255, 0),   // Bright Yellow
532    Rgb::new(92, 92, 255),   // Bright Blue
533    Rgb::new(255, 0, 255),   // Bright Magenta
534    Rgb::new(0, 255, 255),   // Bright Cyan
535    Rgb::new(255, 255, 255), // Bright White
536];
537
538/// Convert an ANSI 16-color value to its canonical RGB representation.
539#[must_use]
540pub fn ansi16_to_rgb(color: Ansi16) -> Rgb {
541    ANSI16_PALETTE[color.as_u8() as usize]
542}
543
544/// Convert an RGB color to the nearest ANSI 256-color index.
545#[must_use]
546pub fn rgb_to_256(r: u8, g: u8, b: u8) -> u8 {
547    if r == g && g == b {
548        if r < 8 {
549            return 16;
550        }
551        if r > 248 {
552            return 231;
553        }
554        let idx = ((r - 8) / 10).min(23);
555        return 232 + idx;
556    }
557
558    16 + 36 * cube_index(r) + 6 * cube_index(g) + cube_index(b)
559}
560
561/// Map an 8-bit channel value to the nearest ANSI 256-color 6×6×6 cube index.
562///
563/// The cube levels are `[0, 95, 135, 175, 215, 255]`, which are **not**
564/// uniformly spaced.  This function uses the midpoints between adjacent
565/// levels (48, 115, 155, 195, 235) so each channel maps to the closest
566/// cube entry rather than an equal-width bin.
567fn cube_index(v: u8) -> u8 {
568    if v < 48 {
569        0
570    } else if v < 115 {
571        1
572    } else {
573        (v - 35) / 40
574    }
575}
576
577/// Convert an ANSI 256-color index to its RGB representation.
578#[must_use]
579pub fn ansi256_to_rgb(index: u8) -> Rgb {
580    if index < 16 {
581        return ANSI16_PALETTE[index as usize];
582    }
583    if index >= 232 {
584        let gray = 8 + 10 * (index - 232);
585        return Rgb::new(gray, gray, gray);
586    }
587    let idx = index - 16;
588    let r = idx / 36;
589    let g = (idx / 6) % 6;
590    let b = idx % 6;
591    const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
592    Rgb::new(LEVELS[r as usize], LEVELS[g as usize], LEVELS[b as usize])
593}
594
595/// Convert an RGB color to the nearest ANSI 16-color value.
596#[must_use]
597pub fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Ansi16 {
598    let target = Rgb::new(r, g, b);
599    let mut best = Ansi16::Black;
600    let mut best_dist = u64::MAX;
601
602    for (idx, candidate) in ANSI16_PALETTE.iter().enumerate() {
603        let dist = weighted_distance(target, *candidate);
604        if dist < best_dist {
605            best = Ansi16::from_u8(idx as u8).unwrap_or(Ansi16::Black);
606            best_dist = dist;
607        }
608    }
609
610    best
611}
612
613/// Convert an ANSI 256-color index to the nearest ANSI 16-color value.
614#[must_use]
615pub fn rgb_to_ansi16_from_ansi256(index: u8) -> Ansi16 {
616    let rgb = ansi256_to_rgb(index);
617    rgb_to_ansi16(rgb.r, rgb.g, rgb.b)
618}
619
620/// Convert an RGB color to monochrome (black or white) based on luminance.
621#[must_use]
622pub fn rgb_to_mono(r: u8, g: u8, b: u8) -> MonoColor {
623    let luma = Rgb::new(r, g, b).luminance_u8();
624    if luma >= 128 {
625        MonoColor::White
626    } else {
627        MonoColor::Black
628    }
629}
630
631fn weighted_distance(a: Rgb, b: Rgb) -> u64 {
632    let dr = a.r as i32 - b.r as i32;
633    let dg = a.g as i32 - b.g as i32;
634    let db = a.b as i32 - b.b as i32;
635    let dr2 = (dr * dr) as u64;
636    let dg2 = (dg * dg) as u64;
637    let db2 = (db * db) as u64;
638    2126 * dr2 + 7152 * dg2 + 722 * db2
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    // --- ColorProfile tests ---
646
647    #[test]
648    fn truecolor_passthrough() {
649        let color = Color::rgb(12, 34, 56);
650        assert_eq!(color.downgrade(ColorProfile::TrueColor), color);
651    }
652
653    #[test]
654    fn profile_from_flags_prefers_mono() {
655        assert_eq!(
656            ColorProfile::from_flags(true, true, true),
657            ColorProfile::Mono
658        );
659        assert_eq!(
660            ColorProfile::from_flags(true, false, false),
661            ColorProfile::TrueColor
662        );
663        assert_eq!(
664            ColorProfile::from_flags(false, true, false),
665            ColorProfile::Ansi256
666        );
667        assert_eq!(
668            ColorProfile::from_flags(false, false, false),
669            ColorProfile::Ansi16
670        );
671    }
672
673    #[test]
674    fn supports_true_color() {
675        assert!(ColorProfile::TrueColor.supports_true_color());
676        assert!(!ColorProfile::Ansi256.supports_true_color());
677        assert!(!ColorProfile::Ansi16.supports_true_color());
678        assert!(!ColorProfile::Mono.supports_true_color());
679    }
680
681    #[test]
682    fn supports_256_colors() {
683        assert!(ColorProfile::TrueColor.supports_256_colors());
684        assert!(ColorProfile::Ansi256.supports_256_colors());
685        assert!(!ColorProfile::Ansi16.supports_256_colors());
686        assert!(!ColorProfile::Mono.supports_256_colors());
687    }
688
689    #[test]
690    fn supports_color() {
691        assert!(ColorProfile::TrueColor.supports_color());
692        assert!(ColorProfile::Ansi256.supports_color());
693        assert!(ColorProfile::Ansi16.supports_color());
694        assert!(!ColorProfile::Mono.supports_color());
695    }
696
697    // --- ColorProfile::detect_from_env tests ---
698
699    #[test]
700    fn detect_no_color_gives_mono() {
701        // NO_COLOR presence (any value) should force Mono
702        assert_eq!(
703            ColorProfile::detect_from_env(Some("1"), None, None),
704            ColorProfile::Mono
705        );
706        assert_eq!(
707            ColorProfile::detect_from_env(Some(""), None, None),
708            ColorProfile::Mono
709        );
710        // NO_COLOR takes precedence over COLORTERM
711        assert_eq!(
712            ColorProfile::detect_from_env(Some("1"), Some("truecolor"), Some("xterm-256color")),
713            ColorProfile::Mono
714        );
715    }
716
717    #[test]
718    fn detect_colorterm_truecolor() {
719        assert_eq!(
720            ColorProfile::detect_from_env(None, Some("truecolor"), None),
721            ColorProfile::TrueColor
722        );
723    }
724
725    #[test]
726    fn detect_colorterm_24bit() {
727        assert_eq!(
728            ColorProfile::detect_from_env(None, Some("24bit"), None),
729            ColorProfile::TrueColor
730        );
731    }
732
733    #[test]
734    fn detect_term_256color() {
735        assert_eq!(
736            ColorProfile::detect_from_env(None, None, Some("xterm-256color")),
737            ColorProfile::Ansi256
738        );
739        assert_eq!(
740            ColorProfile::detect_from_env(None, None, Some("screen-256color")),
741            ColorProfile::Ansi256
742        );
743    }
744
745    #[test]
746    fn detect_colorterm_unknown_falls_to_term() {
747        // COLORTERM=yes is not truecolor, so fall back to TERM
748        assert_eq!(
749            ColorProfile::detect_from_env(None, Some("yes"), Some("xterm-256color")),
750            ColorProfile::Ansi256
751        );
752    }
753
754    #[test]
755    fn detect_defaults_to_ansi16() {
756        assert_eq!(
757            ColorProfile::detect_from_env(None, None, None),
758            ColorProfile::Ansi16
759        );
760        assert_eq!(
761            ColorProfile::detect_from_env(None, None, Some("xterm")),
762            ColorProfile::Ansi16
763        );
764        assert_eq!(
765            ColorProfile::detect_from_env(None, Some(""), Some("dumb")),
766            ColorProfile::Ansi16
767        );
768    }
769
770    // --- WCAG Contrast tests ---
771
772    #[test]
773    fn wcag_luminance_black_is_zero() {
774        let lum = relative_luminance(Rgb::new(0, 0, 0));
775        assert!((lum - 0.0).abs() < 0.001);
776    }
777
778    #[test]
779    fn wcag_luminance_white_is_one() {
780        let lum = relative_luminance(Rgb::new(255, 255, 255));
781        assert!((lum - 1.0).abs() < 0.001);
782    }
783
784    #[test]
785    fn wcag_luminance_green_is_brightest() {
786        // Green has highest weight (0.7152) in luminance formula
787        let r_lum = relative_luminance(Rgb::new(255, 0, 0));
788        let g_lum = relative_luminance(Rgb::new(0, 255, 0));
789        let b_lum = relative_luminance(Rgb::new(0, 0, 255));
790        assert!(g_lum > r_lum);
791        assert!(g_lum > b_lum);
792    }
793
794    #[test]
795    fn contrast_ratio_black_white_is_21() {
796        let black = Rgb::new(0, 0, 0);
797        let white = Rgb::new(255, 255, 255);
798        let ratio = contrast_ratio(black, white);
799        // Should be exactly 21:1
800        assert!((ratio - 21.0).abs() < 0.01, "ratio was {}", ratio);
801    }
802
803    #[test]
804    fn contrast_ratio_is_symmetric() {
805        let a = Rgb::new(100, 150, 200);
806        let b = Rgb::new(50, 75, 100);
807        let ratio_ab = contrast_ratio(a, b);
808        let ratio_ba = contrast_ratio(b, a);
809        assert!((ratio_ab - ratio_ba).abs() < 0.001);
810    }
811
812    #[test]
813    fn contrast_ratio_same_color_is_one() {
814        let color = Rgb::new(128, 128, 128);
815        let ratio = contrast_ratio(color, color);
816        assert!((ratio - 1.0).abs() < 0.001);
817    }
818
819    #[test]
820    fn meets_wcag_aa_black_white() {
821        let black = Rgb::new(0, 0, 0);
822        let white = Rgb::new(255, 255, 255);
823        assert!(meets_wcag_aa(black, white));
824        assert!(meets_wcag_aa(white, black));
825    }
826
827    #[test]
828    fn meets_wcag_aa_low_contrast_fails() {
829        // Two similar grays should fail WCAG AA
830        let gray1 = Rgb::new(128, 128, 128);
831        let gray2 = Rgb::new(140, 140, 140);
832        assert!(!meets_wcag_aa(gray1, gray2));
833    }
834
835    #[test]
836    fn meets_wcag_aaa_black_white() {
837        let black = Rgb::new(0, 0, 0);
838        let white = Rgb::new(255, 255, 255);
839        assert!(meets_wcag_aaa(black, white));
840    }
841
842    #[test]
843    fn best_text_color_chooses_highest_contrast() {
844        let dark_bg = Rgb::new(30, 30, 30);
845        let candidates = [
846            Rgb::new(50, 50, 50),    // dark gray - low contrast
847            Rgb::new(255, 255, 255), // white - high contrast
848            Rgb::new(100, 100, 100), // mid gray - medium contrast
849        ];
850        let best = best_text_color(dark_bg, &candidates);
851        assert_eq!(best, Rgb::new(255, 255, 255));
852
853        let light_bg = Rgb::new(240, 240, 240);
854        let best_on_light = best_text_color(light_bg, &candidates);
855        assert_eq!(best_on_light, Rgb::new(50, 50, 50));
856    }
857
858    #[test]
859    fn wcag_constants_are_correct() {
860        assert!((WCAG_AA_NORMAL_TEXT - 4.5).abs() < 0.001);
861        assert!((WCAG_AA_LARGE_TEXT - 3.0).abs() < 0.001);
862        assert!((WCAG_AAA_NORMAL_TEXT - 7.0).abs() < 0.001);
863        assert!((WCAG_AAA_LARGE_TEXT - 4.5).abs() < 0.001);
864    }
865
866    // --- Rgb tests ---
867
868    #[test]
869    fn rgb_as_key_is_unique() {
870        let a = Rgb::new(1, 2, 3);
871        let b = Rgb::new(3, 2, 1);
872        assert_ne!(a.as_key(), b.as_key());
873        assert_eq!(a.as_key(), Rgb::new(1, 2, 3).as_key());
874    }
875
876    #[test]
877    fn rgb_luminance_black_is_zero() {
878        assert_eq!(Rgb::new(0, 0, 0).luminance_u8(), 0);
879    }
880
881    #[test]
882    fn rgb_luminance_white_is_255() {
883        assert_eq!(Rgb::new(255, 255, 255).luminance_u8(), 255);
884    }
885
886    #[test]
887    fn rgb_luminance_green_is_brightest_channel() {
888        // Green has highest weight in BT.709 luma
889        let green_only = Rgb::new(0, 128, 0).luminance_u8();
890        let red_only = Rgb::new(128, 0, 0).luminance_u8();
891        let blue_only = Rgb::new(0, 0, 128).luminance_u8();
892        assert!(green_only > red_only);
893        assert!(green_only > blue_only);
894    }
895
896    #[test]
897    fn rgb_from_packed_rgba() {
898        let packed = PackedRgba::rgb(10, 20, 30);
899        let rgb: Rgb = packed.into();
900        assert_eq!(rgb, Rgb::new(10, 20, 30));
901    }
902
903    #[test]
904    fn relative_luminance_packed_ignores_alpha() {
905        let opaque = PackedRgba::rgba(10, 20, 30, 255);
906        let transparent = PackedRgba::rgba(10, 20, 30, 0);
907        let l1 = relative_luminance_packed(opaque);
908        let l2 = relative_luminance_packed(transparent);
909        assert!(
910            (l1 - l2).abs() < 1.0e-12,
911            "luminance should ignore alpha (l1={l1}, l2={l2})"
912        );
913    }
914
915    // --- Ansi16 tests ---
916
917    #[test]
918    fn ansi16_from_u8_valid_range() {
919        for i in 0..=15 {
920            assert!(Ansi16::from_u8(i).is_some());
921        }
922    }
923
924    #[test]
925    fn ansi16_from_u8_invalid() {
926        assert!(Ansi16::from_u8(16).is_none());
927        assert!(Ansi16::from_u8(255).is_none());
928    }
929
930    #[test]
931    fn ansi16_round_trip() {
932        for i in 0..=15 {
933            let color = Ansi16::from_u8(i).unwrap();
934            assert_eq!(color.as_u8(), i);
935        }
936    }
937
938    // --- rgb_to_256 tests ---
939
940    #[test]
941    fn rgb_to_256_grayscale_rules() {
942        assert_eq!(rgb_to_256(0, 0, 0), 16);
943        assert_eq!(rgb_to_256(8, 8, 8), 232);
944        assert_eq!(rgb_to_256(18, 18, 18), 233);
945        assert_eq!(rgb_to_256(249, 249, 249), 231);
946    }
947
948    #[test]
949    fn rgb_to_256_primary_red() {
950        assert_eq!(rgb_to_256(255, 0, 0), 196);
951    }
952
953    #[test]
954    fn rgb_to_256_primary_green() {
955        assert_eq!(rgb_to_256(0, 255, 0), 46);
956    }
957
958    #[test]
959    fn rgb_to_256_primary_blue() {
960        assert_eq!(rgb_to_256(0, 0, 255), 21);
961    }
962
963    // --- ansi256_to_rgb tests ---
964
965    #[test]
966    fn ansi256_to_rgb_round_trip() {
967        let rgb = ansi256_to_rgb(196);
968        assert_eq!(rgb, Rgb::new(255, 0, 0));
969    }
970
971    #[test]
972    fn ansi256_to_rgb_first_16_match_palette() {
973        for i in 0..16 {
974            let rgb = ansi256_to_rgb(i);
975            assert_eq!(rgb, ANSI16_PALETTE[i as usize]);
976        }
977    }
978
979    #[test]
980    fn ansi256_to_rgb_grayscale_ramp() {
981        // Index 232 = darkest gray (8,8,8), 255 = lightest (238,238,238)
982        let darkest = ansi256_to_rgb(232);
983        assert_eq!(darkest, Rgb::new(8, 8, 8));
984        let lightest = ansi256_to_rgb(255);
985        assert_eq!(lightest, Rgb::new(238, 238, 238));
986    }
987
988    #[test]
989    fn ansi256_color_cube_corners() {
990        // Index 16 = (0,0,0) in cube
991        assert_eq!(ansi256_to_rgb(16), Rgb::new(0, 0, 0));
992        // Index 231 = (255,255,255) in cube
993        assert_eq!(ansi256_to_rgb(231), Rgb::new(255, 255, 255));
994    }
995
996    // --- rgb_to_ansi16 tests ---
997
998    #[test]
999    fn rgb_to_ansi16_basics() {
1000        assert_eq!(rgb_to_ansi16(0, 0, 0), Ansi16::Black);
1001        assert_eq!(rgb_to_ansi16(255, 0, 0), Ansi16::BrightRed);
1002        assert_eq!(rgb_to_ansi16(0, 255, 0), Ansi16::BrightGreen);
1003        assert_eq!(rgb_to_ansi16(0, 0, 255), Ansi16::Blue);
1004    }
1005
1006    #[test]
1007    fn rgb_to_ansi16_white() {
1008        assert_eq!(rgb_to_ansi16(255, 255, 255), Ansi16::BrightWhite);
1009    }
1010
1011    // --- rgb_to_mono tests ---
1012
1013    #[test]
1014    fn mono_fallback() {
1015        assert_eq!(rgb_to_mono(0, 0, 0), MonoColor::Black);
1016        assert_eq!(rgb_to_mono(255, 255, 255), MonoColor::White);
1017        assert_eq!(rgb_to_mono(200, 200, 200), MonoColor::White);
1018        assert_eq!(rgb_to_mono(30, 30, 30), MonoColor::Black);
1019    }
1020
1021    #[test]
1022    fn mono_boundary() {
1023        // Luminance threshold is 128
1024        assert_eq!(rgb_to_mono(128, 128, 128), MonoColor::White);
1025        assert_eq!(rgb_to_mono(127, 127, 127), MonoColor::Black);
1026    }
1027
1028    // --- Color downgrade chain tests ---
1029
1030    #[test]
1031    fn downgrade_rgb_to_ansi256() {
1032        let color = Color::rgb(255, 0, 0);
1033        let downgraded = color.downgrade(ColorProfile::Ansi256);
1034        assert!(matches!(downgraded, Color::Ansi256(_)));
1035    }
1036
1037    #[test]
1038    fn downgrade_rgb_to_ansi16() {
1039        let color = Color::rgb(255, 0, 0);
1040        let downgraded = color.downgrade(ColorProfile::Ansi16);
1041        assert!(matches!(downgraded, Color::Ansi16(_)));
1042    }
1043
1044    #[test]
1045    fn downgrade_rgb_to_mono() {
1046        let color = Color::rgb(255, 255, 255);
1047        let downgraded = color.downgrade(ColorProfile::Mono);
1048        assert_eq!(downgraded, Color::Mono(MonoColor::White));
1049    }
1050
1051    #[test]
1052    fn downgrade_ansi256_to_ansi16() {
1053        let color = Color::Ansi256(196);
1054        let downgraded = color.downgrade(ColorProfile::Ansi16);
1055        assert!(matches!(downgraded, Color::Ansi16(_)));
1056    }
1057
1058    #[test]
1059    fn downgrade_ansi256_to_mono() {
1060        let color = Color::Ansi256(232); // dark gray
1061        let downgraded = color.downgrade(ColorProfile::Mono);
1062        assert_eq!(downgraded, Color::Mono(MonoColor::Black));
1063    }
1064
1065    #[test]
1066    fn downgrade_ansi16_to_mono() {
1067        let color = Color::Ansi16(Ansi16::BrightWhite);
1068        let downgraded = color.downgrade(ColorProfile::Mono);
1069        assert_eq!(downgraded, Color::Mono(MonoColor::White));
1070    }
1071
1072    #[test]
1073    fn downgrade_mono_stays_mono() {
1074        let color = Color::Mono(MonoColor::Black);
1075        assert_eq!(color.downgrade(ColorProfile::Mono), color);
1076    }
1077
1078    #[test]
1079    fn downgrade_ansi16_stays_at_ansi256() {
1080        let color = Color::Ansi16(Ansi16::Red);
1081        // Ansi16 should pass through at Ansi256 level
1082        assert_eq!(color.downgrade(ColorProfile::Ansi256), color);
1083    }
1084
1085    // --- Color::to_rgb tests ---
1086
1087    #[test]
1088    fn color_to_rgb_all_variants() {
1089        assert_eq!(Color::rgb(1, 2, 3).to_rgb(), Rgb::new(1, 2, 3));
1090        assert_eq!(Color::Ansi256(196).to_rgb(), Rgb::new(255, 0, 0));
1091        assert_eq!(Color::Ansi16(Ansi16::Black).to_rgb(), Rgb::new(0, 0, 0));
1092        assert_eq!(
1093            Color::Mono(MonoColor::White).to_rgb(),
1094            Rgb::new(255, 255, 255)
1095        );
1096        assert_eq!(Color::Mono(MonoColor::Black).to_rgb(), Rgb::new(0, 0, 0));
1097    }
1098
1099    // --- Color from PackedRgba ---
1100
1101    #[test]
1102    fn color_from_packed_rgba() {
1103        let packed = PackedRgba::rgb(42, 84, 126);
1104        let color: Color = packed.into();
1105        assert_eq!(color, Color::Rgb(Rgb::new(42, 84, 126)));
1106    }
1107
1108    #[test]
1109    fn color_from_packed_rgba_ignores_alpha() {
1110        let packed = PackedRgba::rgba(42, 84, 126, 10);
1111        let color: Color = packed.into();
1112        assert_eq!(color, Color::Rgb(Rgb::new(42, 84, 126)));
1113    }
1114
1115    // --- ColorCache tests ---
1116
1117    #[test]
1118    fn cache_tracks_hits() {
1119        let mut cache = ColorCache::with_capacity(ColorProfile::Ansi16, 8);
1120        let rgb = Rgb::new(10, 20, 30);
1121        let _ = cache.downgrade_rgb(rgb);
1122        let _ = cache.downgrade_rgb(rgb);
1123        let stats = cache.stats();
1124        assert_eq!(stats.hits, 1);
1125        assert_eq!(stats.misses, 1);
1126        assert_eq!(stats.size, 1);
1127    }
1128
1129    #[test]
1130    fn cache_clears_on_overflow() {
1131        let mut cache = ColorCache::with_capacity(ColorProfile::Ansi16, 2);
1132        let _ = cache.downgrade_rgb(Rgb::new(1, 0, 0));
1133        let _ = cache.downgrade_rgb(Rgb::new(2, 0, 0));
1134        assert_eq!(cache.stats().size, 2);
1135        // Third entry should trigger clear
1136        let _ = cache.downgrade_rgb(Rgb::new(3, 0, 0));
1137        assert_eq!(cache.stats().size, 1);
1138    }
1139
1140    #[test]
1141    fn cache_downgrade_packed() {
1142        let mut cache = ColorCache::with_capacity(ColorProfile::Ansi16, 8);
1143        let packed = PackedRgba::rgb(255, 0, 0);
1144        let result = cache.downgrade_packed(packed);
1145        assert!(matches!(result, Color::Ansi16(_)));
1146    }
1147
1148    #[test]
1149    fn cache_default_capacity() {
1150        let cache = ColorCache::new(ColorProfile::TrueColor);
1151        assert_eq!(cache.stats().capacity, 4096);
1152    }
1153
1154    #[test]
1155    fn cache_minimum_capacity_is_one() {
1156        let cache = ColorCache::with_capacity(ColorProfile::Ansi16, 0);
1157        assert_eq!(cache.stats().capacity, 1);
1158    }
1159}
1160
1161#[cfg(test)]
1162mod downgrade_edge_cases {
1163    //! Tests for color downgrade edge cases and boundary conditions.
1164    //!
1165    //! These tests verify correct behavior at palette boundaries,
1166    //! grayscale thresholds, and the sequential downgrade pipeline.
1167
1168    use super::*;
1169
1170    // =========================================================================
1171    // Sequential downgrade verification
1172    // =========================================================================
1173
1174    #[test]
1175    fn sequential_downgrade_truecolor_to_mono() {
1176        // Verify the full downgrade pipeline: RGB -> 256 -> 16 -> Mono
1177        let white = Color::rgb(255, 255, 255);
1178        let black = Color::rgb(0, 0, 0);
1179
1180        // White through all stages
1181        let w256 = white.downgrade(ColorProfile::Ansi256);
1182        assert!(matches!(w256, Color::Ansi256(231))); // Pure white in cube
1183        let w16 = w256.downgrade(ColorProfile::Ansi16);
1184        assert!(matches!(w16, Color::Ansi16(Ansi16::BrightWhite)));
1185        let wmono = w16.downgrade(ColorProfile::Mono);
1186        assert_eq!(wmono, Color::Mono(MonoColor::White));
1187
1188        // Black through all stages
1189        let b256 = black.downgrade(ColorProfile::Ansi256);
1190        assert!(matches!(b256, Color::Ansi256(16))); // Pure black
1191        let b16 = b256.downgrade(ColorProfile::Ansi16);
1192        assert!(matches!(b16, Color::Ansi16(Ansi16::Black)));
1193        let bmono = b16.downgrade(ColorProfile::Mono);
1194        assert_eq!(bmono, Color::Mono(MonoColor::Black));
1195    }
1196
1197    #[test]
1198    fn sequential_downgrade_preserves_intent() {
1199        // Red should stay "reddish" through the pipeline
1200        let red = Color::rgb(255, 0, 0);
1201
1202        let r256 = red.downgrade(ColorProfile::Ansi256);
1203        let Color::Ansi256(idx) = r256 else {
1204            panic!("Expected Ansi256");
1205        };
1206        assert_eq!(idx, 196); // Pure red in 256-color
1207
1208        let r16 = r256.downgrade(ColorProfile::Ansi16);
1209        let Color::Ansi16(ansi) = r16 else {
1210            panic!("Expected Ansi16");
1211        };
1212        // Should map to BrightRed (not some other color)
1213        assert_eq!(ansi, Ansi16::BrightRed);
1214    }
1215
1216    // =========================================================================
1217    // rgb_to_256 edge cases
1218    // =========================================================================
1219
1220    #[test]
1221    fn rgb_to_256_grayscale_boundaries() {
1222        // Test exact boundary values for grayscale detection
1223        // r < 8 -> 16 (black)
1224        assert_eq!(rgb_to_256(0, 0, 0), 16);
1225        assert_eq!(rgb_to_256(7, 7, 7), 16);
1226
1227        // r >= 8 -> grayscale ramp starts
1228        assert_eq!(rgb_to_256(8, 8, 8), 232);
1229
1230        // r > 248 -> 231 (white in cube)
1231        assert_eq!(rgb_to_256(249, 249, 249), 231);
1232        assert_eq!(rgb_to_256(255, 255, 255), 231);
1233
1234        // r = 248 is still in grayscale ramp
1235        assert_eq!(rgb_to_256(248, 248, 248), 255);
1236    }
1237
1238    #[test]
1239    fn rgb_to_256_grayscale_ramp_coverage() {
1240        // Grayscale ramp 232-255 covers values 8-238
1241        // Each step is 10 units: 8, 18, 28, ..., 238
1242        for i in 0..24 {
1243            let gray_val = 8 + i * 10;
1244            let idx = rgb_to_256(gray_val, gray_val, gray_val);
1245            assert!(
1246                (232..=255).contains(&idx),
1247                "Gray {} mapped to {} (expected 232-255)",
1248                gray_val,
1249                idx
1250            );
1251        }
1252    }
1253
1254    #[test]
1255    fn rgb_to_256_cube_corners() {
1256        // Test all 8 corners of the 6x6x6 RGB cube
1257        assert_eq!(rgb_to_256(0, 0, 0), 16); // Handled as grayscale
1258        assert_eq!(rgb_to_256(255, 0, 0), 196); // Red corner
1259        assert_eq!(rgb_to_256(0, 255, 0), 46); // Green corner
1260        assert_eq!(rgb_to_256(0, 0, 255), 21); // Blue corner
1261        assert_eq!(rgb_to_256(255, 255, 0), 226); // Yellow corner
1262        assert_eq!(rgb_to_256(255, 0, 255), 201); // Magenta corner
1263        assert_eq!(rgb_to_256(0, 255, 255), 51); // Cyan corner
1264        // White handled as grayscale, maps to 231
1265        assert_eq!(rgb_to_256(255, 255, 255), 231);
1266    }
1267
1268    #[test]
1269    fn rgb_to_256_non_gray_avoids_grayscale() {
1270        // When channels differ, should use cube even if values are gray-ish
1271        // r=100, g=100, b=99 is NOT grayscale (not all equal)
1272        let idx = rgb_to_256(100, 100, 99);
1273        // Should be in cube range (16-231), not grayscale (232-255)
1274        assert!(
1275            (16..=231).contains(&idx),
1276            "Non-gray {} should use cube",
1277            idx
1278        );
1279    }
1280
1281    // =========================================================================
1282    // cube_index edge cases
1283    // =========================================================================
1284
1285    #[test]
1286    fn cube_index_boundaries() {
1287        // cube_index uses thresholds: 0-47->0, 48-114->1, 115+->computed
1288        // Test the boundary values (using super:: to access private fn)
1289        assert_eq!(super::cube_index(0), 0);
1290        assert_eq!(super::cube_index(47), 0);
1291        assert_eq!(super::cube_index(48), 1);
1292        assert_eq!(super::cube_index(114), 1);
1293        assert_eq!(super::cube_index(115), 2);
1294        assert_eq!(super::cube_index(155), 3);
1295        assert_eq!(super::cube_index(195), 4);
1296        assert_eq!(super::cube_index(235), 5);
1297        assert_eq!(super::cube_index(255), 5);
1298    }
1299
1300    // =========================================================================
1301    // ansi256_to_rgb edge cases
1302    // =========================================================================
1303
1304    #[test]
1305    fn ansi256_to_rgb_full_range() {
1306        // Every index should produce valid RGB (this is a sanity check)
1307        for i in 0..=255 {
1308            let rgb = ansi256_to_rgb(i);
1309            // Verify the values are reasonable (non-panic)
1310            let _ = (rgb.r, rgb.g, rgb.b);
1311        }
1312    }
1313
1314    #[test]
1315    fn ansi256_to_rgb_grayscale_range() {
1316        // Indices 232-255 should produce grayscale (r=g=b)
1317        for i in 232..=255 {
1318            let rgb = ansi256_to_rgb(i);
1319            assert_eq!(rgb.r, rgb.g);
1320            assert_eq!(rgb.g, rgb.b);
1321        }
1322    }
1323
1324    #[test]
1325    fn ansi256_to_rgb_first_16_are_palette() {
1326        // Indices 0-15 should use the 16-color palette
1327        for i in 0..16 {
1328            let rgb = ansi256_to_rgb(i);
1329            assert_eq!(rgb, ANSI16_PALETTE[i as usize]);
1330        }
1331    }
1332
1333    // =========================================================================
1334    // rgb_to_ansi16 edge cases
1335    // =========================================================================
1336
1337    #[test]
1338    fn rgb_to_ansi16_pure_primaries() {
1339        // Pure primaries should map to their bright variants
1340        assert_eq!(rgb_to_ansi16(255, 0, 0), Ansi16::BrightRed);
1341        assert_eq!(rgb_to_ansi16(0, 255, 0), Ansi16::BrightGreen);
1342        // Blue maps to regular Blue because the bright blue in palette is different
1343        assert_eq!(rgb_to_ansi16(0, 0, 255), Ansi16::Blue);
1344    }
1345
1346    #[test]
1347    fn rgb_to_ansi16_grays() {
1348        // Dark gray should map to BrightBlack (127,127,127)
1349        assert_eq!(rgb_to_ansi16(127, 127, 127), Ansi16::BrightBlack);
1350        // Mid gray closer to White (229,229,229)
1351        assert_eq!(rgb_to_ansi16(200, 200, 200), Ansi16::White);
1352    }
1353
1354    #[test]
1355    fn rgb_to_ansi16_extremes() {
1356        // Pure black and white
1357        assert_eq!(rgb_to_ansi16(0, 0, 0), Ansi16::Black);
1358        assert_eq!(rgb_to_ansi16(255, 255, 255), Ansi16::BrightWhite);
1359    }
1360
1361    // =========================================================================
1362    // rgb_to_mono edge cases
1363    // =========================================================================
1364
1365    #[test]
1366    fn rgb_to_mono_luminance_boundary() {
1367        // Luminance threshold is 128
1368        // Test values near the boundary
1369        assert_eq!(rgb_to_mono(128, 128, 128), MonoColor::White);
1370        assert_eq!(rgb_to_mono(127, 127, 127), MonoColor::Black);
1371
1372        // Test with weighted luminance (green has highest weight)
1373        // Green at ~180 should give luminance ~128 (0.7152 * 180 = 128.7)
1374        assert_eq!(rgb_to_mono(0, 180, 0), MonoColor::White);
1375        assert_eq!(rgb_to_mono(0, 178, 0), MonoColor::Black);
1376    }
1377
1378    #[test]
1379    fn rgb_to_mono_color_saturation_irrelevant() {
1380        // Mono cares only about luminance, not saturation
1381        // Pure red (luma = 0.2126 * 255 = 54) -> black
1382        assert_eq!(rgb_to_mono(255, 0, 0), MonoColor::Black);
1383        // Pure green (luma = 0.7152 * 255 = 182) -> white
1384        assert_eq!(rgb_to_mono(0, 255, 0), MonoColor::White);
1385        // Pure blue (luma = 0.0722 * 255 = 18) -> black
1386        assert_eq!(rgb_to_mono(0, 0, 255), MonoColor::Black);
1387    }
1388
1389    // =========================================================================
1390    // Color downgrade stability tests
1391    // =========================================================================
1392
1393    #[test]
1394    fn downgrade_at_same_level_is_identity() {
1395        // Downgrading to the same level should not change the color
1396        let ansi16 = Color::Ansi16(Ansi16::Red);
1397        assert_eq!(ansi16.downgrade(ColorProfile::Ansi16), ansi16);
1398
1399        let ansi256 = Color::Ansi256(100);
1400        assert_eq!(ansi256.downgrade(ColorProfile::Ansi256), ansi256);
1401
1402        let mono = Color::Mono(MonoColor::Black);
1403        assert_eq!(mono.downgrade(ColorProfile::Mono), mono);
1404
1405        let rgb = Color::rgb(1, 2, 3);
1406        assert_eq!(rgb.downgrade(ColorProfile::TrueColor), rgb);
1407    }
1408
1409    #[test]
1410    fn downgrade_ansi16_passes_through_ansi256() {
1411        // Ansi16 should not change when downgraded to Ansi256
1412        // (it's already "lower fidelity")
1413        let color = Color::Ansi16(Ansi16::Cyan);
1414        assert_eq!(color.downgrade(ColorProfile::Ansi256), color);
1415    }
1416
1417    #[test]
1418    fn downgrade_mono_passes_through_all() {
1419        // Mono should never change
1420        let black = Color::Mono(MonoColor::Black);
1421        let white = Color::Mono(MonoColor::White);
1422
1423        assert_eq!(black.downgrade(ColorProfile::TrueColor), black);
1424        assert_eq!(black.downgrade(ColorProfile::Ansi256), black);
1425        assert_eq!(black.downgrade(ColorProfile::Ansi16), black);
1426        assert_eq!(black.downgrade(ColorProfile::Mono), black);
1427
1428        assert_eq!(white.downgrade(ColorProfile::TrueColor), white);
1429        assert_eq!(white.downgrade(ColorProfile::Ansi256), white);
1430        assert_eq!(white.downgrade(ColorProfile::Ansi16), white);
1431        assert_eq!(white.downgrade(ColorProfile::Mono), white);
1432    }
1433
1434    // =========================================================================
1435    // Luminance edge cases
1436    // =========================================================================
1437
1438    #[test]
1439    fn luminance_formula_correctness() {
1440        // BT.709: 0.2126 R + 0.7152 G + 0.0722 B
1441        // Pure channels
1442        let r_luma = Rgb::new(255, 0, 0).luminance_u8();
1443        let g_luma = Rgb::new(0, 255, 0).luminance_u8();
1444        let b_luma = Rgb::new(0, 0, 255).luminance_u8();
1445
1446        // Red: 0.2126 * 255 = 54.2
1447        assert!(
1448            (50..=58).contains(&r_luma),
1449            "Red luma {} not near 54",
1450            r_luma
1451        );
1452        // Green: 0.7152 * 255 = 182.4
1453        assert!(
1454            (178..=186).contains(&g_luma),
1455            "Green luma {} not near 182",
1456            g_luma
1457        );
1458        // Blue: 0.0722 * 255 = 18.4
1459        assert!(
1460            (15..=22).contains(&b_luma),
1461            "Blue luma {} not near 18",
1462            b_luma
1463        );
1464
1465        // Combined should match
1466        let all = Rgb::new(255, 255, 255).luminance_u8();
1467        assert_eq!(all, 255);
1468    }
1469
1470    #[test]
1471    fn luminance_mid_values() {
1472        // Test some mid-range values
1473        let mid_gray = Rgb::new(128, 128, 128).luminance_u8();
1474        // Should be approximately 128
1475        assert!(
1476            (126..=130).contains(&mid_gray),
1477            "Mid gray luma {} not near 128",
1478            mid_gray
1479        );
1480    }
1481}