From 0378fafe6476c79a7bb36a5684de1ab452245bb3 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Thu, 27 Aug 2026 18:31:53 +0200 Subject: [PATCH] test: add tests for queue-based light recalculation on block edits --- tests/testLightEngine.cpp | 133 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tests/testLightEngine.cpp diff --git a/tests/testLightEngine.cpp b/tests/testLightEngine.cpp new file mode 100644 index 0000000..49c3c20 --- /dev/null +++ b/tests/testLightEngine.cpp @@ -0,0 +1,133 @@ +#include "testHarness.h" +#include "vf/LightEngine.h" + +using namespace vf; + +namespace { + +void fillBox(World& world, Vec3i lo, Vec3i hi, Block block) { + for (int x = lo.x; x <= hi.x; ++x) + for (int y = lo.y; y <= hi.y; ++y) + for (int z = lo.z; z <= hi.z; ++z) + world.setBlock(x, y, z, block); +} + +// Compares an incrementally maintained engine against a full rebuild of the +// same world+box. Equality is the correctness oracle for incremental edits. +bool matchesRebuild(const LightEngine& incremental, World& world) { + Vec3i origin = incremental.regionOrigin(); + Vec3i dims = incremental.regionDims(); + LightEngine fresh(world, origin, dims); + fresh.rebuild(); + for (int lx = 0; lx < dims.x; ++lx) + for (int ly = 0; ly < dims.y; ++ly) + for (int lz = 0; lz < dims.z; ++lz) { + int gx = origin.x + lx, gy = origin.y + ly, gz = origin.z + lz; + if (incremental.sunAt(gx, gy, gz) != fresh.sunAt(gx, gy, gz)) return false; + if (incremental.blockAt(gx, gy, gz) != fresh.blockAt(gx, gy, gz)) return false; + } + return true; +} + +} // namespace + +VF_TEST(lightEngineSkyFillsOpenColumnStraightDown) { + World world; + fillBox(world, {0, 0, 0}, {7, 0, 7}, Block::Stone); + LightEngine engine(world, {0, 0, 0}, {8, 8, 8}); + engine.rebuild(); + CHECK_EQ(static_cast(engine.sunAt(3, 7, 3)), maxLightLevel); + CHECK_EQ(static_cast(engine.sunAt(3, 1, 3)), maxLightLevel); // no vertical loss +} + +VF_TEST(lightEngineBlockEmitterFallsOffRadially) { + World world; + world.setBlock(5, 5, 5, Block::Glowstone); + LightEngine engine(world, {0, 0, 0}, {11, 11, 11}); + engine.rebuild(); + CHECK_EQ(static_cast(engine.blockAt(5, 5, 5)), maxLightLevel); + CHECK_EQ(static_cast(engine.blockAt(6, 5, 5)), maxLightLevel - 1); + CHECK_EQ(static_cast(engine.blockAt(8, 5, 5)), maxLightLevel - 3); +} + +VF_TEST(lightEnginePlacingOpaqueDarkensColumnBelow) { + World world; + fillBox(world, {0, 0, 0}, {4, 11, 4}, Block::Stone); // solid box + for (int y = 1; y <= 11; ++y) world.setBlock(2, y, 2, Block::Air); // walled shaft + LightEngine engine(world, {0, 0, 0}, {5, 12, 5}); + engine.rebuild(); + CHECK_EQ(static_cast(engine.sunAt(2, 1, 2)), maxLightLevel); + + engine.setBlock(2, 6, 2, Block::Stone); // seal the shaft midway + CHECK_EQ(static_cast(engine.sunAt(2, 5, 2)), 0); // shadow below the lid + CHECK_EQ(static_cast(engine.sunAt(2, 7, 2)), maxLightLevel); // still lit above + CHECK(matchesRebuild(engine, world)); +} + +VF_TEST(lightEngineBreakingOpaqueRePropagates) { + World world; + fillBox(world, {0, 0, 0}, {4, 11, 4}, Block::Stone); + for (int y = 1; y <= 11; ++y) world.setBlock(2, y, 2, Block::Air); + world.setBlock(2, 6, 2, Block::Stone); // lid already in place + LightEngine engine(world, {0, 0, 0}, {5, 12, 5}); + engine.rebuild(); + CHECK_EQ(static_cast(engine.sunAt(2, 5, 2)), 0); // in shadow + + engine.setBlock(2, 6, 2, Block::Air); // reopen the shaft + CHECK_EQ(static_cast(engine.sunAt(2, 5, 2)), maxLightLevel); // relit straight down + CHECK(matchesRebuild(engine, world)); +} + +VF_TEST(lightEngineRemovingEmitterDarkensRoom) { + World world; + fillBox(world, {0, 0, 0}, {8, 8, 8}, Block::Stone); + fillBox(world, {1, 1, 1}, {7, 7, 7}, Block::Air); // sealed room, no sky + world.setBlock(4, 4, 4, Block::Glowstone); + LightEngine engine(world, {0, 0, 0}, {9, 9, 9}); + engine.rebuild(); + CHECK(engine.blockAt(5, 4, 4) > 0); + + engine.setBlock(4, 4, 4, Block::Air); // remove the emitter + CHECK_EQ(static_cast(engine.blockAt(5, 4, 4)), 0); + CHECK_EQ(static_cast(engine.blockAt(4, 4, 4)), 0); + CHECK(matchesRebuild(engine, world)); +} + +VF_TEST(lightEngineEditAcrossChunkBorderMatchesRebuild) { + World world; + // floor spanning four chunk columns so light crosses chunk borders in the box. + fillBox(world, {12, 0, 12}, {35, 0, 35}, Block::Stone); + LightEngine engine(world, {12, 0, 12}, {24, 20, 24}); // spans chunks x/z 0..2 + engine.rebuild(); + + world.setBlock(0, 0, 0, Block::Air); + engine.setBlock(15, 5, 15, Block::Glowstone); // near a chunk boundary + engine.setBlock(16, 5, 16, Block::Stone); + engine.setBlock(31, 5, 31, Block::Glowstone); // in a different chunk + engine.setBlock(31, 5, 31, Block::Air); + CHECK(matchesRebuild(engine, world)); +} + +VF_TEST(lightEngineEditSequenceStaysConsistentWithRebuild) { + World world; + fillBox(world, {0, 0, 0}, {15, 0, 15}, Block::Stone); + LightEngine engine(world, {0, 0, 0}, {16, 16, 16}); + engine.rebuild(); + + const Block palette[4] = {Block::Stone, Block::Air, Block::Glowstone, Block::Water}; + uint32_t rng = 0x1234567u; + auto nextRng = [&rng]() { + rng ^= rng << 13; + rng ^= rng >> 17; + rng ^= rng << 5; + return rng; + }; + for (int i = 0; i < 120; ++i) { + int x = static_cast(nextRng() % 16); + int y = 1 + static_cast(nextRng() % 12); + int z = static_cast(nextRng() % 16); + Block b = palette[nextRng() % 4]; + engine.setBlock(x, y, z, b); + } + CHECK(matchesRebuild(engine, world)); +}