Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
/target
.cache/index.json
.idea
3 changes: 2 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"
2 changes: 1 addition & 1 deletion src/bot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
38 changes: 14 additions & 24 deletions src/executor/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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]]
Expand Down Expand Up @@ -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,
}));
}

Expand Down Expand Up @@ -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,
}));
}

Expand Down
30 changes: 15 additions & 15 deletions src/executor/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
49 changes: 24 additions & 25 deletions src/executor/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}
}
}
Expand Down Expand Up @@ -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;
Expand Down
23 changes: 11 additions & 12 deletions src/executor/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,7 +29,7 @@ const PROGRESS_BAR_WIDTH: usize = 40;
pub struct TestRunOutput {
pub results: Vec<TestResult>,
/// First failure detail per failed test: (test_name, failure_detail)
pub failures: Vec<(String, FailureDetail)>,
pub failures: Vec<(String, AssertFailure)>,
}

pub struct TestExecutor {
Expand Down Expand Up @@ -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<Option<FailureDetail>> =
let mut test_failures: Vec<Option<AssertFailure>> =
(0..tests_with_offsets.len()).map(|_| None).collect();

// Track which tests have been cleaned up
Expand Down Expand Up @@ -384,20 +383,20 @@ 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!(
" {} [{}] Tick {}: expected {}, got {}",
"✗".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
Expand Down Expand Up @@ -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, _))| {
Expand All @@ -628,7 +627,7 @@ impl TestExecutor {
entry: &TimelineEntry,
value_idx: usize,
offset: [i32; 3],
) -> Result<actions::ActionOutcome> {
) -> Result<ActionOutcome> {
actions::execute_action(
&mut self.bot,
tick,
Expand Down Expand Up @@ -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);
Expand Down
50 changes: 25 additions & 25 deletions src/executor/recorder/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading