use std::path::{Path, PathBuf};
use crate::compare::{is_local_digest_attribute, values_agree};
pub struct Branch {
pub kind: &'static str,
pub snapshot: fn(&[u8]) -> String,
}
impl Branch {
#[must_use]
pub const fn new(kind: &'static str, snapshot: fn(&[u8]) -> String) -> Self {
Self { kind, snapshot }
}
}
pub struct Harness {
golden_dir: PathBuf,
fixture_dir: PathBuf,
fixture_extension: &'static str,
regenerate: &'static str,
}
impl Harness {
#[must_use]
pub fn new(
manifest_dir: &str,
fixture_extension: &'static str,
regenerate: &'static str,
) -> Self {
let golden_dir = Path::new(manifest_dir).join("tests/golden");
let fixture_dir = golden_dir.join("fixtures");
Self {
golden_dir,
fixture_dir,
fixture_extension,
regenerate,
}
}
#[must_use]
pub fn with_fixture_dir(mut self, fixture_dir: PathBuf) -> Self {
self.fixture_dir = fixture_dir;
self
}
fn fixtures(&self) -> Vec<String> {
let fixtures = stems(&self.fixture_dir, self.fixture_extension);
assert!(
!fixtures.is_empty(),
"no `*.{}` fixture under {}; the harness would pass vacuously",
self.fixture_extension,
self.fixture_dir.display()
);
fixtures
}
fn fixture_bytes(&self, name: &str) -> Vec<u8> {
let input = self
.fixture_dir
.join(format!("{name}.{}", self.fixture_extension));
std::fs::read(&input)
.unwrap_or_else(|error| panic!("read fixture {}: {error}", input.display()))
}
#[must_use]
pub fn fixture_inputs(&self) -> Vec<(String, Vec<u8>)> {
self.fixtures()
.into_iter()
.map(|name| {
let bytes = self.fixture_bytes(&name);
(name, bytes)
})
.collect()
}
pub fn check(&self, branches: &[Branch]) {
let update = std::env::var_os("UPDATE_GOLDEN").is_some();
let fixtures = self.fixtures();
let mut failures: Vec<String> = Vec::new();
for branch in branches {
failures.extend(self.check_branch(branch, &fixtures, update));
}
assert!(
failures.is_empty(),
"{} golden(s) drifted; if the change is intended regenerate with `{}` and review the diff:\n\n{}",
failures.len(),
self.regenerate,
failures.join("\n\n")
);
}
pub fn check_determinism(&self, branches: &[Branch]) {
for name in self.fixtures() {
let bytes = self.fixture_bytes(&name);
for branch in branches {
let first = (branch.snapshot)(&bytes);
let second = (branch.snapshot)(&bytes);
if first != second {
let (line, one, two) = first_line_diff(&first, &second);
panic!(
"fixture `{name}`: nondeterministic {} at line {line}\n run 1: {one}\n run 2: {two}",
branch.kind
);
}
}
}
}
fn check_branch(&self, branch: &Branch, fixtures: &[String], update: bool) -> Vec<String> {
let dir = self.golden_dir.join(branch.kind);
let kind = branch.kind;
let mut failures: Vec<String> = Vec::new();
for name in fixtures {
let bytes = self.fixture_bytes(name);
let actual = (branch.snapshot)(&bytes);
let path = dir.join(format!("{name}.json"));
if update {
std::fs::write(&path, actual.as_bytes())
.unwrap_or_else(|error| panic!("write {}: {error}", path.display()));
continue;
}
match read_golden(&path) {
Ok(expected) => {
if let Err(mismatch) = snapshots_agree(&expected, &actual) {
failures.push(format!(
"fixture `{name}`: {kind} diverged from {}\n {mismatch}",
path.display()
));
}
}
Err(error) => failures.push(format!(
"fixture `{name}`: cannot read {kind} golden {} ({error}); regenerate with `{}`",
path.display(),
self.regenerate
)),
}
}
for orphan in stems(&dir, "json")
.iter()
.filter(|name| !fixtures.contains(name))
{
failures.push(format!(
"golden `{orphan}.json` under {} has no `{orphan}.{}` fixture; delete the golden or restore the input",
dir.display(),
self.fixture_extension
));
}
failures
}
}
fn stems(dir: &Path, extension: &str) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(dir)
.unwrap_or_else(|error| panic!("read {}: {error}", dir.display()))
.map(|entry| {
entry
.unwrap_or_else(|error| panic!("read entry: {error}"))
.path()
})
.filter(|path| path.extension().is_some_and(|found| found == extension))
.map(|path| {
path.file_stem()
.expect("directory entry with an extension has a stem")
.to_string_lossy()
.into_owned()
})
.collect();
names.sort();
names
}
fn read_golden(path: &Path) -> std::io::Result<String> {
Ok(std::fs::read_to_string(path)?.replace("\r\n", "\n"))
}
pub fn snapshots_agree(expected: &str, actual: &str) -> Result<(), String> {
if expected == actual {
return Ok(());
}
let (Ok(expected_value), Ok(actual_value)) = (
serde_json::from_str::<serde_json::Value>(expected),
serde_json::from_str::<serde_json::Value>(actual),
) else {
let (line, golden_line, actual_line) = first_line_diff(expected, actual);
return Err(format!(
"at line {line}\n golden: {golden_line}\n actual: {actual_line}"
));
};
values_agree(&expected_value, &actual_value)
}
fn first_line_diff(expected: &str, actual: &str) -> (usize, String, String) {
let mut expected_lines = expected.lines();
let mut actual_lines = actual.lines();
let mut line = 0usize;
loop {
line += 1;
match (expected_lines.next(), actual_lines.next()) {
(Some(left), Some(right)) if left == right => {}
(left, right) => {
let truncate = |value: Option<&str>| match value {
Some(text) if text.len() > 200 => format!("{}…", &text[..200]),
Some(text) => text.to_owned(),
None => "<end of file>".to_owned(),
};
return (line, truncate(left), truncate(right));
}
}
}
}
pub const ELIDED_DIGEST: &str = "<elided: digest over tolerantly compared geometry>";
pub fn elide_local_digests(attributes: &mut std::collections::BTreeMap<String, String>) {
for (key, value) in attributes.iter_mut() {
if is_local_digest_attribute(key) {
ELIDED_DIGEST.clone_into(value);
}
}
}
#[must_use]
pub fn snapshot_text(value: &serde_json::Value) -> String {
let mut text = serde_json::to_string_pretty(value).expect("serialize snapshot");
text.push('\n');
text
}
#[cfg(test)]
mod tests {
use super::snapshots_agree;
use crate::compare::FLOAT_TOLERANCE;
const LINUX_CONE_V: f64 = 1.802_581_857_082_682;
const WINDOWS_CONE_V: f64 = 1.802_581_857_082_681_5;
#[test]
fn byte_identical_text_agrees_without_parsing() {
assert!(snapshots_agree("not json at all\n", "not json at all\n").is_ok());
}
#[test]
fn last_place_platform_disagreement_agrees() {
let golden = format!("{{\"v\": {LINUX_CONE_V:?}}}");
let snapshot = format!("{{\"v\": {WINDOWS_CONE_V:?}}}");
assert_ne!(
golden, snapshot,
"the texts must differ, or this test proves nothing"
);
assert!(snapshots_agree(&golden, &snapshot).is_ok());
}
#[test]
fn drift_beyond_the_tolerance_disagrees() {
let moved = LINUX_CONE_V * (1.0 + 1000.0 * FLOAT_TOLERANCE);
let error = snapshots_agree(
&format!("{{\"v\": {LINUX_CONE_V:?}}}"),
&format!("{{\"v\": {moved:?}}}"),
)
.expect_err("a change above the tolerance must be reported");
assert!(error.contains(".v"), "{error}");
}
#[test]
fn non_json_text_falls_back_to_a_line_diff() {
let error = snapshots_agree("not json\nsecond\n", "not json\nthird\n")
.expect_err("unparseable text must still be compared");
assert!(error.contains("at line 2"), "{error}");
}
}