| name | block_testing |
| description | Testing strategies and infrastructure for game block behavior in perovskite_game_api. Use when asked to write tests for block placement, dig handlers, timers, inventories, or other game mechanics. |
The test infrastructure lives in perovskite_game_api/src/test_support.rs and is gated behind the test-support feature flag (which also enables perovskite_server/test-support and googletest).
All testing uses the googletest crate (v0.14.2). Most tests use the #[gtest] macro; simple unit tests that don't need a live server use the standard #[test] macro.
Setup: Enabling Tests
Tests that use TestFixture must be in a crate or binary that depends on perovskite_game_api with features = ["test-support"]. Import everything with:
use perovskite_game_api::test_support::*;
use googletest::prelude::*;
Core: TestFixture
TestFixture is a googletest fixture — it implements googletest::fixtures::Fixture and is set up/torn down automatically per test. It provides a thread-local in-memory world backed by InMemGameDatabase.
Lifecycle
#[gtest]
fn my_test(fixture: &TestFixture) -> googletest::Result<()> {
fixture.start_server(|builder| crate::configure_default_game(builder))?;
fixture.run_assertions_in_server(|gs| { ... })?;
Ok(())
}
start_server creates a flatland world filled with air by default. Pass a GameBuilder callback to register blocks, items, timers, etc. Call configure_default_game to load the full default game content.
Running Assertions
run_assertions_in_server — access GameState directly:
fixture.run_assertions_in_server(|gs: &GameState| {
gs.game_map().set_block(ZERO_COORD, MY_BLOCK, None).or_fail()?;
expect_that!(gs.game_map().get_block(ZERO_COORD).or_fail()?, IsBlock(MY_BLOCK));
Ok(())
})?;
run_with_context — access a full HandlerContext (needed for popups, inventory views, item manager, etc.). The initiator is EventInitiator::Plugin("TestSupport_TestFixture"):
fixture.run_with_context(|ctx| {
ctx.block_types().get_by_name(MY_BLOCK.0).expect("not found");
ctx.item_manager().get_item(MY_ITEM.0).expect("not found");
ctx.game_map().set_block(ZERO_COORD, block_id, None).or_fail()?;
Ok(())
})?;
HandlerContext derefs to GameState, so ctx.game_map(), ctx.block_types(), and ctx.item_manager() all work.
Running Timers
Timers registered via the block/game API don't fire automatically in tests. Drive them manually:
fixture.run_timer_inline("default:furnace_timer")?;
fixture.run_all_timers_inline()?;
These are the primary way to test time-dependent game logic (liquid flow, furnace smelting, crop growth, etc.).
Convenience Constant
pub const ZERO_COORD: BlockCoordinate = BlockCoordinate::new(0, 0, 0);
Use ZERO_COORD as the default coordinate for single-block tests. For multi-block tests, construct coordinates directly: BlockCoordinate::new(x, y, z). Positive Y is up.
Flatland Mapgen
By default, start_server sets up an all-air world. To test blocks that need a solid ground, use GameBuilderTestExt::set_flatland_mapgen:
fixture.start_server(|builder| {
builder.set_flatland_mapgen(DIRT);
Ok(())
})?;
FlatlandMapgen is also public and implements MapgenInterface if you need it directly.
Custom Matchers
IsBlock<T> — match block type, ignoring variant
expect_that!(gs.game_map().get_block(coord).or_fail()?, IsBlock(MY_BLOCK));
expect_that!(gs.game_map().get_block(coord).or_fail()?, IsBlock(AIR_ID));
IsBlock accepts anything that implements TryToBlockId — typically a BuiltBlock, a BlockId, or a StaticBlockName.
IsBlockWithVariant<T> — match block type AND variant
expect_that!(gs.game_map().get_block(coord).or_fail()?, IsBlockWithVariant(specific_block_id));
The error message shows human-readable block names and the variant hex value for both expected and actual.
IsItemStack<T, U> — match item name and quantity/wear
expect_that!(item_stack, IsItemStack(MY_ITEM.0, eq(5)));
expect_that!(item_stack, IsItemStack(MY_ITEM.0, lt(10u32)));
expect_that!(
dig_result.item_stacks,
elements_are![IsItemStack(DIRT.0, eq(1))]
);
The second argument is any Matcher<u32> from the googletest prelude: eq, lt, gt, ge, le, etc.
Key GameState / Map Operations
These are available inside both run_assertions_in_server and run_with_context:
gs.game_map().set_block(coord, block_id, None).or_fail()?;
let id: BlockId = gs.game_map().get_block(coord).or_fail()?;
let result = gs.game_map().dig_block(coord, &EventInitiator::Engine, None).or_fail()?;
let (block_id, value) = ctx.game_map().get_block_with_extended_data(coord, |block_type, data| {
Ok(Some(data.simple_data.get("key").cloned()))
})?;
ctx.game_map().mutate_block_atomically(coord, |block_id, ext| {
let data = ext.get_or_insert_with(Default::default);
data.simple_data.insert("key".into(), "val".into());
Ok(())
})?;
gs.game_map().run_all_timers_inline().or_fail()?;
Testing Inventory Behavior
Inventories are accessed through popups created in a run_with_context call. This pattern mimics what a player sees when they open an interactive block:
fixture.run_with_context(|ctx| {
ctx.game_map().set_block(ZERO_COORD, furnace_id, None).or_fail()?;
let popup = make_furnace_popup(&ctx, ZERO_COORD).or_fail()?;
let fuel_view = popup.inventory_views().get(FURNACE_FUEL).unwrap();
let leftover = fuel_view.put(&popup, 0, item_stack.into()).or_fail()?;
expect_that!(leftover, none());
Ok(())
})?;
fixture.run_timer_inline("default:furnace_timer")?;
fixture.run_with_context(|ctx| {
let popup = make_furnace_popup(&ctx, ZERO_COORD).or_fail()?;
let output_view = popup.inventory_views().get(FURNACE_OUTPUT).unwrap();
let contents = output_view.peek(&popup).or_fail()?;
expect_that!(contents[0], some(IsItemStack(IRON_INGOT.0, ge(1u32))));
Ok(())
})?;
Testing Dig Drops
fixture.run_assertions_in_server(|gs| {
gs.game_map().set_block(ZERO_COORD, DIRT_WITH_GRASS, None).or_fail()?;
let result = gs.game_map()
.dig_block(ZERO_COORD, &EventInitiator::Engine, None)
.or_fail()?;
expect_that!(result.item_stacks, elements_are![IsItemStack(DIRT.0, eq(1))]);
expect_that!(gs.game_map().get_block(ZERO_COORD).or_fail()?, IsBlock(AIR_ID));
Ok(())
})?;
Testing Liquid Flow
Liquid flow is timer-driven. Set the liquid block somewhere with a free space below, then run timers:
fixture.run_assertions_in_server(|gs| {
let water_id = gs.block_types()
.get_by_name(WATER.0)
.expect("WATER not found")
.with_variant_unchecked(0xfff);
let above = BlockCoordinate::new(0, 2, 0);
let below = BlockCoordinate::new(0, 1, 0);
gs.game_map().set_block(above, water_id, None).or_fail()?;
gs.game_map().set_block(below, AIR_ID, None).or_fail()?;
gs.game_map().run_all_timers_inline().or_fail()?;
expect_that!(gs.game_map().get_block(below).or_fail()?, IsBlock(WATER));
Ok(())
})?;
Note: chunk boundaries (e.g., Y=15 and Y=16 are in adjacent chunks) are worth testing separately as edge cases.
Testing Variants
fixture.run_assertions_in_server(|gs| {
gs.game_map().set_block(ZERO_COORD, my_block.with_variant_unchecked(3), None).or_fail()?;
let id = gs.game_map().get_block(ZERO_COORD).or_fail()?;
expect_that!(id, IsBlock(my_block));
expect_that!(id, IsBlockWithVariant(my_block.with_variant_unchecked(3)));
assert_eq!(id.variant(), 3);
Ok(())
})?;
Pure Unit Tests (No Fixture)
For logic that doesn't need a live server (e.g., recipe matching, pure algorithms):
#[cfg(test)]
mod tests {
#[test]
fn test_pure_logic() {
let recipe = MyRecipe { ... };
assert!(recipe.matches(&stacks));
}
}
Subsystem-specific sharp edges
configure_default_game already includes most mods
configure_default_game registers the majority of perovskite_game_api content (carts, circuits, farming, etc.) in
one call. Do not call individual subsystem registration functions (e.g. carts::register_carts) alongside it
— they will be called twice and every duplicated texture/block will panic with "Resource already exists".
fixture.start_server(|builder| crate::configure_default_game(builder))?;
fixture.start_server(|builder| {
crate::configure_default_game(builder)?;
crate::carts::register_carts(builder)
})?;
If a subsystem is not included by configure_default_game, you can call it alone — but check lib.rs first to
confirm it isn't already wired in.
Error propagation from map reads
Functions that read from ServerGameMap return Result<_>, not Option<_>. Map access errors (chunk not loaded,
I/O failure) should be propagated with ? or .or_fail()? rather than swallowed or converted to a sentinel like
BlockId::AIR. This applies throughout the codebase, not just to carts.
Common Imports
#[cfg(test)]
mod tests {
use crate::test_support::{TestFixture, ZERO_COORD, IsBlock, IsBlockWithVariant, IsItemStack};
use googletest::prelude::*;
use perovskite_core::block_id::special_block_defs::AIR_ID;
use perovskite_core::coordinates::BlockCoordinate;
use perovskite_server::game_state::{GameState, event::EventInitiator};
}