using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using TheLivingWorld.Api.Generation; using TheLivingWorld.Api.Simulation; using TheLivingWorld.Api.Storage; using TheLivingWorld.Core.Contracts; using TheLivingWorld.Core.Export; using TheLivingWorld.Osm; using TheLivingWorld.Osm.Import; using TheLivingWorld.Osm.Overpass; namespace TheLivingWorld.Tests; public sealed class WorldGenerationServiceTests : IDisposable { private readonly string _root = Path.Combine(Path.GetTempPath(), $"tlw-gen-{Guid.NewGuid():n}"); private readonly WorldStore _store; public WorldGenerationServiceTests() { _store = new WorldStore( Options.Create(new WorldStorageOptions { RootDirectory = _root }), NullLogger.Instance); } [Fact] public async Task StartAsync_rejects_creation_when_the_slot_budget_is_full() { await _store.SaveSummaryAsync(Summary("taken-aaaaaaaa", "Taken")); using var service = CreateService(maxConcurrentWorlds: 1); var failure = await Assert.ThrowsAsync(() => service.StartAsync(new CreateWorldRequest { Name = "Overflow", Latitude = 31.8966010, Longitude = -100.4858591, SizeKm = 10, }, CancellationToken.None)); Assert.Equal(1, failure.MaxConcurrentWorlds); Assert.Equal(1, await _store.CountAsync()); Assert.Equal("Taken", Assert.Single(await _store.ListAsync()).Name); } [Fact] public async Task StartAsync_uses_requested_start_game_time_on_the_pending_clock() { using var service = CreateService(maxConcurrentWorlds: 2); var start = new DateTime(1995, 6, 15, 8, 30, 0, DateTimeKind.Unspecified); var summary = await service.StartAsync(new CreateWorldRequest { Name = "Custom clock", Latitude = 31.8966010, Longitude = -100.4858591, SizeKm = 5, StartGameTime = start, }, CancellationToken.None); Assert.NotNull(summary.Clock); Assert.Equal(start, summary.Clock.GameTime); var stored = await _store.GetSummaryAsync(summary.Id); Assert.NotNull(stored?.Clock); Assert.Equal(start, stored.Clock.GameTime); } private WorldGenerationService CreateService(int maxConcurrentWorlds) { // The capacity check runs before any Overpass work, so this generator is never invoked by the // rejection test. The acceptance test only asserts the pending summary was written. var generator = new OsmWorldGenerator( new OverpassClient( new HttpClient(new UnreachableHandler()), Options.Create(new OsmOptions { Endpoints = ["https://unreachable.example/api"], CacheDirectory = Path.Combine(_root, "osm-cache"), MaxAttemptsPerEndpoint = 1, RequestTimeoutSeconds = 1, }), NullLogger.Instance), new OsmWorldBuilder(NullLogger.Instance), NullLogger.Instance); return new WorldGenerationService( generator, _store, new ChunkExporter(), new WorldSimulationHost(_store, NullLogger.Instance), Options.Create(new WorldStorageOptions { RootDirectory = _root, MaxConcurrentWorlds = maxConcurrentWorlds, }), new NeverStoppingLifetime(), NullLogger.Instance); } private static WorldSummaryDto Summary(string id, string name) => new() { Id = id, Name = name, Latitude = 31.9, Longitude = -100.5, SizeMeters = 10_000, Status = WorldStatus.Ready, CreatedAt = DateTimeOffset.UtcNow, }; public void Dispose() { if (Directory.Exists(_root)) Directory.Delete(_root, recursive: true); GC.SuppressFinalize(this); } private sealed class NeverStoppingLifetime : IHostApplicationLifetime { public CancellationToken ApplicationStarted => CancellationToken.None; public CancellationToken ApplicationStopped => CancellationToken.None; public CancellationToken ApplicationStopping => CancellationToken.None; public void StopApplication() { } } private sealed class UnreachableHandler : HttpMessageHandler { protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) => throw new InvalidOperationException("Overpass must not be contacted by these tests."); } }