diff --git a/README.md b/README.md index ce2a620..4a28e7f 100644 --- a/README.md +++ b/README.md @@ -25,33 +25,34 @@ See https://hegel.dev/reference/installation for details. Here's a quick example of how to write a Hegel test: ```rust -use hegel::generators as gs; +use hegel::generators::integers; use hegel::TestCase; -fn my_sort(ls: &[i32]) -> Vec { - let mut result: Vec = ls.to_vec(); - result.sort(); - result.dedup(); - result -} - #[hegel::test] -fn test_matches_builtin(tc: TestCase) { - let mut vec1 = tc.draw(gs::vecs(gs::integers::())); - let vec2 = my_sort(&vec1); - vec1.sort(); - assert_eq!(vec1, vec2); +fn test_addition_commutative(tc: TestCase) { + let x = tc.draw(integers::()); + let y = tc.draw(integers::()); + assert_eq!(x + y, y + x); } ``` -This test will fail when run with `cargo test`! Hegel will produce a minimal failing test case for us: +This test will fail! Integer addition panics on overflow. Hegel will produce a minimal failing test case for us: ``` -Draw 1: [0, 0] -thread 'test_matches_builtin' (2) panicked at src/main.rs:15:5: -assertion `left == right` failed - left: [0, 0] - right: [0] +let x = 1; +let y = 2147483647; +thread 'test_addition_commutative' (2) panicked at examples/readme.rs:8:16: +attempt to add with overflow ``` -Hegel reports the minimal example showing that our sort is incorrectly dropping duplicates. If we remove `result.dedup()` from `my_sort()`, this test will then pass (because it's just comparing the standard sort against itself). +For a passing test, try: + +```rust +#[hegel::test] +fn test_wrapping_addition_commutative(tc: TestCase) { + let add = i32::wrapping_add; + let x = tc.draw(integers::()); + let y = tc.draw(integers::()); + assert_eq!(add(x, y), add(y, x)); +} +``` diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..be4fabf --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,4 @@ +RELEASE_TYPE: patch + +This release should significantly improve the format and quality of output +printing for failing test cases. diff --git a/hegel-macros/Cargo.toml b/hegel-macros/Cargo.toml index 964bfa1..c128cde 100644 --- a/hegel-macros/Cargo.toml +++ b/hegel-macros/Cargo.toml @@ -16,4 +16,4 @@ proc-macro = true [dependencies] proc-macro2 = "1.0" quote = "1.0" -syn = { version = "2.0", features = ["full"] } +syn = { version = "2.0", features = ["full", "visit-mut"] } diff --git a/hegel-macros/src/explicit_test_case.rs b/hegel-macros/src/explicit_test_case.rs new file mode 100644 index 0000000..65f614e --- /dev/null +++ b/hegel-macros/src/explicit_test_case.rs @@ -0,0 +1,48 @@ +use proc_macro2::TokenStream; +use syn::ItemFn; + +/// Check if an attribute's path is `hegel::test`. +fn has_hegel_test_attr(func: &ItemFn) -> bool { + func.attrs.iter().any(|attr| { + let segments: Vec<_> = attr.path().segments.iter().collect(); + segments.len() == 2 && segments[0].ident == "hegel" && segments[1].ident == "test" + }) +} + +/// This macro always produces a compile error when it actually runs. +/// +/// In correct usage (`#[hegel::test]` above, `#[hegel::explicit_test_case]` below), +/// `#[hegel::test]` processes first and consumes the explicit_test_case attributes +/// directly from `func.attrs`, so this macro never executes. +/// +/// If this macro DOES execute, it means either: +/// - Wrong order: `#[hegel::explicit_test_case]` is above `#[hegel::test]` +/// - Bare function: no `#[hegel::test]` at all +pub fn expand_explicit_test_case(_attr: TokenStream, item: TokenStream) -> TokenStream { + let func: ItemFn = match syn::parse2(item) { + Ok(f) => f, + Err(e) => return e.to_compile_error(), + }; + + if has_hegel_test_attr(&func) { + // #[hegel::test] is below us, meaning we're in the wrong order. + // (If it were above us, it would have consumed our attribute before we ran.) + syn::Error::new_spanned( + &func.sig, + "#[hegel::explicit_test_case] must appear below #[hegel::test], not above it.\n\ + Write:\n \ + #[hegel::test]\n \ + #[hegel::explicit_test_case(...)]\n \ + fn my_test(tc: hegel::TestCase) { ... }", + ) + .to_compile_error() + } else { + // No #[hegel::test] at all. + syn::Error::new_spanned( + &func.sig, + "#[hegel::explicit_test_case] can only be used together with #[hegel::test].\n\ + Add #[hegel::test] above #[hegel::explicit_test_case].", + ) + .to_compile_error() + } +} diff --git a/hegel-macros/src/hegel_test.rs b/hegel-macros/src/hegel_test.rs index ed4ed9f..245847d 100644 --- a/hegel-macros/src/hegel_test.rs +++ b/hegel-macros/src/hegel_test.rs @@ -1,7 +1,10 @@ +use std::collections::HashMap; + use proc_macro2::TokenStream; use quote::quote; use syn::parse::{Parse, ParseStream}; -use syn::{Expr, FnArg, Ident, ItemFn, Token}; +use syn::visit_mut::VisitMut; +use syn::{Expr, FnArg, Ident, ItemFn, Pat, Token}; /// A single named argument in a `#[hegel::test(...)]` expression. struct SettingArg { @@ -59,6 +62,219 @@ impl Parse for TestArgs { } } +/// Extract a simple identifier from a pattern, handling type annotations. +fn extract_ident_from_pat(pat: &Pat) -> Option { + match pat { + Pat::Ident(pat_ident) => Some(pat_ident.ident.to_string()), + Pat::Type(pat_type) => extract_ident_from_pat(&pat_type.pat), + _ => None, + } +} + +/// Check if a `let` binding is of the form `let = .draw()`. +fn is_tc_draw_binding(node: &syn::Local, tc_ident: &str) -> Option { + let var_name = extract_ident_from_pat(&node.pat)?; + + let init = node.init.as_ref()?; + let method_call = match &*init.expr { + Expr::MethodCall(mc) => mc, + _ => return None, + }; + + if method_call.method != "draw" || method_call.args.len() != 1 { + return None; + } + + let is_tc = match &*method_call.receiver { + Expr::Path(path) => path.path.is_ident(tc_ident), + _ => false, + }; + if !is_tc { + return None; + } + + Some(var_name) +} + +/// Pass 1: Collect all draw variable names and determine per-name repeatable flags. +/// +/// If any use of a name appears in a repeatable context (nested block, closure), +/// ALL uses of that name become repeatable. This ensures the runtime never sees +/// inconsistent repeatable flags for the same name. +struct DrawNameCollector { + tc_ident: String, + repeatable_depth: usize, + name_flags: HashMap, +} + +impl VisitMut for DrawNameCollector { + fn visit_block_mut(&mut self, node: &mut syn::Block) { + self.repeatable_depth += 1; + syn::visit_mut::visit_block_mut(self, node); + self.repeatable_depth -= 1; + } + + fn visit_expr_closure_mut(&mut self, node: &mut syn::ExprClosure) { + self.repeatable_depth += 1; + syn::visit_mut::visit_expr_closure_mut(self, node); + self.repeatable_depth -= 1; + } + + fn visit_item_fn_mut(&mut self, _node: &mut syn::ItemFn) {} + + fn visit_local_mut(&mut self, node: &mut syn::Local) { + syn::visit_mut::visit_local_mut(self, node); + + if let Some(var_name) = is_tc_draw_binding(node, &self.tc_ident) { + let repeatable = self.repeatable_depth > 0; + let entry = self.name_flags.entry(var_name).or_insert(false); + if repeatable { + *entry = true; + } + } + } +} + +/// Pass 2: Rewrite `let x = tc.draw(gen)` to `let x = tc.draw_named(gen, "x", repeatable)`. +/// +/// Uses the pre-computed name_flags from DrawNameCollector so that every use of +/// a given name gets the same repeatable flag. +struct DrawRewriter { + tc_ident: String, + name_flags: HashMap, +} + +impl VisitMut for DrawRewriter { + fn visit_item_fn_mut(&mut self, _node: &mut syn::ItemFn) {} + + fn visit_local_mut(&mut self, node: &mut syn::Local) { + syn::visit_mut::visit_local_mut(self, node); + + let var_name = match is_tc_draw_binding(node, &self.tc_ident) { + Some(name) => name, + None => return, + }; + + let repeatable = self.name_flags.get(&var_name).copied().unwrap_or(false); + + let init = node.init.as_mut().unwrap(); + let method_call = match &mut *init.expr { + Expr::MethodCall(mc) => mc, + _ => unreachable!(), + }; + + let span = method_call.method.span(); + method_call.method = Ident::new("draw_named", span); + method_call.args.push(Expr::Lit(syn::ExprLit { + attrs: vec![], + lit: syn::Lit::Str(syn::LitStr::new(&var_name, span)), + })); + method_call.args.push(Expr::Lit(syn::ExprLit { + attrs: vec![], + lit: syn::Lit::Bool(syn::LitBool::new(repeatable, span)), + })); + } +} + +/// A parsed explicit test case: a list of (name, expression_source) pairs. +struct ParsedExplicitTestCase { + entries: Vec<(String, String)>, // (name, expr_source) +} + +/// Check if an attribute path matches `hegel::explicit_test_case`. +fn is_explicit_test_case_attr(attr: &syn::Attribute) -> bool { + let segments: Vec<_> = attr.path().segments.iter().collect(); + segments.len() == 2 && segments[0].ident == "hegel" && segments[1].ident == "explicit_test_case" +} + +/// Extract `#[hegel::explicit_test_case(...)]` attributes directly from `func.attrs`. +/// Returns the parsed test cases and removes the attributes from the list. +/// Returns `Err` with a compile error if any attribute is malformed. +fn extract_explicit_test_cases( + attrs: &mut Vec, +) -> Result, TokenStream> { + let mut cases = Vec::new(); + let mut error = None; + attrs.retain(|attr| { + if !is_explicit_test_case_attr(attr) { + return true; + } + + let syn::Meta::List(list) = &attr.meta else { + error = Some( + syn::Error::new_spanned( + attr, + "#[hegel::explicit_test_case] requires arguments.\n\ + Usage: #[hegel::explicit_test_case(name = value, ...)]", + ) + .to_compile_error(), + ); + return false; + }; + + let parsed: syn::Result = syn::parse2(list.tokens.clone()); + match parsed { + Ok(args) if args.entries.is_empty() => { + error = Some( + syn::Error::new_spanned( + attr, + "#[hegel::explicit_test_case] requires at least one name = value pair.\n\ + Usage: #[hegel::explicit_test_case(name = value, ...)]", + ) + .to_compile_error(), + ); + } + Ok(args) => { + let entries = args + .entries + .iter() + .map(|arg| { + let name = arg.name.to_string(); + let expr = &arg.value; + let expr_source = quote::quote!(#expr).to_string(); + (name, expr_source) + }) + .collect(); + cases.push(ParsedExplicitTestCase { entries }); + } + Err(e) => { + error = Some(e.to_compile_error()); + } + } + false // remove this attr + }); + if let Some(err) = error { + return Err(err); + } + Ok(cases) +} + +/// Parsed arguments for a single `#[hegel::explicit_test_case(name = expr, ...)]`. +struct ExplicitTestCaseAttrArgs { + entries: Vec, +} + +struct ExplicitTestCaseEntry { + name: Ident, + value: Expr, +} + +impl syn::parse::Parse for ExplicitTestCaseAttrArgs { + fn parse(input: syn::parse::ParseStream) -> syn::Result { + let mut entries = Vec::new(); + while !input.is_empty() { + let name: Ident = input.parse()?; + let _eq: Token![=] = input.parse()?; + let value: Expr = input.parse()?; + entries.push(ExplicitTestCaseEntry { name, value }); + if !input.is_empty() { + let _comma: Token![,] = input.parse()?; + } + } + Ok(ExplicitTestCaseAttrArgs { entries }) + } +} + pub fn expand_test(attr: proc_macro2::TokenStream, item: proc_macro2::TokenStream) -> TokenStream { let test_args: TestArgs = if attr.is_empty() { TestArgs { @@ -72,7 +288,7 @@ pub fn expand_test(attr: proc_macro2::TokenStream, item: proc_macro2::TokenStrea } }; - let func: ItemFn = match syn::parse2(item) { + let mut func: ItemFn = match syn::parse2(item) { Ok(f) => f, Err(e) => return e.to_compile_error(), }; @@ -110,7 +326,47 @@ pub fn expand_test(attr: proc_macro2::TokenStream, item: proc_macro2::TokenStrea } } - let body = &func.block; + // Extract #[hegel::explicit_test_case(...)] attributes (they haven't been + // processed yet because #[hegel::test] runs first as the outermost attribute). + let explicit_cases = match extract_explicit_test_cases(&mut func.attrs) { + Ok(cases) => cases, + Err(err) => return err, + }; + + // Rewrite `let x = tc.draw(gen)` -> `let x = tc.draw_named(gen, "x", repeatable)` + // + // Two-pass approach: + // 1. Collect all draw variable names and determine per-name repeatable flags. + // If any use of a name is in a nested block/closure, all uses are repeatable. + // 2. Rewrite draws using the computed flags. + // + // We visit the function body's statements directly (not the block itself) so that + // the outermost block doesn't count as a nesting level. + let body = { + let mut body = (*func.block).clone(); + if let Some(tc_name) = extract_ident_from_pat(param_pat) { + // Pass 1: collect names + let mut collector = DrawNameCollector { + tc_ident: tc_name.clone(), + repeatable_depth: 0, + name_flags: HashMap::new(), + }; + for stmt in &mut body.stmts { + collector.visit_stmt_mut(stmt); + } + + // Pass 2: rewrite + let mut rewriter = DrawRewriter { + tc_ident: tc_name, + name_flags: collector.name_flags, + }; + for stmt in &mut body.stmts { + rewriter.visit_stmt_mut(stmt); + } + } + body + }; + let test_name = func.sig.ident.to_string(); let settings_args_chain: Vec = test_args @@ -129,8 +385,38 @@ pub fn expand_test(attr: proc_macro2::TokenStream, item: proc_macro2::TokenStrea None => quote! { hegel::Settings::new() #(#settings_args_chain)* }, }; + // Generate explicit test case blocks (run before the property test). + let explicit_blocks: Vec = explicit_cases + .iter() + .map(|case| { + let with_value_calls: Vec = case + .entries + .iter() + .map(|(name, expr_source)| { + let expr: syn::Expr = syn::parse_str(expr_source).unwrap_or_else(|e| { + panic!("Failed to parse explicit_test_case expression: {}", e) + }); + let source_lit = syn::LitStr::new(expr_source, proc_macro2::Span::call_site()); + quote! { + .with_value(#name, #source_lit, #expr) + } + }) + .collect(); + + quote! { + { + let __hegel_etc = hegel::ExplicitTestCase::new() + #(#with_value_calls)*; + __hegel_etc.run(|#param_pat: &hegel::ExplicitTestCase| #body); + } + } + }) + .collect(); + let new_body: TokenStream = quote! { { + #(#explicit_blocks)* + hegel::Hegel::new(|#param_pat: #param_ty| #body) .settings(#settings_expr) .__database_key(format!("{}::{}", module_path!(), #test_name)) diff --git a/hegel-macros/src/lib.rs b/hegel-macros/src/lib.rs index b1302c0..c24490d 100644 --- a/hegel-macros/src/lib.rs +++ b/hegel-macros/src/lib.rs @@ -1,5 +1,6 @@ mod composite; mod enum_gen; +mod explicit_test_case; mod hegel_test; mod stateful; mod struct_gen; @@ -34,6 +35,37 @@ pub fn composite(_attr: TokenStream, item: TokenStream) -> TokenStream { composite::expand_composite(input).into() } +/// Define an explicit test case to run before the property-based test. +/// +/// Must be placed **below** `#[hegel::test]`. Multiple attributes are allowed. +/// +/// ```ignore +/// #[hegel::test] +/// #[hegel::explicit_test_case(x = 42, y = "hello")] +/// fn my_test(tc: hegel::TestCase) { +/// let x: i32 = tc.draw(hegel::generators::integers()); +/// let y: String = tc.draw(hegel::generators::text()); +/// // ... +/// } +/// ``` +/// +/// Arguments correspond to the names they would be printed with in a failing +/// test case, so need suffixing if they're repeated. For example: +/// +/// ```ignore +/// #[hegel::test] +/// #[hegel::explicit_test_case(x_1 = 1, x_2 = 2, x_3 = 4 )] +/// fn my_test(tc: hegel::TestCase) { +/// for _ in 0..3 { +/// let x: i32 = tc.draw(hegel::generators::integers()); +/// } +/// } +/// ``` +#[proc_macro_attribute] +pub fn explicit_test_case(attr: TokenStream, item: TokenStream) -> TokenStream { + explicit_test_case::expand_explicit_test_case(attr.into(), item.into()).into() +} + #[proc_macro_attribute] pub fn state_machine(_attr: TokenStream, item: TokenStream) -> TokenStream { let block = parse_macro_input!(item as ItemImpl); diff --git a/src/explicit_test_case.rs b/src/explicit_test_case.rs new file mode 100644 index 0000000..0fe916d --- /dev/null +++ b/src/explicit_test_case.rs @@ -0,0 +1,160 @@ +use std::any::Any; +use std::cell::RefCell; +use std::collections::HashMap; +use std::fmt::Debug; + +use crate::generators::Generator; +use crate::test_case::ASSUME_FAIL_STRING; + +struct ExplicitValue { + source_expr: String, + value: Option>, + debug_repr: String, +} + +/// A test case with pre-defined values for explicit/example-based testing. +/// +/// Created by `#[hegel::explicit_test_case]`. Values are looked up by name +/// when `draw` or `draw_named` is called, instead of being generated by +/// the server. +pub struct ExplicitTestCase { + values: RefCell>, + notes: RefCell>, +} + +impl ExplicitTestCase { + #[doc(hidden)] + pub fn new() -> Self { + ExplicitTestCase { + values: RefCell::new(HashMap::new()), + notes: RefCell::new(Vec::new()), + } + } + + #[doc(hidden)] + pub fn with_value(self, name: &str, source_expr: &str, value: T) -> Self { + let debug_repr = format!("{:?}", value); + self.values.borrow_mut().insert( + name.to_string(), + ExplicitValue { + source_expr: source_expr.to_string(), + value: Some(Box::new(value)), + debug_repr, + }, + ); + self + } + + pub fn draw(&self, generator: impl Generator) -> T { + self.draw_named(generator, "unnamed", true) + } + + pub fn draw_named( + &self, + _generator: impl Generator, + name: &str, + _repeatable: bool, + ) -> T { + let mut values = self.values.borrow_mut(); + let entry = match values.get_mut(name) { + Some(e) => e, + None => { + let available: Vec<_> = values.keys().cloned().collect(); + panic!( + "Explicit test case: no value provided for {:?}. Available: {:?}", + name, available + ); + } + }; + + let boxed = match entry.value.take() { + Some(v) => v, + None => { + panic!( + "Explicit test case: value {:?} was already consumed by a previous draw", + name + ); + } + }; + + let source = &entry.source_expr; + let debug = &entry.debug_repr; + + // Only show the "// = debug" comment if the source and debug differ + // (ignoring whitespace). + let source_normalized: String = source.chars().filter(|c| !c.is_whitespace()).collect(); + let debug_normalized: String = debug.chars().filter(|c| !c.is_whitespace()).collect(); + + if source_normalized == debug_normalized { + eprintln!("let {} = {};", name, source); + } else { + eprintln!("let {} = {}; // = {}", name, source, debug); + } + + match boxed.downcast::() { + Ok(typed) => *typed, + Err(_) => panic!( + "Explicit test case: type mismatch for {:?}. \ + The value provided in #[hegel::explicit_test_case] \ + does not match the type expected by draw.", + name + ), + } + } + + pub fn draw_silent(&self, _generator: impl Generator) -> T { + panic!("draw_silent is not supported in explicit test cases"); + } + + pub fn note(&self, message: &str) { + self.notes.borrow_mut().push(message.to_string()); + } + + pub fn assume(&self, condition: bool) { + if !condition { + panic!("{}", ASSUME_FAIL_STRING); + } + } + + #[doc(hidden)] + pub fn start_span(&self, _label: u64) { + panic!("start_span is not supported in explicit test cases"); + } + + #[doc(hidden)] + pub fn stop_span(&self, _discard: bool) { + panic!("stop_span is not supported in explicit test cases"); + } + + #[doc(hidden)] + pub fn run(&self, f: F) { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + f(self); + })); + + match result { + Ok(()) => { + let values = self.values.borrow(); + let unused: Vec<_> = values + .iter() + .filter(|(_, v)| v.value.is_some()) + .map(|(k, _)| k.clone()) + .collect(); + if !unused.is_empty() { + panic!( + "Explicit test case: the following values were provided \ + but never drawn: {:?}", + unused + ); + } + } + Err(payload) => { + let notes = self.notes.borrow(); + for note in notes.iter() { + eprintln!("{}", note); + } + std::panic::resume_unwind(payload); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 417f210..a37063d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -188,6 +188,7 @@ pub(crate) mod antithesis; pub(crate) mod cbor_utils; pub(crate) mod control; +pub mod explicit_test_case; pub mod generators; pub(crate) mod protocol; pub(crate) mod runner; @@ -198,6 +199,7 @@ mod uv; #[doc(hidden)] pub use control::currently_in_test_context; +pub use explicit_test_case::ExplicitTestCase; pub use generators::Generator; pub use test_case::TestCase; @@ -296,6 +298,7 @@ pub use hegel_macros::DefaultGenerator; /// } /// ``` pub use hegel_macros::composite; +pub use hegel_macros::explicit_test_case; /// Derive a [`StateMachine`](crate::stateful::StateMachine) implementation from an `impl` block. /// diff --git a/src/test_case.rs b/src/test_case.rs index 4f229da..30b8440 100644 --- a/src/test_case.rs +++ b/src/test_case.rs @@ -4,6 +4,7 @@ use crate::protocol::{Channel, Connection, SERVER_CRASHED_MESSAGE}; use crate::runner::Verbosity; use ciborium::Value; use std::cell::RefCell; +use std::collections::HashMap; use std::rc::Rc; use std::sync::{Arc, LazyLock}; @@ -63,12 +64,13 @@ pub(crate) struct TestCaseGlobalData { verbosity: Verbosity, is_last_run: bool, test_aborted: bool, + named_draw_counts: HashMap, + named_draw_repeatable: HashMap, } #[derive(Clone)] pub(crate) struct TestCaseLocalData { span_depth: usize, - draw_count: usize, indent: usize, on_draw: Rc, } @@ -129,10 +131,11 @@ impl TestCase { verbosity, is_last_run, test_aborted: false, + named_draw_counts: HashMap::new(), + named_draw_repeatable: HashMap::new(), })), local: RefCell::new(TestCaseLocalData { span_depth: 0, - draw_count: 0, indent: 0, on_draw, }), @@ -152,10 +155,37 @@ impl TestCase { /// let s: String = tc.draw(gs::text()); /// } /// ``` + /// + /// Note: when run inside a `#[hegel::test]`, `draw()` will typically be + /// rewritten to `draw_named()` with an appropriate variable name + /// in order to give better test output. pub fn draw(&self, generator: impl Generator) -> T { + self.draw_named(generator, "unnamed", true) + } + + /// Draw a value from a generator with a specific name for output. + /// + /// When `repeatable` is true, a counter suffix is appended (e.g. `x_1`, `x_2`). + /// When `repeatable` is false, reusing the same name panics. + /// + /// Using the same name with different values of `repeatable` is an error. + /// + /// On the final replay of a failing test case, this prints: + /// - `let name = value;` (when not repeatable) + /// - `let name_N = value;` (when repeatable) + /// + /// Note: although this is public API and you are welcome to use it, + /// it's not really intended for direct use. It is the target that + /// `#[hegel::test]` rewrites `draw()` calls to where appropriate. + pub fn draw_named( + &self, + generator: impl Generator, + name: &str, + repeatable: bool, + ) -> T { let value = generator.do_draw(self); if self.local.borrow().span_depth == 0 { - self.record_draw(&value); + self.record_named_draw(&value, name, repeatable); } value } @@ -215,22 +245,57 @@ impl TestCase { global: self.global.clone(), local: RefCell::new(TestCaseLocalData { span_depth: 0, - draw_count: 0, indent: local.indent + extra_indent, on_draw: local.on_draw.clone(), }), } } - fn record_draw(&self, value: &T) { - let mut local = self.local.borrow_mut(); - local.draw_count += 1; - let count = local.draw_count; + fn record_named_draw(&self, value: &T, name: &str, repeatable: bool) { + let mut global = self.global.borrow_mut(); + + match global.named_draw_repeatable.get(name) { + Some(&prev) if prev != repeatable => { + panic!( + "draw_named: name {:?} used with inconsistent repeatable flag (was {}, now {})", + name, prev, repeatable + ); + } + _ => { + global + .named_draw_repeatable + .insert(name.to_string(), repeatable); + } + } + + let count = global + .named_draw_counts + .entry(name.to_string()) + .or_insert(0); + *count += 1; + let current_count = *count; + drop(global); + + if !repeatable && current_count > 1 { + panic!( + "draw_named: name {:?} used more than once but repeatable is false", + name + ); + } + + let local = self.local.borrow(); let indent = local.indent; + + let display_name = if repeatable { + format!("{}_{}", name, current_count) + } else { + name.to_string() + }; + (local.on_draw)(&format!( - "{:indent$}Draw {}: {:?}", + "{:indent$}let {} = {:?};", "", - count, + display_name, value, indent = indent )); diff --git a/tests/test_draw_named.rs b/tests/test_draw_named.rs new file mode 100644 index 0000000..e5a1273 --- /dev/null +++ b/tests/test_draw_named.rs @@ -0,0 +1,306 @@ +mod common; + +use common::project::TempRustProject; +use common::utils::{assert_matches_regex, expect_panic}; +use hegel::TestCase; +use hegel::generators; + +// ============================================================ +// draw_named runtime behavior +// ============================================================ + +#[test] +fn test_draw_named_non_repeatable_reuse_panics() { + expect_panic( + || { + hegel::Hegel::new(|tc: hegel::TestCase| { + let _a = tc.draw_named(generators::booleans(), "x", false); + let _b = tc.draw_named(generators::booleans(), "x", false); + }) + .settings(hegel::Settings::new().test_cases(1)) + .run(); + }, + r#"draw_named.*"x".*more than once"#, + ); +} + +#[hegel::test(test_cases = 1)] +fn test_draw_named_repeatable_reuse_ok(tc: TestCase) { + let _a = tc.draw_named(generators::booleans(), "x", true); + let _b = tc.draw_named(generators::booleans(), "x", true); +} + +#[hegel::test(test_cases = 1)] +fn test_draw_named_different_names_ok(tc: TestCase) { + let _a = tc.draw_named(generators::booleans(), "x", false); + let _b = tc.draw_named(generators::booleans(), "y", false); +} + +#[test] +fn test_draw_named_mixed_repeatable_panics() { + expect_panic( + || { + hegel::Hegel::new(|tc: hegel::TestCase| { + let _a = tc.draw_named(generators::booleans(), "x", false); + let _b = tc.draw_named(generators::booleans(), "x", true); + }) + .settings(hegel::Settings::new().test_cases(1)) + .run(); + }, + r#"draw_named.*inconsistent.*repeatable"#, + ); +} + +#[test] +fn test_draw_named_mixed_repeatable_reverse_panics() { + expect_panic( + || { + hegel::Hegel::new(|tc: hegel::TestCase| { + let _a = tc.draw_named(generators::booleans(), "x", true); + let _b = tc.draw_named(generators::booleans(), "x", false); + }) + .settings(hegel::Settings::new().test_cases(1)) + .run(); + }, + r#"draw_named.*inconsistent.*repeatable"#, + ); +} + +// ============================================================ +// draw_named output format (via TempRustProject) +// ============================================================ + +#[test] +fn test_draw_named_non_repeatable_output_format() { + let code = r#" +fn main() { + hegel::hegel(|tc| { + let _x = tc.draw_named(hegel::generators::integers::(), "my_var", false); + panic!("intentional"); + }); +} +"#; + let output = TempRustProject::new() + .main_file(code) + .expect_failure("intentional") + .cargo_run(&[]); + + assert_matches_regex(&output.stderr, r"let my_var = -?\d+;"); + assert!( + !output.stderr.contains("my_var_"), + "Non-repeatable should not have suffix. Actual: {}", + output.stderr + ); +} + +#[test] +fn test_draw_named_repeatable_output_format() { + let code = r#" +fn main() { + hegel::hegel(|tc| { + let _a = tc.draw_named(hegel::generators::integers::(), "val", true); + let _b = tc.draw_named(hegel::generators::integers::(), "val", true); + panic!("intentional"); + }); +} +"#; + let output = TempRustProject::new() + .main_file(code) + .expect_failure("intentional") + .cargo_run(&[]); + + assert_matches_regex(&output.stderr, r"let val_1 = -?\d+;"); + assert_matches_regex(&output.stderr, r"let val_2 = -?\d+;"); +} + +// ============================================================ +// Macro rewriting: succeeding tests (inline) +// ============================================================ + +#[hegel::test(test_cases = 1)] +fn test_macro_unique_names_at_top_level(tc: TestCase) { + let x = tc.draw(generators::booleans()); + let y = tc.draw(generators::booleans()); + let _ = (x, y); +} + +#[hegel::test(test_cases = 1)] +fn test_macro_for_loop_is_repeatable(tc: TestCase) { + for _ in 0..3 { + let val = tc.draw(generators::booleans()); + let _ = val; + } +} + +#[hegel::test(test_cases = 1)] +fn test_macro_while_loop_is_repeatable(tc: TestCase) { + let mut i = 0; + while i < 3 { + let val = tc.draw(generators::booleans()); + let _ = val; + i += 1; + } +} + +#[hegel::test(test_cases = 1)] +fn test_macro_loop_is_repeatable(tc: TestCase) { + let mut i = 0; + loop { + let val = tc.draw(generators::booleans()); + let _ = val; + i += 1; + if i >= 3 { + break; + } + } +} + +#[hegel::test(test_cases = 1)] +fn test_macro_closure_is_repeatable(tc: TestCase) { + #[allow(clippy::let_and_return)] + let f = || { + let val = tc.draw(generators::booleans()); + val + }; + let _a = f(); + let _b = f(); +} + +#[hegel::test(test_cases = 1)] +fn test_macro_non_assignment_draw_not_rewritten(tc: TestCase) { + // draw calls not in `let x = tc.draw(...)` form stay as draw(), + // which delegates to draw_named("draw", true) — repeatable, so no panic. + let _ = vec![ + tc.draw(generators::booleans()), + tc.draw(generators::booleans()), + ]; +} + +#[hegel::test(test_cases = 1)] +fn test_macro_type_annotated_draw(tc: TestCase) { + let x: bool = tc.draw(generators::booleans()); + let y: bool = tc.draw(generators::booleans()); + let _ = (x, y); +} + +#[hegel::test(test_cases = 1)] +fn test_macro_draw_in_if_is_repeatable(tc: TestCase) { + // Draw inside an if block is repeatable (block scope allows variable shadowing). + // Using unique names here, so this trivially succeeds. + if true { + let a = tc.draw(generators::booleans()); + let _ = a; + } + let b = tc.draw(generators::booleans()); + let _ = b; +} + +#[hegel::test(test_cases = 1)] +fn test_macro_variable_shadowing_in_block(tc: TestCase) { + // Same variable name at top level and inside a block should work, + // because the block-nested draw is repeatable (shadowing is expected). + let x = tc.draw(generators::booleans()); + let _ = x; + { + let x = tc.draw(generators::booleans()); + let _ = x; + } +} + +#[hegel::test(test_cases = 1)] +fn test_macro_shadowing_in_if_block(tc: TestCase) { + let x = tc.draw(generators::booleans()); + let _ = x; + if true { + let x = tc.draw(generators::booleans()); + let _ = x; + } +} + +// ============================================================ +// Macro rewriting: failing tests (via TempRustProject) +// ============================================================ + +#[test] +fn test_macro_top_level_same_name_panics() { + let code = r#" +#[hegel::test(test_cases = 1)] +fn test_dup(tc: hegel::TestCase) { + let x = tc.draw(hegel::generators::booleans()); + let x = tc.draw(hegel::generators::booleans()); + let _ = x; +} +"#; + TempRustProject::new() + .test_file("test_dup.rs", code) + .expect_failure(r#"draw_named.*"x".*more than once"#) + .cargo_test(&["--test", "test_dup"]); +} + +#[test] +fn test_macro_if_block_same_name_ok() { + // Draw inside if block is repeatable due to potential shadowing, + // so reusing the same name across the if body and outside is fine. + let code = r#" +#[hegel::test(test_cases = 1)] +fn test_if_dup(tc: hegel::TestCase) { + if true { + let x = tc.draw(hegel::generators::booleans()); + let _ = x; + } + let x = tc.draw(hegel::generators::booleans()); + let _ = x; +} +"#; + TempRustProject::new() + .test_file("test_if_dup.rs", code) + .cargo_test(&["--test", "test_if_dup"]); +} + +// ============================================================ +// Macro rewriting: output format (via TempRustProject) +// ============================================================ + +#[test] +fn test_macro_output_uses_variable_name() { + let code = r#" +fn main() { + hegel::hegel(|tc| { + // Simulate what #[hegel::test] would produce for: + // let my_number: i32 = tc.draw(generators::integers()); + let my_number: i32 = tc.draw_named(hegel::generators::integers(), "my_number", false); + panic!("fail: {}", my_number); + }); +} +"#; + let output = TempRustProject::new() + .main_file(code) + .expect_failure("fail") + .cargo_run(&[]); + + assert_matches_regex(&output.stderr, r"let my_number = -?\d+;"); +} + +#[test] +fn test_macro_loop_output_has_counter() { + let code = r#" +fn main() { + hegel::hegel(|tc| { + // Simulate what #[hegel::test] would produce for a loop: + // for _ in 0..2 { let val: i32 = tc.draw(generators::integers()); } + for _ in 0..2 { + let val: i32 = tc.draw_named(hegel::generators::integers(), "val", true); + let _ = val; + } + panic!("fail"); + }); +} +"#; + let output = TempRustProject::new() + .main_file(code) + .expect_failure("fail") + .cargo_run(&[]); + + assert_matches_regex(&output.stderr, r"let val_1 = -?\d+;"); + assert_matches_regex(&output.stderr, r"let val_2 = -?\d+;"); +} diff --git a/tests/test_explicit_test_case.rs b/tests/test_explicit_test_case.rs new file mode 100644 index 0000000..2287f0a --- /dev/null +++ b/tests/test_explicit_test_case.rs @@ -0,0 +1,370 @@ +mod common; + +use common::project::TempRustProject; +use common::utils::{assert_matches_regex, expect_panic}; +use hegel::TestCase; +use hegel::generators; + +// ============================================================ +// Compile error tests (via TempRustProject) +// ============================================================ + +#[test] +fn test_explicit_test_case_on_bare_function() { + let code = r#" +#[hegel::explicit_test_case(x = 42)] +fn my_func(tc: hegel::TestCase) { + let _ = tc; +} + +fn main() {} +"#; + TempRustProject::new() + .main_file(code) + .expect_failure("can only be used together with.*hegel::test") + .cargo_run(&[]); +} + +#[test] +fn test_explicit_test_case_wrong_order() { + let code = r#" +#[hegel::explicit_test_case(x = 42)] +#[hegel::test] +fn my_test(tc: hegel::TestCase) { + let _ = tc; +} + +fn main() {} +"#; + TempRustProject::new() + .main_file(code) + .expect_failure("must appear below.*hegel::test.*not above") + .cargo_run(&[]); +} + +#[test] +fn test_explicit_test_case_bad_syntax() { + // Semicolon instead of comma should produce a compile error, not a silent empty case. + let code = r#" +#[hegel::test] +#[hegel::explicit_test_case(x = 42;)] +fn my_test(tc: hegel::TestCase) { + let x: i32 = tc.draw(hegel::generators::integers()); + let _ = x; +} + +fn main() {} +"#; + TempRustProject::new() + .main_file(code) + .expect_failure("expected `,`") + .cargo_run(&[]); +} + +#[test] +fn test_explicit_test_case_empty_args() { + let code = r#" +#[hegel::test] +#[hegel::explicit_test_case()] +fn my_test(tc: hegel::TestCase) { + let _ = tc; +} + +fn main() {} +"#; + TempRustProject::new() + .main_file(code) + .expect_failure("requires at least one") + .cargo_run(&[]); +} + +#[test] +fn test_explicit_test_case_no_parens() { + let code = r#" +#[hegel::test] +#[hegel::explicit_test_case] +fn my_test(tc: hegel::TestCase) { + let _ = tc; +} + +fn main() {} +"#; + TempRustProject::new() + .main_file(code) + .expect_failure("requires arguments") + .cargo_run(&[]); +} + +// ============================================================ +// Success cases (inline #[hegel::test]) +// ============================================================ + +#[hegel::test] +#[hegel::explicit_test_case(x = true)] +fn test_single_explicit_case(tc: TestCase) { + let x = tc.draw(generators::booleans()); + let _ = x; +} + +#[hegel::test] +#[hegel::explicit_test_case(x = true)] +#[hegel::explicit_test_case(x = false)] +fn test_multiple_explicit_cases(tc: TestCase) { + let x = tc.draw(generators::booleans()); + let _ = x; +} + +#[hegel::test(test_cases = 1)] +#[hegel::explicit_test_case(x = 42i32)] +fn test_explicit_case_with_property_test(tc: TestCase) { + let x: i32 = tc.draw(generators::integers()); + assert_eq!(x, x); +} + +#[hegel::test(test_cases = 1)] +#[hegel::explicit_test_case(x = 42u32)] +fn test_explicit_case_type_annotated_draw_uses_name(tc: TestCase) { + // This verifies the draw is rewritten to draw_named("x", ...) even with + // a type annotation. If it fell back to "unnamed", the explicit test case + // would panic with "no value provided for unnamed". + let x: u32 = tc.draw(generators::integers()); + let _ = x; +} + +// ============================================================ +// Runtime panic tests +// ============================================================ + +#[test] +fn test_explicit_draw_unnamed() { + let etc = hegel::ExplicitTestCase::new().with_value("unnamed", "42", 42i32); + etc.run(|tc: &hegel::ExplicitTestCase| { + let x: i32 = tc.draw(generators::integers()); + assert_eq!(x, 42); + }); +} + +#[test] +fn test_explicit_note() { + let etc = hegel::ExplicitTestCase::new().with_value("x", "true", true); + etc.run(|tc: &hegel::ExplicitTestCase| { + let _: bool = tc.draw_named(generators::booleans(), "x", false); + tc.note("some note"); + }); +} + +#[test] +fn test_explicit_notes_printed_on_panic_inline() { + expect_panic( + || { + let etc = hegel::ExplicitTestCase::new().with_value("x", "42", 42i32); + etc.run(|tc: &hegel::ExplicitTestCase| { + let _: i32 = tc.draw_named(generators::integers(), "x", false); + tc.note("a note"); + panic!("intentional"); + }); + }, + "intentional", + ); +} + +#[test] +fn test_explicit_assume_passes() { + let etc = hegel::ExplicitTestCase::new(); + etc.assume(true); +} + +#[test] +fn test_explicit_assume_panics() { + expect_panic( + || { + let etc = hegel::ExplicitTestCase::new(); + etc.assume(false); + }, + "__HEGEL_ASSUME_FAIL", + ); +} + +#[test] +fn test_explicit_start_span_panics() { + expect_panic( + || { + let etc = hegel::ExplicitTestCase::new(); + etc.start_span(0); + }, + "start_span is not supported in explicit test cases", + ); +} + +#[test] +fn test_explicit_stop_span_panics() { + expect_panic( + || { + let etc = hegel::ExplicitTestCase::new(); + etc.stop_span(false); + }, + "stop_span is not supported in explicit test cases", + ); +} + +#[test] +fn test_explicit_draw_silent_panics() { + expect_panic( + || { + let etc = hegel::ExplicitTestCase::new().with_value("x", "true", true); + etc.run(|tc: &hegel::ExplicitTestCase| { + let _: bool = tc.draw_silent(generators::booleans()); + }); + }, + "draw_silent is not supported in explicit test cases", + ); +} + +#[test] +fn test_explicit_type_mismatch_panics() { + expect_panic( + || { + let etc = hegel::ExplicitTestCase::new().with_value("x", "42", 42i32); + etc.run(|tc: &hegel::ExplicitTestCase| { + // Try to draw as String instead of i32 + let _: String = tc.draw_named(generators::text(), "x", false); + }); + }, + "type mismatch", + ); +} + +#[test] +fn test_explicit_unused_values_panics() { + expect_panic( + || { + let etc = hegel::ExplicitTestCase::new() + .with_value("x", "true", true) + .with_value("y", "false", false); + etc.run(|tc: &hegel::ExplicitTestCase| { + let _: bool = tc.draw_named(generators::booleans(), "x", false); + // y is never drawn + }); + }, + "never drawn", + ); +} + +#[test] +fn test_explicit_unknown_name_panics() { + expect_panic( + || { + let etc = hegel::ExplicitTestCase::new().with_value("x", "true", true); + etc.run(|tc: &hegel::ExplicitTestCase| { + let _: bool = tc.draw_named(generators::booleans(), "nonexistent", false); + }); + }, + "no value provided for.*nonexistent", + ); +} + +#[test] +fn test_explicit_double_consume_panics() { + expect_panic( + || { + let etc = hegel::ExplicitTestCase::new().with_value("x", "true", true); + etc.run(|tc: &hegel::ExplicitTestCase| { + let _: bool = tc.draw_named(generators::booleans(), "x", false); + let _: bool = tc.draw_named(generators::booleans(), "x", false); + }); + }, + "already consumed", + ); +} + +// ============================================================ +// Output format tests (via TempRustProject) +// ============================================================ + +#[test] +fn test_explicit_output_format_with_comment() { + let code = r#" +fn main() { + let etc = hegel::ExplicitTestCase::new() + .with_value("x", "compute()", 42i32); + etc.run(|tc: &hegel::ExplicitTestCase| { + let _: i32 = tc.draw_named(hegel::generators::integers(), "x", false); + panic!("intentional"); + }); +} +"#; + let output = TempRustProject::new() + .main_file(code) + .expect_failure("intentional") + .cargo_run(&[]); + + // Source and debug differ, so comment should appear + assert_matches_regex(&output.stderr, r"let x = compute\(\); // = 42"); +} + +#[test] +fn test_explicit_output_format_without_comment() { + let code = r#" +fn main() { + let etc = hegel::ExplicitTestCase::new() + .with_value("x", "42", 42i32); + etc.run(|tc: &hegel::ExplicitTestCase| { + let _: i32 = tc.draw_named(hegel::generators::integers(), "x", false); + panic!("intentional"); + }); +} +"#; + let output = TempRustProject::new() + .main_file(code) + .expect_failure("intentional") + .cargo_run(&[]); + + // Source "42" and debug "42" are the same, so no comment + assert_matches_regex(&output.stderr, r"let x = 42;"); + assert!( + !output.stderr.contains("// ="), + "Should not have comment when source matches debug. Actual: {}", + output.stderr + ); +} + +#[test] +fn test_explicit_notes_printed_on_panic() { + let code = r#" +fn main() { + let etc = hegel::ExplicitTestCase::new() + .with_value("x", "42", 42i32); + etc.run(|tc: &hegel::ExplicitTestCase| { + let _: i32 = tc.draw_named(hegel::generators::integers(), "x", false); + tc.note("important debug info"); + panic!("intentional"); + }); +} +"#; + let output = TempRustProject::new() + .main_file(code) + .expect_failure("intentional") + .cargo_run(&[]); + + assert_matches_regex(&output.stderr, "important debug info"); +} + +// ============================================================ +// Macro integration: output from #[hegel::explicit_test_case] +// ============================================================ + +#[test] +fn test_macro_explicit_case_output() { + let code = r#" +#[hegel::test(test_cases = 1)] +#[hegel::explicit_test_case(x = 42i32)] +fn test_explicit(tc: hegel::TestCase) { + let x: i32 = tc.draw(hegel::generators::integers()); + panic!("fail: {}", x); +} +"#; + TempRustProject::new() + .test_file("test_etc.rs", code) + .expect_failure("fail: 42") + .cargo_test(&["--test", "test_etc"]); +} diff --git a/tests/test_output.rs b/tests/test_output.rs index cc4d5e1..794b19a 100644 --- a/tests/test_output.rs +++ b/tests/test_output.rs @@ -22,14 +22,14 @@ fn test_failing_test_output() { .cargo_run(&[]); // For example: - // Draw 1: 0 + // let unnamed_1 = 0; // thread 'main' (1) panicked at src/main.rs:7:9: // intentional failure: 0 // note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace assert_matches_regex( &output.stderr, concat!( - r"Draw 1: -?\d+\n", + r"let unnamed_1 = -?\d+;\n", r"thread '.*' \(\d+\) panicked at src/main\.rs:\d+:\d+:\n", r"intentional failure: -?\d+", ), @@ -48,7 +48,7 @@ fn test_failing_test_output_with_backtrace() { // macOS stable (the exact conditions aren't fully understood). Accept both. let closure_name = r"(?:\{closure#0\}|\{\{closure\}\})"; // For example: - // Draw 1: 0 + // let unnamed_1 = 0; // thread 'main' (1) panicked at src/main.rs:7:9: // intentional failure: 0 // stack backtrace: @@ -66,7 +66,7 @@ fn test_failing_test_output_with_backtrace() { &format!( concat!( r"(?s)", - r"Draw 1: -?\d+\n", + r"let unnamed_1 = -?\d+;\n", r"thread 'main' \(\d+\) panicked at src/main\.rs:\d+:\d+:\n", r"intentional failure: -?\d+\n", r"stack backtrace:\n", @@ -103,7 +103,7 @@ fn test_failing_test_output_with_full_backtrace() { &format!( concat!( r"(?s)", - r"Draw 1: -?\d+\n", + r"let unnamed_1 = -?\d+;\n", r"thread 'main' \(\d+\) panicked at src/main\.rs:\d+:\d+:\n", r"intentional failure: -?\d+\n", r"stack backtrace:\n",