Skip to main content

tui_test/assert/
color.rs

1//! Color parsing and comparison for `expect --fg/--bg`.
2
3use super::super::terminal::cell::Color;
4use crate::terminal::emu::Emulator;
5
6/// The spelling of [`Expected::Default`], on the command line and in messages.
7pub const DEFAULT: &str = "default";
8
9#[derive(Debug, Clone)]
10pub enum Expected {
11    /// The terminal's default color, i.e. the cell set no color of its own.
12    Default,
13    Ansi256(u8),
14    Hex(u8, u8, u8),
15    Rgb(u8, u8, u8),
16}
17
18impl Expected {
19    pub fn parse(s: &str) -> anyhow::Result<Self> {
20        let s = s.trim();
21        if s.eq_ignore_ascii_case(DEFAULT) {
22            return Ok(Expected::Default);
23        }
24        if let Some(hex) = s.strip_prefix('#') {
25            let (r, g, b) = parse_hex(hex).map_err(|_| invalid(s))?;
26            return Ok(Expected::Hex(r, g, b));
27        }
28        if s.contains(',') {
29            let parts: Vec<&str> = s.split(',').map(|p| p.trim()).collect();
30            let parsed: Result<Vec<u8>, _> = parts.iter().map(|p| p.parse::<u8>()).collect();
31            match parsed.ok().as_deref() {
32                Some([r, g, b]) => return Ok(Expected::Rgb(*r, *g, *b)),
33                _ => return Err(invalid(s)),
34            }
35        }
36        let n: u8 = s.parse().map_err(|_| invalid(s))?;
37        Ok(Expected::Ansi256(n))
38    }
39
40    pub fn describe(&self) -> String {
41        match self {
42            Expected::Default => DEFAULT.to_string(),
43            Expected::Ansi256(n) => n.to_string(),
44            Expected::Hex(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"),
45            Expected::Rgb(r, g, b) => format!("{r},{g},{b}"),
46        }
47    }
48}
49
50/// A consistent, enumerated error for any unparseable color value.
51fn invalid(got: &str) -> anyhow::Error {
52    anyhow::anyhow!(
53        "color must be \"{DEFAULT}\", ansi256 (0-255), hex (#rrggbb), or rgb (r,g,b) (got: \"{got}\")"
54    )
55}
56
57fn parse_hex(hex: &str) -> anyhow::Result<(u8, u8, u8)> {
58    if hex.len() != 6 {
59        anyhow::bail!("hex color must be 6 digits");
60    }
61    let r = u8::from_str_radix(&hex[0..2], 16)?;
62    let g = u8::from_str_radix(&hex[2..4], 16)?;
63    let b = u8::from_str_radix(&hex[4..6], 16)?;
64    Ok((r, g, b))
65}
66
67/// Does a cell's resolved color match the expected color?
68///
69/// A cell that set no color of its own matches `default`, and also matches the
70/// concrete RGB value the terminal currently uses for that foreground or
71/// background. It never matches an ANSI index because it did not select one.
72///
73/// A concrete `#rrggbb` is resolved through the session profile, the same table
74/// the screenshot renderer draws with. These used to be two separate hardcoded
75/// tables that disagreed on every ANSI slot, so `expect --fg "#800000"` passed
76/// on a cell a screenshot painted `#e88388`.
77pub fn matches(
78    cell: Option<Color>,
79    expected: &Expected,
80    colors: &dyn Emulator,
81    is_fg: bool,
82) -> bool {
83    match expected {
84        Expected::Default => cell.is_none(),
85        Expected::Ansi256(n) => cell.is_some_and(|cell| cell.to_index() == *n),
86        Expected::Hex(er, eg, eb) | Expected::Rgb(er, eg, eb) => {
87            let got = colors.resolve(cell, is_fg);
88            (got.r, got.g, got.b) == (*er, *eg, *eb)
89        }
90    }
91}
92
93/// Render a cell's color in the same space as the expected value, for messages.
94pub fn describe_cell(
95    cell: Option<Color>,
96    expected: &Expected,
97    colors: &dyn Emulator,
98    is_fg: bool,
99) -> String {
100    match expected {
101        Expected::Default => cell
102            .map(|cell| cell.to_index().to_string())
103            .unwrap_or_else(|| DEFAULT.to_string()),
104        Expected::Ansi256(_) => cell
105            .map(|cell| cell.to_index().to_string())
106            .unwrap_or_else(|| DEFAULT.to_string()),
107        _ => colors.resolve(cell, is_fg).to_hex(),
108    }
109}
110
111pub fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
112    if r == g && g == b {
113        if r < 8 {
114            return 16;
115        }
116        if r > 248 {
117            return 231;
118        }
119        return (232 + ((r as i32 - 8) * 24 / 247)) as u8;
120    }
121    let cube = |v: u8| -> i32 {
122        if v < 48 {
123            0
124        } else if v < 115 {
125            1
126        } else {
127            (v as i32 - 35) / 40
128        }
129    };
130    (16 + 36 * cube(r) + 6 * cube(g) + cube(b)) as u8
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::profile::{Colors, Profile};
137    use crate::terminal::alacritty::AlacrittyEmu;
138    use crate::terminal::cell::Color;
139    use crate::terminal::emu::Emulator;
140
141    /// A real emulator, so these exercise the same resolution path a session
142    /// uses rather than a stand-in that could drift from it.
143    fn emu_with(colors: Colors) -> AlacrittyEmu {
144        AlacrittyEmu::new(
145            10,
146            2,
147            &Profile {
148                colors,
149                ..Default::default()
150            },
151        )
152    }
153
154    #[test]
155    fn parse_forms() {
156        assert!(matches!(
157            Expected::parse("9").unwrap(),
158            Expected::Ansi256(9)
159        ));
160        assert!(matches!(
161            Expected::parse("#ff0000").unwrap(),
162            Expected::Hex(255, 0, 0)
163        ));
164        assert!(matches!(
165            Expected::parse("255,0,0").unwrap(),
166            Expected::Rgb(255, 0, 0)
167        ));
168    }
169
170    #[test]
171    fn matches_palette_and_default() {
172        let c = emu_with(Colors::default());
173        let idx = |i| Some(Color::from_index(i));
174        assert!(matches(idx(9), &Expected::Ansi256(9), &c, true));
175        assert!(!matches(idx(2), &Expected::Ansi256(9), &c, true));
176        assert!(matches(idx(196), &Expected::Ansi256(196), &c, true));
177        assert!(matches(
178            Some(Color::Rgb(255, 0, 0)),
179            &Expected::Rgb(255, 0, 0),
180            &c,
181            true
182        ));
183    }
184
185    #[test]
186    fn default_color_matches_its_resolved_foreground_or_background() {
187        let c = emu_with(Colors::default());
188        assert!(!matches(None, &Expected::Ansi256(0), &c, true));
189        assert!(matches(None, &Expected::Hex(192, 192, 192), &c, true));
190        assert!(matches(None, &Expected::Hex(0, 0, 0), &c, false));
191        assert!(!matches(None, &Expected::Hex(0, 0, 0), &c, true));
192        assert!(matches(None, &Expected::Default, &c, true));
193        assert_eq!(
194            describe_cell(None, &Expected::Hex(0, 0, 0), &c, true),
195            "#c0c0c0"
196        );
197    }
198
199    #[test]
200    fn default_color_assertions_follow_runtime_changes() {
201        let mut c = emu_with(Colors::default());
202        c.process(b"\x1b]10;#010203\x07\x1b]11;#040506\x07");
203        assert!(matches(None, &Expected::Hex(1, 2, 3), &c, true));
204        assert!(matches(None, &Expected::Hex(4, 5, 6), &c, false));
205    }
206
207    #[test]
208    fn a_colored_cell_is_not_default() {
209        let c = emu_with(Colors::default());
210        let red = Some(Color::from_index(1));
211        assert!(!matches(red, &Expected::Default, &c, true));
212        assert!(matches(red, &Expected::Ansi256(1), &c, true));
213        assert!(matches!(
214            Expected::parse("default").unwrap(),
215            Expected::Default
216        ));
217        assert!(matches!(
218            Expected::parse("DEFAULT").unwrap(),
219            Expected::Default
220        ));
221        assert_eq!(describe_cell(red, &Expected::Default, &c, true), "1");
222    }
223
224    /// The regression test for the bug this module used to carry: the color a
225    /// screenshot paints and the color an assertion matches are now the same
226    /// value for every slot, because both come from the profile.
227    #[test]
228    fn an_assertion_matches_the_color_a_screenshot_paints() {
229        let colors = emu_with(Colors::default());
230        for index in 0u8..=255 {
231            let cell = Some(Color::from_index(index));
232            let painted = colors.resolve(cell, true);
233            assert!(
234                matches(
235                    cell,
236                    &Expected::Hex(painted.r, painted.g, painted.b),
237                    &colors,
238                    true
239                ),
240                "slot {index} paints {} but does not match it",
241                painted.to_hex()
242            );
243        }
244    }
245
246    /// An assertion compares against what the terminal is *currently*
247    /// showing, so a program that recolors a slot changes what matches.
248    ///
249    /// This is the other half of the screenshot test: both read the same
250    /// state, so a colour a screenshot paints is a colour an assertion
251    /// matches, at every point in a session rather than only at the start.
252    #[test]
253    fn an_assertion_follows_a_color_a_program_set() {
254        use crate::terminal::emu::Emulator;
255        let mut emu = emu_with(Colors::default());
256        let red = Some(Color::from_index(1));
257        let configured = Colors::default().red;
258
259        assert!(matches(
260            red,
261            &Expected::Hex(configured.r, configured.g, configured.b),
262            &emu,
263            true
264        ));
265
266        emu.process(b"\x1b]4;1;#22c55e\x07");
267        assert!(
268            matches(red, &Expected::Hex(0x22, 0xc5, 0x5e), &emu, true),
269            "the assertion follows the colour the program set"
270        );
271        assert!(
272            !matches(
273                red,
274                &Expected::Hex(configured.r, configured.g, configured.b),
275                &emu,
276                true
277            ),
278            "the configured colour is no longer what slot 1 shows"
279        );
280        assert!(
281            matches(red, &Expected::Ansi256(1), &emu, true),
282            "the index is unaffected: it names a slot, not a colour"
283        );
284
285        emu.process(b"\x1b]104;1\x07");
286        assert!(
287            matches(
288                red,
289                &Expected::Hex(configured.r, configured.g, configured.b),
290                &emu,
291                true
292            ),
293            "a reset restores the configured colour"
294        );
295    }
296
297    /// A profile's palette is what an assertion compares against, so two
298    /// profiles genuinely disagree rather than sharing one hardcoded table.
299    #[test]
300    fn a_recolored_profile_moves_what_an_assertion_matches() {
301        let colors = emu_with(Colors {
302            red: crate::profile::Rgb::new(1, 2, 3),
303            ..Default::default()
304        });
305        let red = Some(Color::from_index(1));
306        assert!(matches(red, &Expected::Hex(1, 2, 3), &colors, true));
307        assert!(!matches(red, &Expected::Hex(128, 0, 0), &colors, true));
308        assert!(
309            matches(red, &Expected::Ansi256(1), &colors, true),
310            "the index is unaffected by the palette"
311        );
312    }
313}