use crate::utils::trad::Lang;
use std::borrow::Cow;
use std::fmt;
use std::io::IsTerminal;
use std::{
sync::{Arc, LazyLock, Mutex},
time::Duration,
};
static LANG: LazyLock<Lang> = LazyLock::new(|| Lang::from_env().unwrap_or_default());
pub(super) fn msg(key: &str) -> Cow<'static, str> {
LANG.get(key)
}
pub(super) fn msgf<T: fmt::Display>(key: &str, args: &[T]) -> String {
LANG.format(key, args)
}
pub struct QueryTrace {
pub sql: String,
pub elapsed: Duration,
pub failed: bool,
pub expected: bool,
}
pub type TraceSink = Arc<Mutex<Vec<QueryTrace>>>;
pub enum Reason<E> {
Win,
Error(E),
Panic(String),
TimedOut(u64),
Setup(String),
}
pub enum Issue {
TransactionEnded,
SwallowedFailures(usize),
RollbackFailed(String),
}
impl fmt::Display for Issue {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Issue::TransactionEnded => f.write_str(&msg("runique_test.transaction_ended")),
Issue::SwallowedFailures(n) => {
f.write_str(&msgf("runique_test.swallowed_failures", &[n]))
}
Issue::RollbackFailed(e) => f.write_str(&msgf("runique_test.rollback_failed", &[e])),
}
}
}
pub struct TestFailure {
pub name_test: String,
pub message: String,
}
impl fmt::Debug for TestFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&msgf("runique_test.test_failed", &[&self.name_test]))
}
}
impl fmt::Display for TestFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(self, f)
}
}
impl std::error::Error for TestFailure {}
pub struct FormatResult<E> {
pub name_test: String,
pub reason: Reason<E>,
pub trace: Vec<QueryTrace>,
pub issues: Vec<Issue>,
}
impl<E: fmt::Display> FormatResult<E> {
pub fn passed(&self) -> bool {
matches!(self.reason, Reason::Win) && self.issues.is_empty()
}
pub fn failure_message(&self) -> Option<String> {
reason_message(&self.reason).or_else(|| self.issues.first().map(Issue::to_string))
}
}
fn reason_message<E: fmt::Display>(reason: &Reason<E>) -> Option<String> {
match reason {
Reason::Win => None,
Reason::Error(e) => Some(e.to_string()),
Reason::Panic(message) => Some(msgf("runique_test.panicked", &[message])),
Reason::TimedOut(secs) => Some(msgf("runique_test.timed_out", &[secs])),
Reason::Setup(message) => Some(message.clone()),
}
}
impl<E: fmt::Display> fmt::Display for FormatResult<E> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f)?;
let color = use_color();
for (n, query) in (1..).zip(&self.trace) {
let step = if color {
format!("\x1b[1;36m{n:>2}.\x1b[0m")
} else {
format!("{n:>2}.")
};
let failed = match (query.failed, query.expected) {
(false, _) => String::new(),
(true, false) => format!(" ✗ {}", msg("runique_test.query_failed")),
(true, true) => format!(
" ✗ {} ({})",
msg("runique_test.query_failed"),
msg("runique_test.expected")
),
};
writeln!(
f,
" {step} {:>6.1} ms {}{failed}",
query.elapsed.as_secs_f64() * 1000.0,
shorten_sql(&query.sql)
)?;
}
if let Some(message) = reason_message(&self.reason) {
writeln!(f, " ✗ {message}")?;
}
for issue in &self.issues {
writeln!(f, " ✗ {issue}")?;
}
Ok(())
}
}
fn use_color() -> bool {
std::env::var_os("NO_COLOR").is_none()
&& (std::env::var_os(crate::cli::test_runner::COLOR_ENV).is_some()
|| std::io::stdout().is_terminal())
}
fn shorten_sql(sql: &str) -> String {
let sql = sql.split_once(" RETURNING ").map_or(sql, |(head, _)| head);
match (sql.starts_with("SELECT "), sql.find(" FROM ")) {
(true, Some(from)) => format!("SELECT …{}", &sql[from..]),
_ => sql.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::shorten_sql;
#[test]
fn select_column_list_is_collapsed() {
let sql =
r#"SELECT "blog"."id", "blog"."title" FROM "blog" WHERE "blog"."id" = $1 LIMIT $2"#;
assert_eq!(
shorten_sql(sql),
r#"SELECT … FROM "blog" WHERE "blog"."id" = $1 LIMIT $2"#
);
}
#[test]
fn returning_clause_is_dropped() {
let sql = r#"INSERT INTO "blog" ("title") VALUES ($1) RETURNING "id", "title""#;
assert_eq!(
shorten_sql(sql),
r#"INSERT INTO "blog" ("title") VALUES ($1)"#
);
}
#[test]
fn other_statements_are_left_alone() {
let sql = r#"UPDATE "blog" SET "title" = $1 WHERE "blog"."id" = $2"#;
assert_eq!(shorten_sql(sql), sql);
}
#[test]
fn messages_are_translated_with_their_arguments() {
use super::{Issue, TestFailure, msg, msgf};
let key = "runique_test.transaction_ended";
let lang = crate::utils::trad::Lang::from_env().unwrap_or_default();
assert_eq!(msg(key), lang.get(key));
assert_ne!(msg(key), key);
assert_eq!(Issue::TransactionEnded.to_string(), msg(key));
assert!(Issue::SwallowedFailures(3).to_string().contains('3'));
assert!(
Issue::RollbackFailed("lost".into())
.to_string()
.contains("lost")
);
let failure = TestFailure {
name_test: "user::add_email".into(),
message: String::new(),
};
assert!(format!("{failure:?}").contains("user::add_email"));
assert_eq!(failure.to_string(), format!("{failure:?}"));
assert!(msgf("runique_test.swallowed_failures", &[7]).contains('7'));
}
}