using OpenTK.Mathematics; using VoxelEngine.Core; using VoxelEngine.World; using VoxelEngine.World.Blocks; using VoxelEngine.World.Chunk; using VoxelEngine.World.Persistence; using VoxelEngine.World.Streaming; using Xunit; using VoxelChunk = VoxelEngine.World.Chunk.Chunk; namespace VoxelEngine.Tests.Streaming; // Synchronous executor: runs the submitted action on the calling thread. internal sealed class SynchronousJobExecutor : IJobExecutor { public void Submit(Action work) => work(); } // Fake renderer: never touches GL. Just records lifecycle. internal sealed class FakeRenderer : IChunkRenderer { public bool Disposed { get; private set; } public int DrawCount { get; private set; } public float[] Mesh { get; } public FakeRenderer(float[] mesh) { this.Mesh = mesh; } public void Draw() => this.DrawCount++; public void Dispose() => this.Disposed = true; } // Minimal mesher returning a fixed-length non-empty mesh. internal sealed class FakeMesher : IChunkMesher { public float[] Build(VoxelChunk chunk, BlockRegistry registry, Func? neighborOpaqueOutside = null) => new float[6]; // one vertex worth } public class ChunkStreamerTests { private static (ChunkStreamer streamer, World.World world, List created) CreateStreamer(Configuration cfg) { var registry = new BlockRegistry(); var store = new InMemoryChunkModificationStore(); var world = new World.World(registry, store); var mesher = new FakeMesher(); var executor = new SynchronousJobExecutor(); var created = new List(); Func factory = m => { var r = new FakeRenderer(m); created.Add(r); return r; }; Func generate = _ => new VoxelChunk(); var streamer = new ChunkStreamer(cfg, world, generate, mesher, registry, executor, factory); return (streamer, world, created); } [Fact] public void Tick_FirstCall_LoadsAllChunksInLoadBox() { var cfg = new Configuration { RenderDistanceHorizontal = 1, RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var (streamer, world, created) = CreateStreamer(cfg); streamer.Tick(new Vector3i(0, 0, 0)); // Load box H=1, V=0 → 3 × 1 × 3 = 9 chunks Assert.Equal(9, created.Count); for (int x = -1; x <= 1; x++) for (int z = -1; z <= 1; z++) { var pos = new Vector3i(x, 0, z); Assert.NotNull(world.GetChunk(pos)); Assert.True(streamer.IsReady(pos)); } } [Fact] public void Tick_PlayerMoves_LoadsNewChunksOnly() { var cfg = new Configuration { RenderDistanceHorizontal = 1, RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var (streamer, world, created) = CreateStreamer(cfg); streamer.Tick(new Vector3i(0, 0, 0)); // loads 9 int firstWave = created.Count; streamer.Tick(new Vector3i(1, 0, 0)); // loads only the 3 new column to +x Assert.Equal(firstWave + 3, created.Count); } [Fact] public void Tick_RespectsMaxMeshUploadsPerFrame() { var cfg = new Configuration { RenderDistanceHorizontal = 1, RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 2, // cap drains per tick }; var (streamer, _, created) = CreateStreamer(cfg); // With sync executor every submitted job completes immediately and // posts into _completed. The cap throttles how many drain per Tick(). streamer.Tick(new Vector3i(0, 0, 0)); // dispatch fills the queue; drain ≤ 2 Assert.Equal(2, created.Count); streamer.Tick(new Vector3i(0, 0, 0)); // same player chunk, drains 2 more Assert.Equal(4, created.Count); } [Fact] public void Tick_ClampsYToWorldBounds() { var cfg = new Configuration { RenderDistanceHorizontal = 0, RenderDistanceVertical = 2, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var (streamer, world, created) = CreateStreamer(cfg); // Player at y=0 (chunk Y = 0). V=2 wants Y ∈ [-2, 2], but valid Y is [0, 8). // So only Y ∈ {0, 1, 2} qualifies → 1 × 3 × 1 = 3 chunks. streamer.Tick(new Vector3i(0, 0, 0)); Assert.Equal(3, created.Count); Assert.NotNull(world.GetChunk(new Vector3i(0, 0, 0))); Assert.NotNull(world.GetChunk(new Vector3i(0, 1, 0))); Assert.NotNull(world.GetChunk(new Vector3i(0, 2, 0))); } [Fact] public void Tick_AppliesModificationDiffOnLoad() { var cfg = new Configuration { RenderDistanceHorizontal = 0, RenderDistanceVertical = 0, MaxGenerateJobsInFlight = 100, MaxMeshUploadsPerFrame = 100, }; var registry = new BlockRegistry(); var store = new InMemoryChunkModificationStore(); var world = new World.World(registry, store); var mesher = new FakeMesher(); var executor = new SynchronousJobExecutor(); Func factory = m => new FakeRenderer(m); Func generate = _ => new VoxelChunk(); // all air // Pre-record an edit before the chunk loads. var pos = new Vector3i(0, 0, 0); int localIndex = (5 * VoxelChunk.Size + 6) * VoxelChunk.Size + 7; store.RecordEdit(pos, localIndex, registry.Stone.Id); var streamer = new ChunkStreamer(cfg, world, generate, mesher, registry, executor, factory); streamer.Tick(pos); Assert.Equal(registry.Stone.Id, world.GetBlock(5, 6, 7).Id); } }