From 7ebec8c347ff092f0df369e0f6f1307f8fe8ce6f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Mar 2026 19:59:19 +0000 Subject: [PATCH] Add Google Test framework with coord and block type tests - Fetch googletest v1.14.0 via FetchContent; register with CTest - tests/test_coord_utils.cpp: worldToChunk/worldToLocal/chunkToWorld, including negative-coord edge cases and round-trip invariants - tests/test_block_types.cpp: block solidity/transparency/liquid flags, hardness values, preferred tool lookup, and tool speed multipliers - Update CLAUDE.md to document what is testable, how to run, and how to add new test files https://claude.ai/code/session_01HypWGGXnZ7R8gCzpt7o2yH --- CLAUDE.md | 44 +++++++++- CMakeLists.txt | 3 + tests/CMakeLists.txt | 45 +++++++++++ tests/test_block_types.cpp | 162 +++++++++++++++++++++++++++++++++++++ tests/test_coord_utils.cpp | 125 ++++++++++++++++++++++++++++ 5 files changed, 375 insertions(+), 4 deletions(-) create mode 100644 tests/CMakeLists.txt create mode 100644 tests/test_block_types.cpp create mode 100644 tests/test_coord_utils.cpp diff --git a/CLAUDE.md b/CLAUDE.md index 0c6d69a..9ec2171 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -333,11 +333,47 @@ This is a game, not a server. Fatal errors call `std::exit()` or throw and termi --- -## No Test Framework +## Test Framework -There is no automated test suite. Verification is done manually: -- Launch with `--debug` flag to enable the ImGui debug menu +The project uses **Google Test** (fetched via CMake `FetchContent` at configure time). Tests live in `tests/` and cover pure-logic subsystems that have no OpenGL dependency. + +### Running Tests + +```bash +cmake -B build -DCMAKE_BUILD_TYPE=Debug +cmake --build build -j$(nproc) --target voxel_tests +cd build && ctest --output-on-failure +# or run the binary directly: +./build/tests/voxel_tests +``` + +### Test Files + +| File | What it covers | +|---|---| +| `tests/test_coord_utils.cpp` | `worldToChunk`, `worldToLocal`, `chunkToWorld` — including negative coords and round-trip invariants | +| `tests/test_block_types.cpp` | `initBlockTypes`, `isBlockSolid/Transparent/Liquid`, `getBlockHardness`, `getBlockPreferredTool`, `getToolSpeedMultiplier` | + +### What Can Be Tested (and What Cannot) + +**Testable without a GPU/display:** +- Coordinate math (`CoordUtils.h` — header-only, no GL calls) +- Block registry (`BlockTypes.cpp` — uses only TOOL_* integer constants from ToolModelGenerator.h, no GL calls) +- Any future pure-logic utility (inventory math, raycast geometry, etc.) + +**Cannot be unit-tested without a context:** +- Anything that calls OpenGL (Renderer, ChunkManager mesh uploads, VAO/VBO wrappers) +- Audio (requires an OpenAL device) +- The job system's worker threads (depend on ChunkManager + RegionManager state) + +For those, manual verification remains the approach: +- Launch with `--debug` to enable the ImGui debug menu - Use `/noclip` + `/time` to explore generation artifacts - Check chunk loading seams visually at region boundaries (multiples of 32 chunks) -When adding logic that is easily unit-testable (coordinate math, block registry lookups), consider adding assertions (`assert()`) rather than a full test framework, consistent with the existing codebase style. +### Adding New Tests + +1. Create `tests/test_.cpp` +2. Add it to `add_executable(voxel_tests ...)` in `tests/CMakeLists.txt` +3. If the module has `.cpp` files with no GL calls, add them to `voxel_testable` in `tests/CMakeLists.txt` +4. Use `TEST()` for stateless tests; use a `::testing::Test` fixture with `SetUpTestSuite()` when global state (like `initBlockTypes()`) must be initialised once diff --git a/CMakeLists.txt b/CMakeLists.txt index 969fb86..56c912e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,3 +9,6 @@ set(GLM_BUILD_LIBRARY OFF CACHE BOOL "Use header-only GLM" FORCE) add_subdirectory(libs/glm-1.0.2) add_subdirectory(src) + +enable_testing() +add_subdirectory(tests) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..ce6d41e --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,45 @@ +include(FetchContent) + +FetchContent_Declare( + googletest + GIT_REPOSITORY https://github.com/google/googletest.git + GIT_TAG v1.14.0 +) +set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) +set(BUILD_GMOCK OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(googletest) + +# Static library containing pure-logic source files that have no GL calls. +# BlockTypes.cpp pulls in ToolModelGenerator.h (for TOOL_* constants) which +# includes glad/glad.h — that is fine for compilation because we only need +# GLuint typedef definitions; we never invoke any GL function from these files. +add_library(voxel_testable STATIC + ${CMAKE_SOURCE_DIR}/src/utils/BlockTypes.cpp +) +target_include_directories(voxel_testable PUBLIC + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_SOURCE_DIR}/src/utils + ${CMAKE_SOURCE_DIR}/src/world + ${CMAKE_SOURCE_DIR}/src/rendering + ${CMAKE_SOURCE_DIR}/libs/glad/include +) +target_link_libraries(voxel_testable PUBLIC glm::glm) + +add_executable(voxel_tests + test_coord_utils.cpp + test_block_types.cpp +) +target_include_directories(voxel_tests PRIVATE + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_SOURCE_DIR}/src/utils + ${CMAKE_SOURCE_DIR}/src/world + ${CMAKE_SOURCE_DIR}/src/rendering + ${CMAKE_SOURCE_DIR}/libs/glad/include +) +target_link_libraries(voxel_tests PRIVATE + voxel_testable + GTest::gtest_main +) + +include(GoogleTest) +gtest_discover_tests(voxel_tests) diff --git a/tests/test_block_types.cpp b/tests/test_block_types.cpp new file mode 100644 index 0000000..8f81e49 --- /dev/null +++ b/tests/test_block_types.cpp @@ -0,0 +1,162 @@ +#include +#include "utils/BlockTypes.h" +#include "rendering/ToolModelGenerator.h" + +// All tests that call isBlock* or getBlock* must call initBlockTypes() first. +// We use a test fixture so the registry is initialised exactly once per suite. + +class BlockTypesTest : public ::testing::Test +{ +protected: + static void SetUpTestSuite() + { + initBlockTypes(); + } +}; + +// --------------------------------------------------------------------------- +// Air (ID 0) — special-cased in every inline helper +// --------------------------------------------------------------------------- + +TEST_F(BlockTypesTest, AirIsNotSolid) { EXPECT_FALSE(isBlockSolid(0)); } +TEST_F(BlockTypesTest, AirIsTransparent) { EXPECT_TRUE(isBlockTransparent(0)); } +TEST_F(BlockTypesTest, AirIsNotLiquid) { EXPECT_FALSE(isBlockLiquid(0)); } + +// --------------------------------------------------------------------------- +// Solid opaque blocks +// --------------------------------------------------------------------------- + +TEST_F(BlockTypesTest, DirtSolid) { EXPECT_TRUE(isBlockSolid(1)); } +TEST_F(BlockTypesTest, DirtNotTransparent) { EXPECT_FALSE(isBlockTransparent(1)); } +TEST_F(BlockTypesTest, DirtNotLiquid) { EXPECT_FALSE(isBlockLiquid(1)); } + +TEST_F(BlockTypesTest, GrassSolid) { EXPECT_TRUE(isBlockSolid(2)); } +TEST_F(BlockTypesTest, GrassNotTransparent) { EXPECT_FALSE(isBlockTransparent(2)); } + +TEST_F(BlockTypesTest, StoneSolid) { EXPECT_TRUE(isBlockSolid(3)); } +TEST_F(BlockTypesTest, StoneNotTransparent) { EXPECT_FALSE(isBlockTransparent(3)); } + +TEST_F(BlockTypesTest, CobblestoneSolid) { EXPECT_TRUE(isBlockSolid(22)); } + +// --------------------------------------------------------------------------- +// Transparent solid blocks (leaves, glass) +// --------------------------------------------------------------------------- + +TEST_F(BlockTypesTest, OakLeavesSolid) { EXPECT_TRUE(isBlockSolid(6)); } +TEST_F(BlockTypesTest, OakLeavesTransparent) { EXPECT_TRUE(isBlockTransparent(6)); } +TEST_F(BlockTypesTest, OakLeavesNotLiquid) { EXPECT_FALSE(isBlockLiquid(6)); } + +TEST_F(BlockTypesTest, GlassSolid) { EXPECT_TRUE(isBlockSolid(7)); } +TEST_F(BlockTypesTest, GlassTransparent) { EXPECT_TRUE(isBlockTransparent(7)); } + +// --------------------------------------------------------------------------- +// Water (source = ID 9, flow levels = IDs 10–17) +// --------------------------------------------------------------------------- + +TEST_F(BlockTypesTest, WaterSourceNotSolid) { EXPECT_FALSE(isBlockSolid(9)); } +TEST_F(BlockTypesTest, WaterSourceTransparent){ EXPECT_TRUE(isBlockTransparent(9)); } +TEST_F(BlockTypesTest, WaterSourceIsLiquid) { EXPECT_TRUE(isBlockLiquid(9)); } + +TEST_F(BlockTypesTest, WaterFlowLevelsAreLiquid) +{ + for (int id = 10; id <= 17; ++id) + { + EXPECT_FALSE(isBlockSolid(id)) << "ID " << id << " should not be solid"; + EXPECT_TRUE(isBlockTransparent(id)) << "ID " << id << " should be transparent"; + EXPECT_TRUE(isBlockLiquid(id)) << "ID " << id << " should be liquid"; + } +} + +// --------------------------------------------------------------------------- +// Uninitialised block IDs (> 22 and not water) default to non-solid / transparent +// --------------------------------------------------------------------------- + +TEST_F(BlockTypesTest, UnusedIdsDefaultToAirLike) +{ + EXPECT_FALSE(isBlockSolid(100)); + EXPECT_TRUE(isBlockTransparent(100)); + EXPECT_FALSE(isBlockLiquid(100)); +} + +// --------------------------------------------------------------------------- +// getBlockHardness +// --------------------------------------------------------------------------- + +TEST_F(BlockTypesTest, HardnessDirt) { EXPECT_FLOAT_EQ(getBlockHardness(1), 1.0f); } +TEST_F(BlockTypesTest, HardnessGrass) { EXPECT_FLOAT_EQ(getBlockHardness(2), 1.2f); } +TEST_F(BlockTypesTest, HardnessStone) { EXPECT_FLOAT_EQ(getBlockHardness(3), 1.5f); } +TEST_F(BlockTypesTest, HardnessSand) { EXPECT_FLOAT_EQ(getBlockHardness(4), 0.8f); } +TEST_F(BlockTypesTest, HardnessLeaves) { EXPECT_FLOAT_EQ(getBlockHardness(6), 0.2f); } +TEST_F(BlockTypesTest, HardnessCobblestone) { EXPECT_FLOAT_EQ(getBlockHardness(22), 2.0f); } +TEST_F(BlockTypesTest, HardnessDefaultFallback) +{ + // IDs not listed in the switch default to 1.0f + EXPECT_FLOAT_EQ(getBlockHardness(0), 1.0f); + EXPECT_FLOAT_EQ(getBlockHardness(100), 1.0f); +} + +// --------------------------------------------------------------------------- +// getBlockPreferredTool +// --------------------------------------------------------------------------- + +TEST_F(BlockTypesTest, PreferredToolStone) { EXPECT_EQ(getBlockPreferredTool(3), ToolType::Pickaxe); } +TEST_F(BlockTypesTest, PreferredToolCobblestone) { EXPECT_EQ(getBlockPreferredTool(22), ToolType::Pickaxe); } +TEST_F(BlockTypesTest, PreferredToolDirt) { EXPECT_EQ(getBlockPreferredTool(1), ToolType::Shovel); } +TEST_F(BlockTypesTest, PreferredToolGrass) { EXPECT_EQ(getBlockPreferredTool(2), ToolType::Shovel); } +TEST_F(BlockTypesTest, PreferredToolOakPlanks) { EXPECT_EQ(getBlockPreferredTool(8), ToolType::None); } +TEST_F(BlockTypesTest, PreferredToolAir) { EXPECT_EQ(getBlockPreferredTool(0), ToolType::None); } +TEST_F(BlockTypesTest, PreferredToolWater) { EXPECT_EQ(getBlockPreferredTool(9), ToolType::None); } + +// --------------------------------------------------------------------------- +// getToolSpeedMultiplier +// --------------------------------------------------------------------------- + +TEST_F(BlockTypesTest, PickaxeOnStoneSpeedMultipliers) +{ + // Stone (ID 3) prefers pickaxe + EXPECT_FLOAT_EQ(getToolSpeedMultiplier(TOOL_WOOD_PICKAXE, 3), 1.0f); + EXPECT_FLOAT_EQ(getToolSpeedMultiplier(TOOL_STONE_PICKAXE, 3), 1.5f); + EXPECT_FLOAT_EQ(getToolSpeedMultiplier(TOOL_IRON_PICKAXE, 3), 1.9f); + EXPECT_FLOAT_EQ(getToolSpeedMultiplier(TOOL_GOLD_PICKAXE, 3), 2.2f); + EXPECT_FLOAT_EQ(getToolSpeedMultiplier(TOOL_DIAMOND_PICKAXE, 3), 3.0f); +} + +TEST_F(BlockTypesTest, WrongToolOnStonePenalty) +{ + // Using a non-pickaxe on stone should yield the wrong-tool penalty (0.3) + EXPECT_FLOAT_EQ(getToolSpeedMultiplier(TOOL_WOOD_AXE, 3), 0.3f); + EXPECT_FLOAT_EQ(getToolSpeedMultiplier(TOOL_WOOD_SHOVEL, 3), 0.3f); + EXPECT_FLOAT_EQ(getToolSpeedMultiplier(0, 3), 0.3f); +} + +TEST_F(BlockTypesTest, ShovelOnDirtSpeedMultipliers) +{ + // Dirt (ID 1) prefers shovel + EXPECT_FLOAT_EQ(getToolSpeedMultiplier(TOOL_DIAMOND_SHOVEL, 1), 3.0f); + EXPECT_FLOAT_EQ(getToolSpeedMultiplier(TOOL_WOOD_SHOVEL, 1), 1.0f); +} + +TEST_F(BlockTypesTest, NoPreferredToolAlwaysOne) +{ + // Air (ID 0) has no preferred tool → multiplier is always 1.0 + EXPECT_FLOAT_EQ(getToolSpeedMultiplier(TOOL_DIAMOND_PICKAXE, 0), 1.0f); + EXPECT_FLOAT_EQ(getToolSpeedMultiplier(0, 0), 1.0f); + + // Oak planks (ID 8) — preferred tool is None + EXPECT_FLOAT_EQ(getToolSpeedMultiplier(TOOL_DIAMOND_AXE, 8), 1.0f); +} + +// --------------------------------------------------------------------------- +// initBlockTypes — verify g_defaultBlockTypes is a snapshot of initial state +// --------------------------------------------------------------------------- + +TEST_F(BlockTypesTest, DefaultBlockTypesMatchInitial) +{ + // g_defaultBlockTypes is captured at the end of initBlockTypes() + for (int id = 0; id < 256; ++id) + { + EXPECT_EQ(g_blockTypes[id].solid, g_defaultBlockTypes[id].solid) << "ID " << id; + EXPECT_EQ(g_blockTypes[id].transparent, g_defaultBlockTypes[id].transparent) << "ID " << id; + EXPECT_EQ(g_blockTypes[id].isLiquid, g_defaultBlockTypes[id].isLiquid) << "ID " << id; + } +} diff --git a/tests/test_coord_utils.cpp b/tests/test_coord_utils.cpp new file mode 100644 index 0000000..2cb79ff --- /dev/null +++ b/tests/test_coord_utils.cpp @@ -0,0 +1,125 @@ +#include + +// CoordUtils.h is header-only (inline functions). It includes Chunk.h for +// CHUNK_SIZE and glm::ivec3. Chunk.h pulls in glad/glad.h for GLuint typedefs +// only — no GL functions are called here. +#include "utils/CoordUtils.h" + +// CHUNK_SIZE == 16 throughout these tests. + +// --------------------------------------------------------------------------- +// worldToChunk +// --------------------------------------------------------------------------- + +TEST(WorldToChunk, Origin) +{ + EXPECT_EQ(worldToChunk(0, 0, 0), glm::ivec3(0, 0, 0)); +} + +TEST(WorldToChunk, WithinFirstChunk) +{ + EXPECT_EQ(worldToChunk(15, 15, 15), glm::ivec3(0, 0, 0)); + EXPECT_EQ(worldToChunk(1, 7, 3), glm::ivec3(0, 0, 0)); +} + +TEST(WorldToChunk, ExactChunkBoundary) +{ + EXPECT_EQ(worldToChunk(16, 16, 16), glm::ivec3(1, 1, 1)); + EXPECT_EQ(worldToChunk(32, 0, 0), glm::ivec3(2, 0, 0)); +} + +TEST(WorldToChunk, NegativeCoords) +{ + // -1 falls in chunk -1 (floor(-1/16) = -1) + EXPECT_EQ(worldToChunk(-1, -1, -1), glm::ivec3(-1, -1, -1)); + // -16 is the start of chunk -1 + EXPECT_EQ(worldToChunk(-16, -16, -16), glm::ivec3(-1, -1, -1)); + // -17 crosses into chunk -2 + EXPECT_EQ(worldToChunk(-17, 0, 0), glm::ivec3(-2, 0, 0)); +} + +TEST(WorldToChunk, MixedAxes) +{ + EXPECT_EQ(worldToChunk(16, -1, 0), glm::ivec3(1, -1, 0)); +} + +// --------------------------------------------------------------------------- +// worldToLocal +// --------------------------------------------------------------------------- + +TEST(WorldToLocal, Origin) +{ + EXPECT_EQ(worldToLocal(0, 0, 0), glm::ivec3(0, 0, 0)); +} + +TEST(WorldToLocal, WithinFirstChunk) +{ + EXPECT_EQ(worldToLocal(5, 10, 3), glm::ivec3(5, 10, 3)); + EXPECT_EQ(worldToLocal(15, 0, 15), glm::ivec3(15, 0, 15)); +} + +TEST(WorldToLocal, ExactChunkBoundary) +{ + // 16 is the start of the next chunk, local coord wraps to 0 + EXPECT_EQ(worldToLocal(16, 0, 0), glm::ivec3(0, 0, 0)); + EXPECT_EQ(worldToLocal(32, 0, 0), glm::ivec3(0, 0, 0)); +} + +TEST(WorldToLocal, JustPastBoundary) +{ + EXPECT_EQ(worldToLocal(17, 0, 0), glm::ivec3(1, 0, 0)); +} + +TEST(WorldToLocal, NegativeCoords) +{ + // -1 → local 15 (((−1 % 16) + 16) % 16 = 15) + EXPECT_EQ(worldToLocal(-1, 0, 0), glm::ivec3(15, 0, 0)); + // -16 → local 0 + EXPECT_EQ(worldToLocal(-16, 0, 0), glm::ivec3(0, 0, 0)); + // -17 → local 15 + EXPECT_EQ(worldToLocal(-17, 0, 0), glm::ivec3(15, 0, 0)); +} + +TEST(WorldToLocal, RoundTrip) +{ + // worldToLocal and worldToChunk together reconstruct the world coord + for (int w = -48; w <= 48; ++w) + { + glm::ivec3 chunk = worldToChunk(w, 0, 0); + glm::ivec3 local = worldToLocal(w, 0, 0); + int reconstructed = chunk.x * CHUNK_SIZE + local.x; + EXPECT_EQ(reconstructed, w) << "Failed for world coord " << w; + } +} + +// --------------------------------------------------------------------------- +// chunkToWorld +// --------------------------------------------------------------------------- + +TEST(ChunkToWorld, Origin) +{ + EXPECT_EQ(chunkToWorld(0, 0, 0), glm::ivec3(0, 0, 0)); +} + +TEST(ChunkToWorld, PositiveChunks) +{ + EXPECT_EQ(chunkToWorld(1, 0, 0), glm::ivec3(16, 0, 0)); + EXPECT_EQ(chunkToWorld(2, 3, 1), glm::ivec3(32, 48, 16)); +} + +TEST(ChunkToWorld, NegativeChunks) +{ + EXPECT_EQ(chunkToWorld(-1, -1, -1), glm::ivec3(-16, -16, -16)); + EXPECT_EQ(chunkToWorld(-2, 0, 0), glm::ivec3(-32, 0, 0)); +} + +TEST(ChunkToWorld, RoundTrip) +{ + // chunkToWorld is the inverse of worldToChunk for chunk-aligned positions + for (int c = -5; c <= 5; ++c) + { + glm::ivec3 world = chunkToWorld(c, 0, 0); + glm::ivec3 back = worldToChunk(world.x, world.y, world.z); + EXPECT_EQ(back.x, c) << "Failed for chunk coord " << c; + } +}