From 3202dbbbe97e69c8777c4b03e2464775311e413e Mon Sep 17 00:00:00 2001 From: JunkyDeveloper Date: Sat, 24 Jan 2026 00:35:17 +0100 Subject: [PATCH 01/17] more functionality --- src/results.rs | 26 +++----- src/test_spec.rs | 155 ++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 155 insertions(+), 26 deletions(-) diff --git a/src/results.rs b/src/results.rs index 5bed94c..096b343 100644 --- a/src/results.rs +++ b/src/results.rs @@ -9,9 +9,6 @@ pub struct AssertionResult { /// Whether the assertion succeeded pub success: bool, - /// Type of action (e.g., "Assert", "AssertState") - pub action_type: String, - /// Error message if the assertion failed pub error_message: Option, @@ -24,11 +21,10 @@ pub struct AssertionResult { impl AssertionResult { /// Create a successful assertion result - pub fn success(tick: u32, action_type: impl Into) -> Self { + pub fn success(tick: u32) -> Self { Self { tick, success: true, - action_type: action_type.into(), error_message: None, position: None, execution_time_ms: None, @@ -36,11 +32,10 @@ impl AssertionResult { } /// Create a failed assertion result - pub fn failure(tick: u32, action_type: impl Into, error: impl Into) -> Self { + pub fn failure(tick: u32, error: impl Into) -> Self { Self { tick, success: false, - action_type: action_type.into(), error_message: Some(error.into()), position: None, execution_time_ms: None, @@ -218,13 +213,12 @@ mod tests { #[test] fn test_assertion_result_success() { - let result = AssertionResult::success(5, "Assert") + let result = AssertionResult::success(5) .with_position([1, 2, 3]) .with_timing(100); assert!(result.success); assert_eq!(result.tick, 5); - assert_eq!(result.action_type, "Assert"); assert_eq!(result.position, Some([1, 2, 3])); assert_eq!(result.execution_time_ms, Some(100)); assert!(result.error_message.is_none()); @@ -233,11 +227,10 @@ mod tests { #[test] fn test_assertion_result_failure() { let result = - AssertionResult::failure(10, "AssertState", "Block mismatch").with_position([5, 6, 7]); + AssertionResult::failure(10, "Block mismatch").with_position([5, 6, 7]); assert!(!result.success); assert_eq!(result.tick, 10); - assert_eq!(result.action_type, "AssertState"); assert_eq!(result.error_message, Some("Block mismatch".to_string())); assert_eq!(result.position, Some([5, 6, 7])); } @@ -249,8 +242,8 @@ mod tests { .with_execution_time(5000) .with_offset([0, 0, 0]); - result.add_assertion(AssertionResult::success(5, "Assert")); - result.add_assertion(AssertionResult::success(10, "AssertState")); + result.add_assertion(AssertionResult::success(5)); + result.add_assertion(AssertionResult::success(10)); assert!(result.success); assert_eq!(result.passed_count(), 2); @@ -263,13 +256,12 @@ mod tests { fn test_test_result_with_failure() { let mut result = TestResult::new("test2"); - result.add_assertion(AssertionResult::success(5, "Assert")); + result.add_assertion(AssertionResult::success(5)); result.add_assertion(AssertionResult::failure( 10, - "Assert", "Expected stone, got dirt", )); - result.add_assertion(AssertionResult::success(15, "AssertState")); + result.add_assertion(AssertionResult::success(15)); assert!(!result.success); assert_eq!(result.passed_count(), 2); @@ -285,7 +277,7 @@ mod tests { fn test_test_summary() { let result1 = TestResult::new("test1").with_execution_time(1000); let mut result2 = TestResult::new("test2").with_execution_time(2000); - result2.add_assertion(AssertionResult::failure(5, "Assert", "Failed")); + result2.add_assertion(AssertionResult::failure(5, "Failed")); let summary = TestSummary::from_results(vec![result1, result2]); diff --git a/src/test_spec.rs b/src/test_spec.rs index ccb2c60..4631bfe 100644 --- a/src/test_spec.rs +++ b/src/test_spec.rs @@ -22,13 +22,84 @@ pub struct TestSpec { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SetupSpec { - pub cleanup: CleanupSpec, + #[serde(default)] + pub cleanup: Option, + #[serde(default)] + pub player: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CleanupSpec { pub region: [[i32; 3]; 2], } +/// Player inventory slots +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PlayerSlot { + // Hotbar (9 slots) + Hotbar1, + Hotbar2, + Hotbar3, + Hotbar4, + Hotbar5, + Hotbar6, + Hotbar7, + Hotbar8, + Hotbar9, + + // Off-hand + OffHand, + + // Armor + Helmet, + Chestplate, + Leggings, + Boots, +} + +impl PlayerSlot { + /// Convert hotbar number (1-9) to PlayerSlot + pub fn hotbar(n: u8) -> Option { + match n { + 1 => Some(Self::Hotbar1), + 2 => Some(Self::Hotbar2), + 3 => Some(Self::Hotbar3), + 4 => Some(Self::Hotbar4), + 5 => Some(Self::Hotbar5), + 6 => Some(Self::Hotbar6), + 7 => Some(Self::Hotbar7), + 8 => Some(Self::Hotbar8), + 9 => Some(Self::Hotbar9), + _ => None, + } + } +} + +/// Player configuration for advanced mode (initial inventory setup) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct PlayerConfig { + /// Initial inventory state (slot name -> item config) + #[serde(default)] + pub inventory: HashMap, + /// Initially selected hotbar slot (1-9), defaults to 1 + #[serde(default = "default_selected_hotbar")] + pub selected_hotbar: u8, +} + +fn default_selected_hotbar() -> u8 { + 1 +} + +/// Configuration for a single inventory slot +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlotConfig { + /// Item identifier, e.g., "minecraft:honeycomb" + pub item: String, + /// Stack count, defaults to 1 + #[serde(default = "default_count")] + pub count: u8, +} #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimelineEntry { @@ -56,7 +127,9 @@ impl TickSpec { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Block { + /// Block identifier, e.g., "minecraft:stone" pub id: String, + /// Block state properties, e.g., {"powered": "true", "facing": "north"} #[serde(flatten)] pub properties: HashMap, } @@ -100,15 +173,68 @@ impl Block { } } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BlockFace { + Top, // +Y + Bottom, // -Y + North, // -Z + South, // +Z + East, // +X + West, // -X +} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "do", rename_all = "snake_case")] pub enum ActionType { - Place { pos: [i32; 3], block: Block }, - PlaceEach { blocks: Vec }, - Fill { region: [[i32; 3]; 2], with: Block }, - Remove { pos: [i32; 3] }, - Assert { checks: Vec }, + // Block actions + Place { + pos: [i32; 3], + block: Block, + }, + PlaceEach { + blocks: Vec, + }, + Fill { + region: [[i32; 3]; 2], + with: Block, + }, + Remove { + pos: [i32; 3], + }, + + // Assertion actions + Assert { + checks: Vec, + }, + + // Player actions (for item interactions) + /// Use an item on a block face (e.g., honeycomb on copper, axe on log) + UseItemOn { + pos: [i32; 3], + face: BlockFace, + /// Item to use (for simple mode). If not specified, uses player's active item. + #[serde(default)] + item: Option, + }, + + /// Set an item in a player slot + SetSlot { + slot: PlayerSlot, + #[serde(default)] + item: Option, + #[serde(default = "default_count")] + count: u8, + }, + + /// Select which hotbar slot is active (1-9) + SelectHotbar { + slot: u8, + }, +} + +fn default_count() -> u8 { + 1 } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -149,7 +275,11 @@ impl TestSpec { pub fn cleanup_region(&self) -> [[i32; 3]; 2] { self.setup .as_ref() - .map(|s| s.cleanup.region) + .ok_or_else(|| panic!("setup is missing")) + .unwrap() + .cleanup + .as_ref() + .map(|s| s.region) .expect("Cleanup region is required but not present") } @@ -158,8 +288,10 @@ impl TestSpec { let setup = self.setup.as_ref().ok_or_else(|| { anyhow::anyhow!("Test '{}' missing required 'setup' section", self.name) })?; - - let region = setup.cleanup.region; + if let None = setup.cleanup { + anyhow::bail!("Test '{}' missing 'cleanup' section", self.name); + } + let region = setup.cleanup.as_ref().unwrap().region; let min = region[0]; let max = region[1]; @@ -234,6 +366,11 @@ impl TestSpec { self.validate_position(check.pos, ®ion)?; } } + ActionType::UseItemOn { pos, .. } => { + self.validate_position(*pos, ®ion)?; + } + // SetSlot and SelectHotbar don't have positions to validate + ActionType::SetSlot { .. } | ActionType::SelectHotbar { .. } => {} } } From d7799f0eb4d5eeec89b0ce588e293be17bf718d7 Mon Sep 17 00:00:00 2001 From: JunkyDeveloper Date: Sun, 25 Jan 2026 19:33:20 +0100 Subject: [PATCH 02/17] removed hashmap --- Cargo.lock | 7 +++++++ Cargo.toml | 1 + src/test_spec.rs | 13 +++++++------ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a045686..aed6997 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,7 @@ name = "flint-core" version = "0.1.0" dependencies = [ "anyhow", + "rustc-hash", "serde", "serde_json", "serial_test", @@ -249,6 +250,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "1.1.2" diff --git a/Cargo.toml b/Cargo.toml index c512b6c..d323e6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" anyhow = "1.0.100" +rustc-hash = "2.1.1" # rsjsonnet = "0.4.0" # for jsonnet support [dev-dependencies] diff --git a/src/test_spec.rs b/src/test_spec.rs index 4631bfe..851df00 100644 --- a/src/test_spec.rs +++ b/src/test_spec.rs @@ -1,6 +1,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; +use rustc_hash::FxHashMap; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -131,7 +132,7 @@ pub struct Block { pub id: String, /// Block state properties, e.g., {"powered": "true", "facing": "north"} #[serde(flatten)] - pub properties: HashMap, + pub properties: FxHashMap, } impl Block { @@ -414,7 +415,7 @@ mod tests { fn redstone_lever_with_two_properties_command_string() { let mut block = Block { id: "minecraft:lever".to_string(), - properties: HashMap::new(), + properties: FxHashMap::default(), }; block .properties @@ -434,7 +435,7 @@ mod tests { fn only_id_command_string() { let block = Block { id: "minecraft:stone".to_string(), - properties: HashMap::new(), + properties: FxHashMap::default(), }; let result = block.to_command(); assert_eq!(result, "minecraft:stone"); @@ -443,7 +444,7 @@ mod tests { fn empty_id_command_string() { let block = Block { id: "".to_string(), - properties: HashMap::new(), + properties: FxHashMap::default(), }; let result = block.to_command(); assert_eq!(result, ""); @@ -453,7 +454,7 @@ mod tests { fn test_redstone_wire() { let mut block = Block { id: "minecraft:redstone_wire".to_string(), - properties: HashMap::new(), + properties: FxHashMap::default(), }; block .properties @@ -656,7 +657,7 @@ mod tests { // Test when there's both flat properties and nested ones let mut block = Block { id: "minecraft:test".to_string(), - properties: HashMap::new(), + properties: FxHashMap::default(), }; // Add a flat property From 1ef3dd857ff2a68529473dd5a8a3f46182f048bd Mon Sep 17 00:00:00 2001 From: JunkyDeveloper Date: Sun, 25 Jan 2026 22:19:04 +0100 Subject: [PATCH 03/17] added some things to cargo --- Cargo.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index d323e6b..59fb01d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,8 @@ name = "flint-core" version = "0.1.0" edition = "2024" +license = "MIT" +description = "The shared functionality for flint implementations" [dependencies] serde = { version = "1.0", features = ["derive"] } From 16f559cfd1416a42e90221e4a567f941c2d55eac Mon Sep 17 00:00:00 2001 From: JunkyDeveloper Date: Sun, 25 Jan 2026 23:49:01 +0100 Subject: [PATCH 04/17] started to be ready for publishing --- Cargo.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 59fb01d..921339f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,10 @@ version = "0.1.0" edition = "2024" license = "MIT" description = "The shared functionality for flint implementations" +keywords = ["flint", "steel", "minecraft", "test"] +authors = ["JunkyDeveloper "] +categories = ["minecraft", "flint", "test", "steel"] +readme = "README.md" [dependencies] serde = { version = "1.0", features = ["derive"] } From 6086305cc3b4f5034e772ddc2d34439e95165215 Mon Sep 17 00:00:00 2001 From: JunkyDeveloper Date: Sun, 25 Jan 2026 23:49:01 +0100 Subject: [PATCH 05/17] started to be ready for publishing --- Cargo.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 59fb01d..e78d0f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,12 @@ version = "0.1.0" edition = "2024" license = "MIT" description = "The shared functionality for flint implementations" +keywords = ["flint", "steel", "minecraft", "test"] +authors = ["JunkyDeveloper "] +categories = ["minecraft", "flint", "test", "steel"] +readme = "README.md" +repository = "https://github.com/FlintTestMC/flint-core" +homepage = "https://github.com/FlintTestMC/flint-core" [dependencies] serde = { version = "1.0", features = ["derive"] } From b5f0e83d2f7ad26e8b4080816210a81bd851a17d Mon Sep 17 00:00:00 2001 From: JunkyDeveloper Date: Sun, 25 Jan 2026 23:54:30 +0100 Subject: [PATCH 06/17] correct categories --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index e78d0f2..c1692e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ license = "MIT" description = "The shared functionality for flint implementations" keywords = ["flint", "steel", "minecraft", "test"] authors = ["JunkyDeveloper "] -categories = ["minecraft", "flint", "test", "steel"] +categories = ["development-tools::testing", "development-tools", "development-tools::debugging"] readme = "README.md" repository = "https://github.com/FlintTestMC/flint-core" homepage = "https://github.com/FlintTestMC/flint-core" From 694ce6b40d0235500e2139979b0579a4034fdd91 Mon Sep 17 00:00:00 2001 From: JunkyDeveloper Date: Mon, 26 Jan 2026 03:00:47 +0100 Subject: [PATCH 07/17] removed myself --- Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index c1692e2..930f068 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,6 @@ edition = "2024" license = "MIT" description = "The shared functionality for flint implementations" keywords = ["flint", "steel", "minecraft", "test"] -authors = ["JunkyDeveloper "] categories = ["development-tools::testing", "development-tools", "development-tools::debugging"] readme = "README.md" repository = "https://github.com/FlintTestMC/flint-core" From 91f2b67ac107fb96c912468728b2f76caf54e227 Mon Sep 17 00:00:00 2001 From: JunkyDeveloper Date: Thu, 29 Jan 2026 01:28:33 +0100 Subject: [PATCH 08/17] new cargo --- Cargo.lock | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index bcabc13..1f774aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,6 +51,7 @@ version = "0.1.0" dependencies = [ "anyhow", "colored", + "rustc-hash", "serde", "serde_json", "serial_test", @@ -259,6 +260,12 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "1.1.2" From 8673708c3b4b354eaf7345ce9f52c50f71bb5f5a Mon Sep 17 00:00:00 2001 From: JunkyDeveloper Date: Thu, 29 Jan 2026 02:17:37 +0100 Subject: [PATCH 09/17] cicd tests --- .github/dependabot.yml | 15 ++++++++ .github/workflows/audit.yml | 25 ++++++++++++++ .github/workflows/coverage.yml | 49 +++++++++++++++++++++++++++ .github/workflows/docs.yml | 62 ++++++++++++++++++++++++++++++++++ src/results.rs | 32 ++++++++++++++++++ 5 files changed, 183 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/audit.yml create mode 100644 .github/workflows/coverage.yml create mode 100644 .github/workflows/docs.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1ae05a8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "deps" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + commit-message: + prefix: "ci" diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..0829dff --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,25 @@ +name: Security Audit + +on: + push: + paths: + - "**/Cargo.toml" + - "**/Cargo.lock" + pull_request: + paths: + - "**/Cargo.toml" + - "**/Cargo.lock" + schedule: + - cron: "0 0 * * *" # Daily at midnight UTC + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: rustsec/audit-check@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..b2b6d1b --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,49 @@ +name: Coverage + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + +jobs: + coverage: + name: Code Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-action@stable + + - name: Install cargo-tarpaulin + run: cargo install cargo-tarpaulin + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-coverage-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-coverage- + + - name: Generate coverage report + run: cargo tarpaulin --out xml --out html --output-dir coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: coverage/cobertura.xml + fail_ci_if_error: false + + - name: Upload coverage artifacts + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/ diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..0579530 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,62 @@ +name: Documentation + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build Documentation + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-action@stable + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-docs-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-docs- + + - name: Build rustdoc + run: cargo doc --no-deps --document-private-items + + - name: Add redirect to index + run: echo '' > target/doc/index.html + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: target/doc + + deploy: + name: Deploy to GitHub Pages + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/src/results.rs b/src/results.rs index ce9a0fd..0b76f70 100644 --- a/src/results.rs +++ b/src/results.rs @@ -1,6 +1,8 @@ +use crate::format; use crate::results::AssertionResult::Failure; use crate::test_spec::Block; use serde::{Deserialize, Serialize}; +use std::time::Duration; /// Outcome of executing a single action pub enum ActionOutcome { @@ -257,6 +259,36 @@ impl TestSummary { (self.passed_tests as f64 / self.total_tests as f64) * 100.0 } } + + /// Get total execution time as Duration + fn elapsed(&self) -> Duration { + Duration::from_millis(self.total_execution_time_ms) + } + + /// Print concise summary (default mode) + pub fn print_concise_summary(&self) { + format::print_concise_summary(&self.results, self.elapsed()); + } + + /// Print verbose test summary (used in -v mode) + pub fn print_test_summary(&self, separator_width: usize) { + format::print_test_summary(&self.results, separator_width); + } + + /// Print results in JUnit XML format + pub fn print_junit(&self) { + format::print_junit(&self.results, self.elapsed()); + } + + /// Print results in TAP (Test Anything Protocol) format + pub fn print_tap(&self) { + format::print_tap(&self.results); + } + + /// Print results as JSON + pub fn print_json(&self) { + format::print_json(&self.results, self.elapsed()); + } } #[cfg(test)] From 59abbe2deee5e42bb663d04c1c8db11e46099973 Mon Sep 17 00:00:00 2001 From: JunkyDeveloper Date: Sun, 1 Feb 2026 02:44:55 +0100 Subject: [PATCH 10/17] flint-steel into flint-core --- Cargo.toml | 2 +- src/filter.rs | 437 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 14 ++ src/mock.rs | 551 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/runner.rs | 302 +++++++++++++++++++++++++++ src/traits.rs | 188 +++++++++++++++++ 6 files changed, 1493 insertions(+), 1 deletion(-) create mode 100644 src/filter.rs create mode 100644 src/mock.rs create mode 100644 src/runner.rs create mode 100644 src/traits.rs diff --git a/Cargo.toml b/Cargo.toml index fa73cff..ab39518 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ homepage = "https://github.com/FlintTestMC/flint-core" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" anyhow = "1.0.100" -rustc-hash = "2.1.1" +rustc-hash = "2.1" # rsjsonnet = "0.4.0" # for jsonnet support colored = "3" diff --git a/src/filter.rs b/src/filter.rs new file mode 100644 index 0000000..aeea12c --- /dev/null +++ b/src/filter.rs @@ -0,0 +1,437 @@ +//! Test filtering and selection. +//! +//! Provides ways to select which tests to run based on tags, names, or patterns. + +use std::path::Path; + +use anyhow::Result; +use crate::{TestLoader, TestSpec}; + +/// Criteria for selecting tests to run. +#[derive(Debug, Clone, Default)] +pub struct TestFilter { + /// Run only tests with these tags (empty = no tag filter) + pub tags: Vec, + /// Run only tests matching these name patterns (supports glob: `*`, `?`) + pub name_patterns: Vec, + /// Run only this specific test by exact name + pub exact_name: Option, +} + +impl TestFilter { + /// Create a filter that matches all tests. + pub fn all() -> Self { + Self::default() + } + + /// Create a filter for tests with specific tags. + pub fn by_tags(tags: impl IntoIterator>) -> Self { + Self { + tags: tags.into_iter().map(Into::into).collect(), + ..Default::default() + } + } + + /// Create a filter for a single test by exact name. + pub fn by_name(name: impl Into) -> Self { + Self { + exact_name: Some(name.into()), + ..Default::default() + } + } + + /// Create a filter for tests matching name patterns (glob syntax). + pub fn by_patterns(patterns: impl IntoIterator>) -> Self { + Self { + name_patterns: patterns.into_iter().map(Into::into).collect(), + ..Default::default() + } + } + + /// Add tags to the filter (builder pattern). + pub fn with_tags(mut self, tags: impl IntoIterator>) -> Self { + self.tags.extend(tags.into_iter().map(Into::into)); + self + } + + /// Add name patterns to the filter (builder pattern). + pub fn with_patterns(mut self, patterns: impl IntoIterator>) -> Self { + self.name_patterns + .extend(patterns.into_iter().map(Into::into)); + self + } + + /// Set exact name match (builder pattern). + pub fn with_exact_name(mut self, name: impl Into) -> Self { + self.exact_name = Some(name.into()); + self + } + + /// Check if a test spec matches this filter. + pub fn matches(&self, spec: &TestSpec) -> bool { + // Check exact name first + if let Some(ref exact) = self.exact_name + && spec.name != *exact + { + return false; + } + + // Check name patterns + if !self.name_patterns.is_empty() { + let matches_any = self + .name_patterns + .iter() + .any(|pattern| glob_match(pattern, &spec.name)); + if !matches_any { + return false; + } + } + + // Check tags (test must have at least one matching tag) + if !self.tags.is_empty() { + let has_matching_tag = self + .tags + .iter() + .any(|tag| spec.tags.iter().any(|t| t == tag)); + if !has_matching_tag { + return false; + } + } + + true + } + + /// Returns true if no filters are set (matches everything). + pub fn is_empty(&self) -> bool { + self.tags.is_empty() && self.name_patterns.is_empty() && self.exact_name.is_none() + } +} + +/// Simple glob matching supporting `*` (any chars) and `?` (single char). +fn glob_match(pattern: &str, text: &str) -> bool { + let pattern_chars: Vec = pattern.chars().collect(); + let text_chars: Vec = text.chars().collect(); + + glob_match_recursive(&pattern_chars, &text_chars, 0, 0) +} + +fn glob_match_recursive(pattern: &[char], text: &[char], pi: usize, ti: usize) -> bool { + if pi == pattern.len() && ti == text.len() { + return true; + } + if pi == pattern.len() { + return false; + } + + match pattern[pi] { + '*' => { + // Try matching zero or more characters + for i in ti..=text.len() { + if glob_match_recursive(pattern, text, pi + 1, i) { + return true; + } + } + false + } + '?' => { + // Match exactly one character + if ti < text.len() { + glob_match_recursive(pattern, text, pi + 1, ti + 1) + } else { + false + } + } + c => { + // Match literal character + if ti < text.len() && text[ti] == c { + glob_match_recursive(pattern, text, pi + 1, ti + 1) + } else { + false + } + } + } +} + +/// Load and filter tests from a directory. +pub struct TestSelector { + loader: TestLoader, +} + +impl TestSelector { + /// Create a new test selector for the given test directory. + pub fn new(test_path: &Path) -> Result { + let loader = TestLoader::new(test_path, true)?; + Ok(Self { loader }) + } + + /// Load all test specs, optionally filtered. + pub fn load_tests(&self, filter: &TestFilter) -> Result> { + // Get paths based on tag filter (if any) + let paths = if !filter.tags.is_empty() { + self.loader.collect_by_tags(&filter.tags)? + } else { + self.loader.collect_all_test_files()? + }; + + // Load specs and apply additional filters + let mut specs = Vec::new(); + for path in paths { + match TestSpec::from_file(&path) { + Ok(spec) => { + if filter.matches(&spec) { + specs.push(spec); + } + } + Err(e) => { + // Log but don't fail - continue with other tests + eprintln!("Warning: Failed to load test {}: {}", path.display(), e); + } + } + } + + Ok(specs) + } + + /// Load a single test by exact name. + pub fn load_test_by_name(&self, name: &str) -> Result> { + let paths = self.loader.collect_all_test_files()?; + + for path in paths { + if let Ok(spec) = TestSpec::from_file(&path) + && spec.name == name + { + return Ok(Some(spec)); + } + } + + Ok(None) + } + + /// Get all available test names. + pub fn list_test_names(&self) -> Result> { + let paths = self.loader.collect_all_test_files()?; + let mut names = Vec::new(); + + for path in paths { + if let Ok(spec) = TestSpec::from_file(&path) { + names.push(spec.name); + } + } + + names.sort(); + Ok(names) + } + + /// Get all available tags. + pub fn list_tags(&self) -> Result> { + let paths = self.loader.collect_all_test_files()?; + let mut tags = std::collections::HashSet::new(); + + for path in paths { + if let Ok(spec) = TestSpec::from_file(&path) { + for tag in spec.tags { + tags.insert(tag); + } + } + } + + let mut tags: Vec<_> = tags.into_iter().collect(); + tags.sort(); + Ok(tags) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_spec(name: &str, tags: &[&str]) -> TestSpec { + TestSpec { + flint_version: Some("0.1".to_string()), + name: name.to_string(), + description: None, + tags: tags.iter().map(|s| s.to_string()).collect(), + dependencies: vec![], + setup: None, + timeline: vec![], + breakpoints: vec![], + } + } + + // ========================================================================== + // TestFilter Tests + // ========================================================================== + + #[test] + fn test_filter_all_matches_everything() { + let filter = TestFilter::all(); + let spec = make_spec("any_test", &["tag1", "tag2"]); + assert!(filter.matches(&spec)); + } + + #[test] + fn test_filter_is_empty() { + assert!(TestFilter::all().is_empty()); + assert!(!TestFilter::by_tags(["foo"]).is_empty()); + assert!(!TestFilter::by_name("test").is_empty()); + assert!(!TestFilter::by_patterns(["*"]).is_empty()); + } + + #[test] + fn test_filter_by_exact_name() { + let filter = TestFilter::by_name("copper_waxing"); + + assert!(filter.matches(&make_spec("copper_waxing", &[]))); + assert!(!filter.matches(&make_spec("copper_oxidation", &[]))); + assert!(!filter.matches(&make_spec("copper_waxing_test", &[]))); + } + + #[test] + fn test_filter_by_tags_single() { + let filter = TestFilter::by_tags(["redstone"]); + + assert!(filter.matches(&make_spec("test1", &["redstone"]))); + assert!(filter.matches(&make_spec("test2", &["redstone", "other"]))); + assert!(!filter.matches(&make_spec("test3", &["copper"]))); + assert!(!filter.matches(&make_spec("test4", &[]))); + } + + #[test] + fn test_filter_by_tags_multiple() { + let filter = TestFilter::by_tags(["redstone", "copper"]); + + // Matches if test has ANY of the specified tags + assert!(filter.matches(&make_spec("test1", &["redstone"]))); + assert!(filter.matches(&make_spec("test2", &["copper"]))); + assert!(filter.matches(&make_spec("test3", &["redstone", "copper"]))); + assert!(!filter.matches(&make_spec("test4", &["iron"]))); + } + + #[test] + fn test_filter_by_pattern_star() { + let filter = TestFilter::by_patterns(["copper_*"]); + + assert!(filter.matches(&make_spec("copper_waxing", &[]))); + assert!(filter.matches(&make_spec("copper_oxidation", &[]))); + assert!(filter.matches(&make_spec("copper_", &[]))); + assert!(!filter.matches(&make_spec("iron_block", &[]))); + assert!(!filter.matches(&make_spec("coppertest", &[]))); + } + + #[test] + fn test_filter_by_pattern_question() { + let filter = TestFilter::by_patterns(["test_?"]); + + assert!(filter.matches(&make_spec("test_1", &[]))); + assert!(filter.matches(&make_spec("test_a", &[]))); + assert!(!filter.matches(&make_spec("test_12", &[]))); + assert!(!filter.matches(&make_spec("test_", &[]))); + } + + #[test] + fn test_filter_by_pattern_combined() { + let filter = TestFilter::by_patterns(["*_test_*"]); + + assert!(filter.matches(&make_spec("copper_test_1", &[]))); + assert!(filter.matches(&make_spec("redstone_test_basic", &[]))); + assert!(!filter.matches(&make_spec("test_basic", &[]))); + } + + #[test] + fn test_filter_multiple_patterns() { + let filter = TestFilter::by_patterns(["copper_*", "redstone_*"]); + + assert!(filter.matches(&make_spec("copper_waxing", &[]))); + assert!(filter.matches(&make_spec("redstone_repeater", &[]))); + assert!(!filter.matches(&make_spec("iron_block", &[]))); + } + + #[test] + fn test_filter_combined_tags_and_patterns() { + let filter = TestFilter::all() + .with_tags(["redstone"]) + .with_patterns(["*_test"]); + + // Must match both: has "redstone" tag AND name ends with "_test" + assert!(filter.matches(&make_spec("repeater_test", &["redstone"]))); + assert!(!filter.matches(&make_spec("repeater_test", &["copper"]))); // wrong tag + assert!(!filter.matches(&make_spec("repeater", &["redstone"]))); // wrong pattern + } + + #[test] + fn test_filter_builder_pattern() { + let filter = TestFilter::all() + .with_tags(["a", "b"]) + .with_patterns(["test_*"]) + .with_exact_name("test_specific"); + + assert_eq!(filter.tags, vec!["a", "b"]); + assert_eq!(filter.name_patterns, vec!["test_*"]); + assert_eq!(filter.exact_name, Some("test_specific".to_string())); + } + + // ========================================================================== + // Glob Matching Tests + // ========================================================================== + + #[test] + fn test_glob_exact_match() { + assert!(glob_match("hello", "hello")); + assert!(!glob_match("hello", "world")); + assert!(!glob_match("hello", "hello_world")); + } + + #[test] + fn test_glob_star_end() { + assert!(glob_match("hello*", "hello")); + assert!(glob_match("hello*", "hello_world")); + assert!(glob_match("hello*", "hellooooo")); + assert!(!glob_match("hello*", "hell")); + } + + #[test] + fn test_glob_star_start() { + assert!(glob_match("*world", "world")); + assert!(glob_match("*world", "hello_world")); + assert!(!glob_match("*world", "worldly")); + } + + #[test] + fn test_glob_star_middle() { + assert!(glob_match("hello*world", "helloworld")); + assert!(glob_match("hello*world", "hello_world")); + assert!(glob_match("hello*world", "hello_big_world")); + assert!(!glob_match("hello*world", "hello_worlds")); + } + + #[test] + fn test_glob_multiple_stars() { + assert!(glob_match("*a*b*", "ab")); + assert!(glob_match("*a*b*", "xaxbx")); + assert!(glob_match("*a*b*", "aaabbb")); + assert!(!glob_match("*a*b*", "ba")); + } + + #[test] + fn test_glob_question_mark() { + assert!(glob_match("h?llo", "hello")); + assert!(glob_match("h?llo", "hallo")); + assert!(!glob_match("h?llo", "hllo")); + assert!(!glob_match("h?llo", "heello")); + } + + #[test] + fn test_glob_mixed() { + assert!(glob_match("test_?_*", "test_1_abc")); + assert!(glob_match("test_?_*", "test_a_")); + assert!(!glob_match("test_?_*", "test__abc")); + } + + #[test] + fn test_glob_empty() { + assert!(glob_match("", "")); + assert!(glob_match("*", "")); + assert!(glob_match("*", "anything")); + assert!(!glob_match("?", "")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 59db326..5588f2c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,3 +6,17 @@ pub mod spatial; pub mod test_spec; pub mod timeline; pub mod utils; +pub mod filter; +pub mod mock; +pub mod runner; +pub mod traits; + +// Re-export main types for convenience +pub use filter::{TestFilter, TestSelector}; +pub use mock::{MockAdapter, MockPlayer, MockWorld}; +pub use runner::{TestRunConfig, TestRunner}; +pub use traits::{BlockPos, FlintAdapter, FlintPlayer, FlintWorld, Item, ServerInfo}; + +// Re-export flint-core types commonly used with this library +pub use crate::loader::TestLoader; +pub use crate::test_spec::{Block, TestSpec}; \ No newline at end of file diff --git a/src/mock.rs b/src/mock.rs new file mode 100644 index 0000000..4d1a691 --- /dev/null +++ b/src/mock.rs @@ -0,0 +1,551 @@ +//! Mock adapter for testing flint-steel without a real server. +//! +//! This module provides a simple in-memory implementation of the Flint traits +//! that can be used for unit testing and development. + +use rustc_hash::FxHashMap; +use crate::traits::{ + Block, BlockData, BlockFace, BlockPos, FlintAdapter, FlintPlayer, FlintWorld, Item, PlayerSlot, + ServerInfo, +}; + +/// Mock adapter for testing +pub struct MockAdapter; + +impl MockAdapter { + pub fn new() -> Self { + Self + } +} + +impl Default for MockAdapter { + fn default() -> Self { + Self::new() + } +} + +impl FlintAdapter for MockAdapter { + fn create_test_world(&self) -> Box { + Box::new(MockWorld::new()) + } + + fn server_info(&self) -> ServerInfo { + ServerInfo { + minecraft_version: "1.21".to_string(), + } + } +} + +/// Mock world that stores blocks in a HashMap +pub struct MockWorld { + blocks: FxHashMap, + tick: u64, +} + +impl MockWorld { + pub fn new() -> Self { + Self { + blocks: FxHashMap::default(), + tick: 0, + } + } + + /// Get all blocks in the world (for debugging/testing) + pub fn all_blocks(&self) -> &FxHashMap { + &self.blocks + } +} + +impl Default for MockWorld { + fn default() -> Self { + Self::new() + } +} + +impl FlintWorld for MockWorld { + fn do_tick(&mut self) { + self.tick += 1; + } + + fn current_tick(&self) -> u64 { + self.tick + } + + fn get_block(&self, pos: BlockPos) -> BlockData { + self.blocks + .get(&pos) + .cloned() + .unwrap_or_else(|| BlockData::new("minecraft:air")) + } + + fn set_block(&mut self, pos: BlockPos, block: &Block) { + let data = BlockData { + id: block.id.clone(), + properties: block + .properties + .iter() + .map(|(k, v)| { + let value = match v { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + _ => String::new(), + }; + (k.clone(), value) + }) + .collect(), + }; + self.blocks.insert(pos, data); + } + + fn create_player(&mut self) -> Box { + Box::new(MockPlayer::new()) + } +} + +/// Mock player with inventory +pub struct MockPlayer { + slots: FxHashMap, + selected_hotbar: u8, +} + +impl MockPlayer { + pub fn new() -> Self { + Self { + slots: FxHashMap::default(), + selected_hotbar: 1, + } + } +} + +impl Default for MockPlayer { + fn default() -> Self { + Self::new() + } +} + +impl FlintPlayer for MockPlayer { + fn set_slot(&mut self, slot: PlayerSlot, item: Option<&Item>) { + if let Some(item) = item { + self.slots.insert(slot, item.clone()); + } else { + self.slots.remove(&slot); + } + } + + fn get_slot(&self, slot: PlayerSlot) -> Option { + self.slots.get(&slot).cloned() + } + + fn select_hotbar(&mut self, slot: u8) { + if (1..=9).contains(&slot) { + self.selected_hotbar = slot; + } + } + + fn selected_hotbar(&self) -> u8 { + self.selected_hotbar + } + + fn use_item_on(&mut self, _pos: BlockPos, _face: &BlockFace) { + // Mock implementation - does nothing + // A real server would process the item use interaction + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Block; + use serde_json::json; + + // ========================================================================== + // MockAdapter Tests + // ========================================================================== + + #[test] + fn test_adapter_new() { + let adapter = MockAdapter::new(); + let info = adapter.server_info(); + assert_eq!(info.minecraft_version, "1.21"); + } + + #[test] + fn test_adapter_default() { + let adapter = MockAdapter::new(); + let info = adapter.server_info(); + assert_eq!(info.minecraft_version, "1.21"); + } + + #[test] + fn test_adapter_creates_world() { + let adapter = MockAdapter::new(); + let world = adapter.create_test_world(); + assert_eq!(world.current_tick(), 0); + } + + // ========================================================================== + // MockWorld Tests + // ========================================================================== + + #[test] + fn test_world_new() { + let world = MockWorld::new(); + assert_eq!(world.current_tick(), 0); + assert!(world.all_blocks().is_empty()); + } + + #[test] + fn test_world_default() { + let world = MockWorld::default(); + assert_eq!(world.current_tick(), 0); + } + + #[test] + fn test_world_do_tick() { + let mut world = MockWorld::new(); + assert_eq!(world.current_tick(), 0); + + world.do_tick(); + assert_eq!(world.current_tick(), 1); + + world.do_tick(); + world.do_tick(); + assert_eq!(world.current_tick(), 3); + } + + #[test] + fn test_world_get_block_returns_air_for_unset() { + let world = MockWorld::new(); + let block = world.get_block([0, 64, 0]); + assert_eq!(block.id, "minecraft:air"); + assert!(block.properties.is_empty()); + } + + #[test] + fn test_world_set_and_get_block_simple() { + let mut world = MockWorld::new(); + let stone = Block { + id: "minecraft:stone".to_string(), + properties: Default::default(), + }; + + world.set_block([0, 64, 0], &stone); + let retrieved = world.get_block([0, 64, 0]); + + assert_eq!(retrieved.id, "minecraft:stone"); + assert!(retrieved.properties.is_empty()); + } + + #[test] + fn test_world_set_and_get_block_with_string_properties() { + let mut world = MockWorld::new(); + let mut properties = FxHashMap::default(); + properties.insert("facing".to_string(), json!("north")); + properties.insert("half".to_string(), json!("top")); + + let stairs = Block { + id: "minecraft:oak_stairs".to_string(), + properties, + }; + + world.set_block([1, 65, 2], &stairs); + let retrieved = world.get_block([1, 65, 2]); + + assert_eq!(retrieved.id, "minecraft:oak_stairs"); + assert_eq!(retrieved.properties.get("facing"), Some(&"north".to_string())); + assert_eq!(retrieved.properties.get("half"), Some(&"top".to_string())); + } + + #[test] + fn test_world_set_and_get_block_with_bool_properties() { + let mut world = MockWorld::new(); + let mut properties = FxHashMap::default(); + properties.insert("powered".to_string(), json!(true)); + properties.insert("lit".to_string(), json!(false)); + + let lamp = Block { + id: "minecraft:redstone_lamp".to_string(), + properties, + }; + + world.set_block([0, 0, 0], &lamp); + let retrieved = world.get_block([0, 0, 0]); + + assert_eq!(retrieved.id, "minecraft:redstone_lamp"); + assert_eq!(retrieved.properties.get("powered"), Some(&"true".to_string())); + assert_eq!(retrieved.properties.get("lit"), Some(&"false".to_string())); + } + + #[test] + fn test_world_set_and_get_block_with_number_properties() { + let mut world = MockWorld::new(); + let mut properties = FxHashMap::default(); + properties.insert("delay".to_string(), json!(2)); + properties.insert("facing".to_string(), json!("south")); + + let repeater = Block { + id: "minecraft:repeater".to_string(), + properties, + }; + + world.set_block([5, 64, 5], &repeater); + let retrieved = world.get_block([5, 64, 5]); + + assert_eq!(retrieved.id, "minecraft:repeater"); + assert_eq!(retrieved.properties.get("delay"), Some(&"2".to_string())); + assert_eq!(retrieved.properties.get("facing"), Some(&"south".to_string())); + } + + #[test] + fn test_world_overwrite_block() { + let mut world = MockWorld::new(); + let pos = [10, 64, 10]; + + let stone = Block { + id: "minecraft:stone".to_string(), + properties: Default::default(), + }; + world.set_block(pos, &stone); + assert_eq!(world.get_block(pos).id, "minecraft:stone"); + + let dirt = Block { + id: "minecraft:dirt".to_string(), + properties: Default::default(), + }; + world.set_block(pos, &dirt); + assert_eq!(world.get_block(pos).id, "minecraft:dirt"); + } + + #[test] + fn test_world_multiple_positions() { + let mut world = MockWorld::new(); + + let stone = Block { + id: "minecraft:stone".to_string(), + properties: Default::default(), + }; + let dirt = Block { + id: "minecraft:dirt".to_string(), + properties: Default::default(), + }; + let grass = Block { + id: "minecraft:grass_block".to_string(), + properties: Default::default(), + }; + + world.set_block([0, 0, 0], &stone); + world.set_block([1, 1, 1], &dirt); + world.set_block([2, 2, 2], &grass); + + assert_eq!(world.get_block([0, 0, 0]).id, "minecraft:stone"); + assert_eq!(world.get_block([1, 1, 1]).id, "minecraft:dirt"); + assert_eq!(world.get_block([2, 2, 2]).id, "minecraft:grass_block"); + assert_eq!(world.get_block([3, 3, 3]).id, "minecraft:air"); + } + + #[test] + fn test_world_negative_coordinates() { + let mut world = MockWorld::new(); + let stone = Block { + id: "minecraft:stone".to_string(), + properties: Default::default(), + }; + + world.set_block([-100, -64, -100], &stone); + let retrieved = world.get_block([-100, -64, -100]); + + assert_eq!(retrieved.id, "minecraft:stone"); + } + + #[test] + fn test_world_create_player() { + let mut world = MockWorld::new(); + let player = world.create_player(); + assert_eq!(player.selected_hotbar(), 1); + } + + #[test] + fn test_world_all_blocks() { + let mut world = MockWorld::new(); + let stone = Block { + id: "minecraft:stone".to_string(), + properties: Default::default(), + }; + + world.set_block([0, 0, 0], &stone); + world.set_block([1, 1, 1], &stone); + + let blocks = world.all_blocks(); + assert_eq!(blocks.len(), 2); + assert!(blocks.contains_key(&[0, 0, 0])); + assert!(blocks.contains_key(&[1, 1, 1])); + } + + // ========================================================================== + // MockPlayer Tests + // ========================================================================== + + #[test] + fn test_player_new() { + let player = MockPlayer::new(); + assert_eq!(player.selected_hotbar(), 1); + } + + #[test] + fn test_player_default() { + let player = MockPlayer::default(); + assert_eq!(player.selected_hotbar(), 1); + } + + #[test] + fn test_player_select_hotbar_valid() { + let mut player = MockPlayer::new(); + + for slot in 1..=9 { + player.select_hotbar(slot); + assert_eq!(player.selected_hotbar(), slot); + } + } + + #[test] + fn test_player_select_hotbar_invalid_zero() { + let mut player = MockPlayer::new(); + player.select_hotbar(5); + assert_eq!(player.selected_hotbar(), 5); + + player.select_hotbar(0); + // Should remain unchanged + assert_eq!(player.selected_hotbar(), 5); + } + + #[test] + fn test_player_select_hotbar_invalid_too_high() { + let mut player = MockPlayer::new(); + player.select_hotbar(5); + assert_eq!(player.selected_hotbar(), 5); + + player.select_hotbar(10); + // Should remain unchanged + assert_eq!(player.selected_hotbar(), 5); + } + + #[test] + fn test_player_set_and_get_slot() { + let mut player = MockPlayer::new(); + let item = Item::new("minecraft:diamond_sword"); + + player.set_slot(PlayerSlot::Hotbar1, Some(&item)); + let retrieved = player.get_slot(PlayerSlot::Hotbar1); + + assert!(retrieved.is_some()); + let retrieved = retrieved.unwrap(); + assert_eq!(retrieved.id, "minecraft:diamond_sword"); + assert_eq!(retrieved.count, 1); + } + + #[test] + fn test_player_get_empty_slot() { + let player = MockPlayer::new(); + let retrieved = player.get_slot(PlayerSlot::Hotbar1); + assert!(retrieved.is_none()); + } + + #[test] + fn test_player_set_slot_with_count() { + let mut player = MockPlayer::new(); + let item = Item::with_count("minecraft:cobblestone", 64); + + player.set_slot(PlayerSlot::Hotbar2, Some(&item)); + let retrieved = player.get_slot(PlayerSlot::Hotbar2).unwrap(); + + assert_eq!(retrieved.id, "minecraft:cobblestone"); + assert_eq!(retrieved.count, 64); + } + + #[test] + fn test_player_clear_slot() { + let mut player = MockPlayer::new(); + let item = Item::new("minecraft:stick"); + + player.set_slot(PlayerSlot::Hotbar3, Some(&item)); + assert!(player.get_slot(PlayerSlot::Hotbar3).is_some()); + + player.set_slot(PlayerSlot::Hotbar3, None); + assert!(player.get_slot(PlayerSlot::Hotbar3).is_none()); + } + + #[test] + fn test_player_multiple_slots() { + let mut player = MockPlayer::new(); + + let sword = Item::new("minecraft:diamond_sword"); + let pickaxe = Item::new("minecraft:diamond_pickaxe"); + let food = Item::with_count("minecraft:cooked_beef", 32); + + player.set_slot(PlayerSlot::Hotbar1, Some(&sword)); + player.set_slot(PlayerSlot::Hotbar2, Some(&pickaxe)); + player.set_slot(PlayerSlot::Hotbar9, Some(&food)); + + assert_eq!(player.get_slot(PlayerSlot::Hotbar1).unwrap().id, "minecraft:diamond_sword"); + assert_eq!(player.get_slot(PlayerSlot::Hotbar2).unwrap().id, "minecraft:diamond_pickaxe"); + assert_eq!(player.get_slot(PlayerSlot::Hotbar9).unwrap().id, "minecraft:cooked_beef"); + assert_eq!(player.get_slot(PlayerSlot::Hotbar9).unwrap().count, 32); + } + + #[test] + fn test_player_overwrite_slot() { + let mut player = MockPlayer::new(); + + let sword = Item::new("minecraft:iron_sword"); + player.set_slot(PlayerSlot::Hotbar1, Some(&sword)); + assert_eq!(player.get_slot(PlayerSlot::Hotbar1).unwrap().id, "minecraft:iron_sword"); + + let better_sword = Item::new("minecraft:diamond_sword"); + player.set_slot(PlayerSlot::Hotbar1, Some(&better_sword)); + assert_eq!(player.get_slot(PlayerSlot::Hotbar1).unwrap().id, "minecraft:diamond_sword"); + } + + #[test] + fn test_player_use_item_on_does_not_panic() { + let mut player = MockPlayer::new(); + // use_item_on is a no-op in mock, but should not panic + player.use_item_on([0, 64, 0], &BlockFace::Top); + player.use_item_on([-100, 0, 100], &BlockFace::Bottom); + } + + // ========================================================================== + // Integration Tests + // ========================================================================== + + #[test] + fn test_adapter_world_player_integration() { + let adapter = MockAdapter::new(); + let mut world = adapter.create_test_world(); + + // Place some blocks + let stone = Block { + id: "minecraft:stone".to_string(), + properties: Default::default(), + }; + world.set_block([0, 64, 0], &stone); + + // Advance ticks + world.do_tick(); + world.do_tick(); + + // Create player and set inventory + let mut player = world.create_player(); + let item = Item::new("minecraft:honeycomb"); + player.set_slot(PlayerSlot::Hotbar1, Some(&item)); + player.select_hotbar(1); + + // Use item + player.use_item_on([0, 64, 0], &BlockFace::Top); + + // Verify state + assert_eq!(world.current_tick(), 2); + assert_eq!(world.get_block([0, 64, 0]).id, "minecraft:stone"); + assert_eq!(player.get_slot(PlayerSlot::Hotbar1).unwrap().id, "minecraft:honeycomb"); + } +} diff --git a/src/runner.rs b/src/runner.rs new file mode 100644 index 0000000..b7fcf25 --- /dev/null +++ b/src/runner.rs @@ -0,0 +1,302 @@ +//! Test execution engine. +//! +//! The `TestRunner` loads tests and executes them against a server adapter. + +use crate::traits::{BlockData, FlintAdapter, FlintPlayer, FlintWorld, Item, PlayerSlot}; +use rustc_hash::FxHashMap; +use std::time::Instant; +use crate::results::{ActionOutcome, AssertFailure, AssertionResult, InfoType, TestResult, TestSummary}; +use crate::test_spec::ActionType; +use crate::{Block, TestSpec}; +use crate::timeline::TimelineAggregate; + +/// Configuration for test execution +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct TestRunConfig { + /// Enable debug mode with breakpoints + pub debug_enabled: bool, + /// Run tests in parallel + pub parallel: bool, + /// Maximum parallel test worlds + pub max_parallel_worlds: usize, +} + +#[allow(dead_code)] +impl Default for TestRunConfig { + fn default() -> Self { + Self { + debug_enabled: false, + parallel: false, + max_parallel_worlds: 4, + } + } +} + +/// Test execution engine +pub struct TestRunner<'a, A: FlintAdapter> { + adapter: &'a A, + // is needed for later, to run multiple tests in parallel or have more configs + // config: TestRunConfig, +} + +impl<'a, A: FlintAdapter> TestRunner<'a, A> { + pub fn new(adapter: &'a A) -> Self { + Self { adapter } + } + + /// Run a single test + pub fn run_test(&self, spec: &TestSpec) -> TestResult { + let start_time = Instant::now(); + let mut world = self.adapter.create_test_world(); + + // Build timeline for single test (no offset) + let tests_with_offsets = vec![(spec.clone(), [0i32, 0, 0])]; + let timeline = TimelineAggregate::from_tests(&tests_with_offsets); + + let mut result = TestResult::new(&spec.name); + + // Player is created on demand when player actions are used + let mut player: Option> = None; + + // Initialize player from config if present (advanced mode) + if let Some(setup) = &spec.setup + && let Some(player_config) = setup.player.as_ref() + { + let p = player.get_or_insert_with(|| world.create_player()); + + // Set initial inventory + for (slot_name, slot_config) in &player_config.inventory { + let item = Item::with_count(&slot_config.item, slot_config.count); + p.set_slot(*slot_name, Some(&item)); + } + + // Set initial hotbar selection + p.select_hotbar(player_config.selected_hotbar); + } + + // Execute timeline tick by tick + for tick in 0..=timeline.max_tick { + // Check for breakpoints (debug mode) + // if self.config.debug_enabled && timeline.breakpoints.contains(&tick) { + // // TODO: Implement breakpoint pause mechanism + // // For now, just continue + // } + + // Execute actions for this tick + if let Some(actions) = timeline.timeline.get(&tick) { + for (_test_idx, entry, _value_idx) in actions.iter() { + match self.execute_action(&mut *world, &mut player, &entry.action_type, tick) { + ActionOutcome::Action => {} + ActionOutcome::AssertPassed => { + result.add_assertion(AssertionResult::Success(tick)); + } + ActionOutcome::AssertFailed(fail) => { + result.add_assertion(AssertionResult::Failure(fail)); + result.success = false; + result.total_ticks = tick; + result.execution_time_ms = start_time.elapsed().as_millis() as u64; + return result; + } + } + } + } + + // Advance game tick + world.do_tick(); + } + + result.total_ticks = timeline.max_tick; + result.execution_time_ms = start_time.elapsed().as_millis() as u64; + result + } + + /// Run multiple tests + pub fn run_tests(&self, specs: &[TestSpec]) -> TestSummary { + // For now, run sequentially + // TODO: Implement parallel execution + let results: Vec = specs.iter().map(|spec| self.run_test(spec)).collect(); + TestSummary::from_results(results) + } + + /// Execute a single action + fn execute_action( + &self, + world: &mut dyn FlintWorld, + player: &mut Option>, + action: &ActionType, + _tick: u32, + ) -> ActionOutcome { + match action { + ActionType::Place { pos, block } => { + let pos = [pos[0], pos[1], pos[2]]; + world.set_block(pos, block); + ActionOutcome::Action + } + + ActionType::PlaceEach { blocks } => { + for placement in blocks { + let pos = [placement.pos[0], placement.pos[1], placement.pos[2]]; + world.set_block(pos, &placement.block); + } + ActionOutcome::Action + } + + ActionType::Fill { region, with } => { + // Flint handles fill by iterating set_block + // Handle potentially inverted coordinates + let min_x = region[0][0].min(region[1][0]); + let max_x = region[0][0].max(region[1][0]); + let min_y = region[0][1].min(region[1][1]); + let max_y = region[0][1].max(region[1][1]); + let min_z = region[0][2].min(region[1][2]); + let max_z = region[0][2].max(region[1][2]); + + for x in min_x..=max_x { + for y in min_y..=max_y { + for z in min_z..=max_z { + world.set_block([x, y, z], with); + } + } + } + ActionOutcome::Action + } + + ActionType::Remove { pos } => { + let pos = [pos[0], pos[1], pos[2]]; + let air = Block { + id: "minecraft:air".to_string(), + properties: Default::default(), + }; + world.set_block(pos, &air); + ActionOutcome::Action + } + + ActionType::Assert { checks } => { + for check in checks { + let pos = [check.pos[0], check.pos[1], check.pos[2]]; + let actual = world.get_block(pos); + + if !block_matches(&actual, &check.is) { + // Convert expected Block properties to BlockData format + let expected_props: FxHashMap = check + .is + .properties + .iter() + .filter_map(|(k, v)| { + let value = match v { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + _ => return None, + }; + Some((k.clone(), value)) + }) + .collect(); + return ActionOutcome::AssertFailed(AssertFailure { + tick: _tick, + error_message: format!( + "Block mismatch at {:?}: expected '{}', got '{}'", + pos, + BlockData::with_properties(&check.is.id, expected_props.clone()), + actual, + ), + position: pos, + execution_time_ms: None, + expected: InfoType::Block( + BlockData::with_properties(&check.is.id, expected_props.clone()) + .into_block(), + ), + actual: InfoType::Block(actual.into_block()), + }); + } + } + ActionOutcome::AssertPassed + } + + ActionType::UseItemOn { pos, face, item } => { + // Create player on demand if not already created + let p = player.get_or_insert_with(|| world.create_player()); + let pos = [pos[0], pos[1], pos[2]]; + + // Simple mode: if item is specified, set it in hotbar1 and select it + if let Some(item_id) = item { + let item = Item::new(item_id); + p.set_slot(PlayerSlot::Hotbar1, Some(&item)); + p.select_hotbar(1); + } + + p.use_item_on(pos, &face); + ActionOutcome::Action + } + + ActionType::SetSlot { slot, item, count } => { + // Create player on demand if not already created + let p = player.get_or_insert_with(|| world.create_player()); + if let Some(item_id) = item { + let item = Item::with_count(item_id, *count); + p.set_slot(*slot, Some(&item)); + } else { + p.set_slot(*slot, None); + } + ActionOutcome::Action + } + + ActionType::SelectHotbar { slot } => { + // Create player on demand if not already created + let p = player.get_or_insert_with(|| world.create_player()); + p.select_hotbar(*slot); + ActionOutcome::Action + } + } + } +} + +/// Check if actual block matches expected +fn block_matches(actual: &BlockData, expected: &Block) -> bool { + // Check block ID + if actual.id != expected.id { + // Also try without minecraft: prefix + let expected_id = if expected.id.starts_with("minecraft:") { + &expected.id[10..] + } else { + &expected.id + }; + let actual_id = if actual.id.starts_with("minecraft:") { + &actual.id[10..] + } else { + &actual.id + }; + if actual_id != expected_id { + return false; + } + } + + // Check properties if specified + for (key, expected_value) in &expected.properties { + // Skip the "properties" key itself (nested format) - it's handled by Block::to_command + if key == "properties" { + continue; + } + + // Convert expected value to string for comparison + let expected_str = match expected_value { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Null => continue, // Skip null values + _ => continue, // Skip complex types (arrays, objects) + }; + + if let Some(actual_value) = actual.properties.get(key) { + if actual_value != &expected_str { + return false; + } + } else { + // Property expected but not found in actual block - this is a mismatch + return false; + } + } + + true +} diff --git a/src/traits.rs b/src/traits.rs new file mode 100644 index 0000000..6014e4c --- /dev/null +++ b/src/traits.rs @@ -0,0 +1,188 @@ +//! Core traits that server implementations must provide. +//! +//! Servers implement `FlintAdapter` to create test worlds, and `FlintWorld`/`FlintPlayer` +//! to provide the actual block and player operations. + +use std::fmt::{Display, Formatter}; +use rustc_hash::FxHashMap; +use serde::Serialize; +pub(crate) use crate::Block; +pub(crate) use crate::test_spec::{BlockFace, PlayerSlot}; + +/// Position in world coordinates [x, y, z] +pub type BlockPos = [i32; 3]; + +/// An item that can be held or placed in a slot +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Item { + /// Item identifier, e.g., "minecraft:honeycomb" + pub id: String, + /// Stack count (default 1) + pub count: u8, +} + +impl Item { + pub fn new(id: impl Into) -> Self { + let id = id.into(); + if id.starts_with("empty") { + return Item::empty() + } + Self { + id, + count: 1, + } + } + + pub fn empty() -> Self { + Self { + id: "minecraft:air".to_string(), + count: 0, + } + } + + pub fn with_count(id: impl Into, count: u8) -> Self { + Self { + id: id.into(), + count, + } + } +} + +/// Block data returned from get_block +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct BlockData { + /// Block identifier, e.g., "minecraft:stone" + pub id: String, + /// Block state properties, e.g., {"powered": "true", "facing": "north"} + pub properties: FxHashMap, +} +impl Display for BlockData { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.id)?; + + if !self.properties.is_empty() { + write!(f, "[")?; + + let mut properties: Vec<_> = self.properties.iter().collect(); + properties.sort_by_key(|(key, _)| *key); + + for (i, (key, value)) in properties.iter().enumerate() { + if i > 0 { + write!(f, ",")?; + } + write!(f, "{}={}", key, value)?; + } + + write!(f, "]")?; + } + + Ok(()) + } +} + +impl BlockData { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + properties: FxHashMap::default(), + } + } + + pub fn with_properties(id: impl Into, properties: FxHashMap) -> Self { + Self { + id: id.into(), + properties, + } + } + + /// Check if this block is air + pub fn is_air(&self) -> bool { + self.id == "minecraft:air" || self.id == "air" + } + + /// Convert to a Block spec + pub fn into_block(self) -> Block { + Block { + id: self.id, + properties: self + .properties + .into_iter() + .map(|(k, v)| (k, serde_json::Value::String(v))) + .collect(), + } + } +} + +impl From for Block { + fn from(data: BlockData) -> Self { + data.into_block() + } +} + +/// Server metadata +#[derive(Debug, Clone)] +pub struct ServerInfo { + pub minecraft_version: String, +} + +// ============================================================================= +// Core Traits +// ============================================================================= + +/// Main adapter trait - server implements this to create test worlds +pub trait FlintAdapter: Send + Sync { + /// Create a new disposable in-memory test world + fn create_test_world(&self) -> Box; + + /// Server metadata for logging + fn server_info(&self) -> ServerInfo; +} + +/// World operations - server implements this +/// +/// This is the minimal interface servers must provide. +/// Flint handles fill/clear by iterating `set_block()`. +pub trait FlintWorld: Send + Sync { + /// Execute exactly one game tick + fn do_tick(&mut self); + + /// Get current tick count + fn current_tick(&self) -> u64; + + /// Get block at position + fn get_block(&self, pos: BlockPos) -> BlockData; + + /// Set block at position (with neighbor updates) + fn set_block(&mut self, pos: BlockPos, block: &Block); + + /// Create a simulated player in this world + /// + /// Only called when tests use `use_item_on` or player-related actions. + /// Pure block tests (place, fill, assert) don't need a player. + fn create_player(&mut self) -> Box; +} + +/// Player operations - server implements this +/// +/// Hybrid model: Server owns the player entity, but flint can: +/// - Manipulate inventory slots directly +/// - Select hotbar slots +/// - Trigger item use actions +pub trait FlintPlayer: Send + Sync { + /// Set item in a slot (None = empty/clear the slot) + fn set_slot(&mut self, slot: PlayerSlot, item: Option<&Item>); + + /// Get item from a slot (None if empty) + fn get_slot(&self, slot: PlayerSlot) -> Option; + + /// Select which hotbar slot is active (1-9) + fn select_hotbar(&mut self, slot: u8); + + /// Get currently selected hotbar slot (1-9) + fn selected_hotbar(&self) -> u8; + + /// Use the item in the active hotbar slot on a block face + /// + /// This tests the server's actual interaction logic. + fn use_item_on(&mut self, pos: BlockPos, face: &BlockFace); +} From 720c07a5e2785dd409b1aa934e6a374745e1cb75 Mon Sep 17 00:00:00 2001 From: JunkyDeveloper Date: Sun, 1 Feb 2026 02:57:21 +0100 Subject: [PATCH 11/17] removed unused files --- src/filter.rs | 437 --------------------------------------- src/lib.rs | 10 +- src/mock.rs | 551 -------------------------------------------------- 3 files changed, 3 insertions(+), 995 deletions(-) delete mode 100644 src/filter.rs delete mode 100644 src/mock.rs diff --git a/src/filter.rs b/src/filter.rs deleted file mode 100644 index aeea12c..0000000 --- a/src/filter.rs +++ /dev/null @@ -1,437 +0,0 @@ -//! Test filtering and selection. -//! -//! Provides ways to select which tests to run based on tags, names, or patterns. - -use std::path::Path; - -use anyhow::Result; -use crate::{TestLoader, TestSpec}; - -/// Criteria for selecting tests to run. -#[derive(Debug, Clone, Default)] -pub struct TestFilter { - /// Run only tests with these tags (empty = no tag filter) - pub tags: Vec, - /// Run only tests matching these name patterns (supports glob: `*`, `?`) - pub name_patterns: Vec, - /// Run only this specific test by exact name - pub exact_name: Option, -} - -impl TestFilter { - /// Create a filter that matches all tests. - pub fn all() -> Self { - Self::default() - } - - /// Create a filter for tests with specific tags. - pub fn by_tags(tags: impl IntoIterator>) -> Self { - Self { - tags: tags.into_iter().map(Into::into).collect(), - ..Default::default() - } - } - - /// Create a filter for a single test by exact name. - pub fn by_name(name: impl Into) -> Self { - Self { - exact_name: Some(name.into()), - ..Default::default() - } - } - - /// Create a filter for tests matching name patterns (glob syntax). - pub fn by_patterns(patterns: impl IntoIterator>) -> Self { - Self { - name_patterns: patterns.into_iter().map(Into::into).collect(), - ..Default::default() - } - } - - /// Add tags to the filter (builder pattern). - pub fn with_tags(mut self, tags: impl IntoIterator>) -> Self { - self.tags.extend(tags.into_iter().map(Into::into)); - self - } - - /// Add name patterns to the filter (builder pattern). - pub fn with_patterns(mut self, patterns: impl IntoIterator>) -> Self { - self.name_patterns - .extend(patterns.into_iter().map(Into::into)); - self - } - - /// Set exact name match (builder pattern). - pub fn with_exact_name(mut self, name: impl Into) -> Self { - self.exact_name = Some(name.into()); - self - } - - /// Check if a test spec matches this filter. - pub fn matches(&self, spec: &TestSpec) -> bool { - // Check exact name first - if let Some(ref exact) = self.exact_name - && spec.name != *exact - { - return false; - } - - // Check name patterns - if !self.name_patterns.is_empty() { - let matches_any = self - .name_patterns - .iter() - .any(|pattern| glob_match(pattern, &spec.name)); - if !matches_any { - return false; - } - } - - // Check tags (test must have at least one matching tag) - if !self.tags.is_empty() { - let has_matching_tag = self - .tags - .iter() - .any(|tag| spec.tags.iter().any(|t| t == tag)); - if !has_matching_tag { - return false; - } - } - - true - } - - /// Returns true if no filters are set (matches everything). - pub fn is_empty(&self) -> bool { - self.tags.is_empty() && self.name_patterns.is_empty() && self.exact_name.is_none() - } -} - -/// Simple glob matching supporting `*` (any chars) and `?` (single char). -fn glob_match(pattern: &str, text: &str) -> bool { - let pattern_chars: Vec = pattern.chars().collect(); - let text_chars: Vec = text.chars().collect(); - - glob_match_recursive(&pattern_chars, &text_chars, 0, 0) -} - -fn glob_match_recursive(pattern: &[char], text: &[char], pi: usize, ti: usize) -> bool { - if pi == pattern.len() && ti == text.len() { - return true; - } - if pi == pattern.len() { - return false; - } - - match pattern[pi] { - '*' => { - // Try matching zero or more characters - for i in ti..=text.len() { - if glob_match_recursive(pattern, text, pi + 1, i) { - return true; - } - } - false - } - '?' => { - // Match exactly one character - if ti < text.len() { - glob_match_recursive(pattern, text, pi + 1, ti + 1) - } else { - false - } - } - c => { - // Match literal character - if ti < text.len() && text[ti] == c { - glob_match_recursive(pattern, text, pi + 1, ti + 1) - } else { - false - } - } - } -} - -/// Load and filter tests from a directory. -pub struct TestSelector { - loader: TestLoader, -} - -impl TestSelector { - /// Create a new test selector for the given test directory. - pub fn new(test_path: &Path) -> Result { - let loader = TestLoader::new(test_path, true)?; - Ok(Self { loader }) - } - - /// Load all test specs, optionally filtered. - pub fn load_tests(&self, filter: &TestFilter) -> Result> { - // Get paths based on tag filter (if any) - let paths = if !filter.tags.is_empty() { - self.loader.collect_by_tags(&filter.tags)? - } else { - self.loader.collect_all_test_files()? - }; - - // Load specs and apply additional filters - let mut specs = Vec::new(); - for path in paths { - match TestSpec::from_file(&path) { - Ok(spec) => { - if filter.matches(&spec) { - specs.push(spec); - } - } - Err(e) => { - // Log but don't fail - continue with other tests - eprintln!("Warning: Failed to load test {}: {}", path.display(), e); - } - } - } - - Ok(specs) - } - - /// Load a single test by exact name. - pub fn load_test_by_name(&self, name: &str) -> Result> { - let paths = self.loader.collect_all_test_files()?; - - for path in paths { - if let Ok(spec) = TestSpec::from_file(&path) - && spec.name == name - { - return Ok(Some(spec)); - } - } - - Ok(None) - } - - /// Get all available test names. - pub fn list_test_names(&self) -> Result> { - let paths = self.loader.collect_all_test_files()?; - let mut names = Vec::new(); - - for path in paths { - if let Ok(spec) = TestSpec::from_file(&path) { - names.push(spec.name); - } - } - - names.sort(); - Ok(names) - } - - /// Get all available tags. - pub fn list_tags(&self) -> Result> { - let paths = self.loader.collect_all_test_files()?; - let mut tags = std::collections::HashSet::new(); - - for path in paths { - if let Ok(spec) = TestSpec::from_file(&path) { - for tag in spec.tags { - tags.insert(tag); - } - } - } - - let mut tags: Vec<_> = tags.into_iter().collect(); - tags.sort(); - Ok(tags) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_spec(name: &str, tags: &[&str]) -> TestSpec { - TestSpec { - flint_version: Some("0.1".to_string()), - name: name.to_string(), - description: None, - tags: tags.iter().map(|s| s.to_string()).collect(), - dependencies: vec![], - setup: None, - timeline: vec![], - breakpoints: vec![], - } - } - - // ========================================================================== - // TestFilter Tests - // ========================================================================== - - #[test] - fn test_filter_all_matches_everything() { - let filter = TestFilter::all(); - let spec = make_spec("any_test", &["tag1", "tag2"]); - assert!(filter.matches(&spec)); - } - - #[test] - fn test_filter_is_empty() { - assert!(TestFilter::all().is_empty()); - assert!(!TestFilter::by_tags(["foo"]).is_empty()); - assert!(!TestFilter::by_name("test").is_empty()); - assert!(!TestFilter::by_patterns(["*"]).is_empty()); - } - - #[test] - fn test_filter_by_exact_name() { - let filter = TestFilter::by_name("copper_waxing"); - - assert!(filter.matches(&make_spec("copper_waxing", &[]))); - assert!(!filter.matches(&make_spec("copper_oxidation", &[]))); - assert!(!filter.matches(&make_spec("copper_waxing_test", &[]))); - } - - #[test] - fn test_filter_by_tags_single() { - let filter = TestFilter::by_tags(["redstone"]); - - assert!(filter.matches(&make_spec("test1", &["redstone"]))); - assert!(filter.matches(&make_spec("test2", &["redstone", "other"]))); - assert!(!filter.matches(&make_spec("test3", &["copper"]))); - assert!(!filter.matches(&make_spec("test4", &[]))); - } - - #[test] - fn test_filter_by_tags_multiple() { - let filter = TestFilter::by_tags(["redstone", "copper"]); - - // Matches if test has ANY of the specified tags - assert!(filter.matches(&make_spec("test1", &["redstone"]))); - assert!(filter.matches(&make_spec("test2", &["copper"]))); - assert!(filter.matches(&make_spec("test3", &["redstone", "copper"]))); - assert!(!filter.matches(&make_spec("test4", &["iron"]))); - } - - #[test] - fn test_filter_by_pattern_star() { - let filter = TestFilter::by_patterns(["copper_*"]); - - assert!(filter.matches(&make_spec("copper_waxing", &[]))); - assert!(filter.matches(&make_spec("copper_oxidation", &[]))); - assert!(filter.matches(&make_spec("copper_", &[]))); - assert!(!filter.matches(&make_spec("iron_block", &[]))); - assert!(!filter.matches(&make_spec("coppertest", &[]))); - } - - #[test] - fn test_filter_by_pattern_question() { - let filter = TestFilter::by_patterns(["test_?"]); - - assert!(filter.matches(&make_spec("test_1", &[]))); - assert!(filter.matches(&make_spec("test_a", &[]))); - assert!(!filter.matches(&make_spec("test_12", &[]))); - assert!(!filter.matches(&make_spec("test_", &[]))); - } - - #[test] - fn test_filter_by_pattern_combined() { - let filter = TestFilter::by_patterns(["*_test_*"]); - - assert!(filter.matches(&make_spec("copper_test_1", &[]))); - assert!(filter.matches(&make_spec("redstone_test_basic", &[]))); - assert!(!filter.matches(&make_spec("test_basic", &[]))); - } - - #[test] - fn test_filter_multiple_patterns() { - let filter = TestFilter::by_patterns(["copper_*", "redstone_*"]); - - assert!(filter.matches(&make_spec("copper_waxing", &[]))); - assert!(filter.matches(&make_spec("redstone_repeater", &[]))); - assert!(!filter.matches(&make_spec("iron_block", &[]))); - } - - #[test] - fn test_filter_combined_tags_and_patterns() { - let filter = TestFilter::all() - .with_tags(["redstone"]) - .with_patterns(["*_test"]); - - // Must match both: has "redstone" tag AND name ends with "_test" - assert!(filter.matches(&make_spec("repeater_test", &["redstone"]))); - assert!(!filter.matches(&make_spec("repeater_test", &["copper"]))); // wrong tag - assert!(!filter.matches(&make_spec("repeater", &["redstone"]))); // wrong pattern - } - - #[test] - fn test_filter_builder_pattern() { - let filter = TestFilter::all() - .with_tags(["a", "b"]) - .with_patterns(["test_*"]) - .with_exact_name("test_specific"); - - assert_eq!(filter.tags, vec!["a", "b"]); - assert_eq!(filter.name_patterns, vec!["test_*"]); - assert_eq!(filter.exact_name, Some("test_specific".to_string())); - } - - // ========================================================================== - // Glob Matching Tests - // ========================================================================== - - #[test] - fn test_glob_exact_match() { - assert!(glob_match("hello", "hello")); - assert!(!glob_match("hello", "world")); - assert!(!glob_match("hello", "hello_world")); - } - - #[test] - fn test_glob_star_end() { - assert!(glob_match("hello*", "hello")); - assert!(glob_match("hello*", "hello_world")); - assert!(glob_match("hello*", "hellooooo")); - assert!(!glob_match("hello*", "hell")); - } - - #[test] - fn test_glob_star_start() { - assert!(glob_match("*world", "world")); - assert!(glob_match("*world", "hello_world")); - assert!(!glob_match("*world", "worldly")); - } - - #[test] - fn test_glob_star_middle() { - assert!(glob_match("hello*world", "helloworld")); - assert!(glob_match("hello*world", "hello_world")); - assert!(glob_match("hello*world", "hello_big_world")); - assert!(!glob_match("hello*world", "hello_worlds")); - } - - #[test] - fn test_glob_multiple_stars() { - assert!(glob_match("*a*b*", "ab")); - assert!(glob_match("*a*b*", "xaxbx")); - assert!(glob_match("*a*b*", "aaabbb")); - assert!(!glob_match("*a*b*", "ba")); - } - - #[test] - fn test_glob_question_mark() { - assert!(glob_match("h?llo", "hello")); - assert!(glob_match("h?llo", "hallo")); - assert!(!glob_match("h?llo", "hllo")); - assert!(!glob_match("h?llo", "heello")); - } - - #[test] - fn test_glob_mixed() { - assert!(glob_match("test_?_*", "test_1_abc")); - assert!(glob_match("test_?_*", "test_a_")); - assert!(!glob_match("test_?_*", "test__abc")); - } - - #[test] - fn test_glob_empty() { - assert!(glob_match("", "")); - assert!(glob_match("*", "")); - assert!(glob_match("*", "anything")); - assert!(!glob_match("?", "")); - } -} diff --git a/src/lib.rs b/src/lib.rs index 5588f2c..4886b77 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,21 +2,17 @@ pub mod format; pub mod index; pub mod loader; pub mod results; +pub mod runner; pub mod spatial; pub mod test_spec; pub mod timeline; -pub mod utils; -pub mod filter; -pub mod mock; -pub mod runner; pub mod traits; +pub mod utils; // Re-export main types for convenience -pub use filter::{TestFilter, TestSelector}; -pub use mock::{MockAdapter, MockPlayer, MockWorld}; pub use runner::{TestRunConfig, TestRunner}; pub use traits::{BlockPos, FlintAdapter, FlintPlayer, FlintWorld, Item, ServerInfo}; // Re-export flint-core types commonly used with this library pub use crate::loader::TestLoader; -pub use crate::test_spec::{Block, TestSpec}; \ No newline at end of file +pub use crate::test_spec::{Block, TestSpec}; diff --git a/src/mock.rs b/src/mock.rs deleted file mode 100644 index 4d1a691..0000000 --- a/src/mock.rs +++ /dev/null @@ -1,551 +0,0 @@ -//! Mock adapter for testing flint-steel without a real server. -//! -//! This module provides a simple in-memory implementation of the Flint traits -//! that can be used for unit testing and development. - -use rustc_hash::FxHashMap; -use crate::traits::{ - Block, BlockData, BlockFace, BlockPos, FlintAdapter, FlintPlayer, FlintWorld, Item, PlayerSlot, - ServerInfo, -}; - -/// Mock adapter for testing -pub struct MockAdapter; - -impl MockAdapter { - pub fn new() -> Self { - Self - } -} - -impl Default for MockAdapter { - fn default() -> Self { - Self::new() - } -} - -impl FlintAdapter for MockAdapter { - fn create_test_world(&self) -> Box { - Box::new(MockWorld::new()) - } - - fn server_info(&self) -> ServerInfo { - ServerInfo { - minecraft_version: "1.21".to_string(), - } - } -} - -/// Mock world that stores blocks in a HashMap -pub struct MockWorld { - blocks: FxHashMap, - tick: u64, -} - -impl MockWorld { - pub fn new() -> Self { - Self { - blocks: FxHashMap::default(), - tick: 0, - } - } - - /// Get all blocks in the world (for debugging/testing) - pub fn all_blocks(&self) -> &FxHashMap { - &self.blocks - } -} - -impl Default for MockWorld { - fn default() -> Self { - Self::new() - } -} - -impl FlintWorld for MockWorld { - fn do_tick(&mut self) { - self.tick += 1; - } - - fn current_tick(&self) -> u64 { - self.tick - } - - fn get_block(&self, pos: BlockPos) -> BlockData { - self.blocks - .get(&pos) - .cloned() - .unwrap_or_else(|| BlockData::new("minecraft:air")) - } - - fn set_block(&mut self, pos: BlockPos, block: &Block) { - let data = BlockData { - id: block.id.clone(), - properties: block - .properties - .iter() - .map(|(k, v)| { - let value = match v { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Bool(b) => b.to_string(), - serde_json::Value::Number(n) => n.to_string(), - _ => String::new(), - }; - (k.clone(), value) - }) - .collect(), - }; - self.blocks.insert(pos, data); - } - - fn create_player(&mut self) -> Box { - Box::new(MockPlayer::new()) - } -} - -/// Mock player with inventory -pub struct MockPlayer { - slots: FxHashMap, - selected_hotbar: u8, -} - -impl MockPlayer { - pub fn new() -> Self { - Self { - slots: FxHashMap::default(), - selected_hotbar: 1, - } - } -} - -impl Default for MockPlayer { - fn default() -> Self { - Self::new() - } -} - -impl FlintPlayer for MockPlayer { - fn set_slot(&mut self, slot: PlayerSlot, item: Option<&Item>) { - if let Some(item) = item { - self.slots.insert(slot, item.clone()); - } else { - self.slots.remove(&slot); - } - } - - fn get_slot(&self, slot: PlayerSlot) -> Option { - self.slots.get(&slot).cloned() - } - - fn select_hotbar(&mut self, slot: u8) { - if (1..=9).contains(&slot) { - self.selected_hotbar = slot; - } - } - - fn selected_hotbar(&self) -> u8 { - self.selected_hotbar - } - - fn use_item_on(&mut self, _pos: BlockPos, _face: &BlockFace) { - // Mock implementation - does nothing - // A real server would process the item use interaction - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::traits::Block; - use serde_json::json; - - // ========================================================================== - // MockAdapter Tests - // ========================================================================== - - #[test] - fn test_adapter_new() { - let adapter = MockAdapter::new(); - let info = adapter.server_info(); - assert_eq!(info.minecraft_version, "1.21"); - } - - #[test] - fn test_adapter_default() { - let adapter = MockAdapter::new(); - let info = adapter.server_info(); - assert_eq!(info.minecraft_version, "1.21"); - } - - #[test] - fn test_adapter_creates_world() { - let adapter = MockAdapter::new(); - let world = adapter.create_test_world(); - assert_eq!(world.current_tick(), 0); - } - - // ========================================================================== - // MockWorld Tests - // ========================================================================== - - #[test] - fn test_world_new() { - let world = MockWorld::new(); - assert_eq!(world.current_tick(), 0); - assert!(world.all_blocks().is_empty()); - } - - #[test] - fn test_world_default() { - let world = MockWorld::default(); - assert_eq!(world.current_tick(), 0); - } - - #[test] - fn test_world_do_tick() { - let mut world = MockWorld::new(); - assert_eq!(world.current_tick(), 0); - - world.do_tick(); - assert_eq!(world.current_tick(), 1); - - world.do_tick(); - world.do_tick(); - assert_eq!(world.current_tick(), 3); - } - - #[test] - fn test_world_get_block_returns_air_for_unset() { - let world = MockWorld::new(); - let block = world.get_block([0, 64, 0]); - assert_eq!(block.id, "minecraft:air"); - assert!(block.properties.is_empty()); - } - - #[test] - fn test_world_set_and_get_block_simple() { - let mut world = MockWorld::new(); - let stone = Block { - id: "minecraft:stone".to_string(), - properties: Default::default(), - }; - - world.set_block([0, 64, 0], &stone); - let retrieved = world.get_block([0, 64, 0]); - - assert_eq!(retrieved.id, "minecraft:stone"); - assert!(retrieved.properties.is_empty()); - } - - #[test] - fn test_world_set_and_get_block_with_string_properties() { - let mut world = MockWorld::new(); - let mut properties = FxHashMap::default(); - properties.insert("facing".to_string(), json!("north")); - properties.insert("half".to_string(), json!("top")); - - let stairs = Block { - id: "minecraft:oak_stairs".to_string(), - properties, - }; - - world.set_block([1, 65, 2], &stairs); - let retrieved = world.get_block([1, 65, 2]); - - assert_eq!(retrieved.id, "minecraft:oak_stairs"); - assert_eq!(retrieved.properties.get("facing"), Some(&"north".to_string())); - assert_eq!(retrieved.properties.get("half"), Some(&"top".to_string())); - } - - #[test] - fn test_world_set_and_get_block_with_bool_properties() { - let mut world = MockWorld::new(); - let mut properties = FxHashMap::default(); - properties.insert("powered".to_string(), json!(true)); - properties.insert("lit".to_string(), json!(false)); - - let lamp = Block { - id: "minecraft:redstone_lamp".to_string(), - properties, - }; - - world.set_block([0, 0, 0], &lamp); - let retrieved = world.get_block([0, 0, 0]); - - assert_eq!(retrieved.id, "minecraft:redstone_lamp"); - assert_eq!(retrieved.properties.get("powered"), Some(&"true".to_string())); - assert_eq!(retrieved.properties.get("lit"), Some(&"false".to_string())); - } - - #[test] - fn test_world_set_and_get_block_with_number_properties() { - let mut world = MockWorld::new(); - let mut properties = FxHashMap::default(); - properties.insert("delay".to_string(), json!(2)); - properties.insert("facing".to_string(), json!("south")); - - let repeater = Block { - id: "minecraft:repeater".to_string(), - properties, - }; - - world.set_block([5, 64, 5], &repeater); - let retrieved = world.get_block([5, 64, 5]); - - assert_eq!(retrieved.id, "minecraft:repeater"); - assert_eq!(retrieved.properties.get("delay"), Some(&"2".to_string())); - assert_eq!(retrieved.properties.get("facing"), Some(&"south".to_string())); - } - - #[test] - fn test_world_overwrite_block() { - let mut world = MockWorld::new(); - let pos = [10, 64, 10]; - - let stone = Block { - id: "minecraft:stone".to_string(), - properties: Default::default(), - }; - world.set_block(pos, &stone); - assert_eq!(world.get_block(pos).id, "minecraft:stone"); - - let dirt = Block { - id: "minecraft:dirt".to_string(), - properties: Default::default(), - }; - world.set_block(pos, &dirt); - assert_eq!(world.get_block(pos).id, "minecraft:dirt"); - } - - #[test] - fn test_world_multiple_positions() { - let mut world = MockWorld::new(); - - let stone = Block { - id: "minecraft:stone".to_string(), - properties: Default::default(), - }; - let dirt = Block { - id: "minecraft:dirt".to_string(), - properties: Default::default(), - }; - let grass = Block { - id: "minecraft:grass_block".to_string(), - properties: Default::default(), - }; - - world.set_block([0, 0, 0], &stone); - world.set_block([1, 1, 1], &dirt); - world.set_block([2, 2, 2], &grass); - - assert_eq!(world.get_block([0, 0, 0]).id, "minecraft:stone"); - assert_eq!(world.get_block([1, 1, 1]).id, "minecraft:dirt"); - assert_eq!(world.get_block([2, 2, 2]).id, "minecraft:grass_block"); - assert_eq!(world.get_block([3, 3, 3]).id, "minecraft:air"); - } - - #[test] - fn test_world_negative_coordinates() { - let mut world = MockWorld::new(); - let stone = Block { - id: "minecraft:stone".to_string(), - properties: Default::default(), - }; - - world.set_block([-100, -64, -100], &stone); - let retrieved = world.get_block([-100, -64, -100]); - - assert_eq!(retrieved.id, "minecraft:stone"); - } - - #[test] - fn test_world_create_player() { - let mut world = MockWorld::new(); - let player = world.create_player(); - assert_eq!(player.selected_hotbar(), 1); - } - - #[test] - fn test_world_all_blocks() { - let mut world = MockWorld::new(); - let stone = Block { - id: "minecraft:stone".to_string(), - properties: Default::default(), - }; - - world.set_block([0, 0, 0], &stone); - world.set_block([1, 1, 1], &stone); - - let blocks = world.all_blocks(); - assert_eq!(blocks.len(), 2); - assert!(blocks.contains_key(&[0, 0, 0])); - assert!(blocks.contains_key(&[1, 1, 1])); - } - - // ========================================================================== - // MockPlayer Tests - // ========================================================================== - - #[test] - fn test_player_new() { - let player = MockPlayer::new(); - assert_eq!(player.selected_hotbar(), 1); - } - - #[test] - fn test_player_default() { - let player = MockPlayer::default(); - assert_eq!(player.selected_hotbar(), 1); - } - - #[test] - fn test_player_select_hotbar_valid() { - let mut player = MockPlayer::new(); - - for slot in 1..=9 { - player.select_hotbar(slot); - assert_eq!(player.selected_hotbar(), slot); - } - } - - #[test] - fn test_player_select_hotbar_invalid_zero() { - let mut player = MockPlayer::new(); - player.select_hotbar(5); - assert_eq!(player.selected_hotbar(), 5); - - player.select_hotbar(0); - // Should remain unchanged - assert_eq!(player.selected_hotbar(), 5); - } - - #[test] - fn test_player_select_hotbar_invalid_too_high() { - let mut player = MockPlayer::new(); - player.select_hotbar(5); - assert_eq!(player.selected_hotbar(), 5); - - player.select_hotbar(10); - // Should remain unchanged - assert_eq!(player.selected_hotbar(), 5); - } - - #[test] - fn test_player_set_and_get_slot() { - let mut player = MockPlayer::new(); - let item = Item::new("minecraft:diamond_sword"); - - player.set_slot(PlayerSlot::Hotbar1, Some(&item)); - let retrieved = player.get_slot(PlayerSlot::Hotbar1); - - assert!(retrieved.is_some()); - let retrieved = retrieved.unwrap(); - assert_eq!(retrieved.id, "minecraft:diamond_sword"); - assert_eq!(retrieved.count, 1); - } - - #[test] - fn test_player_get_empty_slot() { - let player = MockPlayer::new(); - let retrieved = player.get_slot(PlayerSlot::Hotbar1); - assert!(retrieved.is_none()); - } - - #[test] - fn test_player_set_slot_with_count() { - let mut player = MockPlayer::new(); - let item = Item::with_count("minecraft:cobblestone", 64); - - player.set_slot(PlayerSlot::Hotbar2, Some(&item)); - let retrieved = player.get_slot(PlayerSlot::Hotbar2).unwrap(); - - assert_eq!(retrieved.id, "minecraft:cobblestone"); - assert_eq!(retrieved.count, 64); - } - - #[test] - fn test_player_clear_slot() { - let mut player = MockPlayer::new(); - let item = Item::new("minecraft:stick"); - - player.set_slot(PlayerSlot::Hotbar3, Some(&item)); - assert!(player.get_slot(PlayerSlot::Hotbar3).is_some()); - - player.set_slot(PlayerSlot::Hotbar3, None); - assert!(player.get_slot(PlayerSlot::Hotbar3).is_none()); - } - - #[test] - fn test_player_multiple_slots() { - let mut player = MockPlayer::new(); - - let sword = Item::new("minecraft:diamond_sword"); - let pickaxe = Item::new("minecraft:diamond_pickaxe"); - let food = Item::with_count("minecraft:cooked_beef", 32); - - player.set_slot(PlayerSlot::Hotbar1, Some(&sword)); - player.set_slot(PlayerSlot::Hotbar2, Some(&pickaxe)); - player.set_slot(PlayerSlot::Hotbar9, Some(&food)); - - assert_eq!(player.get_slot(PlayerSlot::Hotbar1).unwrap().id, "minecraft:diamond_sword"); - assert_eq!(player.get_slot(PlayerSlot::Hotbar2).unwrap().id, "minecraft:diamond_pickaxe"); - assert_eq!(player.get_slot(PlayerSlot::Hotbar9).unwrap().id, "minecraft:cooked_beef"); - assert_eq!(player.get_slot(PlayerSlot::Hotbar9).unwrap().count, 32); - } - - #[test] - fn test_player_overwrite_slot() { - let mut player = MockPlayer::new(); - - let sword = Item::new("minecraft:iron_sword"); - player.set_slot(PlayerSlot::Hotbar1, Some(&sword)); - assert_eq!(player.get_slot(PlayerSlot::Hotbar1).unwrap().id, "minecraft:iron_sword"); - - let better_sword = Item::new("minecraft:diamond_sword"); - player.set_slot(PlayerSlot::Hotbar1, Some(&better_sword)); - assert_eq!(player.get_slot(PlayerSlot::Hotbar1).unwrap().id, "minecraft:diamond_sword"); - } - - #[test] - fn test_player_use_item_on_does_not_panic() { - let mut player = MockPlayer::new(); - // use_item_on is a no-op in mock, but should not panic - player.use_item_on([0, 64, 0], &BlockFace::Top); - player.use_item_on([-100, 0, 100], &BlockFace::Bottom); - } - - // ========================================================================== - // Integration Tests - // ========================================================================== - - #[test] - fn test_adapter_world_player_integration() { - let adapter = MockAdapter::new(); - let mut world = adapter.create_test_world(); - - // Place some blocks - let stone = Block { - id: "minecraft:stone".to_string(), - properties: Default::default(), - }; - world.set_block([0, 64, 0], &stone); - - // Advance ticks - world.do_tick(); - world.do_tick(); - - // Create player and set inventory - let mut player = world.create_player(); - let item = Item::new("minecraft:honeycomb"); - player.set_slot(PlayerSlot::Hotbar1, Some(&item)); - player.select_hotbar(1); - - // Use item - player.use_item_on([0, 64, 0], &BlockFace::Top); - - // Verify state - assert_eq!(world.current_tick(), 2); - assert_eq!(world.get_block([0, 64, 0]).id, "minecraft:stone"); - assert_eq!(player.get_slot(PlayerSlot::Hotbar1).unwrap().id, "minecraft:honeycomb"); - } -} From 3046c152fa32babcbd5d50267adbb618eeaab497 Mon Sep 17 00:00:00 2001 From: JunkyDeveloper Date: Sun, 1 Feb 2026 02:59:56 +0100 Subject: [PATCH 12/17] fmt --check is now happy --- .github/workflows/coverage.yml | 49 ---------------------------------- src/runner.rs | 10 ++++--- src/test_spec.rs | 10 +++---- src/traits.rs | 15 +++++------ 4 files changed, 17 insertions(+), 67 deletions(-) delete mode 100644 .github/workflows/coverage.yml diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml deleted file mode 100644 index b2b6d1b..0000000 --- a/.github/workflows/coverage.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Coverage - -on: - push: - branches: [main] - pull_request: - branches: [main] - -env: - CARGO_TERM_COLOR: always - -jobs: - coverage: - name: Code Coverage - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-action@stable - - - name: Install cargo-tarpaulin - run: cargo install cargo-tarpaulin - - - name: Cache cargo registry - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-coverage-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo-coverage- - - - name: Generate coverage report - run: cargo tarpaulin --out xml --out html --output-dir coverage - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - with: - files: coverage/cobertura.xml - fail_ci_if_error: false - - - name: Upload coverage artifacts - uses: actions/upload-artifact@v4 - with: - name: coverage-report - path: coverage/ diff --git a/src/runner.rs b/src/runner.rs index b7fcf25..c058a9a 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -2,13 +2,15 @@ //! //! The `TestRunner` loads tests and executes them against a server adapter. +use crate::results::{ + ActionOutcome, AssertFailure, AssertionResult, InfoType, TestResult, TestSummary, +}; +use crate::test_spec::ActionType; +use crate::timeline::TimelineAggregate; use crate::traits::{BlockData, FlintAdapter, FlintPlayer, FlintWorld, Item, PlayerSlot}; +use crate::{Block, TestSpec}; use rustc_hash::FxHashMap; use std::time::Instant; -use crate::results::{ActionOutcome, AssertFailure, AssertionResult, InfoType, TestResult, TestSummary}; -use crate::test_spec::ActionType; -use crate::{Block, TestSpec}; -use crate::timeline::TimelineAggregate; /// Configuration for test execution #[allow(dead_code)] diff --git a/src/test_spec.rs b/src/test_spec.rs index 851df00..2369aa9 100644 --- a/src/test_spec.rs +++ b/src/test_spec.rs @@ -1,7 +1,7 @@ +use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; -use rustc_hash::FxHashMap; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -415,7 +415,7 @@ mod tests { fn redstone_lever_with_two_properties_command_string() { let mut block = Block { id: "minecraft:lever".to_string(), - properties: FxHashMap::default(), + properties: FxHashMap::default(), }; block .properties @@ -435,7 +435,7 @@ mod tests { fn only_id_command_string() { let block = Block { id: "minecraft:stone".to_string(), - properties: FxHashMap::default(), + properties: FxHashMap::default(), }; let result = block.to_command(); assert_eq!(result, "minecraft:stone"); @@ -444,7 +444,7 @@ mod tests { fn empty_id_command_string() { let block = Block { id: "".to_string(), - properties: FxHashMap::default(), + properties: FxHashMap::default(), }; let result = block.to_command(); assert_eq!(result, ""); @@ -454,7 +454,7 @@ mod tests { fn test_redstone_wire() { let mut block = Block { id: "minecraft:redstone_wire".to_string(), - properties: FxHashMap::default(), + properties: FxHashMap::default(), }; block .properties diff --git a/src/traits.rs b/src/traits.rs index 6014e4c..355c555 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -3,11 +3,11 @@ //! Servers implement `FlintAdapter` to create test worlds, and `FlintWorld`/`FlintPlayer` //! to provide the actual block and player operations. -use std::fmt::{Display, Formatter}; -use rustc_hash::FxHashMap; -use serde::Serialize; pub(crate) use crate::Block; pub(crate) use crate::test_spec::{BlockFace, PlayerSlot}; +use rustc_hash::FxHashMap; +use serde::Serialize; +use std::fmt::{Display, Formatter}; /// Position in world coordinates [x, y, z] pub type BlockPos = [i32; 3]; @@ -25,14 +25,11 @@ impl Item { pub fn new(id: impl Into) -> Self { let id = id.into(); if id.starts_with("empty") { - return Item::empty() - } - Self { - id, - count: 1, + return Item::empty(); } + Self { id, count: 1 } } - + pub fn empty() -> Self { Self { id: "minecraft:air".to_string(), From b91d5f15a33179a242759fea84578fe5d8d2d7ac Mon Sep 17 00:00:00 2001 From: JunkyDeveloper Date: Sun, 1 Feb 2026 03:02:09 +0100 Subject: [PATCH 13/17] clippy is now happy --- src/runner.rs | 2 +- src/test_spec.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/runner.rs b/src/runner.rs index c058a9a..282e52d 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -228,7 +228,7 @@ impl<'a, A: FlintAdapter> TestRunner<'a, A> { p.select_hotbar(1); } - p.use_item_on(pos, &face); + p.use_item_on(pos, face); ActionOutcome::Action } diff --git a/src/test_spec.rs b/src/test_spec.rs index 2369aa9..ff4a3ea 100644 --- a/src/test_spec.rs +++ b/src/test_spec.rs @@ -289,7 +289,7 @@ impl TestSpec { let setup = self.setup.as_ref().ok_or_else(|| { anyhow::anyhow!("Test '{}' missing required 'setup' section", self.name) })?; - if let None = setup.cleanup { + if setup.cleanup.is_none() { anyhow::bail!("Test '{}' missing 'cleanup' section", self.name); } let region = setup.cleanup.as_ref().unwrap().region; From 63f2279842d5a3732497bb37196c339c7d3eed7c Mon Sep 17 00:00:00 2001 From: JunkyDeveloper Date: Sun, 1 Feb 2026 04:42:48 +0100 Subject: [PATCH 14/17] add a schema for test and maybe later a better validation support --- flint-content/test_spec_schema.json | 408 ++++++++++++++++++++++++++++ 1 file changed, 408 insertions(+) create mode 100644 flint-content/test_spec_schema.json diff --git a/flint-content/test_spec_schema.json b/flint-content/test_spec_schema.json new file mode 100644 index 0000000..b8d8086 --- /dev/null +++ b/flint-content/test_spec_schema.json @@ -0,0 +1,408 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flint.dev/schemas/test-spec.json", + "title": "Flint Test Specification", + "description": "Schema for Flint test specification files (.json) used by the test framework", + "type": "object", + "required": ["name", "timeline"], + "properties": { + "flintVersion": { + "type": "string", + "description": "Version of the Flint test specification format" + }, + "name": { + "type": "string", + "description": "Name of the test" + }, + "description": { + "type": "string", + "description": "Description of what the test verifies" + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Tags for categorizing and filtering tests" + }, + "dependencies": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Names of tests that must pass before this test can run" + }, + "setup": { + "$ref": "#/$defs/SetupSpec", + "description": "Setup configuration including cleanup region and player configuration" + }, + "timeline": { + "type": "array", + "items": { "$ref": "#/$defs/TimelineEntry" }, + "description": "Sequence of actions to perform during the test" + }, + "breakpoints": { + "type": "array", + "items": { "type": "integer", "minimum": 0 }, + "default": [], + "description": "Tick numbers at which to pause execution for debugging" + } + }, + "additionalProperties": false, + "$defs": { + "Coordinate": { + "type": "array", + "items": { "type": "integer" }, + "minItems": 3, + "maxItems": 3, + "description": "A 3D coordinate [x, y, z]" + }, + "Region": { + "type": "array", + "items": { "$ref": "#/$defs/Coordinate" }, + "minItems": 2, + "maxItems": 2, + "description": "A region defined by two coordinates [[minX, minY, minZ], [maxX, maxY, maxZ]]" + }, + "SetupSpec": { + "type": "object", + "properties": { + "cleanup": { + "$ref": "#/$defs/CleanupSpec", + "description": "Cleanup region configuration" + }, + "player": { + "$ref": "#/$defs/PlayerConfig", + "description": "Player configuration for initial inventory and hotbar" + } + }, + "additionalProperties": false + }, + "CleanupSpec": { + "type": "object", + "required": ["region"], + "properties": { + "region": { + "$ref": "#/$defs/Region", + "description": "The region to clear before and after the test" + } + }, + "additionalProperties": false + }, + "PlayerSlot": { + "type": "string", + "enum": [ + "hotbar_1", "hotbar_2", "hotbar_3", "hotbar_4", "hotbar_5", + "hotbar_6", "hotbar_7", "hotbar_8", "hotbar_9", + "off_hand", + "helmet", "chestplate", "leggings", "boots" + ], + "description": "Player inventory slot identifier" + }, + "SlotConfig": { + "type": "object", + "required": ["item"], + "properties": { + "item": { + "type": "string", + "description": "Item identifier, e.g., 'minecraft:honeycomb'" + }, + "count": { + "type": "integer", + "minimum": 1, + "maximum": 64, + "default": 1, + "description": "Stack count" + } + }, + "additionalProperties": false + }, + "PlayerConfig": { + "type": "object", + "properties": { + "inventory": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/SlotConfig" }, + "propertyNames": { "$ref": "#/$defs/PlayerSlot" }, + "default": {}, + "description": "Initial inventory state (slot name -> item config)" + }, + "selected_hotbar": { + "type": "integer", + "minimum": 1, + "maximum": 9, + "default": 1, + "description": "Initially selected hotbar slot (1-9)" + } + }, + "additionalProperties": false + }, + "TickSpec": { + "oneOf": [ + { + "type": "integer", + "minimum": 0, + "description": "A single tick number" + }, + { + "type": "array", + "items": { "type": "integer", "minimum": 0 }, + "minItems": 1, + "description": "Multiple tick numbers at which to execute the action" + } + ], + "description": "Tick specification - either a single tick or array of ticks" + }, + "Block": { + "oneOf": [ + { + "type": "string", + "description": "Block identifier as a string, e.g., 'minecraft:stone' or 'minecraft:lever[powered=true]'" + }, + { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "Block identifier, e.g., 'minecraft:lever'" + }, + "properties": { + "type": "object", + "additionalProperties": true, + "description": "Block state properties as a nested object" + } + }, + "additionalProperties": true, + "description": "Block with identifier and optional state properties (can be flat or nested under 'properties')" + } + ], + "description": "Block specification - either a string identifier or an object with id and properties" + }, + "BlockFace": { + "type": "string", + "enum": ["top", "bottom", "north", "south", "east", "west"], + "description": "Face of a block" + }, + "BlockPlacement": { + "type": "object", + "required": ["pos", "block"], + "properties": { + "pos": { + "$ref": "#/$defs/Coordinate", + "description": "Position to place the block" + }, + "block": { + "$ref": "#/$defs/Block", + "description": "Block to place" + } + }, + "additionalProperties": false + }, + "BlockCheck": { + "type": "object", + "required": ["pos", "is"], + "properties": { + "pos": { + "$ref": "#/$defs/Coordinate", + "description": "Position to check" + }, + "is": { + "$ref": "#/$defs/Block", + "description": "Expected block at the position" + } + }, + "additionalProperties": false + }, + "TimelineEntry": { + "type": "object", + "required": ["at", "do"], + "properties": { + "at": { + "$ref": "#/$defs/TickSpec", + "description": "Tick(s) at which to execute this action" + }, + "do": { + "type": "string", + "enum": ["place", "place_each", "fill", "remove", "assert", "use_item_on", "set_slot", "select_hotbar"], + "description": "Type of action to perform" + } + }, + "allOf": [ + { + "if": { + "properties": { "do": { "const": "place" } }, + "required": ["do"] + }, + "then": { + "properties": { + "at": true, + "do": true, + "pos": { + "$ref": "#/$defs/Coordinate", + "description": "Position to place the block" + }, + "block": { + "$ref": "#/$defs/Block", + "description": "Block to place" + } + }, + "required": ["pos", "block"], + "additionalProperties": false + } + }, + { + "if": { + "properties": { "do": { "const": "place_each" } }, + "required": ["do"] + }, + "then": { + "properties": { + "at": true, + "do": true, + "blocks": { + "type": "array", + "items": { "$ref": "#/$defs/BlockPlacement" }, + "description": "List of blocks to place" + } + }, + "required": ["blocks"], + "additionalProperties": false + } + }, + { + "if": { + "properties": { "do": { "const": "fill" } }, + "required": ["do"] + }, + "then": { + "properties": { + "at": true, + "do": true, + "region": { + "$ref": "#/$defs/Region", + "description": "Region to fill" + }, + "with": { + "$ref": "#/$defs/Block", + "description": "Block to fill the region with" + } + }, + "required": ["region", "with"], + "additionalProperties": false + } + }, + { + "if": { + "properties": { "do": { "const": "remove" } }, + "required": ["do"] + }, + "then": { + "properties": { + "at": true, + "do": true, + "pos": { + "$ref": "#/$defs/Coordinate", + "description": "Position of the block to remove" + } + }, + "required": ["pos"], + "additionalProperties": false + } + }, + { + "if": { + "properties": { "do": { "const": "assert" } }, + "required": ["do"] + }, + "then": { + "properties": { + "at": true, + "do": true, + "checks": { + "type": "array", + "items": { "$ref": "#/$defs/BlockCheck" }, + "description": "List of block checks to perform" + } + }, + "required": ["checks"], + "additionalProperties": false + } + }, + { + "if": { + "properties": { "do": { "const": "use_item_on" } }, + "required": ["do"] + }, + "then": { + "properties": { + "at": true, + "do": true, + "pos": { + "$ref": "#/$defs/Coordinate", + "description": "Position of the block to interact with" + }, + "face": { + "$ref": "#/$defs/BlockFace", + "description": "Face of the block to interact with" + }, + "item": { + "type": "string", + "description": "Item to use (optional, uses player's active item if not specified)" + } + }, + "required": ["pos", "face"], + "additionalProperties": false + } + }, + { + "if": { + "properties": { "do": { "const": "set_slot" } }, + "required": ["do"] + }, + "then": { + "properties": { + "at": true, + "do": true, + "slot": { + "$ref": "#/$defs/PlayerSlot", + "description": "Inventory slot to set" + }, + "item": { + "type": "string", + "description": "Item to put in the slot (omit to clear)" + }, + "count": { + "type": "integer", + "minimum": 1, + "maximum": 64, + "default": 1, + "description": "Stack count" + } + }, + "required": ["slot"], + "additionalProperties": false + } + }, + { + "if": { + "properties": { "do": { "const": "select_hotbar" } }, + "required": ["do"] + }, + "then": { + "properties": { + "at": true, + "do": true, + "slot": { + "type": "integer", + "minimum": 1, + "maximum": 9, + "description": "Hotbar slot to select (1-9)" + } + }, + "required": ["slot"], + "additionalProperties": false + } + } + ] + } + } +} From bd477d7121016e70a0e1bb596a5a135ae8a86386 Mon Sep 17 00:00:00 2001 From: JunkyDeveloper Date: Mon, 2 Feb 2026 18:35:25 +0100 Subject: [PATCH 15/17] reworked the critic and remove mentioned duplicate --- src/lib.rs | 4 +- src/runner.rs | 56 ++-------- src/test_spec.rs | 278 +++++++++++++++++++++++++++++------------------ src/traits.rs | 113 +------------------ 4 files changed, 187 insertions(+), 264 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 4886b77..c564088 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,8 +11,8 @@ pub mod utils; // Re-export main types for convenience pub use runner::{TestRunConfig, TestRunner}; -pub use traits::{BlockPos, FlintAdapter, FlintPlayer, FlintWorld, Item, ServerInfo}; +pub use traits::{BlockPos, FlintAdapter, FlintPlayer, FlintWorld, ServerInfo}; // Re-export flint-core types commonly used with this library pub use crate::loader::TestLoader; -pub use crate::test_spec::{Block, TestSpec}; +pub use crate::test_spec::{Block, Item, PlayerSlot, TestSpec}; diff --git a/src/runner.rs b/src/runner.rs index 282e52d..2ef928d 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -5,11 +5,10 @@ use crate::results::{ ActionOutcome, AssertFailure, AssertionResult, InfoType, TestResult, TestSummary, }; -use crate::test_spec::ActionType; +use crate::test_spec::{ActionType, Item, PlayerSlot}; use crate::timeline::TimelineAggregate; -use crate::traits::{BlockData, FlintAdapter, FlintPlayer, FlintWorld, Item, PlayerSlot}; +use crate::traits::{FlintAdapter, FlintPlayer, FlintWorld}; use crate::{Block, TestSpec}; -use rustc_hash::FxHashMap; use std::time::Instant; /// Configuration for test execution @@ -68,8 +67,7 @@ impl<'a, A: FlintAdapter> TestRunner<'a, A> { let p = player.get_or_insert_with(|| world.create_player()); // Set initial inventory - for (slot_name, slot_config) in &player_config.inventory { - let item = Item::with_count(&slot_config.item, slot_config.count); + for (slot_name, item) in &player_config.inventory { p.set_slot(*slot_name, Some(&item)); } @@ -180,36 +178,16 @@ impl<'a, A: FlintAdapter> TestRunner<'a, A> { let actual = world.get_block(pos); if !block_matches(&actual, &check.is) { - // Convert expected Block properties to BlockData format - let expected_props: FxHashMap = check - .is - .properties - .iter() - .filter_map(|(k, v)| { - let value = match v { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Bool(b) => b.to_string(), - serde_json::Value::Number(n) => n.to_string(), - _ => return None, - }; - Some((k.clone(), value)) - }) - .collect(); return ActionOutcome::AssertFailed(AssertFailure { tick: _tick, error_message: format!( "Block mismatch at {:?}: expected '{}', got '{}'", - pos, - BlockData::with_properties(&check.is.id, expected_props.clone()), - actual, + pos, check.is.to_command(), actual.to_command(), ), position: pos, execution_time_ms: None, - expected: InfoType::Block( - BlockData::with_properties(&check.is.id, expected_props.clone()) - .into_block(), - ), - actual: InfoType::Block(actual.into_block()), + expected: InfoType::Block(check.is.clone()), + actual: InfoType::Block(actual), }); } } @@ -254,8 +232,8 @@ impl<'a, A: FlintAdapter> TestRunner<'a, A> { } } -/// Check if actual block matches expected -fn block_matches(actual: &BlockData, expected: &Block) -> bool { +/// Check if actual block matches expected. +fn block_matches(actual: &Block, expected: &Block) -> bool { // Check block ID if actual.id != expected.id { // Also try without minecraft: prefix @@ -274,24 +252,10 @@ fn block_matches(actual: &BlockData, expected: &Block) -> bool { } } - // Check properties if specified + // Check properties if specified in expected for (key, expected_value) in &expected.properties { - // Skip the "properties" key itself (nested format) - it's handled by Block::to_command - if key == "properties" { - continue; - } - - // Convert expected value to string for comparison - let expected_str = match expected_value { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Bool(b) => b.to_string(), - serde_json::Value::Number(n) => n.to_string(), - serde_json::Value::Null => continue, // Skip null values - _ => continue, // Skip complex types (arrays, objects) - }; - if let Some(actual_value) = actual.properties.get(key) { - if actual_value != &expected_str { + if actual_value != expected_value { return false; } } else { diff --git a/src/test_spec.rs b/src/test_spec.rs index ff4a3ea..e0e4567 100644 --- a/src/test_spec.rs +++ b/src/test_spec.rs @@ -1,6 +1,8 @@ use rustc_hash::FxHashMap; -use serde::{Deserialize, Serialize}; +use serde::de::{MapAccess, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; use std::collections::HashMap; +use std::fmt::Formatter; use std::path::PathBuf; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -82,7 +84,7 @@ impl PlayerSlot { pub struct PlayerConfig { /// Initial inventory state (slot name -> item config) #[serde(default)] - pub inventory: HashMap, + pub inventory: HashMap, /// Initially selected hotbar slot (1-9), defaults to 1 #[serde(default = "default_selected_hotbar")] pub selected_hotbar: u8, @@ -92,16 +94,43 @@ fn default_selected_hotbar() -> u8 { 1 } -/// Configuration for a single inventory slot -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SlotConfig { +/// An item that can be held or placed in a slot. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Item { /// Item identifier, e.g., "minecraft:honeycomb" - pub item: String, - /// Stack count, defaults to 1 + pub id: String, + /// Stack count (default 1) #[serde(default = "default_count")] pub count: u8, } +impl Item { + /// Create a new item with count 1. + pub fn new(id: impl Into) -> Self { + let id = id.into(); + if id.starts_with("empty") { + return Item::empty(); + } + Self { id, count: 1 } + } + + /// Create an empty item (air with count 0). + pub fn empty() -> Self { + Self { + id: "minecraft:air".to_string(), + count: 0, + } + } + + /// Create an item with a specific count. + pub fn with_count(id: impl Into, count: u8) -> Self { + Self { + id: id.into(), + count, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimelineEntry { #[serde(rename = "at")] @@ -126,52 +155,114 @@ impl TickSpec { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Block specification with ID and properties. +/// +/// Deserializes from JSON with backwards compatibility: +/// - `"powered": false` → `"powered": "false"` +/// - `"delay": 2` → `"delay": "2"` +/// - `"facing": "north"` → `"facing": "north"` +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct Block { /// Block identifier, e.g., "minecraft:stone" pub id: String, /// Block state properties, e.g., {"powered": "true", "facing": "north"} - #[serde(flatten)] - pub properties: FxHashMap, + #[serde(flatten, skip_serializing_if = "FxHashMap::is_empty")] + pub properties: FxHashMap, } impl Block { + /// Create a new block with no properties. + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + properties: FxHashMap::default(), + } + } + + /// Create a block with the given properties. + pub fn with_properties(id: impl Into, properties: FxHashMap) -> Self { + Self { + id: id.into(), + properties, + } + } + + /// Check if this block is air. + pub fn is_air(&self) -> bool { + self.id == "minecraft:air" || self.id == "air" + } + + /// Generate a Minecraft command string like `minecraft:lever[powered=false,face=floor]`. pub fn to_command(&self) -> String { if self.properties.is_empty() { self.id.clone() } else { - let mut props: Vec = Vec::new(); - - for (key, value) in &self.properties { - if key == "properties" { - if let serde_json::Value::Object(nested) = value { - for (nested_key, nested_value) in nested { - let val = match nested_value { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Bool(b) => b.to_string(), - serde_json::Value::Number(n) => n.to_string(), - _ => nested_value.to_string(), - }; - props.push(format!("{}={}", nested_key, val)); + let props: Vec = self + .properties + .iter() + .map(|(key, value)| format!("{}={}", key, value)) + .collect(); + format!("{}[{}]", self.id, props.join(",")) + } + } +} + +impl<'de> Deserialize<'de> for Block { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct BlockVisitor; + + impl<'de> Visitor<'de> for BlockVisitor { + type Value = Block; + + fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { + formatter.write_str("a block object with 'id' field and optional properties") + } + + fn visit_map(self, mut map: M) -> Result + where + M: MapAccess<'de>, + { + let mut id: Option = None; + let mut properties = FxHashMap::default(); + + while let Some(key) = map.next_key::()? { + if key == "id" { + id = Some(map.next_value()?); + } else if key == "properties" { + // Handle nested properties object + let nested: FxHashMap = map.next_value()?; + for (k, v) in nested { + let value_str = json_value_to_string(&v); + properties.insert(k, value_str); } + } else { + // Handle flat properties - convert JSON values to strings + let value: serde_json::Value = map.next_value()?; + let value_str = json_value_to_string(&value); + properties.insert(key, value_str); } - } else { - let val = match value { - serde_json::Value::String(s) => s.clone(), - serde_json::Value::Bool(b) => b.to_string(), - serde_json::Value::Number(n) => n.to_string(), - _ => value.to_string(), - }; - props.push(format!("{}={}", key, val)); } - } - if props.is_empty() { - self.id.clone() - } else { - format!("{}[{}]", self.id, props.join(",")) + let id = id.ok_or_else(|| serde::de::Error::missing_field("id"))?; + Ok(Block { id, properties }) } } + + deserializer.deserialize_map(BlockVisitor) + } +} + +/// Convert a JSON value to a string representation for block properties. +fn json_value_to_string(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Null => String::new(), + _ => value.to_string(), } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -410,19 +501,16 @@ impl TestSpec { #[cfg(test)] mod tests { use super::*; - use serde_json::Value; + #[test] fn redstone_lever_with_two_properties_command_string() { - let mut block = Block { - id: "minecraft:lever".to_string(), - properties: FxHashMap::default(), - }; + let mut block = Block::new("minecraft:lever"); block .properties - .insert("powered".to_string(), Value::from(false)); + .insert("powered".to_string(), "false".to_string()); block .properties - .insert("face".to_string(), Value::from("floor")); + .insert("face".to_string(), "floor".to_string()); let result = block.to_command(); assert!( result == "minecraft:lever[powered=false,face=floor]" @@ -431,46 +519,38 @@ mod tests { result ); } + #[test] fn only_id_command_string() { - let block = Block { - id: "minecraft:stone".to_string(), - properties: FxHashMap::default(), - }; + let block = Block::new("minecraft:stone"); let result = block.to_command(); assert_eq!(result, "minecraft:stone"); } + #[test] fn empty_id_command_string() { - let block = Block { - id: "".to_string(), - properties: FxHashMap::default(), - }; + let block = Block::new(""); let result = block.to_command(); assert_eq!(result, ""); } #[test] fn test_redstone_wire() { - let mut block = Block { - id: "minecraft:redstone_wire".to_string(), - properties: FxHashMap::default(), - }; + let mut block = Block::new("minecraft:redstone_wire"); block .properties - .insert("north".to_string(), Value::from("side")); + .insert("north".to_string(), "side".to_string()); block .properties - .insert("east".to_string(), Value::from("up")); + .insert("east".to_string(), "up".to_string()); block .properties - .insert("south".to_string(), Value::from("none")); + .insert("south".to_string(), "none".to_string()); block .properties - .insert("west".to_string(), Value::from("side")); + .insert("west".to_string(), "side".to_string()); let result = block.to_command(); - // Prüfe dass ID und Properties vorhanden sind assert!(result.starts_with("minecraft:redstone_wire[")); assert!(result.ends_with("]")); assert!(result.contains("north=side")); @@ -478,6 +558,7 @@ mod tests { assert!(result.contains("south=none")); assert!(result.contains("west=side")); } + #[test] fn test_parse_lever() { let json = r#"{ @@ -488,12 +569,11 @@ mod tests { let block: Block = serde_json::from_str(json).unwrap(); assert_eq!(block.id, "minecraft:lever"); - assert_eq!(block.properties.get("powered"), Some(&Value::Bool(false))); - assert_eq!( - block.properties.get("face"), - Some(&Value::String("floor".to_string())) - ); + // Values are converted to strings + assert_eq!(block.properties.get("powered"), Some(&"false".to_string())); + assert_eq!(block.properties.get("face"), Some(&"floor".to_string())); } + #[test] #[should_panic(expected = "missing field `id`")] fn test_parse_missing_id() { @@ -504,6 +584,7 @@ mod tests { let _block: Block = serde_json::from_str(json).unwrap(); } + #[test] #[should_panic(expected = "missing field `id`")] fn test_parse_missing_object() { @@ -522,11 +603,9 @@ mod tests { let block: Block = serde_json::from_str(json).unwrap(); assert_eq!(block.id, "minecraft:lever"); - assert_eq!(block.properties.get("powered"), Some(&Value::Null)); - assert_eq!( - block.properties.get("face"), - Some(&Value::String("floor".to_string())) - ); + // Null is converted to empty string + assert_eq!(block.properties.get("powered"), Some(&String::new())); + assert_eq!(block.properties.get("face"), Some(&"floor".to_string())); } #[test] @@ -541,7 +620,9 @@ mod tests { let block: Block = serde_json::from_str(json).unwrap(); assert_eq!(block.id, "minecraft:chest"); - assert!(block.properties.get("metadata").unwrap().is_object()); + assert_eq!(block.properties.get("facing"), Some(&"north".to_string())); + // Complex objects are serialized as JSON strings + assert!(block.properties.contains_key("metadata")); } #[test] @@ -553,7 +634,8 @@ mod tests { let block: Block = serde_json::from_str(json).unwrap(); assert_eq!(block.id, "minecraft:custom_block"); - assert!(block.properties.get("colors").unwrap().is_array()); + // Arrays are serialized as JSON strings + assert!(block.properties.contains_key("colors")); } #[test] @@ -566,6 +648,7 @@ mod tests { let block: Block = serde_json::from_str(json).unwrap(); assert_eq!(block.id, ""); assert_eq!(block.properties.len(), 1); + assert_eq!(block.properties.get("powered"), Some(&"false".to_string())); } #[test] @@ -580,7 +663,7 @@ mod tests { assert_eq!(block.id, "minecraft:custom"); assert_eq!( block.properties.get("name"), - Some(&Value::String("Test \"quoted\" value".to_string())) + Some(&"Test \"quoted\" value".to_string()) ); } @@ -595,14 +678,14 @@ mod tests { let block: Block = serde_json::from_str(json).unwrap(); assert_eq!(block.id, "minecraft:block"); - assert!(block.properties.get("integer").unwrap().is_number()); - assert!(block.properties.get("float").unwrap().is_number()); - assert!(block.properties.get("negative").unwrap().is_number()); + // Numbers are converted to strings + assert_eq!(block.properties.get("integer"), Some(&"42".to_string())); + assert_eq!(block.properties.get("float"), Some(&"3.14".to_string())); + assert_eq!(block.properties.get("negative"), Some(&"-10".to_string())); } #[test] fn test_nested_properties_object() { - // Test the new format where properties are nested inside a "properties" key let json = r#"{ "id": "minecraft:lever", "properties": { @@ -614,7 +697,6 @@ mod tests { let block: Block = serde_json::from_str(json).unwrap(); let result = block.to_command(); - // Order may vary due to HashMap assert!(result.contains("minecraft:lever[")); assert!(result.contains("powered=true")); assert!(result.contains("face=floor")); @@ -640,7 +722,6 @@ mod tests { #[test] fn test_empty_nested_properties() { - // When properties object is empty, should return just the id let json = r#"{ "id": "minecraft:stone", "properties": {} @@ -652,33 +733,6 @@ mod tests { assert_eq!(result, "minecraft:stone"); } - #[test] - fn test_mixed_flat_and_nested_properties() { - // Test when there's both flat properties and nested ones - let mut block = Block { - id: "minecraft:test".to_string(), - properties: FxHashMap::default(), - }; - - // Add a flat property - block - .properties - .insert("flat_prop".to_string(), Value::from("value1")); - - // Add nested properties - let mut nested = serde_json::Map::new(); - nested.insert("nested_prop".to_string(), Value::from("value2")); - block - .properties - .insert("properties".to_string(), Value::Object(nested)); - - let result = block.to_command(); - - assert!(result.contains("minecraft:test[")); - assert!(result.contains("flat_prop=value1")); - assert!(result.contains("nested_prop=value2")); - } - #[test] fn test_nested_properties_bool_values() { let json = r#"{ @@ -695,4 +749,16 @@ mod tests { assert!(result.contains("extended=true")); assert!(result.contains("facing=up")); } + + #[test] + fn test_is_air() { + let air = Block::new("minecraft:air"); + assert!(air.is_air()); + + let air_short = Block::new("air"); + assert!(air_short.is_air()); + + let stone = Block::new("minecraft:stone"); + assert!(!stone.is_air()); + } } diff --git a/src/traits.rs b/src/traits.rs index 355c555..1dbd34b 100644 --- a/src/traits.rs +++ b/src/traits.rs @@ -3,119 +3,12 @@ //! Servers implement `FlintAdapter` to create test worlds, and `FlintWorld`/`FlintPlayer` //! to provide the actual block and player operations. -pub(crate) use crate::Block; -pub(crate) use crate::test_spec::{BlockFace, PlayerSlot}; -use rustc_hash::FxHashMap; -use serde::Serialize; -use std::fmt::{Display, Formatter}; +use crate::Block; +use crate::test_spec::{BlockFace, Item, PlayerSlot}; /// Position in world coordinates [x, y, z] pub type BlockPos = [i32; 3]; -/// An item that can be held or placed in a slot -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Item { - /// Item identifier, e.g., "minecraft:honeycomb" - pub id: String, - /// Stack count (default 1) - pub count: u8, -} - -impl Item { - pub fn new(id: impl Into) -> Self { - let id = id.into(); - if id.starts_with("empty") { - return Item::empty(); - } - Self { id, count: 1 } - } - - pub fn empty() -> Self { - Self { - id: "minecraft:air".to_string(), - count: 0, - } - } - - pub fn with_count(id: impl Into, count: u8) -> Self { - Self { - id: id.into(), - count, - } - } -} - -/// Block data returned from get_block -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct BlockData { - /// Block identifier, e.g., "minecraft:stone" - pub id: String, - /// Block state properties, e.g., {"powered": "true", "facing": "north"} - pub properties: FxHashMap, -} -impl Display for BlockData { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.id)?; - - if !self.properties.is_empty() { - write!(f, "[")?; - - let mut properties: Vec<_> = self.properties.iter().collect(); - properties.sort_by_key(|(key, _)| *key); - - for (i, (key, value)) in properties.iter().enumerate() { - if i > 0 { - write!(f, ",")?; - } - write!(f, "{}={}", key, value)?; - } - - write!(f, "]")?; - } - - Ok(()) - } -} - -impl BlockData { - pub fn new(id: impl Into) -> Self { - Self { - id: id.into(), - properties: FxHashMap::default(), - } - } - - pub fn with_properties(id: impl Into, properties: FxHashMap) -> Self { - Self { - id: id.into(), - properties, - } - } - - /// Check if this block is air - pub fn is_air(&self) -> bool { - self.id == "minecraft:air" || self.id == "air" - } - - /// Convert to a Block spec - pub fn into_block(self) -> Block { - Block { - id: self.id, - properties: self - .properties - .into_iter() - .map(|(k, v)| (k, serde_json::Value::String(v))) - .collect(), - } - } -} - -impl From for Block { - fn from(data: BlockData) -> Self { - data.into_block() - } -} - /// Server metadata #[derive(Debug, Clone)] pub struct ServerInfo { @@ -147,7 +40,7 @@ pub trait FlintWorld: Send + Sync { fn current_tick(&self) -> u64; /// Get block at position - fn get_block(&self, pos: BlockPos) -> BlockData; + fn get_block(&self, pos: BlockPos) -> Block; /// Set block at position (with neighbor updates) fn set_block(&mut self, pos: BlockPos, block: &Block); From 0a5675c3a1360b03e6b5e7bd308caf8ac2364c39 Mon Sep 17 00:00:00 2001 From: JunkyDeveloper Date: Mon, 2 Feb 2026 18:43:43 +0100 Subject: [PATCH 16/17] cargo fmt --- src/runner.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/runner.rs b/src/runner.rs index 2ef928d..19bf7cd 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -182,7 +182,9 @@ impl<'a, A: FlintAdapter> TestRunner<'a, A> { tick: _tick, error_message: format!( "Block mismatch at {:?}: expected '{}', got '{}'", - pos, check.is.to_command(), actual.to_command(), + pos, + check.is.to_command(), + actual.to_command(), ), position: pos, execution_time_ms: None, From cd0febcf65c6c3257ac834fa14fd3af3006479ca Mon Sep 17 00:00:00 2001 From: JunkyDeveloper Date: Mon, 2 Feb 2026 18:47:14 +0100 Subject: [PATCH 17/17] clippy is happy --- src/runner.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/runner.rs b/src/runner.rs index 19bf7cd..6c921b3 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -68,7 +68,7 @@ impl<'a, A: FlintAdapter> TestRunner<'a, A> { // Set initial inventory for (slot_name, item) in &player_config.inventory { - p.set_slot(*slot_name, Some(&item)); + p.set_slot(*slot_name, Some(item)); } // Set initial hotbar selection