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:
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
|
||||
Reference in new issue
Block a user