test: Phase 0.5 — the three automation tests (density purity, ClassifyTile, DiffLayer)

The plugin had zero tests, and the docs make dozens of "bit-identical" and
"conservative verdict" claims that nothing machine-checks. OPSTACK-PLAN §4
Phase 0.5 asks for these before any op-stack work is built on top.

- VoxelForgeTestFixture.h — headless world (transient strate definitions ->
  UVoxelSettings -> a real UVoxelStrateManager::Initialize), so the tests hit
  UVoxelGenerator::GetDensityAt where the ~30 thread_local caches actually live.
  One strate per archetype, pinned via FixedStrates so slot index -> archetype
  is stable across seeds.

- DensityPurity — 10k points re-sampled in shuffled order on the same thread and
  on N worker threads, asserting BIT equality. AVoxelWorld::ValidateDeterminism
  is game-thread only and structurally cannot see worker-cache divergence, which
  is how AUDIT C2 stayed hidden. Includes a flat-field canary so a collapsed
  noise field (AUDIT C1) can't make the test pass vacuously, and a diff-layer
  pass that exercises the direct-mapped DiffSlots cache.

- ClassifyTileSoundness — scans tiles for a non-Mixed verdict, then brute-forces
  the exact mesher lattice (g in [-1, Cells+1], margin included) and asserts every
  sample is on the claimed side of IsoLevel 0. A false AllSolid/AllAir is an
  invisible collisionless hole; T1.d v1 was reverted for exactly that in June.
  Errors out rather than passing if no tile yielded a verdict to check.

- DiffLayerContention — N reader threads running the worker call mix while the
  game thread writes and Clear()s, asserting survival and a monotonic ModsVersion.

UNVERIFIED: never compiled — this module has never pulled in AutomationTest.h
and Private/Tests/ is new. See OPSTACK-PROGRESS.md for the likely error spots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Fr0zkaandClaude Opus 5 committed 2026-07-27 01:50:26 +02:00
1 parent 3128852d4e
commit 6eec796403
4 files changed
+914

No files matched your search

