acdream/tests/AcDream.Core.Tests/Streaming/LandblockStreamerTests.cs
Erik 774a7070a8 fix(A.5 T10-T12): Start() race + null mesh test + real mesh stub
Code review on T10-T12 bundle (commits 0cf86bb/00bb030/0405947 + audit
fix 76e1a64) found 3 Important issues:

1. LandblockStreamer.Start() had an idempotency race — the XML doc
   claimed thread-safety but the implementation checked _worker != null
   before assigning, allowing two callers to both pass the check and
   spawn duplicate worker threads. Fixed via Interlocked.CompareExchange.

2. No test verified the worker emits Failed when buildMeshOrNull returns
   null. Added Load_WhenBuildMeshReturnsNull_ReportsFailed.

3. StreamingControllerTests.cs:81 used MeshData: default! when
   constructing a Loaded result. If a future test flows MeshData
   through the apply callback, the null reference would NRE rather
   than producing a meaningful assertion failure. Replaced with a real
   empty LandblockMeshData instance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:49:14 +02:00

186 lines
6.8 KiB
C#

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<WorldEntity>());
var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
System.Array.Empty<uint>());
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<LandblockStreamResult.Loaded>(result);
Assert.Equal(0xA9B4FFFEu, loaded.LandblockId);
Assert.Same(stubLandblock, loaded.Landblock);
}
[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<LandblockStreamResult.Failed>(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<WorldEntity>());
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<LandblockStreamResult.Failed>(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<LandblockStreamResult.Failed>(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<LandblockStreamResult.Unloaded>(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<WorldEntity>());
var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
System.Array.Empty<uint>());
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<LandblockStreamResult.Loaded>(result);
// The loader MUST have run on a different thread than the test thread.
Assert.NotNull(loaderThreadId);
Assert.NotEqual(testThreadId, loaderThreadId.Value);
}
}