Skip to main content

xdoc/testing/
mod.rs

1//! Test helpers for the XML engine.
2//!
3//! These helpers are intentionally generic and domain-free. They centralize
4//! fixture paths, golden file comparisons, and parser/writer roundtrips so
5//! modules do not need to duplicate ad-hoc test utilities.
6
7use std::error::Error;
8use std::fmt;
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use crate::core::{Document, ErrorKind, XmlError, XmlResult};
13use crate::parser::{parse_str, parse_str_with_config, ParserConfig};
14use crate::writer::{to_string_compact, to_string_with_config, WriterConfig};
15
16/// Repository-relative directory for XML fixtures.
17pub const XML_FIXTURES_DIR: &str = "tests/fixtures/xml";
18
19/// Repository-relative directory for golden files.
20pub const GOLDEN_DIR: &str = "tests/golden";
21
22/// Result of parsing, serializing, reparsing, and serializing again.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct RoundtripResult {
25    original: Document,
26    compact_xml: String,
27    reparsed: Document,
28    reserialized_xml: String,
29}
30
31impl RoundtripResult {
32    pub fn original(&self) -> &Document {
33        &self.original
34    }
35
36    pub fn compact_xml(&self) -> &str {
37        &self.compact_xml
38    }
39
40    pub fn reparsed(&self) -> &Document {
41        &self.reparsed
42    }
43
44    pub fn reserialized_xml(&self) -> &str {
45        &self.reserialized_xml
46    }
47}
48
49/// Compact, line-oriented diff for XML golden comparisons.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct XmlDiff {
52    expected: String,
53    actual: String,
54    summary: String,
55}
56
57impl XmlDiff {
58    pub fn new(expected: impl Into<String>, actual: impl Into<String>) -> Self {
59        let expected = expected.into();
60        let actual = actual.into();
61        let summary = diff_summary(&expected, &actual);
62
63        Self {
64            expected,
65            actual,
66            summary,
67        }
68    }
69
70    pub fn expected(&self) -> &str {
71        &self.expected
72    }
73
74    pub fn actual(&self) -> &str {
75        &self.actual
76    }
77
78    pub fn summary(&self) -> &str {
79        &self.summary
80    }
81}
82
83impl fmt::Display for XmlDiff {
84    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
85        write!(
86            formatter,
87            "XML output differs from expected golden file\n{}\nexpected:\n{}\nactual:\n{}",
88            self.summary, self.expected, self.actual
89        )
90    }
91}
92
93impl Error for XmlDiff {}
94
95/// Resolves a path relative to the repository root.
96pub fn repo_path(root: impl AsRef<Path>, relative: impl AsRef<Path>) -> PathBuf {
97    root.as_ref().join(relative)
98}
99
100/// Resolves an XML fixture path relative to the repository root.
101pub fn xml_fixture_path(root: impl AsRef<Path>, name: impl AsRef<Path>) -> PathBuf {
102    repo_path(root, XML_FIXTURES_DIR).join(name)
103}
104
105/// Resolves a golden file path relative to the repository root.
106pub fn golden_path(root: impl AsRef<Path>, name: impl AsRef<Path>) -> PathBuf {
107    repo_path(root, GOLDEN_DIR).join(name)
108}
109
110/// Reads a UTF-8 file and maps filesystem errors to `XmlError`.
111pub fn read_utf8_file(path: impl AsRef<Path>) -> XmlResult<String> {
112    let path = path.as_ref();
113    fs::read_to_string(path).map_err(|error| {
114        XmlError::new(
115            ErrorKind::Io,
116            format!("failed to read `{}`: {error}", path.display()),
117        )
118    })
119}
120
121/// Reads an XML fixture from `tests/fixtures/xml`.
122pub fn read_xml_fixture(root: impl AsRef<Path>, name: impl AsRef<Path>) -> XmlResult<String> {
123    read_utf8_file(xml_fixture_path(root, name))
124}
125
126/// Reads a golden file from `tests/golden`.
127pub fn read_golden(root: impl AsRef<Path>, name: impl AsRef<Path>) -> XmlResult<String> {
128    read_utf8_file(golden_path(root, name))
129}
130
131/// Compares XML strings while allowing a single final line ending in fixtures.
132pub fn compare_xml(expected: &str, actual: &str) -> Result<(), XmlDiff> {
133    let expected = without_final_line_ending(expected);
134    let actual = without_final_line_ending(actual);
135
136    if expected == actual {
137        Ok(())
138    } else {
139        Err(XmlDiff::new(expected, actual))
140    }
141}
142
143/// Panics with a compact diff when two XML strings differ.
144pub fn assert_xml_eq(expected: &str, actual: &str) {
145    if let Err(diff) = compare_xml(expected, actual) {
146        panic!("{diff}");
147    }
148}
149
150/// Reads a golden file and compares it with actual XML output.
151pub fn assert_matches_golden(
152    root: impl AsRef<Path>,
153    golden_name: impl AsRef<Path>,
154    actual: &str,
155) -> XmlResult<()> {
156    let expected = read_golden(root, golden_name)?;
157    assert_xml_eq(&expected, actual);
158    Ok(())
159}
160
161/// Parses XML, serializes it compactly, reparses it, and verifies stability.
162pub fn assert_compact_roundtrip(xml: &str) -> XmlResult<RoundtripResult> {
163    assert_compact_roundtrip_with_config(xml, &ParserConfig::default())
164}
165
166/// Parses XML with explicit config, serializes compactly, reparses it, and verifies stability.
167pub fn assert_compact_roundtrip_with_config(
168    xml: &str,
169    config: &ParserConfig,
170) -> XmlResult<RoundtripResult> {
171    let original = parse_str_with_config(xml, config)?;
172    let compact_xml = to_string_compact(&original)?;
173    let reparsed = parse_str_with_config(&compact_xml, config)?;
174    let reserialized_xml = to_string_compact(&reparsed)?;
175
176    if compact_xml != reserialized_xml {
177        return Err(XmlError::new(
178            ErrorKind::InvalidOperation,
179            XmlDiff::new(&compact_xml, &reserialized_xml).to_string(),
180        ));
181    }
182
183    Ok(RoundtripResult {
184        original,
185        compact_xml,
186        reparsed,
187        reserialized_xml,
188    })
189}
190
191/// Serializes a document using an explicit writer configuration and compares it to a golden file.
192pub fn assert_document_matches_golden(
193    root: impl AsRef<Path>,
194    golden_name: impl AsRef<Path>,
195    document: &Document,
196    config: &WriterConfig,
197) -> XmlResult<()> {
198    let actual = to_string_with_config(document, config)?;
199    assert_matches_golden(root, golden_name, &actual)
200}
201
202/// Parses a fixture from `tests/fixtures/xml`.
203pub fn parse_xml_fixture(root: impl AsRef<Path>, name: impl AsRef<Path>) -> XmlResult<Document> {
204    let xml = read_xml_fixture(root, name)?;
205    parse_str(&xml)
206}
207
208fn without_final_line_ending(value: &str) -> &str {
209    value
210        .strip_suffix("\r\n")
211        .or_else(|| value.strip_suffix('\n'))
212        .unwrap_or(value)
213}
214
215fn diff_summary(expected: &str, actual: &str) -> String {
216    let expected_lines: Vec<_> = expected.lines().collect();
217    let actual_lines: Vec<_> = actual.lines().collect();
218    let line_count = expected_lines.len().max(actual_lines.len());
219
220    for index in 0..line_count {
221        let expected_line = expected_lines.get(index).copied().unwrap_or("");
222        let actual_line = actual_lines.get(index).copied().unwrap_or("");
223        if expected_line != actual_line {
224            let column = first_different_column(expected_line, actual_line);
225            return format!(
226                "first difference at line {}, column {}\nexpected line: {}\nactual line:   {}",
227                index + 1,
228                column,
229                expected_line,
230                actual_line
231            );
232        }
233    }
234
235    format!(
236        "length differs: expected {} bytes, actual {} bytes",
237        expected.len(),
238        actual.len()
239    )
240}
241
242fn first_different_column(expected: &str, actual: &str) -> usize {
243    let mut expected_chars = expected.chars();
244    let mut actual_chars = actual.chars();
245    let mut column = 1;
246
247    loop {
248        match (expected_chars.next(), actual_chars.next()) {
249            (Some(left), Some(right)) if left == right => column += 1,
250            _ => return column,
251        }
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn testing_compare_xml_allows_final_fixture_newline() {
261        compare_xml("<Root/>\n", "<Root/>").expect("only final newline differs");
262    }
263
264    #[test]
265    fn testing_compare_xml_reports_first_different_line() {
266        let error = compare_xml("<Root>\n  <A/>\n</Root>", "<Root>\n  <B/>\n</Root>")
267            .expect_err("XML must differ");
268
269        assert!(error.summary().contains("line 2"));
270        assert!(error.summary().contains("<A/>"));
271        assert!(error.summary().contains("<B/>"));
272    }
273
274    #[test]
275    fn testing_compact_roundtrip_stabilizes_parser_writer_output() -> XmlResult<()> {
276        let result = assert_compact_roundtrip("<Root><Item>A</Item><Item>B</Item></Root>")?;
277
278        assert_eq!(
279            result.compact_xml(),
280            "<Root><Item>A</Item><Item>B</Item></Root>"
281        );
282        assert_eq!(result.compact_xml(), result.reserialized_xml());
283        assert_eq!(result.original(), result.reparsed());
284        Ok(())
285    }
286}