@@ -0,0 +1,234 @@
// VoxelForgeTestFixture.h
// Fixture partagée par les tests d'automatisation VoxelForge (Phase 0.5 de OPSTACK-PLAN.md).
// Shared fixture for the VoxelForge automation tests (OPSTACK-PLAN.md, Phase 0.5).
//
// WHY THIS EXISTS
// ---------------
// The interesting invariants (density purity across worker threads, ClassifyTile soundness)
// only fire on the REAL path — UVoxelGenerator::GetDensityAt — because that is where the
// thread_local per-chunk caches live (CP_*, GSurfColCache, the diff slots, the SDF cache).
// Calling GetSurfaceDensity / GetMazeDensity directly bypasses every one of them and would
// test almost nothing. GetDensityAt in turn needs a live UVoxelStrateManager, whose only
// entry point is Initialize(UVoxelSettings*, int32) reading TSoftObjectPtr pools.
//
// So the fixture builds a whole synthetic world in memory: transient strate definitions →
// a transient UVoxelSettings pointing at them → a real UVoxelStrateManager::Initialize.
//
// ⚠️ KNOWN RISK, stated rather than hidden: the settings hold TSoftObjectPtr, and we point
// them at TRANSIENT objects (/Engine/Transient.<name>). LoadSynchronous() resolves those via
// FindObject, which works for in-memory objects — but it is the one part of this fixture that
// has never been compiled or run. IsValid() below checks the layout actually materialised, and
// every test hard-FAILS with a clear message when it didn't. A silent skip would be worse than
// a failure: it would look like a pass.
//
// Everything is held by TStrongObjectPtr so the GC cannot eat the world mid-test.
#pragma once
#if WITH_DEV_AUTOMATION_TESTS
#include "CoreMinimal.h"
#include "UObject/StrongObjectPtr.h"
#include "UObject/Package.h"
#include "VoxelTypes.h"
#include "VoxelSettings.h"
#include "VoxelStrateTypes.h"
#include "VoxelStrateDefinition.h"
#include "VoxelStrateManager.h"
#include "VoxelDiffLayer.h"
#include "VoxelGenerator.h"
namespace VoxelForgeTest
{
/**
* FTestWorld — a complete, headless VoxelForge world: settings + strate layout +
* generator + diff layer. No AActor, no UWorld, no PIE.
*
* The default layout stacks one strate of EVERY archetype (in ECaveGeneratorType order),
* so a single fixture exercises all eight density functions and their per-chunk caches,
* plus the gap-bedrock path when InterStrateGapChunks > 0.
*/
struct FTestWorld
{
TStrongObjectPtr<UVoxelSettings> Settings;
TStrongObjectPtr<UVoxelStrateManager> StrateManager;
TStrongObjectPtr<UVoxelDiffLayer> DiffLayer;
TStrongObjectPtr<UVoxelGenerator> Generator;
TArray<TStrongObjectPtr<UVoxelStrateDefinition>> Definitions;
/** World Z (voxel coords) span actually covered by the layout — handy for picking samples. */
int32 TopChunkZ = 0;
int32 BottomChunkZ = 0;
/**
* Build the world. Seed stays SMALL on purpose: AUDIT C1 (unbounded SeedF) is a real
* open bug and a large seed would collapse the noise fields to constants, which would
* make a purity test pass trivially for the wrong reason.
*/
void Build(int32 InSeed = 1337, int32 InGapChunks = 2)
{
Settings = TStrongObjectPtr<UVoxelSettings>(
NewObject<UVoxelSettings>(GetTransientPackage(), NAME_None, RF_Transient));
Settings->Seed = InSeed;
Settings->InterStrateGapChunks = InGapChunks;
// Une strate par archétype. PINNED via FixedStrates, pas via le pool : Initialize()
// mélange le pool avec le seed, ce qui rendrait la correspondance archétype → Z
// dépendante du seed et un message d'échec impossible à relire.
// One strate per archetype, PINNED through FixedStrates rather than the pool:
// Initialize() shuffles the pool by seed, which would make the archetype → Z mapping
// seed-dependent and a failure message unreadable. Slot i == Archetypes[i].
static const ECaveGeneratorType Archetypes[] = {
ECaveGeneratorType::TunnelNetwork,
ECaveGeneratorType::FlatPlain,
ECaveGeneratorType::CrystalChamber,
ECaveGeneratorType::Maze,
ECaveGeneratorType::SurfaceWorld,
ECaveGeneratorType::VerticalShafts,
ECaveGeneratorType::FloatingIslands,
ECaveGeneratorType::Underwater,
};
for (int32 i = 0; i < UE_ARRAY_COUNT(Archetypes); ++i)
{
UVoxelStrateDefinition* Def = NewObject<UVoxelStrateDefinition>(
GetTransientPackage(), NAME_None, RF_Transient);
Def->GeneratorType = Archetypes[i];
Def->StrateHeightInChunks = 4;
// Hard transitions: param blending across a boundary would make "which archetype
// owns this chunk" ambiguous, and these tests want an unambiguous mapping.
Def->TransitionType = EVoxelStrateTransition::Hard;
Definitions.Add(TStrongObjectPtr<UVoxelStrateDefinition>(Def));
const TSoftObjectPtr<UVoxelStrateDefinition> SoftDef(Def);
Settings->FixedStrates.Add(i, SoftDef);
Settings->StratePool.Add(SoftDef); // fallback if a fixed entry fails to resolve
}
Settings->TotalStrates = UE_ARRAY_COUNT(Archetypes);
StrateManager = TStrongObjectPtr<UVoxelStrateManager>(
NewObject<UVoxelStrateManager>(GetTransientPackage(), NAME_None, RF_Transient));
StrateManager->Initialize(Settings.Get(), Settings->Seed);
DiffLayer = TStrongObjectPtr<UVoxelDiffLayer>(
NewObject<UVoxelDiffLayer>(GetTransientPackage(), NAME_None, RF_Transient));
Generator = TStrongObjectPtr<UVoxelGenerator>(
NewObject<UVoxelGenerator>(GetTransientPackage(), NAME_None, RF_Transient));
Generator->InitializeSettings(Settings.Get());
Generator->SetStrateManager(StrateManager.Get());
Generator->SetDiffLayer(DiffLayer.Get());
CacheZBounds();
}
/** Re-run Initialize (bumps LayoutVersion) — the live-edit path AUDIT C2 is about. */
void Reinitialize()
{
StrateManager->Initialize(Settings.Get(), Settings->Seed);
CacheZBounds();
}
/** False when the soft-pointer resolve failed and no strate layout exists. */
bool IsValid() const
{
return StrateManager.IsValid() && StrateManager->GetNumStrates() > 0;
}
FString WhyInvalid() const
{
return TEXT("FTestWorld could not build a strate layout. Most likely the ")
TEXT("TSoftObjectPtr -> transient UVoxelStrateDefinition resolve failed inside ")
TEXT("UVoxelStrateManager::Initialize (LoadSynchronous on /Engine/Transient.*). ")
TEXT("See the header comment in VoxelForgeTestFixture.h. This is a FIXTURE ")
TEXT("failure, not a generator failure — do not read it as a density bug.");
}
/** Voxel-Z of the middle of the layout — a point guaranteed inside a real strate. */
float MidVoxelZ() const
{
return (float)((TopChunkZ + BottomChunkZ) / 2 * CHUNK_SIZE + CHUNK_SIZE / 2);
}
/** Layout slot index of each archetype — the Archetypes[] order in Build(), pinned via
* FixedStrates so it is stable across seeds. SurfaceWorld matters most: it is the only
* archetype ClassifyTile can currently prove anything about (besides bedrock gaps). */
static constexpr int32 SlotTunnelNetwork = 0;
static constexpr int32 SlotFlatPlain = 1;
static constexpr int32 SlotCrystalChamber = 2;
static constexpr int32 SlotMaze = 3;
static constexpr int32 SlotSurfaceWorld = 4;
static constexpr int32 SlotVerticalShafts = 5;
static constexpr int32 SlotFloatingIsland = 6;
static constexpr int32 SlotUnderwater = 7;
/** Voxel-Z span of one layout slot. False if the layout is shorter than expected. */
bool GetSlotVoxelZRange(int32 SlotIndex, int32& OutTopVoxelZ, int32& OutBottomVoxelZ) const
{
const TArray<FStrateSlot>& Layout = StrateManager->GetLayout();
if (!Layout.IsValidIndex(SlotIndex)) { return false; }
OutTopVoxelZ = Layout[SlotIndex].TopChunkZ * CHUNK_SIZE + CHUNK_SIZE - 1;
OutBottomVoxelZ = Layout[SlotIndex].BottomChunkZ * CHUNK_SIZE;
return true;
}
private:
void CacheZBounds()
{
TopChunkZ = 0;
BottomChunkZ = 0;
for (const FStrateSlot& Slot : StrateManager->GetLayout())
{
TopChunkZ = FMath::Max(TopChunkZ, Slot.TopChunkZ);
BottomChunkZ = FMath::Min(BottomChunkZ, Slot.BottomChunkZ);
}
}
};
/**
* A spread of world sample points that deliberately crosses chunk boundaries, strate
* boundaries and bedrock gaps — the exact conditions under which a per-chunk cache with a
* missing key input produces a wrong answer. Integer XY on purpose: that is the branch
* GetDensityAt's T1.a column cache actually takes (fractional XY bypasses the cache).
*/
inline void BuildSamplePoints(const FTestWorld& World, int32 Count, int32 Seed,
TArray<FVector>& OutPoints)
{
OutPoints.Reset(Count);
FRandomStream Rng(Seed);
const int32 TopVoxelZ = World.TopChunkZ * CHUNK_SIZE + CHUNK_SIZE - 1;
const int32 BottomVoxelZ = World.BottomChunkZ * CHUNK_SIZE;
for (int32 i = 0; i < Count; ++i)
{
// XY range spans several chunks either side of the origin so the (0,0) spine, the
// passages and plain interior rock all appear in the sample set.
const int32 X = Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE);
const int32 Y = Rng.RandRange(-3 * CHUNK_SIZE, 3 * CHUNK_SIZE);
const int32 Z = Rng.RandRange(BottomVoxelZ, TopVoxelZ);
OutPoints.Add(FVector((float)X, (float)Y, (float)Z));
}
}
/** Deterministic shuffle of an index array — the "different query order" half of purity. */
inline void BuildShuffledOrder(int32 Count, int32 Seed, TArray<int32>& OutOrder)
{
OutOrder.Reset(Count);
for (int32 i = 0; i < Count; ++i) { OutOrder.Add(i); }
FRandomStream Rng(Seed);
for (int32 i = Count - 1; i > 0; --i)
{
OutOrder.Swap(i, Rng.RandRange(0, i));
}
}
/** Bit-exact float compare — NOT FMath::IsNearlyEqual. Window invariance is a bit property
* (ARCHITECTURE §8.4): a 1-ULP difference between two chunk windows is a visible seam. */
inline bool BitEqual(float A, float B)
{
return FMath::IsNaN(A) == FMath::IsNaN(B)
&& (FMath::IsNaN(A) || *reinterpret_cast<const uint32*>(&A) == *reinterpret_cast<const uint32*>(&B));
}
}
#endif // WITH_DEV_AUTOMATION_TESTS