using System.Threading.Tasks; using AcDream.App.Streaming; using AcDream.Core.World; using DatReaderWriter.DBObjs; using Xunit; namespace AcDream.Core.Tests.Streaming; public class LandblockStreamerTests { private const int SpinTimeoutMs = 2000; private const int SpinStepMs = 10; private const int SpinMaxIterations = SpinTimeoutMs / SpinStepMs; [Fact] public async Task Load_FollowedByDrain_ReturnsLoadedRecord() { var stubLandblock = new LoadedLandblock( 0xA9B4FFFEu, new LandBlock(), System.Array.Empty()); var stubMesh = new AcDream.Core.Terrain.LandblockMeshData( System.Array.Empty(), System.Array.Empty()); using var streamer = new LandblockStreamer( loadLandblock: id => id == 0xA9B4FFFEu ? stubLandblock : null, buildMeshOrNull: (_, _) => stubMesh); streamer.Start(); streamer.EnqueueLoad(0xA9B4FFFEu); // Spin until the worker produces a completion, with a 2s timeout. LandblockStreamResult? result = null; for (int i = 0; i < SpinMaxIterations && result is null; i++) { var drained = streamer.DrainCompletions(maxBatchSize: LandblockStreamer.DefaultDrainBatchSize); if (drained.Count > 0) result = drained[0]; else await Task.Delay(SpinStepMs); } Assert.NotNull(result); var loaded = Assert.IsType(result); Assert.Equal(0xA9B4FFFEu, loaded.LandblockId); Assert.Same(stubLandblock, loaded.Landblock); } [Fact] public async Task LoadNear_OvertakesQueuedFarLoads() { var callOrder = new System.Collections.Generic.List<(uint Id, LandblockStreamJobKind Kind)>(); var stubMesh = new AcDream.Core.Terrain.LandblockMeshData( System.Array.Empty(), System.Array.Empty()); using var streamer = new LandblockStreamer( loadLandblock: (id, kind) => { callOrder.Add((id, kind)); return new LoadedLandblock(id, new LandBlock(), System.Array.Empty()); }, buildMeshOrNull: (_, _) => stubMesh); streamer.EnqueueLoad(0xAAAAFFFFu, LandblockStreamJobKind.LoadFar); streamer.EnqueueLoad(0xBBBBFFFFu, LandblockStreamJobKind.LoadFar); streamer.EnqueueLoad(0xCCCCFFFFu, LandblockStreamJobKind.LoadFar); streamer.EnqueueLoad(0xDDDDFFFFu, LandblockStreamJobKind.LoadNear); streamer.Start(); var result = await DrainFirstAsync(streamer); var loaded = Assert.IsType(result); Assert.Equal(0xDDDDFFFFu, loaded.LandblockId); Assert.Equal((0xDDDDFFFFu, LandblockStreamJobKind.LoadNear), callOrder[0]); } [Fact] public async Task PromoteToNear_ProducesPromotedWithMeshData() { int meshBuildCalls = 0; var entity = new WorldEntity { Id = 7, SourceGfxObjOrSetupId = 0, Position = System.Numerics.Vector3.Zero, Rotation = System.Numerics.Quaternion.Identity, MeshRefs = System.Array.Empty() }; using var streamer = new LandblockStreamer( loadLandblock: (id, kind) => new LoadedLandblock(id, new LandBlock(), new[] { entity }), buildMeshOrNull: (_, _) => { meshBuildCalls++; return new AcDream.Core.Terrain.LandblockMeshData( System.Array.Empty(), System.Array.Empty()); }); streamer.EnqueueLoad(0xA9B4FFFFu, LandblockStreamJobKind.PromoteToNear); streamer.Start(); var result = await DrainFirstAsync(streamer); var promoted = Assert.IsType(result); Assert.Equal(0xA9B4FFFFu, promoted.LandblockId); Assert.Same(entity, promoted.Entities[0]); Assert.NotNull(promoted.MeshData); Assert.Equal(1, meshBuildCalls); } [Fact] public async Task PromoteToNear_OvertakesAndSupersedesQueuedFarLoadForSameLandblock() { var callOrder = new System.Collections.Generic.List<(uint Id, LandblockStreamJobKind Kind)>(); var stubMesh = new AcDream.Core.Terrain.LandblockMeshData( System.Array.Empty(), System.Array.Empty()); using var streamer = new LandblockStreamer( loadLandblock: (id, kind) => { callOrder.Add((id, kind)); return new LoadedLandblock(id, new LandBlock(), System.Array.Empty()); }, buildMeshOrNull: (_, _) => stubMesh); streamer.EnqueueLoad(0xA9B4FFFFu, LandblockStreamJobKind.LoadFar); streamer.EnqueueLoad(0xA9B4FFFFu, LandblockStreamJobKind.PromoteToNear); streamer.Start(); var result = await DrainFirstAsync(streamer); var promoted = Assert.IsType(result); Assert.Equal(0xA9B4FFFFu, promoted.LandblockId); Assert.Equal((0xA9B4FFFFu, LandblockStreamJobKind.PromoteToNear), callOrder[0]); } [Fact] public async Task Load_WhenLoaderReturnsNull_ReportsFailed() { using var streamer = new LandblockStreamer( loadLandblock: _ => null); streamer.Start(); streamer.EnqueueLoad(0x12340000u); LandblockStreamResult? result = null; for (int i = 0; i < SpinMaxIterations && result is null; i++) { var drained = streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize); if (drained.Count > 0) result = drained[0]; else await Task.Delay(SpinStepMs); } Assert.NotNull(result); Assert.IsType(result); } [Fact] public async Task Load_WhenBuildMeshReturnsNull_ReportsFailed() { // Phase A.5 T10-T12 follow-up: the mesh-build factory may return // null (e.g., LandBlock dat missing or corrupt). The worker must // emit Failed in that case instead of constructing Loaded with a // null MeshData (which would NRE downstream). var stubLandblock = new LoadedLandblock( 0xABCDFFFEu, new LandBlock(), System.Array.Empty()); using var streamer = new LandblockStreamer( loadLandblock: _ => stubLandblock, buildMeshOrNull: (_, _) => null); // mesh-build returns null streamer.Start(); streamer.EnqueueLoad(0xABCDFFFEu); LandblockStreamResult? result = null; for (int i = 0; i < SpinMaxIterations && result is null; i++) { var drained = streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize); if (drained.Count > 0) result = drained[0]; else await Task.Delay(SpinStepMs); } Assert.NotNull(result); var failed = Assert.IsType(result); Assert.Equal(0xABCDFFFEu, failed.LandblockId); Assert.Contains("mesh", failed.Error, System.StringComparison.OrdinalIgnoreCase); } [Fact] public async Task Load_WhenLoaderThrows_ReportsFailedWithMessage() { using var streamer = new LandblockStreamer( loadLandblock: _ => throw new System.InvalidOperationException("boom")); streamer.Start(); streamer.EnqueueLoad(0x55550000u); LandblockStreamResult? result = null; for (int i = 0; i < SpinMaxIterations && result is null; i++) { var drained = streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize); if (drained.Count > 0) result = drained[0]; else await Task.Delay(SpinStepMs); } var failed = Assert.IsType(result); Assert.Contains("boom", failed.Error); } [Fact] public async Task Unload_ProducesUnloadedResult() { using var streamer = new LandblockStreamer(loadLandblock: _ => null); streamer.Start(); streamer.EnqueueUnload(0xABCD0000u); LandblockStreamResult? result = null; for (int i = 0; i < SpinMaxIterations && result is null; i++) { var drained = streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize); if (drained.Count > 0) result = drained[0]; else await Task.Delay(SpinStepMs); } var unloaded = Assert.IsType(result); Assert.Equal(0xABCD0000u, unloaded.LandblockId); } [Fact] public async Task Load_ExecutesLoaderOnWorkerThread() { // Phase A.5 T11: the load delegate now runs on the dedicated worker // thread (not the calling/render thread). This test verifies the // async hand-off: EnqueueLoad returns immediately and the result // appears in the outbox only after the worker processes the inbox. int testThreadId = System.Environment.CurrentManagedThreadId; int? loaderThreadId = null; var stubLandblock = new LoadedLandblock( 0x77770FFEu, new LandBlock(), System.Array.Empty()); var stubMesh = new AcDream.Core.Terrain.LandblockMeshData( System.Array.Empty(), System.Array.Empty()); using var streamer = new LandblockStreamer( loadLandblock: id => { loaderThreadId = System.Environment.CurrentManagedThreadId; return stubLandblock; }, buildMeshOrNull: (_, _) => stubMesh); streamer.Start(); streamer.EnqueueLoad(0x77770FFEu); // Spin until the worker produces a completion. LandblockStreamResult? result = null; for (int i = 0; i < SpinMaxIterations && result is null; i++) { var drained = streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize); if (drained.Count > 0) result = drained[0]; else await Task.Delay(SpinStepMs); } Assert.NotNull(result); Assert.IsType(result); // The loader MUST have run on a different thread than the test thread. Assert.NotNull(loaderThreadId); Assert.NotEqual(testThreadId, loaderThreadId.Value); } private static async Task DrainFirstAsync(LandblockStreamer streamer) { for (int i = 0; i < SpinMaxIterations; i++) { var drained = streamer.DrainCompletions(maxBatchSize: LandblockStreamer.DefaultDrainBatchSize); if (drained.Count > 0) return drained[0]; await Task.Delay(SpinStepMs); } throw new Xunit.Sdk.XunitException("Timed out waiting for streamer completion."); } }