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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<module>.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
3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
45 changes: 45 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
162 changes: 162 additions & 0 deletions tests/test_block_types.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
#include <gtest/gtest.h>
#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;
}
}
125 changes: 125 additions & 0 deletions tests/test_coord_utils.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
#include <gtest/gtest.h>

// 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;
}
}
Loading