diff --git a/.gitignore b/.gitignore index 42f67a5..4763b0d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target .cache/index.json +.idea \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 2066137..e68269d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1411,9 +1411,10 @@ dependencies = [ [[package]] name = "flint-core" version = "0.1.0" -source = "git+https://github.com/FlintTestMC/flint-core?rev=640d118#640d1180d7075bf62e9128e6e48d9f26404e202b" +source = "git+https://github.com/FlintTestMC/flint-core?rev=b04ad23#b04ad23184dca7fd5fe7e57b482827315ff25955" dependencies = [ "anyhow", + "colored", "serde", "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index e4366f5..72a18c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,5 +17,5 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } parking_lot = "0.12" futures = "0.3" -flint-core = { git = "https://github.com/FlintTestMC/flint-core", rev = "640d118" } +flint-core = { git = "https://github.com/FlintTestMC/flint-core", rev = "b04ad23" } clap_complete = "4.5.65" diff --git a/README.md b/README.md index 9066858..24dfd6b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A command-line tool for running [Flint](https://github.com/FlintTestMC/flint-cor ## Requirements -- Rust 1.85+ (2024 edition) +- Rust nightly (`rustup override set nightly`) - Minecraft server 1.21.5+ - Bot needs operator permissions on the server diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..5d56faf --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly" diff --git a/src/bot.rs b/src/bot.rs index cb5c086..b4b2e55 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -73,7 +73,7 @@ impl TestBot { let local = tokio::task::LocalSet::new(); local.block_on(&rt, async move { - async fn handler(bot: Client, event: Event, state: State) -> anyhow::Result<()> { + async fn handler(bot: Client, event: Event, state: State) -> Result<()> { match event { Event::Init => { *state.client_handle.write() = Some(bot.clone()); diff --git a/src/executor/actions.rs b/src/executor/actions.rs index 9782f7b..28fa49b 100644 --- a/src/executor/actions.rs +++ b/src/executor/actions.rs @@ -3,6 +3,7 @@ use crate::bot::TestBot; use anyhow::Result; use colored::Colorize; +use flint_core::results::{ActionOutcome, AssertFailure, InfoType}; use flint_core::test_spec::{ActionType, TimelineEntry}; use super::block::{block_matches, extract_block_id}; @@ -12,24 +13,6 @@ pub const BLOCK_POLL_ATTEMPTS: u32 = 10; pub const BLOCK_POLL_DELAY_MS: u64 = 50; pub const PLACE_EACH_DELAY_MS: u64 = 10; -/// Details about a failed assertion -pub struct FailureDetail { - pub tick: u32, - pub expected: String, - pub actual: String, - pub position: [i32; 3], -} - -/// Outcome of executing a single action -pub enum ActionOutcome { - /// Non-assertion action completed (place, fill, remove) - Action, - /// Assertion passed - AssertPassed, - /// Assertion failed with details - AssertFailed(FailureDetail), -} - /// Apply offset to a position pub fn apply_offset(pos: [i32; 3], offset: [i32; 3]) -> [i32; 3] { [pos[0] + offset[0], pos[1] + offset[1], pos[2] + offset[2]] @@ -207,11 +190,13 @@ pub async fn execute_action( ); } - return Ok(ActionOutcome::AssertFailed(FailureDetail { + return Ok(ActionOutcome::AssertFailed(AssertFailure { tick, - expected: check.is.id.clone(), - actual: actual_name, + expected: InfoType::String(check.is.id.clone()), + actual: InfoType::String(actual_name), position: check.pos, + error_message: "Block was different".to_string(), + execution_time_ms: None, })); } @@ -259,11 +244,16 @@ pub async fn execute_action( ); } - return Ok(ActionOutcome::AssertFailed(FailureDetail { + return Ok(ActionOutcome::AssertFailed(AssertFailure { tick, - expected: format!("{}={}", prop_name, expected_value), - actual: format!("{}={}", prop_name, actual_prop), + expected: InfoType::String(format!( + "{}={}", + prop_name, expected_value + )), + actual: InfoType::String(format!("{}={}", prop_name, actual_prop)), position: check.pos, + error_message: "Block was different".to_string(), + execution_time_ms: None, })); } diff --git a/src/executor/block.rs b/src/executor/block.rs index d1520b5..ec69d2d 100644 --- a/src/executor/block.rs +++ b/src/executor/block.rs @@ -95,23 +95,23 @@ pub fn extract_block_id(debug_str: &str) -> String { /// Input: "minecraft:oak_fence[east=true,west=false]" pub fn make_block(block_str: &str) -> Block { // Check for properties: "minecraft:oak_fence[east=true,west=false]" - if let Some(open_bracket) = block_str.find('[') { - if let Some(close_bracket) = block_str.find(']') { - let id = block_str[..open_bracket].to_string(); - let props_str = &block_str[open_bracket + 1..close_bracket]; - - let mut properties = HashMap::new(); - for pair in props_str.split(',') { - if let Some((k, v)) = pair.split_once('=') { - properties.insert( - k.trim().to_string(), - serde_json::Value::String(v.trim().to_string()), - ); - } + if let Some(open_bracket) = block_str.find('[') + && let Some(close_bracket) = block_str.find(']') + { + let id = block_str[..open_bracket].to_string(); + let props_str = &block_str[open_bracket + 1..close_bracket]; + + let mut properties = HashMap::new(); + for pair in props_str.split(',') { + if let Some((k, v)) = pair.split_once('=') { + properties.insert( + k.trim().to_string(), + serde_json::Value::String(v.trim().to_string()), + ); } - - return Block { id, properties }; } + + return Block { id, properties }; } Block { diff --git a/src/executor/handlers.rs b/src/executor/handlers.rs index b426d97..d7189c1 100644 --- a/src/executor/handlers.rs +++ b/src/executor/handlers.rs @@ -101,20 +101,19 @@ impl TestExecutor { let pattern_lower = pattern.to_lowercase(); let mut found = 0; for test_file in all_test_files { - if let Ok(test) = TestSpec::from_file(test_file) { - if test.name.to_lowercase().contains(&pattern_lower) { - let tags = if test.tags.is_empty() { - String::new() - } else { - format!(" [{}]", test.tags.join(", ")) - }; - self.bot - .send_command(&format!("say - {}{}", test.name, tags)) - .await?; - found += 1; - tokio::time::sleep(tokio::time::Duration::from_millis(TEST_RESULT_DELAY_MS)) - .await; - } + if let Ok(test) = TestSpec::from_file(test_file) + && test.name.to_lowercase().contains(&pattern_lower) + { + let tags = if test.tags.is_empty() { + String::new() + } else { + format!(" [{}]", test.tags.join(", ")) + }; + self.bot + .send_command(&format!("say - {}{}", test.name, tags)) + .await?; + found += 1; + tokio::time::sleep(tokio::time::Duration::from_millis(TEST_RESULT_DELAY_MS)).await; } } if found == 0 { @@ -140,22 +139,22 @@ impl TestExecutor { // First pass: look for exact match let mut found_test = None; for test_file in all_test_files { - if let Ok(test) = TestSpec::from_file(test_file) { - if test.name.to_lowercase() == name_lower { - found_test = Some(test); - break; - } + if let Ok(test) = TestSpec::from_file(test_file) + && test.name.to_lowercase() == name_lower + { + found_test = Some(test); + break; } } // Second pass: fall back to partial match if no exact match if found_test.is_none() { for test_file in all_test_files { - if let Ok(test) = TestSpec::from_file(test_file) { - if test.name.to_lowercase().contains(&name_lower) { - found_test = Some(test); - break; - } + if let Ok(test) = TestSpec::from_file(test_file) + && test.name.to_lowercase().contains(&name_lower) + { + found_test = Some(test); + break; } } } @@ -518,7 +517,7 @@ impl TestExecutor { } // Also check for blocks that were removed (in initial but now air/gone) - for (pos, _prev_block) in &initial_snapshot { + for pos in initial_snapshot.keys() { if !current_blocks.contains_key(pos) { // Block is gone (probably outside scan range now, skip) continue; diff --git a/src/executor/mod.rs b/src/executor/mod.rs index 419b528..358f34a 100644 --- a/src/executor/mod.rs +++ b/src/executor/mod.rs @@ -10,12 +10,11 @@ use crate::bot::TestBot; use anyhow::Result; use colored::Colorize; use flint_core::loader::TestLoader; -use flint_core::results::TestResult; +use flint_core::results::{ActionOutcome, AssertFailure, TestResult}; use flint_core::test_spec::{TestSpec, TimelineEntry}; use flint_core::timeline::TimelineAggregate; use std::io::Write; -pub use actions::FailureDetail; pub use tick::{COMMAND_DELAY_MS, MIN_RETRY_DELAY_MS}; // Timing constants @@ -30,7 +29,7 @@ const PROGRESS_BAR_WIDTH: usize = 40; pub struct TestRunOutput { pub results: Vec, /// First failure detail per failed test: (test_name, failure_detail) - pub failures: Vec<(String, FailureDetail)>, + pub failures: Vec<(String, AssertFailure)>, } pub struct TestExecutor { @@ -356,7 +355,7 @@ impl TestExecutor { let mut test_results: Vec<(usize, usize)> = vec![(0, 0); tests_with_offsets.len()]; // Track first failure detail per test - let mut test_failures: Vec> = + let mut test_failures: Vec> = (0..tests_with_offsets.len()).map(|_| None).collect(); // Track which tests have been cleaned up @@ -384,11 +383,11 @@ impl TestExecutor { .execute_action(current_tick, entry, *value_idx, *offset) .await { - Ok(actions::ActionOutcome::AssertPassed) => { + Ok(ActionOutcome::AssertPassed) => { test_results[*test_idx].0 += 1; } - Ok(actions::ActionOutcome::Action) => {} - Ok(actions::ActionOutcome::AssertFailed(detail)) => { + Ok(ActionOutcome::Action) => {} + Ok(ActionOutcome::AssertFailed(detail)) => { test_results[*test_idx].1 += 1; if verbose { println!( @@ -396,8 +395,8 @@ impl TestExecutor { "✗".red().bold(), test.name, current_tick, - detail.expected.green(), - detail.actual.red() + String::from(&detail.expected).green(), + String::from(&detail.actual).red() ); } // Store first failure per test @@ -609,7 +608,7 @@ impl TestExecutor { tokio::time::sleep(tokio::time::Duration::from_millis(CLEANUP_DELAY_MS)).await; // Collect failure details - let failures: Vec<(String, FailureDetail)> = tests_with_offsets + let failures: Vec<(String, AssertFailure)> = tests_with_offsets .iter() .enumerate() .filter_map(|(idx, (test, _))| { @@ -628,7 +627,7 @@ impl TestExecutor { entry: &TimelineEntry, value_idx: usize, offset: [i32; 3], - ) -> Result { + ) -> Result { actions::execute_action( &mut self.bot, tick, @@ -667,7 +666,7 @@ pub fn format_number(n: u32) -> String { let s = n.to_string(); let mut result = String::with_capacity(s.len() + s.len() / 3); for (i, c) in s.chars().enumerate() { - if i > 0 && (s.len() - i) % 3 == 0 { + if i > 0 && (s.len() - i).is_multiple_of(3) { result.push(','); } result.push(c); diff --git a/src/executor/recorder/state.rs b/src/executor/recorder/state.rs index 34f2b72..5ffcd35 100644 --- a/src/executor/recorder/state.rs +++ b/src/executor/recorder/state.rs @@ -180,34 +180,34 @@ impl RecorderState { pub fn convert_actions_to_asserts(&mut self) -> usize { let mut converted_count = 0; - if let Some(step) = self.timeline.last_mut() { - if step.tick == self.current_tick { - let mut new_actions = Vec::new(); - - // Drain existing actions and convert them - for action in step.actions.drain(..) { - match action { - RecordedAction::Place { pos, block } => { - new_actions.push(RecordedAction::Assert { pos, block }); - converted_count += 1; - } - RecordedAction::Remove { pos } => { - // Removing a block means asserting it is air - new_actions.push(RecordedAction::Assert { - pos, - block: "minecraft:air".to_string(), - }); - converted_count += 1; - } - // Keep existing asserts unchanged - assert_action @ RecordedAction::Assert { .. } => { - new_actions.push(assert_action); - } + if let Some(step) = self.timeline.last_mut() + && step.tick == self.current_tick + { + let mut new_actions = Vec::new(); + + // Drain existing actions and convert them + for action in step.actions.drain(..) { + match action { + RecordedAction::Place { pos, block } => { + new_actions.push(RecordedAction::Assert { pos, block }); + converted_count += 1; + } + RecordedAction::Remove { pos } => { + // Removing a block means asserting it is air + new_actions.push(RecordedAction::Assert { + pos, + block: "minecraft:air".to_string(), + }); + converted_count += 1; + } + // Keep existing asserts unchanged + assert_action @ RecordedAction::Assert { .. } => { + new_actions.push(assert_action); } } - - step.actions = new_actions; } + + step.actions = new_actions; } converted_count diff --git a/src/format.rs b/src/format.rs deleted file mode 100644 index 567f870..0000000 --- a/src/format.rs +++ /dev/null @@ -1,162 +0,0 @@ -use crate::executor::FailureDetail; -use flint_core::results::TestResult; -use std::time::Duration; - -/// Print results as JSON to stdout -pub fn print_json(results: &[TestResult], failures: &[(String, FailureDetail)], elapsed: Duration) { - let total = results.len(); - let passed = results.iter().filter(|r| r.success).count(); - let failed = total - passed; - - let failure_objects: Vec = failures - .iter() - .map(|(name, detail)| { - serde_json::json!({ - "test": name, - "tick": detail.tick, - "expected": detail.expected, - "actual": detail.actual, - "position": detail.position, - }) - }) - .collect(); - - let test_objects: Vec = results - .iter() - .map(|r| { - serde_json::json!({ - "name": r.test_name, - "success": r.success, - "total_ticks": r.total_ticks, - "execution_time_ms": r.execution_time_ms, - }) - }) - .collect(); - - let output = serde_json::json!({ - "summary": { - "total": total, - "passed": passed, - "failed": failed, - "duration_secs": elapsed.as_secs_f64(), - }, - "tests": test_objects, - "failures": failure_objects, - }); - - println!("{}", serde_json::to_string_pretty(&output).unwrap()); -} - -/// Print results in TAP (Test Anything Protocol) version 13 format -pub fn print_tap(results: &[TestResult], failures: &[(String, FailureDetail)]) { - println!("TAP version 13"); - println!("1..{}", results.len()); - - // Build a lookup from test name to failure detail - let failure_map: std::collections::HashMap<&str, &FailureDetail> = failures - .iter() - .map(|(name, detail)| (name.as_str(), detail)) - .collect(); - - for (i, result) in results.iter().enumerate() { - let number = i + 1; - if result.success { - println!("ok {} - {}", number, result.test_name); - } else { - println!("not ok {} - {}", number, result.test_name); - if let Some(detail) = failure_map.get(result.test_name.as_str()) { - println!(" ---"); - println!( - " message: \"expected {}, got {}\"", - detail.expected, detail.actual - ); - println!( - " at: [{}, {}, {}]", - detail.position[0], detail.position[1], detail.position[2] - ); - println!(" tick: {}", detail.tick); - println!(" ..."); - } - } - } -} - -/// Print results in JUnit XML format -pub fn print_junit( - results: &[TestResult], - failures: &[(String, FailureDetail)], - elapsed: Duration, -) { - let total = results.len(); - let failed = results.iter().filter(|r| !r.success).count(); - - let failure_map: std::collections::HashMap<&str, &FailureDetail> = failures - .iter() - .map(|(name, detail)| (name.as_str(), detail)) - .collect(); - - println!(r#""#); - println!( - r#""#, - total, - failed, - elapsed.as_secs_f64() - ); - println!( - r#" "#, - total, - failed, - elapsed.as_secs_f64() - ); - - for result in results { - // Split test name into classname (directory path) and name (leaf) - let (classname, name) = match result.test_name.rsplit_once('/') { - Some((prefix, leaf)) => (prefix, leaf), - None => ("", result.test_name.as_str()), - }; - - let time = result.execution_time_ms as f64 / 1000.0; - - if result.success { - println!( - r#" "#, - xml_escape(classname), - xml_escape(name), - time - ); - } else { - println!( - r#" "#, - xml_escape(classname), - xml_escape(name), - time - ); - if let Some(detail) = failure_map.get(result.test_name.as_str()) { - println!( - r#" "#, - xml_escape(&detail.expected), - xml_escape(&detail.actual), - detail.position[0], - detail.position[1], - detail.position[2], - detail.tick - ); - } else { - println!(r#" "#); - } - println!(" "); - } - } - - println!(" "); - println!(""); -} - -fn xml_escape(s: &str) -> String { - s.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} diff --git a/src/main.rs b/src/main.rs index 7e1b808..6f2bfc6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,17 +1,16 @@ mod bot; mod executor; -mod format; use anyhow::{Context, Result}; use clap::{CommandFactory, Parser, ValueEnum}; use clap_complete::Shell; use colored::Colorize; -use executor::FailureDetail; +use flint_core::format; +use flint_core::format::{format_number, print_concise_summary, print_test_summary}; use flint_core::loader::TestLoader; -use flint_core::results::TestResult; +use flint_core::results::AssertFailure; use flint_core::spatial::calculate_test_offset_default; use flint_core::test_spec::{ActionType, TestSpec}; -use std::collections::BTreeMap; use std::path::Path; use std::path::PathBuf; use std::time::Instant; @@ -57,179 +56,6 @@ fn print_chunk_header(chunk_idx: usize, total_chunks: usize, chunk_len: usize) { println!(); } -/// Print verbose test summary (used in -v mode) -fn print_test_summary(results: &[TestResult]) { - println!("\n{}", "═".repeat(SEPARATOR_WIDTH).dimmed()); - println!("{}", "Test Summary".cyan().bold()); - print_separator(); - - let total_passed = results.iter().filter(|r| r.success).count(); - let total_failed = results.len() - total_passed; - - for result in results { - let status = if result.success { - "PASS".green().bold() - } else { - "FAIL".red().bold() - }; - println!(" [{}] {}", status, result.test_name); - } - - println!( - "\n{} tests run: {} passed, {} failed\n", - results.len(), - total_passed.to_string().green(), - total_failed.to_string().red() - ); -} - -/// Print concise summary (default mode) -fn print_concise_summary( - results: &[TestResult], - failures: &[(String, FailureDetail)], - elapsed: std::time::Duration, -) { - let total = results.len(); - let total_passed = results.iter().filter(|r| r.success).count(); - let total_failed = total - total_passed; - let secs = elapsed.as_secs_f64(); - - println!(); - if total_failed == 0 { - println!( - "{} All {} tests passed ({:.3}s)", - "✓".green().bold(), - format_number(total), - secs - ); - } else { - println!( - "{} of {} tests failed ({:.3}s)", - format_number(total_failed).red().bold(), - format_number(total), - secs - ); - println!(); - print_failure_tree(failures); - println!(); - println!( - "{} passed, {} failed", - format_number(total_passed).green(), - format_number(total_failed).red() - ); - } - println!(); -} - -/// Format a number with comma separators (e.g., 1247 -> "1,247") -fn format_number(n: usize) -> String { - let s = n.to_string(); - let mut result = String::with_capacity(s.len() + s.len() / 3); - for (i, c) in s.chars().enumerate() { - if i > 0 && (s.len() - i) % 3 == 0 { - result.push(','); - } - result.push(c); - } - result -} - -// ── Failure tree rendering ────────────────────────────────── - -/// A tree node for grouping failures by path segments -struct TreeNode { - children: BTreeMap, - failure: Option, -} - -impl TreeNode { - fn new() -> Self { - Self { - children: BTreeMap::new(), - failure: None, - } - } - - fn insert(&mut self, segments: &[&str], detail: FailureDetail) { - if segments.is_empty() { - self.failure = Some(detail); - return; - } - let child = self - .children - .entry(segments[0].to_string()) - .or_insert_with(TreeNode::new); - if segments.len() == 1 { - child.failure = Some(detail); - } else { - child.insert(&segments[1..], detail); - } - } -} - -/// Print the failure tree -fn print_failure_tree(failures: &[(String, FailureDetail)]) { - let mut root = TreeNode::new(); - - for (name, detail) in failures { - let segments: Vec<&str> = name.split('/').collect(); - // Use a placeholder FailureDetail since we can't clone - root.insert( - &segments, - FailureDetail { - tick: detail.tick, - expected: detail.expected.clone(), - actual: detail.actual.clone(), - position: detail.position, - }, - ); - } - - // Render each top-level child - let keys: Vec<_> = root.children.keys().cloned().collect(); - for (i, key) in keys.iter().enumerate() { - let is_last = i == keys.len() - 1; - let child = root.children.get(key).unwrap(); - render_tree_node(key, child, "", is_last); - } -} - -fn render_tree_node(name: &str, node: &TreeNode, prefix: &str, is_last: bool) { - let connector = if is_last { "└── " } else { "├── " }; - let child_prefix = if is_last { " " } else { "│ " }; - - if node.children.is_empty() { - // Leaf node: print name with failure detail - if let Some(ref detail) = node.failure { - println!("{}{}{}", prefix, connector, name); - let detail_connector = if is_last { " " } else { "│ " }; - println!( - "{}{}└─ t{}: expected {}, got {} @ ({},{},{})", - prefix, - detail_connector, - detail.tick, - detail.expected.green(), - detail.actual.red(), - detail.position[0], - detail.position[1], - detail.position[2] - ); - } else { - println!("{}{}{}", prefix, connector, name); - } - } else { - // Branch node - println!("{}{}{}", prefix, connector, name); - let new_prefix = format!("{}{}", prefix, child_prefix); - let keys: Vec<_> = node.children.keys().cloned().collect(); - for (i, key) in keys.iter().enumerate() { - let child_is_last = i == keys.len() - 1; - let child = node.children.get(key).unwrap(); - render_tree_node(key, child, &new_prefix, child_is_last); - } - } -} - // ───────────────────────────────────────────────────────────── #[derive(Parser, Debug)] @@ -306,7 +132,12 @@ async fn main() -> Result<()> { let args = Args::parse(); if let Some(shell) = args.completions { - clap_complete::generate(shell, &mut Args::command(), "flintmc", &mut std::io::stdout()); + clap_complete::generate( + shell, + &mut Args::command(), + "flintmc", + &mut std::io::stdout(), + ); return Ok(()); } @@ -522,7 +353,7 @@ async fn main() -> Result<()> { let start_time = Instant::now(); let mut all_results = Vec::new(); - let mut all_failures: Vec<(String, FailureDetail)> = Vec::new(); + let mut all_failures: Vec<(String, AssertFailure)> = Vec::new(); for (chunk_idx, chunk) in chunks.iter().enumerate() { if verbose { @@ -591,14 +422,14 @@ async fn main() -> Result<()> { match args.format { OutputFormat::Pretty => { if verbose { - print_test_summary(&all_results); + print_test_summary(&all_results, SEPARATOR_WIDTH); } else { - print_concise_summary(&all_results, &all_failures, elapsed); + print_concise_summary(&all_results, elapsed); } } - OutputFormat::Json => format::print_json(&all_results, &all_failures, elapsed), - OutputFormat::Tap => format::print_tap(&all_results, &all_failures), - OutputFormat::Junit => format::print_junit(&all_results, &all_failures, elapsed), + OutputFormat::Json => format::print_json(&all_results, elapsed), + OutputFormat::Tap => format::print_tap(&all_results), + OutputFormat::Junit => format::print_junit(&all_results, elapsed), } if all_results.iter().any(|r| !r.success) {