#![allow(
clippy::unwrap_in_result,
reason = "unwraps are allowed anywhere in tests"
)]
#![allow(
clippy::arithmetic_side_effects,
reason = "tests are allowed have arithmetic_side_effects"
)]
use std::{
env, fmt,
io::{self, IsTerminal as _},
path::Path,
sync::Once,
};
use chrono::{DateTime, Utc};
use serde::{
de::{value::StrDeserializer, IntoDeserializer as _},
Deserialize,
};
use tracing::debug;
use tracing_subscriber::util::SubscriberInitExt as _;
use crate::{datetime, json, schema, string, warning};
#[track_caller]
pub fn setup() {
static INITIALIZED: Once = Once::new();
INITIALIZED.call_once_force(|state| {
if state.is_poisoned() {
return;
}
let is_tty = io::stderr().is_terminal();
let level = match env::var("RUST_LOG") {
Ok(s) => s.parse().unwrap_or(tracing::Level::INFO),
Err(err) => match err {
env::VarError::NotPresent => tracing::Level::INFO,
env::VarError::NotUnicode(_) => {
panic!("`RUST_LOG` is not unicode");
}
},
};
let subscriber = tracing_subscriber::fmt()
.with_ansi(is_tty)
.with_file(true)
.with_level(false)
.with_line_number(true)
.with_max_level(level)
.with_target(false)
.with_test_writer()
.without_time()
.finish();
subscriber
.try_init()
.expect("Init tracing_subscriber::Subscriber");
});
}
pub fn read_file_content(file_path: &Path) -> io::Result<String> {
let mut content = std::fs::read_to_string(file_path)?;
json_strip_comments::strip(&mut content)?;
Ok(content)
}
impl<'buf> From<&'buf str> for string::ReasonableLen<'buf> {
fn from(value: &'buf str) -> Self {
string::ReasonableLen::new(value).unwrap()
}
}
pub trait ApproxEq<Rhs = Self> {
type Tolerance;
fn default_tolerance() -> Self::Tolerance;
#[must_use]
fn approx_eq(&self, other: &Rhs) -> bool {
self.approx_eq_tolerance(other, Self::default_tolerance())
}
#[must_use]
fn approx_eq_tolerance(&self, other: &Rhs, tolerance: Self::Tolerance) -> bool;
}
impl<T> ApproxEq for Option<T>
where
T: ApproxEq,
{
type Tolerance = T::Tolerance;
fn default_tolerance() -> Self::Tolerance {
T::default_tolerance()
}
fn approx_eq(&self, other: &Self) -> bool {
match (self, other) {
(Some(a), Some(b)) => a.approx_eq(b),
(None, None) => true,
_ => false,
}
}
fn approx_eq_tolerance(&self, other: &Self, tolerance: Self::Tolerance) -> bool {
match (self, other) {
(Some(a), Some(b)) => a.approx_eq_tolerance(b, tolerance),
(None, None) => true,
_ => false,
}
}
}
pub(crate) type WarningMap = std::collections::BTreeMap<json::test::PathGlob, Vec<String>>;
#[derive(Clone, Debug, Default)]
pub(crate) enum Expectation<T> {
Present(ExpectValue<T>),
#[default]
Absent,
}
#[derive(Clone, Debug)]
pub(crate) enum ExpectValue<T> {
Some(T),
Null,
}
impl<T> ExpectValue<T>
where
T: fmt::Debug,
{
pub fn into_option(self) -> Option<T> {
match self {
Self::Some(v) => Some(v),
Self::Null => None,
}
}
#[track_caller]
pub fn expect_value(self) -> T {
match self {
ExpectValue::Some(v) => v,
ExpectValue::Null => panic!("the field expects a value"),
}
}
}
impl<'de, T> Deserialize<'de> for Expectation<T>
where
T: Deserialize<'de>,
{
#[expect(clippy::unwrap_in_result, reason = "This is test util code")]
#[track_caller]
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
if value.is_null() {
return Ok(Expectation::Present(ExpectValue::Null));
}
let v = T::deserialize(value).unwrap();
Ok(Expectation::Present(ExpectValue::Some(v)))
}
}
pub(crate) struct ExpectFile<T> {
pub value: Option<T>,
pub expect_file_name: String,
}
impl ExpectFile<String> {
pub fn as_deref(&self) -> ExpectFile<&str> {
ExpectFile {
value: self.value.as_deref(),
expect_file_name: self.expect_file_name.clone(),
}
}
}
impl<T> ExpectFile<T> {
pub fn with_value(value: Option<T>, file_name: &str) -> Self {
Self {
value,
expect_file_name: file_name.to_owned(),
}
}
pub fn only_file_name(file_name: &str) -> Self {
Self {
value: None,
expect_file_name: file_name.to_owned(),
}
}
}
pub(crate) trait IntoFields<E> {
fn into_fields(self) -> E;
}
#[track_caller]
pub fn datetime_from_str(s: &str) -> DateTime<Utc> {
let de: StrDeserializer<'_, serde::de::value::Error> = s.into_deserializer();
datetime::test::deser_to_utc(de).unwrap()
}
#[track_caller]
pub fn read_expect_json(json_file_path: &Path, feature: &str) -> ExpectFile<String> {
let json_dir = json_file_path
.parent()
.expect("The given file should live in a dir");
let json_file_name = json_file_path
.file_stem()
.expect("The `json_file_path` should be a file")
.to_str()
.expect("The `json_file_path` should have a valid name");
let expect_file_name = format!("output_{feature}__{json_file_name}.json");
debug!("Try to read expectation file: `{expect_file_name}`");
let json = read_file_content(&json_dir.join(&expect_file_name)).ok();
debug!("Successfully Read expectation file: `{expect_file_name}`");
ExpectFile {
value: json,
expect_file_name,
}
}
#[track_caller]
pub fn parse_expect_json<'de, T>(json: ExpectFile<&'de str>) -> ExpectFile<T>
where
T: Deserialize<'de>,
{
let ExpectFile {
value,
expect_file_name,
} = json;
let value = value.map(|json| {
serde_json::from_str(json)
.unwrap_or_else(|_| panic!("Unable to parse expect JSON `{expect_file_name}`"))
});
ExpectFile {
value,
expect_file_name: expect_file_name.clone(),
}
}
pub(crate) type ExpectedWarnings<'a> = &'a [(&'a str, &'a [&'a str])];
#[track_caller]
pub(crate) fn assert_schema_warnings(
warnings: &warning::Set<schema::Warning>,
expected: ExpectedWarnings<'_>,
) {
warning::test::assert_warnings("the `expected` argument", warnings, warning_map(expected));
}
#[track_caller]
fn warning_map(expected: ExpectedWarnings<'_>) -> Expectation<WarningMap> {
let mut map = WarningMap::new();
for (path, ids) in expected {
let ids = ids.iter().map(|id| (*id).to_owned()).collect();
let replaced = map.insert(json::test::PathGlob::from(*path), ids);
assert!(
replaced.is_none(),
"The `expected` argument lists `{path}` more than once; \
one entry would silently replace the other"
);
}
Expectation::Present(ExpectValue::Some(map))
}
#[test]
#[should_panic(expected = "lists `$.currency` more than once")]
fn assert_schema_warnings_rejects_a_repeated_path() {
setup();
let warnings = warning::Set::<schema::Warning>::new();
assert_schema_warnings(
&warnings,
&[
("$.currency", &["null_field"]),
("$.currency", &["unexpected_field"]),
],
);
}
#[track_caller]
#[expect(
clippy::arithmetic_side_effects,
reason = "subtraction is safe: we only reach this branch when len > MAX_FIELD_DISPLAY >= truncated len"
)]
pub(crate) fn expect_no_unexpected_fields(
expect_file_name: &str,
unexpected_fields: &json::PathSet<'_>,
) {
if !unexpected_fields.is_empty() {
const MAX_FIELD_DISPLAY: usize = 20;
if unexpected_fields.len() > MAX_FIELD_DISPLAY {
let truncated_fields = unexpected_fields
.iter()
.take(MAX_FIELD_DISPLAY)
.copied()
.collect::<Vec<_>>();
panic!(
"The expect file `{expect_file_name}` didn't expect `{}` unexpected fields;\n\
displaying the first ({}):\n{}\n... and {} more",
unexpected_fields.len(),
truncated_fields.len(),
truncated_fields
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(",\n"),
unexpected_fields.len() - truncated_fields.len(),
)
} else {
panic!(
"The expect file `{expect_file_name}` didn't expect `{}` unexpected fields:\n{}",
unexpected_fields.len(),
unexpected_fields.to_strings().join(",\n")
)
};
}
}
#[track_caller]
pub(crate) fn expect_unexpected_fields(
expect_file_name: &str,
unexpected_fields: &mut json::PathSet<'_>,
expected: Expectation<Vec<json::test::PathGlob>>,
) {
if let Expectation::Present(expectation) = expected {
let unexpected_fields_expect = expectation.expect_value();
for expect_glob in unexpected_fields_expect {
unexpected_fields.filter_matches(&expect_glob);
}
expect_no_unexpected_fields(expect_file_name, unexpected_fields);
} else {
expect_no_unexpected_fields(expect_file_name, unexpected_fields);
}
}
#[macro_export]
macro_rules! assert_approx_eq {
($left:expr, $right:expr $(,)?) => ({
use $crate::test::ApproxEq;
match (&$left, &$right) {
(left_val, right_val) => {
if !((*left_val).approx_eq(&*right_val)) {
let left = stringify!($left);
let right = stringify!($right);
panic!(
"assertion `{left} == {right}` failed\n\
\tleft: {left_val:?}\n\
\tright: {right_val:?}"
);
}
}
}
});
($left:expr, $right:expr, $($arg:tt)+) => ({
use $crate::test::ApproxEq;
match (&$left, &$right) {
(left_val, right_val) => {
if !((*left_val).approx_eq(&*right_val)) {
let left = stringify!($left);
let right = stringify!($right);
panic!(
"assertion `{left} == {right}` failed: {}\n\
\tleft: {left_val:?}\n\
\tright: {right_val:?}",
std::format_args!($($arg)+)
);
}
}
}
});
}
#[macro_export]
macro_rules! assert_approx_eq_tolerance {
($left:expr, $right:expr, $tolerance:expr $(,)?) => ({
use $crate::test::ApproxEq;
match (&$left, &$right) {
(left_val, right_val) => {
if !((*left_val).approx_eq_tolerance(&*right_val, $tolerance)) {
let left = stringify!($left);
let right = stringify!($right);
panic!(
"assertion `{left} ~= {right}` failed with tolerance `{}`\n\
\t{left}: {left_val:?}\n\
\t{right}: {right_val:?}",
$tolerance
);
}
}
}
});
($left:expr, $right:expr, $tolerance:expr, $($arg:tt)+) => ({
use $crate::test::ApproxEq;
match (&$left, &$right) {
(left_val, right_val) => {
if !((*left_val).approx_eq_tolerance(&*right_val, $tolerance)) {
let left = stringify!($left);
let right = stringify!($right);
panic!(
"assertion `{left} ~= {right}` failed with tolerance `{}`: {}\n\
\t{left}: {left_val:?}\n\
\t{right}: {right_val:?}",
$tolerance,
std::format_args!($($arg)+)
);
}
}
}
});
}