Skip to main content

tui_test/assert/
snapshot.rs

1//! Terminal snapshot serialization + on-disk `.snap` comparison.
2
3use std::path::{Path, PathBuf};
4
5use serde_json::{json, Map, Value};
6
7use super::super::terminal::cell::{display_width, truncate_to_columns, Attrs, Color, EmuCell};
8
9pub enum SnapshotStatus {
10    Passed,
11    Written,
12    Updated,
13    Failed { expected: String, actual: String },
14}
15
16fn snapshot_dir(base: &Path) -> PathBuf {
17    base.join("__snapshots__")
18}
19
20fn sanitize(name: &str) -> String {
21    name.chars()
22        .map(|c| if " /\\<>:\"'|?*".contains(c) { '-' } else { c })
23        .collect()
24}
25
26fn snapshot_path(base: &Path, name: &str) -> PathBuf {
27    snapshot_dir(base).join(format!("{}.snap", sanitize(name)))
28}
29
30fn color_value(c: Option<Color>) -> Value {
31    match c {
32        None => Value::String(crate::assert::color::DEFAULT.to_string()),
33        Some(Color::Rgb(r, g, b)) => Value::String(format!("#{r:02x}{g:02x}{b:02x}")),
34        Some(c) => json!(c.to_index()),
35    }
36}
37
38fn shift(prev: &EmuCell, cur: &EmuCell) -> Map<String, Value> {
39    let mut m = Map::new();
40    if prev.fg != cur.fg {
41        m.insert("fg".into(), color_value(cur.fg));
42    }
43    if prev.bg != cur.bg {
44        m.insert("bg".into(), color_value(cur.bg));
45    }
46    for (attr, key) in [
47        (Attrs::BOLD, "bold"),
48        (Attrs::DIM, "dim"),
49        (Attrs::ITALIC, "italic"),
50        (Attrs::INVERSE, "inverse"),
51        (Attrs::INVISIBLE, "invisible"),
52        (Attrs::STRIKE, "strike"),
53        (Attrs::BLINK, "blink"),
54    ] {
55        if prev.has(attr) != cur.has(attr) {
56            m.insert(key.into(), json!(cur.has(attr)));
57        }
58    }
59    // The style, not a boolean: a curly underline and a single one are
60    // different renderings, and a snapshot that only recorded "underlined"
61    // would pass when one silently became the other.
62    if prev.underline != cur.underline {
63        m.insert("underline".into(), json!(cur.underline.name()));
64    }
65    m
66}
67
68fn baseline() -> EmuCell {
69    EmuCell::blank()
70}
71
72/// Serialize a grid into a boxed text view, optionally followed by attributes.
73///
74/// The attributes are a JSON object carrying whatever the box itself cannot
75/// record exactly: the full window title, and the color shifts when they are
76/// asked for. It is emitted only when there is something to put in it, so a
77/// plain snapshot is still just the box.
78pub fn serialize(
79    rows: &[Vec<EmuCell>],
80    cols: u16,
81    include_colors: bool,
82    title: Option<&str>,
83) -> String {
84    let mut lines = Vec::with_capacity(rows.len());
85    let mut shifts = Map::new();
86    let mut prev = baseline();
87    for (y, row) in rows.iter().enumerate() {
88        let mut line = String::with_capacity(cols as usize);
89        for (x, cell) in row.iter().enumerate() {
90            // A continuation contributes nothing, exactly as in
91            // `rows_to_strings`: the wide char to its left already spans this
92            // column, so giving it a filler widens the row past the box.
93            line.push_str(&cell.ch);
94            let s = shift(&prev, cell);
95            if !s.is_empty() {
96                shifts.insert(format!("{x},{y}"), Value::Object(s));
97            }
98            prev = cell.clone();
99        }
100        lines.push(line);
101    }
102
103    let view = box_view(&lines.join("\n"), cols, title);
104    let mut attributes = Map::new();
105    // The border shows a title shortened to fit, which is readable but lossy:
106    // two long titles differing only past the cut would otherwise record as
107    // the same snapshot and pass for each other. The full one is recorded here
108    // so the baseline stays exact however narrow the frame is.
109    if let Some(title) = title {
110        attributes.insert("title".to_string(), Value::String(title.to_string()));
111    }
112    if include_colors && !shifts.is_empty() {
113        attributes.insert("colors".to_string(), Value::Object(shifts));
114    }
115    if attributes.is_empty() {
116        view
117    } else {
118        format!(
119            "{view}\n{}",
120            serde_json::to_string_pretty(&Value::Object(attributes)).unwrap_or_default()
121        )
122    }
123}
124
125/// Frame the view, putting the window title in the top border when there is
126/// one.
127///
128/// The title rides in the border rather than on a line of its own so that a
129/// snapshot taken without a title is byte-identical to one taken before titles
130/// were recorded at all, which keeps every stored baseline valid. A title too
131/// long for the border is truncated so the frame stays rectangular.
132fn box_view(view: &str, width: u16, title: Option<&str>) -> String {
133    let width = width as usize;
134    let bar = "─".repeat(width);
135    // `╭─ title ───╮`: one leading dash, the spaced title, then at least one
136    // trailing dash. A title with no room for even one character is dropped
137    // rather than allowed to push the corner out of line.
138    let label = title.and_then(|title| {
139        let room = width.checked_sub(4).filter(|room| *room > 0)?;
140        Some(format!(" {} ", truncate_to_columns(title, room)))
141    });
142    let top = match label {
143        Some(label) => format!(
144            "╭─{label}{}╮",
145            "─".repeat(width - 1 - display_width(&label))
146        ),
147        None => format!("╭{bar}╮"),
148    };
149    let bottom = format!("╰{bar}╯");
150    let mut out = vec![top];
151    for line in view.split('\n') {
152        out.push(format!("│{line}│"));
153    }
154    out.push(bottom);
155    out.join("\n")
156}
157
158/// Compare a freshly serialized snapshot against the stored one. Snapshots are
159/// resolved under `base`/`__snapshots__` so they land in the client's working
160/// directory rather than the daemon's.
161pub fn compare(
162    base: &Path,
163    name: &str,
164    content: &str,
165    update: bool,
166) -> std::io::Result<SnapshotStatus> {
167    let path = snapshot_path(base, name);
168    let trimmed = content.trim();
169    if !path.exists() {
170        std::fs::create_dir_all(snapshot_dir(base))?;
171        std::fs::write(&path, format!("{trimmed}\n"))?;
172        return Ok(SnapshotStatus::Written);
173    }
174    let existing = std::fs::read_to_string(&path)?;
175    let existing = existing.trim();
176    if existing == trimmed {
177        return Ok(SnapshotStatus::Passed);
178    }
179    if update {
180        std::fs::write(&path, format!("{trimmed}\n"))?;
181        return Ok(SnapshotStatus::Updated);
182    }
183    Ok(SnapshotStatus::Failed {
184        expected: existing.to_string(),
185        actual: trimmed.to_string(),
186    })
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::terminal::cell::CONTINUATION;
193
194    /// Just the framed view, dropping any attributes recorded after it.
195    fn box_of(serialized: &str) -> String {
196        match serialized.split_once("╯\n") {
197            Some((frame, _)) => format!("{frame}╯"),
198            None => serialized.to_string(),
199        }
200    }
201
202    /// A snapshot records the palette *slot* a cell chose, never the color
203    /// that slot resolves to.
204    ///
205    /// This is what lets a saved baseline outlive a profile change: the same
206    /// screen recorded under two profiles that disagree about what red looks
207    /// like still produces the same snapshot, so recoloring a terminal does
208    /// not invalidate every snapshot in a suite.
209    #[test]
210    fn a_snapshot_records_the_slot_rather_than_the_color() {
211        let colored = EmuCell {
212            ch: "x".into(),
213            fg: Some(Color::from_index(1)),
214            ..EmuCell::blank()
215        };
216        let out = serialize(&[vec![colored]], 1, true, None);
217        assert!(
218            out.contains("\"fg\": 1"),
219            "the slot is recorded, not an rgb value: {out}"
220        );
221        assert!(
222            !out.contains('#'),
223            "a palette color must not be resolved into the snapshot: {out}"
224        );
225    }
226
227    /// A true-color cell names its own color, so that one *is* recorded
228    /// literally: no profile can change what `38;2;r;g;b` means.
229    #[test]
230    fn a_true_color_cell_records_its_own_value() {
231        let rgb = EmuCell {
232            ch: "x".into(),
233            fg: Some(Color::Rgb(0x11, 0x22, 0x33)),
234            ..EmuCell::blank()
235        };
236        assert!(serialize(&[vec![rgb]], 1, true, None).contains("#112233"));
237    }
238
239    fn cell(s: &str) -> EmuCell {
240        EmuCell {
241            ch: s.into(),
242            ..EmuCell::blank()
243        }
244    }
245
246    /// A wide char spans two columns on its own. Rendering a filler for the
247    /// continuation pushed every later column right and left the content line
248    /// one column wider than the frame drawn around it, so any snapshot
249    /// holding a wide char was written misaligned and compared against that.
250    #[test]
251    fn a_wide_char_does_not_overflow_the_frame() {
252        let rows = vec![vec![
253            cell("你"),
254            cell(CONTINUATION),
255            cell("b"),
256            cell(" "),
257            cell(" "),
258            cell(" "),
259        ]];
260        assert_eq!(
261            serialize(&rows, 6, false, None),
262            "╭──────╮\n│你b   │\n╰──────╯"
263        );
264    }
265
266    /// The window title rides in the top border.
267    ///
268    /// It goes there rather than on a line of its own so that the frame keeps
269    /// its shape and a snapshot taken without a title is byte-identical to one
270    /// taken before titles were recorded, which is what keeps stored baselines
271    /// valid.
272    #[test]
273    fn the_title_rides_in_the_top_border() {
274        let rows = vec![vec![cell("a"); 20]];
275        let bare = serialize(&rows, 20, false, None);
276        let titled = serialize(&rows, 20, false, Some("vim"));
277
278        assert!(
279            bare.starts_with("╭────────────────────╮"),
280            "no title leaves the border untouched: {bare}"
281        );
282        assert!(
283            titled.starts_with("╭─ vim ──────────────╮"),
284            "the title is set into the border: {titled}"
285        );
286        assert_eq!(
287            bare.lines().skip(1).collect::<Vec<_>>(),
288            box_of(&titled).lines().skip(1).collect::<Vec<_>>(),
289            "and nothing below the border changes"
290        );
291    }
292
293    /// The full title is recorded even when the border shows a short one.
294    ///
295    /// The border has to fit, so a long title is cut to size there. A snapshot
296    /// is an assertion rather than a picture: two titles differing only past
297    /// the cut would record identically and pass for each other, so the exact
298    /// one is kept alongside the frame.
299    #[test]
300    fn the_full_title_is_recorded_even_when_the_border_cannot_show_it() {
301        let rows = vec![vec![cell("a"); 12]];
302        let long = "building module A, step 3";
303        let other = "building module B, step 7";
304        let out = serialize(&rows, 12, false, Some(long));
305
306        assert!(
307            box_of(&out).contains('…'),
308            "the border shows a shortened title: {out}"
309        );
310        assert!(
311            out.contains(&format!(r#""title": "{long}""#)),
312            "and the exact one is recorded: {out}"
313        );
314        assert_ne!(
315            out,
316            serialize(&rows, 12, false, Some(other)),
317            "two titles that shorten alike still record differently"
318        );
319    }
320
321    /// Colors keep their own key, so a snapshot can carry both.
322    #[test]
323    fn attributes_hold_the_title_and_the_colors_apart() {
324        let rows = vec![vec![
325            cell("a"),
326            EmuCell {
327                fg: Some(Color::from_index(1)),
328                ..EmuCell::blank()
329            },
330        ]];
331        let out = serialize(&rows, 2, true, Some("t"));
332        let attributes: Value =
333            serde_json::from_str(out.split_once("╯\n").expect("a frame then attributes").1)
334                .expect("attributes parse as json");
335        assert_eq!(attributes["title"], json!("t"));
336        assert!(
337            attributes["colors"].is_object(),
338            "colors stay under their own key: {out}"
339        );
340    }
341
342    /// Every border line stays the same width whatever the title.
343    ///
344    /// A title wider than the frame would otherwise push the corner out and
345    /// produce a snapshot that never matches and cannot be read.
346    #[test]
347    fn a_title_never_changes_the_frame_width() {
348        let rows = vec![vec![cell("a"); 10]];
349        // Measured in columns, not characters. A CJK title is half as many
350        // characters as columns, so a character count would report a square
351        // frame while the drawn one is four columns out.
352        for title in [
353            "",
354            "x",
355            "fits",
356            "a title far wider than the frame",
357            "你好世界你好世界",
358            "🚀 build",
359            "e\u{301}clair",
360        ] {
361            let out = serialize(&rows, 10, false, Some(title));
362            let widths: Vec<usize> = box_of(&out).lines().map(display_width).collect();
363            assert!(
364                widths.iter().all(|w| *w == 12),
365                "title {title:?} bent the frame: {widths:?}\n{out}"
366            );
367        }
368    }
369
370    /// A frame with no room for a title keeps its plain border rather than
371    /// losing a corner to make space.
372    #[test]
373    fn a_frame_too_narrow_for_a_title_stays_plain() {
374        let rows = vec![vec![cell("a"); 3]];
375        assert_eq!(
376            box_of(&serialize(&rows, 3, false, Some("title"))),
377            serialize(&rows, 3, false, None),
378            "three columns cannot hold a title, so none is drawn"
379        );
380    }
381
382    /// Snapshots recorded a bare "is underlined", so a curly underline turning
383    /// single left the snapshot passing. The style name is recorded instead.
384    #[test]
385    fn a_shift_between_underline_styles_is_recorded() {
386        use crate::terminal::cell::UnderlineStyle;
387        let styled = |u| EmuCell {
388            underline: u,
389            ..EmuCell::blank()
390        };
391        let curly = shift(
392            &styled(UnderlineStyle::Single),
393            &styled(UnderlineStyle::Curly),
394        );
395        assert_eq!(curly.get("underline"), Some(&json!("curly")));
396        assert_eq!(
397            shift(
398                &styled(UnderlineStyle::Curly),
399                &styled(UnderlineStyle::None)
400            )
401            .get("underline"),
402            Some(&json!("none"))
403        );
404        assert!(shift(
405            &styled(UnderlineStyle::Curly),
406            &styled(UnderlineStyle::Curly)
407        )
408        .is_empty());
409    }
410}