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    let cube_idx = 16 + 36 * cube_index(r) + 6 * cube_index(g) + cube_index(b);
548
549    if r == g && g == b {
550        if r < 8 {
551            return 16;
552        }
553        if r > 246 {
554            return 231;
555        }
556        let gray_idx = 232 + ((r - 8) / 10).min(23);
557
558        // Compare distance of cube_idx vs gray_idx
559        let target = Rgb::new(r, g, b);
560        let cube_dist = weighted_distance(target, ansi256_to_rgb(cube_idx));
561        let gray_dist = weighted_distance(target, ansi256_to_rgb(gray_idx));
562
563        if cube_dist <= gray_dist {
564            return cube_idx;
565        } else {
566            return gray_idx;
567        }
568    }
569
570    cube_idx
571}
572
573/// Map an 8-bit channel value to the nearest ANSI 256-color 6×6×6 cube index.
574///
575/// The cube levels are `[0, 95, 135, 175, 215, 255]`, which are **not**
576/// uniformly spaced.  This function uses the midpoints between adjacent
577/// levels (48, 115, 155, 195, 235) so each channel maps to the closest
578/// cube entry rather than an equal-width bin.
579fn cube_index(v: u8) -> u8 {
580    if v < 48 {
581        0
582    } else if v < 115 {
583        1
584    } else {
585        (v - 35) / 40
586    }
587}
588
589/// Convert an ANSI 256-color index to its RGB representation.
590#[must_use]
591pub fn ansi256_to_rgb(index: u8) -> Rgb {
592    if index < 16 {
593        return ANSI16_PALETTE[index as usize];
594    }
595    if index >= 232 {
596        let gray = 8 + 10 * (index - 232);
597        return Rgb::new(gray, gray, gray);
598    }
599    let idx = index - 16;
600    let r = idx / 36;
601    let g = (idx / 6) % 6;
602    let b = idx % 6;
603    const LEVELS: [u8; 6] = [0, 95, 135, 175, 215, 255];
604    Rgb::new(LEVELS[r as usize], LEVELS[g as usize], LEVELS[b as usize])
605}
606
607/// Convert an RGB color to the nearest ANSI 16-color value.
608#[must_use]
609pub fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Ansi16 {
610    let target = Rgb::new(r, g, b);
611    let mut best = Ansi16::Black;
612    let mut best_dist = u64::MAX;
613
614    for (idx, candidate) in ANSI16_PALETTE.iter().enumerate() {
615        let dist = weighted_distance(target, *candidate);
616        if dist < best_dist {
617            best = Ansi16::from_u8(idx as u8).unwrap_or(Ansi16::Black);
618            best_dist = dist;
619        }
620    }
621
622    best
623}
624
625/// Convert an ANSI 256-color index to the nearest ANSI 16-color value.
626#[must_use]
627pub fn rgb_to_ansi16_from_ansi256(index: u8) -> Ansi16 {
628    let rgb = ansi256_to_rgb(index);
629    rgb_to_ansi16(rgb.r, rgb.g, rgb.b)
630}
631
632/// Convert an RGB color to monochrome (black or white) based on luminance.
633#[must_use]
634pub fn rgb_to_mono(r: u8, g: u8, b: u8) -> MonoColor {
635    let luma = Rgb::new(r, g, b).luminance_u8();
636    if luma >= 128 {
637        MonoColor::White
638    } else {
639        MonoColor::Black
640    }
641}
642
643fn weighted_distance(a: Rgb, b: Rgb) -> u64 {
644    let dr = a.r as i32 - b.r as i32;
645    let dg = a.g as i32 - b.g as i32;
646    let db = a.b as i32 - b.b as i32;
647    let dr2 = (dr * dr) as u64;
648    let dg2 = (dg * dg) as u64;
649    let db2 = (db * db) as u64;
650    2126 * dr2 + 7152 * dg2 + 722 * db2
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656
657    // --- ColorProfile tests ---
658
659    #[test]
660    fn truecolor_passthrough() {
661        let color = Color::rgb(12, 34, 56);
662        assert_eq!(color.downgrade(ColorProfile::TrueColor), color);
663    }
664
665    #[test]
666    fn profile_from_flags_prefers_mono() {
667        assert_eq!(
668            ColorProfile::from_flags(true, true, true),
669            ColorProfile::Mono
670        );
671        assert_eq!(
672            ColorProfile::from_flags(true, false, false),
673            ColorProfile::TrueColor
674        );
675        assert_eq!(
676            ColorProfile::from_flags(false, true, false),
677            ColorProfile::Ansi256
678        );
679        assert_eq!(
680            ColorProfile::from_flags(false, false, false),
681            ColorProfile::Ansi16
682        );
683    }
684
685    #[test]
686    fn supports_true_color() {
687        assert!(ColorProfile::TrueColor.supports_true_color());
688        assert!(!ColorProfile::Ansi256.supports_true_color());
689        assert!(!ColorProfile::Ansi16.supports_true_color());
690        assert!(!ColorProfile::Mono.supports_true_color());
691    }
692
693    #[test]
694    fn supports_256_colors() {
695        assert!(ColorProfile::TrueColor.supports_256_colors());
696        assert!(ColorProfile::Ansi256.supports_256_colors());
697        assert!(!ColorProfile::Ansi16.supports_256_colors());
698        assert!(!ColorProfile::Mono.supports_256_colors());
699    }
700
701    #[test]
702    fn supports_color() {
703        assert!(ColorProfile::TrueColor.supports_color());
704        assert!(ColorProfile::Ansi256.supports_color());
705        assert!(ColorProfile::Ansi16.supports_color());
706        assert!(!ColorProfile::Mono.supports_color());
707    }
708
709    // --- ColorProfile::detect_from_env tests ---
710
711    #[test]
712    fn detect_no_color_gives_mono() {
713        // NO_COLOR presence (any value) should force Mono
714        assert_eq!(
715            ColorProfile::detect_from_env(Some("1"), None, None),
716            ColorProfile::Mono
717        );
718        assert_eq!(
719            ColorProfile::detect_from_env(Some(""), None, None),
720            ColorProfile::Mono
721        );
722        // NO_COLOR takes precedence over COLORTERM
723        assert_eq!(
724            ColorProfile::detect_from_env(Some("1"), Some("truecolor"), Some("xterm-256color")),
725            ColorProfile::Mono
726        );
727    }
728
729    #[test]
730    fn detect_colorterm_truecolor() {
731        assert_eq!(
732            ColorProfile::detect_from_env(None, Some("truecolor"), None),
733            ColorProfile::TrueColor
734        );
735    }
736
737    #[test]
738    fn detect_colorterm_24bit() {
739        assert_eq!(
740            ColorProfile::detect_from_env(None, Some("24bit"), None),
741            ColorProfile::TrueColor
742        );
743    }
744
745    #[test]
746    fn detect_term_256color() {
747        assert_eq!(
748            ColorProfile::detect_from_env(None, None, Some("xterm-256color")),
749            ColorProfile::Ansi256
750        );
751        assert_eq!(
752            ColorProfile::detect_from_env(None, None, Some("screen-256color")),
753            ColorProfile::Ansi256
754        );
755    }
756
757    #[test]
758    fn detect_colorterm_unknown_falls_to_term() {
759        // COLORTERM=yes is not truecolor, so fall back to TERM
760        assert_eq!(
761            ColorProfile::detect_from_env(None, Some("yes"), Some("xterm-256color")),
762            ColorProfile::Ansi256
763        );
764    }
765
766    #[test]
767    fn detect_defaults_to_ansi16() {
768        assert_eq!(
769            ColorProfile::detect_from_env(None, None, None),
770            ColorProfile::Ansi16
771        );
772        assert_eq!(
773            ColorProfile::detect_from_env(None, None, Some("xterm")),
774            ColorProfile::Ansi16
775        );
776        assert_eq!(
777            ColorProfile::detect_from_env(None, Some(""), Some("dumb")),
778            ColorProfile::Ansi16
779        );
780    }
781
782    // --- WCAG Contrast tests ---
783
784    #[test]
785    fn wcag_luminance_black_is_zero() {
786        let lum = relative_luminance(Rgb::new(0, 0, 0));
787        assert!((lum - 0.0).abs() < 0.001);
788    }
789
790    #[test]
791    fn wcag_luminance_white_is_one() {
792        let lum = relative_luminance(Rgb::new(255, 255, 255));
793        assert!((lum - 1.0).abs() < 0.001);
794    }
795
796    #[test]
797    fn wcag_luminance_green_is_brightest() {
798        // Green has highest weight (0.7152) in luminance formula
799        let r_lum = relative_luminance(Rgb::new(255, 0, 0));
800        let g_lum = relative_luminance(Rgb::new(0, 255, 0));
801        let b_lum = relative_luminance(Rgb::new(0, 0, 255));
802        assert!(g_lum > r_lum);
803        assert!(g_lum > b_lum);
804    }
805
806    #[test]
807    fn contrast_ratio_black_white_is_21() {
808        let black = Rgb::new(0, 0, 0);
809        let white = Rgb::new(255, 255, 255);
810        let ratio = contrast_ratio(black, white);
811        // Should be exactly 21:1
812        assert!((ratio - 21.0).abs() < 0.01, "ratio was {}", ratio);
813    }
814
815    #[test]
816    fn contrast_ratio_is_symmetric() {
817        let a = Rgb::new(100, 150, 200);
818        let b = Rgb::new(50, 75, 100);
819        let ratio_ab = contrast_ratio(a, b);
820        let ratio_ba = contrast_ratio(b, a);
821        assert!((ratio_ab - ratio_ba).abs() < 0.001);
822    }
823
824    #[test]
825    fn contrast_ratio_same_color_is_one() {
826        let color = Rgb::new(128, 128, 128);
827        let ratio = contrast_ratio(color, color);
828        assert!((ratio - 1.0).abs() < 0.001);
829    }
830
831    #[test]
832    fn meets_wcag_aa_black_white() {
833        let black = Rgb::new(0, 0, 0);
834        let white = Rgb::new(255, 255, 255);
835        assert!(meets_wcag_aa(black, white));
836        assert!(meets_wcag_aa(white, black));
837    }
838
839    #[test]
840    fn meets_wcag_aa_low_contrast_fails() {
841        // Two similar grays should fail WCAG AA
842        let gray1 = Rgb::new(128, 128, 128);
843        let gray2 = Rgb::new(140, 140, 140);
844        assert!(!meets_wcag_aa(gray1, gray2));
845    }
846
847    #[test]
848    fn meets_wcag_aaa_black_white() {
849        let black = Rgb::new(0, 0, 0);
850        let white = Rgb::new(255, 255, 255);
851        assert!(meets_wcag_aaa(black, white));
852    }
853
854    #[test]
855    fn best_text_color_chooses_highest_contrast() {
856        let dark_bg = Rgb::new(30, 30, 30);
857        let candidates = [
858            Rgb::new(50, 50, 50),    // dark gray - low contrast
859            Rgb::new(255, 255, 255), // white - high contrast
860            Rgb::new(100, 100, 100), // mid gray - medium contrast
861        ];
862        let best = best_text_color(dark_bg, &candidates);
863        assert_eq!(best, Rgb::new(255, 255, 255));
864
865        let light_bg = Rgb::new(240, 240, 240);
866        let best_on_light = best_text_color(light_bg, &candidates);
867        assert_eq!(best_on_light, Rgb::new(50, 50, 50));
868    }
869
870    #[test]
871    fn wcag_constants_are_correct() {
872        assert!((WCAG_AA_NORMAL_TEXT - 4.5).abs() < 0.001);
873        assert!((WCAG_AA_LARGE_TEXT - 3.0).abs() < 0.001);
874        assert!((WCAG_AAA_NORMAL_TEXT - 7.0).abs() < 0.001);
875        assert!((WCAG_AAA_LARGE_TEXT - 4.5).abs() < 0.001);
876    }
877
878    // --- Rgb tests ---
879
880    #[test]
881    fn rgb_as_key_is_unique() {
882        let a = Rgb::new(1, 2, 3);
883        let b = Rgb::new(3, 2, 1);
884        assert_ne!(a.as_key(), b.as_key());
885        assert_eq!(a.as_key(), Rgb::new(1, 2, 3).as_key());
886    }
887
888    #[test]
889    fn rgb_luminance_black_is_zero() {
890        assert_eq!(Rgb::new(0, 0, 0).luminance_u8(), 0);
891    }
892
893    #[test]
894    fn rgb_luminance_white_is_255() {
895        assert_eq!(Rgb::new(255, 255, 255).luminance_u8(), 255);
896    }
897
898    #[test]
899    fn rgb_luminance_green_is_brightest_channel() {
900        // Green has highest weight in BT.709 luma
901        let green_only = Rgb::new(0, 128, 0).luminance_u8();
902        let red_only = Rgb::new(128, 0, 0).luminance_u8();
903        let blue_only = Rgb::new(0, 0, 128).luminance_u8();
904        assert!(green_only > red_only);
905        assert!(green_only > blue_only);
906    }
907
908    #[test]
909    fn rgb_from_packed_rgba() {
910        let packed = PackedRgba::rgb(10, 20, 30);
911        let rgb: Rgb = packed.into();
912        assert_eq!(rgb, Rgb::new(10, 20, 30));
913    }
914
915    #[test]
916    fn relative_luminance_packed_ignores_alpha() {
917        let opaque = PackedRgba::rgba(10, 20, 30, 255);
918        let transparent = PackedRgba::rgba(10, 20, 30, 0);
919        let l1 = relative_luminance_packed(opaque);
920        let l2 = relative_luminance_packed(transparent);
921        assert!(
922            (l1 - l2).abs() < 1.0e-12,
923            "luminance should ignore alpha (l1={l1}, l2={l2})"
924        );
925    }
926
927    // --- Ansi16 tests ---
928
929    #[test]
930    fn ansi16_from_u8_valid_range() {
931        for i in 0..=15 {
932            assert!(Ansi16::from_u8(i).is_some());
933        }
934    }
935
936    #[test]
937    fn ansi16_from_u8_invalid() {
938        assert!(Ansi16::from_u8(16).is_none());
939        assert!(Ansi16::from_u8(255).is_none());
940    }
941
942    #[test]
943    fn ansi16_round_trip() {
944        for i in 0..=15 {
945            let color = Ansi16::from_u8(i).unwrap();
946            assert_eq!(color.as_u8(), i);
947        }
948    }
949
950    // --- rgb_to_256 tests ---
951
952    #[test]
953    fn rgb_to_256_grayscale_rules() {
954        assert_eq!(rgb_to_256(0, 0, 0), 16);
955        assert_eq!(rgb_to_256(8, 8, 8), 232);
956        assert_eq!(rgb_to_256(18, 18, 18), 233);
957        assert_eq!(rgb_to_256(249, 249, 249), 231);
958    }
959
960    #[test]
961    fn rgb_to_256_primary_red() {
962        assert_eq!(rgb_to_256(255, 0, 0), 196);
963    }
964
965    #[test]
966    fn rgb_to_256_primary_green() {
967        assert_eq!(rgb_to_256(0, 255, 0), 46);
968    }
969
970    #[test]
971    fn rgb_to_256_primary_blue() {
972        assert_eq!(rgb_to_256(0, 0, 255), 21);
973    }
974
975    // --- ansi256_to_rgb tests ---
976
977    #[test]
978    fn ansi256_to_rgb_round_trip() {
979        let rgb = ansi256_to_rgb(196);
980        assert_eq!(rgb, Rgb::new(255, 0, 0));
981    }
982
983    #[test]
984    fn ansi256_to_rgb_first_16_match_palette() {
985        for i in 0..16 {
986            let rgb = ansi256_to_rgb(i);
987            assert_eq!(rgb, ANSI16_PALETTE[i as usize]);
988        }
989    }
990
991    #[test]
992    fn ansi256_to_rgb_grayscale_ramp() {
993        // Index 232 = darkest gray (8,8,8), 255 = lightest (238,238,238)
994        let darkest = ansi256_to_rgb(232);
995        assert_eq!(darkest, Rgb::new(8, 8, 8));
996        let lightest = ansi256_to_rgb(255);
997        assert_eq!(lightest, Rgb::new(238, 238, 238));
998    }
999
1000    #[test]
1001    fn ansi256_color_cube_corners() {
1002        // Index 16 = (0,0,0) in cube
1003        assert_eq!(ansi256_to_rgb(16), Rgb::new(0, 0, 0));
1004        // Index 231 = (255,255,255) in cube
1005        assert_eq!(ansi256_to_rgb(231), Rgb::new(255, 255, 255));
1006    }
1007
1008    // --- rgb_to_ansi16 tests ---
1009
1010    #[test]
1011    fn rgb_to_ansi16_basics() {
1012        assert_eq!(rgb_to_ansi16(0, 0, 0), Ansi16::Black);
1013        assert_eq!(rgb_to_ansi16(255, 0, 0), Ansi16::BrightRed);
1014        assert_eq!(rgb_to_ansi16(0, 255, 0), Ansi16::BrightGreen);
1015        assert_eq!(rgb_to_ansi16(0, 0, 255), Ansi16::Blue);
1016    }
1017
1018    #[test]
1019    fn rgb_to_ansi16_white() {
1020        assert_eq!(rgb_to_ansi16(255, 255, 255), Ansi16::BrightWhite);
1021    }
1022
1023    // --- rgb_to_mono tests ---
1024
1025    #[test]
1026    fn mono_fallback() {
1027        assert_eq!(rgb_to_mono(0, 0, 0), MonoColor::Black);
1028        assert_eq!(rgb_to_mono(255, 255, 255), MonoColor::White);
1029        assert_eq!(rgb_to_mono(200, 200, 200), MonoColor::White);
1030        assert_eq!(rgb_to_mono(30, 30, 30), MonoColor::Black);
1031    }
1032
1033    #[test]
1034    fn mono_boundary() {
1035        // Luminance threshold is 128
1036        assert_eq!(rgb_to_mono(128, 128, 128), MonoColor::White);
1037        assert_eq!(rgb_to_mono(127, 127, 127), MonoColor::Black);
1038    }
1039
1040    // --- Color downgrade chain tests ---
1041
1042    #[test]
1043    fn downgrade_rgb_to_ansi256() {
1044        let color = Color::rgb(255, 0, 0);
1045        let downgraded = color.downgrade(ColorProfile::Ansi256);
1046        assert!(matches!(downgraded, Color::Ansi256(_)));
1047    }
1048
1049    #[test]
1050    fn downgrade_rgb_to_ansi16() {
1051        let color = Color::rgb(255, 0, 0);
1052        let downgraded = color.downgrade(ColorProfile::Ansi16);
1053        assert!(matches!(downgraded, Color::Ansi16(_)));
1054    }
1055
1056    #[test]
1057    fn downgrade_rgb_to_mono() {
1058        let color = Color::rgb(255, 255, 255);
1059        let downgraded = color.downgrade(ColorProfile::Mono);
1060        assert_eq!(downgraded, Color::Mono(MonoColor::White));
1061    }
1062
1063    #[test]
1064    fn downgrade_ansi256_to_ansi16() {
1065        let color = Color::Ansi256(196);
1066        let downgraded = color.downgrade(ColorProfile::Ansi16);
1067        assert!(matches!(downgraded, Color::Ansi16(_)));
1068    }
1069
1070    #[test]
1071    fn downgrade_ansi256_to_mono() {
1072        let color = Color::Ansi256(232); // dark gray
1073        let downgraded = color.downgrade(ColorProfile::Mono);
1074        assert_eq!(downgraded, Color::Mono(MonoColor::Black));
1075    }
1076
1077    #[test]
1078    fn downgrade_ansi16_to_mono() {
1079        let color = Color::Ansi16(Ansi16::BrightWhite);
1080        let downgraded = color.downgrade(ColorProfile::Mono);
1081        assert_eq!(downgraded, Color::Mono(MonoColor::White));
1082    }
1083
1084    #[test]
1085    fn downgrade_mono_stays_mono() {
1086        let color = Color::Mono(MonoColor::Black);
1087        assert_eq!(color.downgrade(ColorProfile::Mono), color);
1088    }
1089
1090    #[test]
1091    fn downgrade_ansi16_stays_at_ansi256() {
1092        let color = Color::Ansi16(Ansi16::Red);
1093        // Ansi16 should pass through at Ansi256 level
1094        assert_eq!(color.downgrade(ColorProfile::Ansi256), color);
1095    }
1096
1097    // --- Color::to_rgb tests ---
1098
1099    #[test]
1100    fn color_to_rgb_all_variants() {
1101        assert_eq!(Color::rgb(1, 2, 3).to_rgb(), Rgb::new(1, 2, 3));
1102        assert_eq!(Color::Ansi256(196).to_rgb(), Rgb::new(255, 0, 0));
1103        assert_eq!(Color::Ansi16(Ansi16::Black).to_rgb(), Rgb::new(0, 0, 0));
1104        assert_eq!(
1105            Color::Mono(MonoColor::White).to_rgb(),
1106            Rgb::new(255, 255, 255)
1107        );
1108        assert_eq!(Color::Mono(MonoColor::Black).to_rgb(), Rgb::new(0, 0, 0));
1109    }
1110
1111    // --- Color from PackedRgba ---
1112
1113    #[test]
1114    fn color_from_packed_rgba() {
1115        let packed = PackedRgba::rgb(42, 84, 126);
1116        let color: Color = packed.into();
1117        assert_eq!(color, Color::Rgb(Rgb::new(42, 84, 126)));
1118    }
1119
1120    #[test]
1121    fn color_from_packed_rgba_ignores_alpha() {
1122        let packed = PackedRgba::rgba(42, 84, 126, 10);
1123        let color: Color = packed.into();
1124        assert_eq!(color, Color::Rgb(Rgb::new(42, 84, 126)));
1125    }
1126
1127    // --- ColorCache tests ---
1128
1129    #[test]
1130    fn cache_tracks_hits() {
1131        let mut cache = ColorCache::with_capacity(ColorProfile::Ansi16, 8);
1132        let rgb = Rgb::new(10, 20, 30);
1133        let _ = cache.downgrade_rgb(rgb);
1134        let _ = cache.downgrade_rgb(rgb);
1135        let stats = cache.stats();
1136        assert_eq!(stats.hits, 1);
1137        assert_eq!(stats.misses, 1);
1138        assert_eq!(stats.size, 1);
1139    }
1140
1141    #[test]
1142    fn cache_clears_on_overflow() {
1143        let mut cache = ColorCache::with_capacity(ColorProfile::Ansi16, 2);
1144        let _ = cache.downgrade_rgb(Rgb::new(1, 0, 0));
1145        let _ = cache.downgrade_rgb(Rgb::new(2, 0, 0));
1146        assert_eq!(cache.stats().size, 2);
1147        // Third entry should trigger clear
1148        let _ = cache.downgrade_rgb(Rgb::new(3, 0, 0));
1149        assert_eq!(cache.stats().size, 1);
1150    }
1151
1152    #[test]
1153    fn cache_downgrade_packed() {
1154        let mut cache = ColorCache::with_capacity(ColorProfile::Ansi16, 8);
1155        let packed = PackedRgba::rgb(255, 0, 0);
1156        let result = cache.downgrade_packed(packed);
1157        assert!(matches!(result, Color::Ansi16(_)));
1158    }
1159
1160    #[test]
1161    fn cache_default_capacity() {
1162        let cache = ColorCache::new(ColorProfile::TrueColor);
1163        assert_eq!(cache.stats().capacity, 4096);
1164    }
1165
1166    #[test]
1167    fn cache_minimum_capacity_is_one() {
1168        let cache = ColorCache::with_capacity(ColorProfile::Ansi16, 0);
1169        assert_eq!(cache.stats().capacity, 1);
1170    }
1171}
1172
1173#[cfg(test)]
1174mod downgrade_edge_cases {
1175    //! Tests for color downgrade edge cases and boundary conditions.
1176    //!
1177    //! These tests verify correct behavior at palette boundaries,
1178    //! grayscale thresholds, and the sequential downgrade pipeline.
1179
1180    use super::*;
1181
1182    // =========================================================================
1183    // Sequential downgrade verification
1184    // =========================================================================
1185
1186    #[test]
1187    fn sequential_downgrade_truecolor_to_mono() {
1188        // Verify the full downgrade pipeline: RGB -> 256 -> 16 -> Mono
1189        let white = Color::rgb(255, 255, 255);
1190        let black = Color::rgb(0, 0, 0);
1191
1192        // White through all stages
1193        let w256 = white.downgrade(ColorProfile::Ansi256);
1194        assert!(matches!(w256, Color::Ansi256(231))); // Pure white in cube
1195        let w16 = w256.downgrade(ColorProfile::Ansi16);
1196        assert!(matches!(w16, Color::Ansi16(Ansi16::BrightWhite)));
1197        let wmono = w16.downgrade(ColorProfile::Mono);
1198        assert_eq!(wmono, Color::Mono(MonoColor::White));
1199
1200        // Black through all stages
1201        let b256 = black.downgrade(ColorProfile::Ansi256);
1202        assert!(matches!(b256, Color::Ansi256(16))); // Pure black
1203        let b16 = b256.downgrade(ColorProfile::Ansi16);
1204        assert!(matches!(b16, Color::Ansi16(Ansi16::Black)));
1205        let bmono = b16.downgrade(ColorProfile::Mono);
1206        assert_eq!(bmono, Color::Mono(MonoColor::Black));
1207    }
1208
1209    #[test]
1210    fn sequential_downgrade_preserves_intent() {
1211        // Red should stay "reddish" through the pipeline
1212        let red = Color::rgb(255, 0, 0);
1213
1214        let r256 = red.downgrade(ColorProfile::Ansi256);
1215        let Color::Ansi256(idx) = r256 else {
1216            panic!("Expected Ansi256");
1217        };
1218        assert_eq!(idx, 196); // Pure red in 256-color
1219
1220        let r16 = r256.downgrade(ColorProfile::Ansi16);
1221        let Color::Ansi16(ansi) = r16 else {
1222            panic!("Expected Ansi16");
1223        };
1224        // Should map to BrightRed (not some other color)
1225        assert_eq!(ansi, Ansi16::BrightRed);
1226    }
1227
1228    // =========================================================================
1229    // rgb_to_256 edge cases
1230    // =========================================================================
1231
1232    #[test]
1233    fn rgb_to_256_grayscale_boundaries() {
1234        // Test exact boundary values for grayscale detection
1235        // r < 8 -> 16 (black)
1236        assert_eq!(rgb_to_256(0, 0, 0), 16);
1237        assert_eq!(rgb_to_256(7, 7, 7), 16);
1238
1239        // r >= 8 -> grayscale ramp starts
1240        assert_eq!(rgb_to_256(8, 8, 8), 232);
1241
1242        // r > 246 -> 231 (white in cube, nearest neighbor for values
1243        // closer to 255 than to 238, the last grayscale ramp entry)
1244        assert_eq!(rgb_to_256(247, 247, 247), 231);
1245        assert_eq!(rgb_to_256(248, 248, 248), 231);
1246        assert_eq!(rgb_to_256(249, 249, 249), 231);
1247        assert_eq!(rgb_to_256(255, 255, 255), 231);
1248
1249        // r = 246 is still in grayscale ramp (closer to 238 than 255)
1250        assert_eq!(rgb_to_256(246, 246, 246), 255);
1251    }
1252
1253    #[test]
1254    fn rgb_to_256_grayscale_ramp_coverage() {
1255        // Grayscale ramp 232-255 covers values 8-238
1256        // Each step is 10 units: 8, 18, 28, ..., 238
1257        for i in 0..24 {
1258            let gray_val = 8 + i * 10;
1259            let idx = rgb_to_256(gray_val, gray_val, gray_val);
1260            assert!(
1261                (232..=255).contains(&idx),
1262                "Gray {} mapped to {} (expected 232-255)",
1263                gray_val,
1264                idx
1265            );
1266        }
1267    }
1268
1269    #[test]
1270    fn rgb_to_256_cube_corners() {
1271        // Test all 8 corners of the 6x6x6 RGB cube
1272        assert_eq!(rgb_to_256(0, 0, 0), 16); // Handled as grayscale
1273        assert_eq!(rgb_to_256(255, 0, 0), 196); // Red corner
1274        assert_eq!(rgb_to_256(0, 255, 0), 46); // Green corner
1275        assert_eq!(rgb_to_256(0, 0, 255), 21); // Blue corner
1276        assert_eq!(rgb_to_256(255, 255, 0), 226); // Yellow corner
1277        assert_eq!(rgb_to_256(255, 0, 255), 201); // Magenta corner
1278        assert_eq!(rgb_to_256(0, 255, 255), 51); // Cyan corner
1279        // White handled as grayscale, maps to 231
1280        assert_eq!(rgb_to_256(255, 255, 255), 231);
1281    }
1282
1283    #[test]
1284    fn rgb_to_256_non_gray_avoids_grayscale() {
1285        // When channels differ, should use cube even if values are gray-ish
1286        // r=100, g=100, b=99 is NOT grayscale (not all equal)
1287        let idx = rgb_to_256(100, 100, 99);
1288        // Should be in cube range (16-231), not grayscale (232-255)
1289        assert!(
1290            (16..=231).contains(&idx),
1291            "Non-gray {} should use cube",
1292            idx
1293        );
1294    }
1295
1296    // =========================================================================
1297    // cube_index edge cases
1298    // =========================================================================
1299
1300    #[test]
1301    fn cube_index_boundaries() {
1302        // cube_index uses thresholds: 0-47->0, 48-114->1, 115+->computed
1303        // Test the boundary values (using super:: to access private fn)
1304        assert_eq!(super::cube_index(0), 0);
1305        assert_eq!(super::cube_index(47), 0);
1306        assert_eq!(super::cube_index(48), 1);
1307        assert_eq!(super::cube_index(114), 1);
1308        assert_eq!(super::cube_index(115), 2);
1309        assert_eq!(super::cube_index(155), 3);
1310        assert_eq!(super::cube_index(195), 4);
1311        assert_eq!(super::cube_index(235), 5);
1312        assert_eq!(super::cube_index(255), 5);
1313    }
1314
1315    // =========================================================================
1316    // ansi256_to_rgb edge cases
1317    // =========================================================================
1318
1319    #[test]
1320    fn ansi256_to_rgb_full_range() {
1321        // Every index should produce valid RGB (this is a sanity check)
1322        for i in 0..=255 {
1323            let rgb = ansi256_to_rgb(i);
1324            // Verify the values are reasonable (non-panic)
1325            let _ = (rgb.r, rgb.g, rgb.b);
1326        }
1327    }
1328
1329    #[test]
1330    fn ansi256_to_rgb_grayscale_range() {
1331        // Indices 232-255 should produce grayscale (r=g=b)
1332        for i in 232..=255 {
1333            let rgb = ansi256_to_rgb(i);
1334            assert_eq!(rgb.r, rgb.g);
1335            assert_eq!(rgb.g, rgb.b);
1336        }
1337    }
1338
1339    #[test]
1340    fn ansi256_to_rgb_first_16_are_palette() {
1341        // Indices 0-15 should use the 16-color palette
1342        for i in 0..16 {
1343            let rgb = ansi256_to_rgb(i);
1344            assert_eq!(rgb, ANSI16_PALETTE[i as usize]);
1345        }
1346    }
1347
1348    // =========================================================================
1349    // rgb_to_ansi16 edge cases
1350    // =========================================================================
1351
1352    #[test]
1353    fn rgb_to_ansi16_pure_primaries() {
1354        // Pure primaries should map to their bright variants
1355        assert_eq!(rgb_to_ansi16(255, 0, 0), Ansi16::BrightRed);
1356        assert_eq!(rgb_to_ansi16(0, 255, 0), Ansi16::BrightGreen);
1357        // Blue maps to regular Blue because the bright blue in palette is different
1358        assert_eq!(rgb_to_ansi16(0, 0, 255), Ansi16::Blue);
1359    }
1360
1361    #[test]
1362    fn rgb_to_ansi16_grays() {
1363        // Dark gray should map to BrightBlack (127,127,127)
1364        assert_eq!(rgb_to_ansi16(127, 127, 127), Ansi16::BrightBlack);
1365        // Mid gray closer to White (229,229,229)
1366        assert_eq!(rgb_to_ansi16(200, 200, 200), Ansi16::White);
1367    }
1368
1369    #[test]
1370    fn rgb_to_ansi16_extremes() {
1371        // Pure black and white
1372        assert_eq!(rgb_to_ansi16(0, 0, 0), Ansi16::Black);
1373        assert_eq!(rgb_to_ansi16(255, 255, 255), Ansi16::BrightWhite);
1374    }
1375
1376    // =========================================================================
1377    // rgb_to_mono edge cases
1378    // =========================================================================
1379
1380    #[test]
1381    fn rgb_to_mono_luminance_boundary() {
1382        // Luminance threshold is 128
1383        // Test values near the boundary
1384        assert_eq!(rgb_to_mono(128, 128, 128), MonoColor::White);
1385        assert_eq!(rgb_to_mono(127, 127, 127), MonoColor::Black);
1386
1387        // Test with weighted luminance (green has highest weight)
1388        // Green at ~180 should give luminance ~128 (0.7152 * 180 = 128.7)
1389        assert_eq!(rgb_to_mono(0, 180, 0), MonoColor::White);
1390        assert_eq!(rgb_to_mono(0, 178, 0), MonoColor::Black);
1391    }
1392
1393    #[test]
1394    fn rgb_to_mono_color_saturation_irrelevant() {
1395        // Mono cares only about luminance, not saturation
1396        // Pure red (luma = 0.2126 * 255 = 54) -> black
1397        assert_eq!(rgb_to_mono(255, 0, 0), MonoColor::Black);
1398        // Pure green (luma = 0.7152 * 255 = 182) -> white
1399        assert_eq!(rgb_to_mono(0, 255, 0), MonoColor::White);
1400        // Pure blue (luma = 0.0722 * 255 = 18) -> black
1401        assert_eq!(rgb_to_mono(0, 0, 255), MonoColor::Black);
1402    }
1403
1404    // =========================================================================
1405    // Color downgrade stability tests
1406    // =========================================================================
1407
1408    #[test]
1409    fn downgrade_at_same_level_is_identity() {
1410        // Downgrading to the same level should not change the color
1411        let ansi16 = Color::Ansi16(Ansi16::Red);
1412        assert_eq!(ansi16.downgrade(ColorProfile::Ansi16), ansi16);
1413
1414        let ansi256 = Color::Ansi256(100);
1415        assert_eq!(ansi256.downgrade(ColorProfile::Ansi256), ansi256);
1416
1417        let mono = Color::Mono(MonoColor::Black);
1418        assert_eq!(mono.downgrade(ColorProfile::Mono), mono);
1419
1420        let rgb = Color::rgb(1, 2, 3);
1421        assert_eq!(rgb.downgrade(ColorProfile::TrueColor), rgb);
1422    }
1423
1424    #[test]
1425    fn downgrade_ansi16_passes_through_ansi256() {
1426        // Ansi16 should not change when downgraded to Ansi256
1427        // (it's already "lower fidelity")
1428        let color = Color::Ansi16(Ansi16::Cyan);
1429        assert_eq!(color.downgrade(ColorProfile::Ansi256), color);
1430    }
1431
1432    #[test]
1433    fn downgrade_mono_passes_through_all() {
1434        // Mono should never change
1435        let black = Color::Mono(MonoColor::Black);
1436        let white = Color::Mono(MonoColor::White);
1437
1438        assert_eq!(black.downgrade(ColorProfile::TrueColor), black);
1439        assert_eq!(black.downgrade(ColorProfile::Ansi256), black);
1440        assert_eq!(black.downgrade(ColorProfile::Ansi16), black);
1441        assert_eq!(black.downgrade(ColorProfile::Mono), black);
1442
1443        assert_eq!(white.downgrade(ColorProfile::TrueColor), white);
1444        assert_eq!(white.downgrade(ColorProfile::Ansi256), white);
1445        assert_eq!(white.downgrade(ColorProfile::Ansi16), white);
1446        assert_eq!(white.downgrade(ColorProfile::Mono), white);
1447    }
1448
1449    // =========================================================================
1450    // Luminance edge cases
1451    // =========================================================================
1452
1453    #[test]
1454    fn luminance_formula_correctness() {
1455        // BT.709: 0.2126 R + 0.7152 G + 0.0722 B
1456        // Pure channels
1457        let r_luma = Rgb::new(255, 0, 0).luminance_u8();
1458        let g_luma = Rgb::new(0, 255, 0).luminance_u8();
1459        let b_luma = Rgb::new(0, 0, 255).luminance_u8();
1460
1461        // Red: 0.2126 * 255 = 54.2
1462        assert!(
1463            (50..=58).contains(&r_luma),
1464            "Red luma {} not near 54",
1465            r_luma
1466        );
1467        // Green: 0.7152 * 255 = 182.4
1468        assert!(
1469            (178..=186).contains(&g_luma),
1470            "Green luma {} not near 182",
1471            g_luma
1472        );
1473        // Blue: 0.0722 * 255 = 18.4
1474        assert!(
1475            (15..=22).contains(&b_luma),
1476            "Blue luma {} not near 18",
1477            b_luma
1478        );
1479
1480        // Combined should match
1481        let all = Rgb::new(255, 255, 255).luminance_u8();
1482        assert_eq!(all, 255);
1483    }
1484
1485    #[test]
1486    fn luminance_mid_values() {
1487        // Test some mid-range values
1488        let mid_gray = Rgb::new(128, 128, 128).luminance_u8();
1489        // Should be approximately 128
1490        assert!(
1491            (126..=130).contains(&mid_gray),
1492            "Mid gray luma {} not near 128",
1493            mid_gray
1494        );
1495    }
1496}