using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.Enums; using SatelliteProvider.Common.Interfaces; using SatelliteProvider.Common.Utils; using SatelliteProvider.DataAccess.Models; using SatelliteProvider.DataAccess.Repositories; using SatelliteProvider.Services.RouteManagement; using SatelliteProvider.Services.RouteManagement.TileProvision; namespace SatelliteProvider.Tests; public class RouteTileDeliveryOrchestratorTests { [Fact] public async Task DeliverAsync_AllTilesSkippedByClient_EmitsManifestAndCompleteWithZeroDelivered() { var expander = new RouteTileExpander( new RoutePointGraphBuilder(Options.Create(new ProcessingConfig { MaxRoutePointSpacingMeters = 200 })), new GeofenceGridCalculator()); var tileRepo = new Mock(); tileRepo .Setup(r => r.GetTilesByLocationHashesAsync(It.IsAny>())) .ReturnsAsync(new Dictionary()); var tileService = new Mock(); var orchestrator = new RouteTileDeliveryOrchestrator( expander, tileRepo.Object, tileService.Object, Options.Create(new MapConfig { TileSizePixels = 256, AllowedZoomLevels = [18] }), Options.Create(new ProcessingConfig { MaxConcurrentDownloads = 2 }), Options.Create(new TileProvisionConfig { MaxTilesPerBatch = 100 }), NullLogger.Instance); var waypoints = new List<(double Lat, double Lon)> { (47.0, 37.0), (47.001, 37.001) }; var candidates = expander.Expand(waypoints, 400, 18, [], false); candidates.Should().NotBeEmpty(); var clientTiles = candidates.Select(c => { var center = GeoUtils.TileToWorldPos(c.X, c.Y, c.Z); var resolution = TileResolutionHelper.ResolutionMetersPerPixel(c.Z, center.Lat, 256); return new ClientTileSnapshot(c.Z, c.X, c.Y, resolution, DateTime.UtcNow.AddHours(1), null); }).ToList(); var sink = new RecordingSink(); var job = new RouteTileDeliveryJob( Guid.NewGuid(), waypoints, 400, 18, [], false, clientTiles); await orchestrator.DeliverAsync(job, sink, CancellationToken.None); sink.Manifest.Should().NotBeNull(); sink.Manifest!.Value.Total.Should().Be((uint)candidates.Count); sink.Manifest.Value.Skipped.Should().Be((uint)candidates.Count); sink.Manifest.Value.ToDeliver.Should().Be(0); sink.Complete.Should().NotBeNull(); sink.Complete!.Value.Delivered.Should().Be(0); sink.Complete.Value.SkippedClient.Should().Be((uint)candidates.Count); sink.Batches.Should().BeEmpty(); tileService.Verify( s => s.DownloadAndStoreSingleTileAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Fact] public async Task DeliverAsync_CachedTileOnDisk_EmitsBatchWithoutDownload() { var tilesDir = Path.Combine(Path.GetTempPath(), "sp-grpc-test-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(tilesDir); try { var expander = new RouteTileExpander( new RoutePointGraphBuilder(Options.Create(new ProcessingConfig { MaxRoutePointSpacingMeters = 200 })), new GeofenceGridCalculator()); var waypoints = new List<(double Lat, double Lon)> { (47.0, 37.0), (47.001, 37.001) }; var candidates = expander.Expand(waypoints, 400, 18, [], false); var candidate = candidates[0]; var jpeg = new byte[] { 0xFF, 0xD8, 0xFF, 0xD9 }; var tilePath = Path.Combine(tilesDir, "tile.jpg"); await File.WriteAllBytesAsync(tilePath, jpeg); var hash = System.Security.Cryptography.SHA256.HashData(jpeg); var center = GeoUtils.TileToWorldPos(candidate.X, candidate.Y, candidate.Z); var locationHash = Uuidv5.LocationHashForTile(candidate.Z, candidate.X, candidate.Y); var entity = new TileEntity { Id = Guid.NewGuid(), TileZoom = candidate.Z, TileX = candidate.X, TileY = candidate.Y, Latitude = center.Lat, Longitude = center.Lon, TileSizeMeters = 100, TileSizePixels = 256, ImageType = "jpg", FilePath = tilePath, Source = TileSourceConverter.GoogleMapsWireValue, CapturedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow, LocationHash = locationHash, ContentSha256 = hash, }; var tileRepo = new Mock(); tileRepo .Setup(r => r.GetTilesByLocationHashesAsync(It.IsAny>())) .ReturnsAsync((IReadOnlyList hashes) => { var dict = new Dictionary(); if (hashes.Contains(locationHash)) { dict[locationHash] = entity; } return dict; }); var tileService = new Mock(); var orchestrator = new RouteTileDeliveryOrchestrator( expander, tileRepo.Object, tileService.Object, Options.Create(new MapConfig { TileSizePixels = 256, AllowedZoomLevels = [18] }), Options.Create(new ProcessingConfig { MaxConcurrentDownloads = 2 }), Options.Create(new TileProvisionConfig { MaxTilesPerBatch = 100 }), NullLogger.Instance); var sink = new RecordingSink(); var job = new RouteTileDeliveryJob(Guid.NewGuid(), waypoints, 400, 18, [], false, []); await orchestrator.DeliverAsync(job, sink, CancellationToken.None); sink.Manifest.Should().NotBeNull(); sink.Manifest!.Value.ToDeliver.Should().BeGreaterThan(0); sink.Batches.Should().NotBeEmpty(); sink.Batches.SelectMany(b => b).Should().Contain(t => t.Candidate.X == candidate.X && t.Candidate.Y == candidate.Y); sink.Complete.Should().NotBeNull(); sink.Complete!.Value.Delivered.Should().BeGreaterThan(0u); } finally { if (Directory.Exists(tilesDir)) { Directory.Delete(tilesDir, recursive: true); } } } private sealed class RecordingSink : IRouteTileDeliverySink { public (uint Total, uint Skipped, uint ToDeliver)? Manifest { get; private set; } public List> Batches { get; } = new(); public (uint Delivered, uint SkippedClient, uint SkippedServer)? Complete { get; private set; } public ValueTask WriteManifestAsync(uint totalCandidates, uint skippedByClient, uint toDeliver, CancellationToken cancellationToken) { Manifest = (totalCandidates, skippedByClient, toDeliver); return ValueTask.CompletedTask; } public ValueTask WriteBatchAsync(uint batchSeq, IReadOnlyList tiles, CancellationToken cancellationToken) { Batches.Add(tiles); return ValueTask.CompletedTask; } public ValueTask WriteProgressAsync(uint delivered, uint total, uint downloading, CancellationToken cancellationToken) => ValueTask.CompletedTask; public ValueTask WriteCompleteAsync(uint delivered, uint skippedClient, uint skippedServerFilter, CancellationToken cancellationToken) { Complete = (delivered, skippedClient, skippedServerFilter); return ValueTask.CompletedTask; } public ValueTask WriteErrorAsync(string code, string message, bool retryable, CancellationToken cancellationToken) => ValueTask.CompletedTask; } }