From 6f23120c49196d9668ddb60525ef314cf54cdba3 Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Mon, 11 May 2026 03:12:49 +0300 Subject: [PATCH] [AZ-364] [AZ-360] Refactor C11+C08: decompose RouteProcessingService Extracts RouteRegionMatcher, RouteCsvWriter, RouteSummaryWriter, RouteImageRenderer, TilesZipBuilder, RegionFileCleaner from the ~750-LOC RouteProcessingService god-class. Moves TileInfo to its own file as a sealed record. Replaces IServiceProvider scope- locator with a direct IRegionService injection (folds AZ-360 / C08). Updates DI registration and tests. Tests: 133 / 133 unit + 5 / 5 smoke green; integration suite exit 0. Pixel-equivalent stitched route image and byte-equivalent CSV / summary / ZIP outputs verified through the smoke run. Co-authored-by: Cursor --- .../RegionFileCleaner.cs | 66 +++ .../RouteCsvWriter.cs | 40 ++ .../RouteImageRenderer.cs | 153 +++++ ...teManagementServiceCollectionExtensions.cs | 5 + .../RouteProcessingService.cs | 544 ++++-------------- .../RouteRegionMatcher.cs | 39 ++ .../RouteSummaryWriter.cs | 76 +++ .../TileInfo.cs | 7 + .../TilesZipBuilder.cs | 95 +++ .../RegionFileCleanerTests.cs | 83 +++ .../RouteCsvWriterTests.cs | 58 ++ ...iceTests.cs => RouteImageRendererTests.cs} | 32 +- .../RouteRegionMatcherTests.cs | 99 ++++ .../RouteSummaryWriterTests.cs | 88 +++ .../TilesZipBuilderTests.cs | 71 +++ _docs/03_implementation/batch_17_report.md | 164 ++++++ 16 files changed, 1181 insertions(+), 439 deletions(-) create mode 100644 SatelliteProvider.Services.RouteManagement/RegionFileCleaner.cs create mode 100644 SatelliteProvider.Services.RouteManagement/RouteCsvWriter.cs create mode 100644 SatelliteProvider.Services.RouteManagement/RouteImageRenderer.cs create mode 100644 SatelliteProvider.Services.RouteManagement/RouteRegionMatcher.cs create mode 100644 SatelliteProvider.Services.RouteManagement/RouteSummaryWriter.cs create mode 100644 SatelliteProvider.Services.RouteManagement/TileInfo.cs create mode 100644 SatelliteProvider.Services.RouteManagement/TilesZipBuilder.cs create mode 100644 SatelliteProvider.Tests/RegionFileCleanerTests.cs create mode 100644 SatelliteProvider.Tests/RouteCsvWriterTests.cs rename SatelliteProvider.Tests/{RouteProcessingServiceTests.cs => RouteImageRendererTests.cs} (71%) create mode 100644 SatelliteProvider.Tests/RouteRegionMatcherTests.cs create mode 100644 SatelliteProvider.Tests/RouteSummaryWriterTests.cs create mode 100644 SatelliteProvider.Tests/TilesZipBuilderTests.cs create mode 100644 _docs/03_implementation/batch_17_report.md diff --git a/SatelliteProvider.Services.RouteManagement/RegionFileCleaner.cs b/SatelliteProvider.Services.RouteManagement/RegionFileCleaner.cs new file mode 100644 index 0000000..77040ef --- /dev/null +++ b/SatelliteProvider.Services.RouteManagement/RegionFileCleaner.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; +using SatelliteProvider.DataAccess.Models; + +namespace SatelliteProvider.Services.RouteManagement; + +// AZ-364 / C11: extracted from RouteProcessingService.CleanupRegionFilesAsync. +// Deletes per-region files (CSV, summary, stitched image) once a route has +// successfully consumed them. Each delete is best-effort: a failed delete is +// logged at warning level and does not abort the cleanup of the remaining +// files. Stitched image path is reconstructed from regionId + ReadyDirectory +// because regions historically did not always persist that path on the entity. +public class RegionFileCleaner +{ + private readonly StorageConfig _storageConfig; + private readonly ILogger _logger; + + public RegionFileCleaner(IOptions storageConfig, ILogger logger) + { + ArgumentNullException.ThrowIfNull(storageConfig); + _storageConfig = storageConfig.Value; + _logger = logger; + } + + public Task CleanupAsync(IEnumerable regions, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(regions); + + foreach (var region in regions) + { + if (cancellationToken.IsCancellationRequested) + break; + + if (region == null) continue; + + DeleteIfPresent(region.CsvFilePath, "region CSV file"); + DeleteIfPresent(region.SummaryFilePath, "region summary file"); + + var stitchedImagePath = Path.Combine(_storageConfig.ReadyDirectory, $"region_{region.Id}_stitched.jpg"); + if (File.Exists(stitchedImagePath)) + { + DeleteIfPresent(stitchedImagePath, "region stitched image"); + } + } + + return Task.CompletedTask; + } + + private void DeleteIfPresent(string? path, string label) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + return; + } + + try + { + File.Delete(path); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to delete {Label}: {FilePath}", label, path); + } + } +} diff --git a/SatelliteProvider.Services.RouteManagement/RouteCsvWriter.cs b/SatelliteProvider.Services.RouteManagement/RouteCsvWriter.cs new file mode 100644 index 0000000..2f3380e --- /dev/null +++ b/SatelliteProvider.Services.RouteManagement/RouteCsvWriter.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; +using SatelliteProvider.Common.Utils; + +namespace SatelliteProvider.Services.RouteManagement; + +// AZ-364 / C11: extracted from RouteProcessingService.GenerateRouteCsvAsync. +// Owns the route__ready.csv path computation and delegates the actual +// rows-to-CSV serialization to the shared TileCsvWriter (Common). Returns +// the produced file path so the orchestrator can persist it on the route. +public class RouteCsvWriter +{ + private readonly StorageConfig _storageConfig; + private readonly ILogger _logger; + + public RouteCsvWriter(IOptions storageConfig, ILogger logger) + { + ArgumentNullException.ThrowIfNull(storageConfig); + _storageConfig = storageConfig.Value; + _logger = logger; + } + + public async Task WriteAsync( + Guid routeId, + IEnumerable tiles, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(tiles); + + Directory.CreateDirectory(_storageConfig.ReadyDirectory); + var filePath = Path.Combine(_storageConfig.ReadyDirectory, $"route_{routeId}_ready.csv"); + + var rows = tiles.Select(t => new TileCsvRow(t.Latitude, t.Longitude, t.FilePath)).ToList(); + await new TileCsvWriter().WriteAsync(filePath, rows, cancellationToken); + + _logger.LogInformation("Route CSV generated: {FilePath} with {Count} tiles", filePath, rows.Count); + return filePath; + } +} diff --git a/SatelliteProvider.Services.RouteManagement/RouteImageRenderer.cs b/SatelliteProvider.Services.RouteManagement/RouteImageRenderer.cs new file mode 100644 index 0000000..6e2a637 --- /dev/null +++ b/SatelliteProvider.Services.RouteManagement/RouteImageRenderer.cs @@ -0,0 +1,153 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; +using SatelliteProvider.Common.DTO; +using SatelliteProvider.Common.Imaging; +using SatelliteProvider.Common.Utils; +using SatelliteProvider.DataAccess.Models; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace SatelliteProvider.Services.RouteManagement; + +// AZ-364 / C11: extracted from RouteProcessingService.StitchRouteTilesAsync. +// Owns the route__stitched.jpg path computation, the per-tile filename +// parsing into TilePlacement, the call into the shared TileGridStitcher +// (Common), and the route-specific overlays (yellow geofence rectangles, +// red 50-arm crosses on each route point). Behavior preserved verbatim per +// AC-2 (pixel-for-pixel identical output for existing scenarios). +public class RouteImageRenderer +{ + private const int TileSizePixels = 256; + + private readonly StorageConfig _storageConfig; + private readonly ILogger _logger; + + public RouteImageRenderer(IOptions storageConfig, ILogger logger) + { + ArgumentNullException.ThrowIfNull(storageConfig); + _storageConfig = storageConfig.Value; + _logger = logger; + } + + public async Task RenderAsync( + Guid routeId, + IReadOnlyList tiles, + int zoomLevel, + IReadOnlyList<(int MinX, int MinY, int MaxX, int MaxY)> geofencePolygonBounds, + IReadOnlyList routePoints, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(tiles); + ArgumentNullException.ThrowIfNull(geofencePolygonBounds); + ArgumentNullException.ThrowIfNull(routePoints); + + if (tiles.Count == 0) + { + _logger.LogWarning("No tiles to stitch for route map"); + return null; + } + + var placements = tiles + .Select(t => + { + var (tileX, tileY) = ExtractTileCoordinatesFromFilename(t.FilePath); + return new TilePlacement(tileX, tileY, t.FilePath); + }) + .Where(p => p.TileX >= 0 && p.TileY >= 0) + .ToList(); + + if (placements.Count == 0) + { + _logger.LogWarning("No tiles with extractable coordinates to stitch for route map"); + return null; + } + + Directory.CreateDirectory(_storageConfig.ReadyDirectory); + var outputPath = Path.Combine(_storageConfig.ReadyDirectory, $"route_{routeId}_stitched.jpg"); + + var stitcher = new TileGridStitcher(); + var result = await stitcher.StitchAsync( + placements, + TileSizePixels, + deduplicateByTileCoords: true, + swallowTileLoadErrors: true, + cancellationToken); + + using var stitchedImage = result.Image; + + foreach (var missing in result.MissingTiles) + { + if (missing.Reason == MissingTileReason.LoadFailed) + { + _logger.LogWarning(missing.Error, "Failed to load tile at {FilePath}, leaving black", missing.Tile.FilePath); + } + else + { + _logger.LogWarning("Tile file not found: {FilePath}, leaving black", missing.Tile.FilePath); + } + } + + var minX = result.MinX; + var minY = result.MinY; + var imageWidth = result.ImageWidth; + var imageHeight = result.ImageHeight; + var yellow = new Rgb24(255, 255, 0); + var red = new Rgb24(255, 0, 0); + + foreach (var (geoMinX, geoMinY, geoMaxX, geoMaxY) in geofencePolygonBounds) + { + var x1 = (geoMinX - minX) * TileSizePixels; + var y1 = (geoMinY - minY + 1) * TileSizePixels; + var x2 = (geoMaxX - minX + 2) * TileSizePixels - 1; + var y2 = (geoMaxY - minY + 1) * TileSizePixels - 1; + + x1 = Math.Max(0, Math.Min(x1, imageWidth - 1)); + y1 = Math.Max(0, Math.Min(y1, imageHeight - 1)); + x2 = Math.Max(0, Math.Min(x2, imageWidth - 1)); + y2 = Math.Max(0, Math.Min(y2, imageHeight - 1)); + + if (x1 >= 0 && y1 >= 0 && x2 < imageWidth && y2 < imageHeight && x2 > x1 && y2 > y1) + { + stitcher.DrawRectangleBorder( + stitchedImage, + new Rectangle(x1, y1, x2 - x1 + 1, y2 - y1 + 1), + yellow); + } + } + + foreach (var point in routePoints) + { + var geoPoint = new GeoPoint(point.Latitude, point.Longitude); + var (tileX, tileY) = GeoUtils.WorldToTilePos(geoPoint, zoomLevel); + + var pixelX = (tileX - minX) * TileSizePixels + TileSizePixels / 2; + var pixelY = (tileY - minY) * TileSizePixels + TileSizePixels / 2; + + if (pixelX >= 0 && pixelX < imageWidth && pixelY >= 0 && pixelY < imageHeight) + { + stitcher.DrawCross(stitchedImage, new Point(pixelX, pixelY), red, 50); + } + } + + await stitchedImage.SaveAsJpegAsync(outputPath, cancellationToken); + return outputPath; + } + + // Parses tile____.jpg via StorageConfig.TryExtractTileCoordinates. + // On parse failure, logs a warning and returns the (-1, -1) sentinel — the + // RenderAsync pipeline filters those out before stitching. ArgumentNullException + // on null input is propagated from StorageConfig. + internal (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath) + { + if (StorageConfig.TryExtractTileCoordinates(filePath, out var tileX, out var tileY)) + { + return (tileX, tileY); + } + + _logger.LogWarning( + "Could not extract tile coordinates from filename {FilePath}; expected pattern tile____", + filePath); + return (-1, -1); + } +} diff --git a/SatelliteProvider.Services.RouteManagement/RouteManagementServiceCollectionExtensions.cs b/SatelliteProvider.Services.RouteManagement/RouteManagementServiceCollectionExtensions.cs index 83317ee..9f00768 100644 --- a/SatelliteProvider.Services.RouteManagement/RouteManagementServiceCollectionExtensions.cs +++ b/SatelliteProvider.Services.RouteManagement/RouteManagementServiceCollectionExtensions.cs @@ -8,6 +8,11 @@ public static class RouteManagementServiceCollectionExtensions { public static IServiceCollection AddRouteManagement(this IServiceCollection services) { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddHostedService(); return services; diff --git a/SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs b/SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs index 3dbb474..67aedf0 100644 --- a/SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs +++ b/SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs @@ -1,37 +1,53 @@ -using System.IO.Compression; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SatelliteProvider.Common.Configs; -using SatelliteProvider.Common.Imaging; +using SatelliteProvider.Common.DTO; +using SatelliteProvider.Common.Interfaces; using SatelliteProvider.Common.Utils; +using SatelliteProvider.DataAccess.Models; using SatelliteProvider.DataAccess.Repositories; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; namespace SatelliteProvider.Services.RouteManagement; +// AZ-364 / C11: thin orchestrator after the god-class decomposition. +// Polls the route repository, classifies region statuses, queues region work +// via IRegionService (AZ-360 / C08 — direct dependency, no IServiceProvider), +// and dispatches output generation to the per-concern collaborators +// (RouteCsvWriter, RouteSummaryWriter, RouteImageRenderer, TilesZipBuilder, +// RegionFileCleaner, RouteRegionMatcher). public class RouteProcessingService : BackgroundService { private readonly IRouteRepository _routeRepository; private readonly IRegionRepository _regionRepository; - private readonly IServiceProvider _serviceProvider; - private readonly StorageConfig _storageConfig; + private readonly IRegionService _regionService; + private readonly RouteCsvWriter _routeCsvWriter; + private readonly RouteSummaryWriter _routeSummaryWriter; + private readonly RouteImageRenderer _routeImageRenderer; + private readonly TilesZipBuilder _tilesZipBuilder; + private readonly RegionFileCleaner _regionFileCleaner; + private readonly RouteRegionMatcher _routeRegionMatcher; private readonly ILogger _logger; private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(5); public RouteProcessingService( IRouteRepository routeRepository, IRegionRepository regionRepository, - IServiceProvider serviceProvider, - IOptions storageConfig, + IRegionService regionService, + RouteCsvWriter routeCsvWriter, + RouteSummaryWriter routeSummaryWriter, + RouteImageRenderer routeImageRenderer, + TilesZipBuilder tilesZipBuilder, + RegionFileCleaner regionFileCleaner, ILogger logger) { _routeRepository = routeRepository; _regionRepository = regionRepository; - _serviceProvider = serviceProvider; - _storageConfig = storageConfig.Value; + _regionService = regionService; + _routeCsvWriter = routeCsvWriter; + _routeSummaryWriter = routeSummaryWriter; + _routeImageRenderer = routeImageRenderer; + _tilesZipBuilder = tilesZipBuilder; + _regionFileCleaner = regionFileCleaner; + _routeRegionMatcher = new RouteRegionMatcher(); _logger = logger; } @@ -58,7 +74,7 @@ public class RouteProcessingService : BackgroundService private async Task ProcessPendingRoutesAsync(CancellationToken cancellationToken) { - var pendingRoutes = await GetRoutesWithPendingMapsAsync(); + var pendingRoutes = await _routeRepository.GetRoutesWithPendingMapsAsync(); foreach (var route in pendingRoutes) { @@ -76,12 +92,6 @@ public class RouteProcessingService : BackgroundService } } - private async Task> GetRoutesWithPendingMapsAsync() - { - var routes = await _routeRepository.GetRoutesWithPendingMapsAsync(); - return routes.Select(r => (r.Id, r.RequestMaps)).ToList(); - } - private async Task ProcessRouteSequentiallyAsync(Guid routeId, CancellationToken cancellationToken) { var route = await _routeRepository.GetByIdAsync(routeId); @@ -90,7 +100,7 @@ public class RouteProcessingService : BackgroundService _logger.LogWarning("Route {RouteId}: Route not found, skipping processing", routeId); return; } - + if (!route.RequestMaps || route.MapsReady) { return; @@ -99,33 +109,30 @@ public class RouteProcessingService : BackgroundService var routePointsList = (await _routeRepository.GetRoutePointsAsync(routeId)).ToList(); var regionIdsList = (await _routeRepository.GetRegionIdsByRouteAsync(routeId)).ToList(); var geofenceRegionIdsList = (await _routeRepository.GetGeofenceRegionIdsByRouteAsync(routeId)).ToList(); - + var allRegionIds = regionIdsList.Union(geofenceRegionIdsList).ToList(); - + if (regionIdsList.Count == 0 && routePointsList.Count > 0) { - using var scope = _serviceProvider.CreateScope(); - var regionService = scope.ServiceProvider.GetRequiredService(); - foreach (var point in routePointsList) { var regionId = Guid.NewGuid(); - - await regionService.RequestRegionAsync( + + await _regionService.RequestRegionAsync( regionId, point.Latitude, point.Longitude, route.RegionSizeMeters, route.ZoomLevel, stitchTiles: false); - + await _routeRepository.LinkRouteToRegionAsync(routeId, regionId); } - + return; } - - var regions = new List(); + + var regions = new List(); foreach (var regionId in allRegionIds) { var region = await _regionRepository.GetByIdAsync(regionId); @@ -134,106 +141,99 @@ public class RouteProcessingService : BackgroundService regions.Add(region); } } - + var completedRegions = regions.Where(r => r.Status == "completed").ToList(); var failedRegions = regions.Where(r => r.Status == "failed").ToList(); var processingRegions = regions.Where(r => r.Status == "queued" || r.Status == "processing").ToList(); - + var completedRoutePointRegions = completedRegions.Where(r => !geofenceRegionIdsList.Contains(r.Id)).ToList(); var completedGeofenceRegions = completedRegions.Where(r => geofenceRegionIdsList.Contains(r.Id)).ToList(); - + var hasRoutePointRegions = regionIdsList.Count > 0; var hasEnoughRoutePointRegions = !hasRoutePointRegions || completedRoutePointRegions.Count >= routePointsList.Count; var hasAllGeofenceRegions = geofenceRegionIdsList.Count == 0 || completedGeofenceRegions.Count >= geofenceRegionIdsList.Count; var hasEnoughCompleted = hasEnoughRoutePointRegions && hasAllGeofenceRegions; - + var activeRegions = completedRegions.Count + processingRegions.Count; var shouldRetryFailed = failedRegions.Count > 0 && !hasEnoughCompleted && activeRegions < allRegionIds.Count; - + if (hasEnoughCompleted) { - var orderedRouteRegions = MatchRegionsToRoutePoints(routePointsList, completedRoutePointRegions, routeId); - var routeRegionIds = orderedRouteRegions.Select(r => r.Id).ToList(); - var allRegionIdsForStitching = routeRegionIds.Concat(completedGeofenceRegions.Select(r => r.Id)).Distinct(); - var geofenceRegionIdsForBorders = completedGeofenceRegions.Select(r => r.Id).ToList(); - - await GenerateRouteMapsAsync(routeId, route, allRegionIdsForStitching, geofenceRegionIdsForBorders, routePointsList, cancellationToken); + var orderedRouteRegions = _routeRegionMatcher.Match(routePointsList, completedRoutePointRegions); + var regionsForOutput = orderedRouteRegions.Concat(completedGeofenceRegions) + .GroupBy(r => r.Id) + .Select(g => g.First()) + .ToList(); + + await GenerateRouteMapsAsync(routeId, route, regionsForOutput, completedGeofenceRegions, routePointsList, cancellationToken); return; } - + if (shouldRetryFailed) { - using var scope = _serviceProvider.CreateScope(); - var regionService = scope.ServiceProvider.GetRequiredService(); - foreach (var failedRegion in failedRegions) { var newRegionId = Guid.NewGuid(); - - await regionService.RequestRegionAsync( + + await _regionService.RequestRegionAsync( newRegionId, failedRegion.Latitude, failedRegion.Longitude, failedRegion.SizeMeters, failedRegion.ZoomLevel, stitchTiles: false); - + await _routeRepository.LinkRouteToRegionAsync(routeId, newRegionId); } - + return; } - + var anyProcessing = processingRegions.Count > 0; if (anyProcessing) { return; } - - _logger.LogWarning("Route {RouteId}: No processing regions, but not enough completed. This is an unexpected state - hasEnoughCompleted={HasEnough}, shouldRetryFailed={ShouldRetry}, anyProcessing={AnyProcessing}", + + _logger.LogWarning( + "Route {RouteId}: No processing regions, but not enough completed. This is an unexpected state - hasEnoughCompleted={HasEnough}, shouldRetryFailed={ShouldRetry}, anyProcessing={AnyProcessing}", routeId, hasEnoughCompleted, shouldRetryFailed, anyProcessing); } private async Task GenerateRouteMapsAsync( Guid routeId, - DataAccess.Models.RouteEntity route, - IEnumerable regionIds, - List geofenceRegionIds, - List routePoints, + RouteEntity route, + IReadOnlyList regionsForOutput, + IReadOnlyList geofenceRegions, + IReadOnlyList routePoints, CancellationToken cancellationToken) { try { - var readyDir = _storageConfig.ReadyDirectory; - Directory.CreateDirectory(readyDir); - var allTiles = new Dictionary(); int totalTilesFromRegions = 0; int duplicateTiles = 0; - foreach (var regionId in regionIds) + foreach (var region in regionsForOutput) { - var region = await _regionRepository.GetByIdAsync(regionId); - if (region == null || string.IsNullOrEmpty(region.CsvFilePath) || !File.Exists(region.CsvFilePath)) + if (string.IsNullOrEmpty(region.CsvFilePath) || !File.Exists(region.CsvFilePath)) { - _logger.LogWarning("Route {RouteId}: Region {RegionId} CSV not found", routeId, regionId); + _logger.LogWarning("Route {RouteId}: Region {RegionId} CSV not found", routeId, region.Id); continue; } var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken); - var lineNumber = 0; foreach (var line in csvLines.Skip(1)) { - lineNumber++; var parts = line.Split(','); if (parts.Length < 3) continue; - if (!double.TryParse(parts[0], out var lat)) + if (!double.TryParse(parts[0], out var lat)) { continue; } - if (!double.TryParse(parts[1], out var lon)) + if (!double.TryParse(parts[1], out var lon)) { continue; } @@ -244,12 +244,7 @@ public class RouteProcessingService : BackgroundService if (!allTiles.ContainsKey(key)) { - allTiles[key] = new TileInfo - { - Latitude = lat, - Longitude = lon, - FilePath = filePath - }; + allTiles[key] = new TileInfo(lat, lon, filePath); } else { @@ -258,65 +253,36 @@ public class RouteProcessingService : BackgroundService } } - var csvPath = Path.Combine(readyDir, $"route_{routeId}_ready.csv"); - await GenerateRouteCsvAsync(csvPath, allTiles.Values, cancellationToken); + var tileList = allTiles.Values.ToList(); + + var csvPath = await _routeCsvWriter.WriteAsync(routeId, tileList, cancellationToken); string? stitchedImagePath = null; if (route.RequestMaps) { - var geofencePolygonBounds = new List<(int MinX, int MinY, int MaxX, int MaxY)>(); - - var geofencesByPolygon = await _routeRepository.GetGeofenceRegionsByPolygonAsync(routeId); - - foreach (var (polygonIndex, polygonRegionIds) in geofencesByPolygon.OrderBy(kvp => kvp.Key)) - { - int? minX = null, minY = null, maxX = null, maxY = null; - - foreach (var geofenceId in polygonRegionIds) - { - var region = await _regionRepository.GetByIdAsync(geofenceId); - if (region != null && !string.IsNullOrEmpty(region.CsvFilePath) && File.Exists(region.CsvFilePath)) - { - var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken); - - foreach (var line in csvLines.Skip(1)) - { - var parts = line.Split(','); - if (parts.Length >= 3) - { - if (double.TryParse(parts[0], out var lat) && double.TryParse(parts[1], out var lon)) - { - var tile = GeoUtils.WorldToTilePos(new Common.DTO.GeoPoint { Lat = lat, Lon = lon }, route.ZoomLevel); - minX = minX == null ? tile.x : Math.Min(minX.Value, tile.x); - minY = minY == null ? tile.y : Math.Min(minY.Value, tile.y); - maxX = maxX == null ? tile.x : Math.Max(maxX.Value, tile.x); - maxY = maxY == null ? tile.y : Math.Max(maxY.Value, tile.y); - } - } - } - } - } - - if (minX.HasValue && minY.HasValue && maxX.HasValue && maxY.HasValue) - { - geofencePolygonBounds.Add((minX.Value, minY.Value, maxX.Value, maxY.Value)); - } - } - - stitchedImagePath = Path.Combine(readyDir, $"route_{routeId}_stitched.jpg"); - - await StitchRouteTilesAsync(allTiles.Values.ToList(), stitchedImagePath, route.ZoomLevel, geofencePolygonBounds, routePoints, cancellationToken); + var geofencePolygonBounds = await ComputeGeofencePolygonBoundsAsync(routeId, route.ZoomLevel, cancellationToken); + stitchedImagePath = await _routeImageRenderer.RenderAsync( + routeId, + tileList, + route.ZoomLevel, + geofencePolygonBounds, + routePoints, + cancellationToken); } string? tilesZipPath = null; if (route.CreateTilesZip) { - tilesZipPath = Path.Combine(readyDir, $"route_{routeId}_tiles.zip"); - await CreateTilesZipAsync(tilesZipPath, allTiles.Values, cancellationToken); + tilesZipPath = await _tilesZipBuilder.BuildAsync(routeId, tileList, cancellationToken); } - var summaryPath = Path.Combine(readyDir, $"route_{routeId}_summary.txt"); - await GenerateRouteSummaryAsync(summaryPath, route, allTiles.Count, totalTilesFromRegions, duplicateTiles, tilesZipPath, cancellationToken); + var summaryPath = await _routeSummaryWriter.WriteAsync( + route, + tileList.Count, + totalTilesFromRegions, + duplicateTiles, + tilesZipPath, + cancellationToken); route.MapsReady = true; route.CsvFilePath = csvPath; @@ -327,7 +293,7 @@ public class RouteProcessingService : BackgroundService await _routeRepository.UpdateRouteAsync(route); - await CleanupRegionFilesAsync(regionIds, cancellationToken); + await _regionFileCleaner.CleanupAsync(regionsForOutput, cancellationToken); _logger.LogInformation("Route {RouteId} maps processing completed successfully", routeId); } @@ -338,313 +304,51 @@ public class RouteProcessingService : BackgroundService } } - private async Task CleanupRegionFilesAsync(IEnumerable regionIds, CancellationToken cancellationToken) - { - foreach (var regionId in regionIds) - { - var region = await _regionRepository.GetByIdAsync(regionId); - if (region == null) continue; - - if (!string.IsNullOrEmpty(region.CsvFilePath) && File.Exists(region.CsvFilePath)) - { - try - { - File.Delete(region.CsvFilePath); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to delete region CSV file: {FilePath}", region.CsvFilePath); - } - } - - if (!string.IsNullOrEmpty(region.SummaryFilePath) && File.Exists(region.SummaryFilePath)) - { - try - { - File.Delete(region.SummaryFilePath); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to delete region summary file: {FilePath}", region.SummaryFilePath); - } - } - - var readyDir = _storageConfig.ReadyDirectory; - var stitchedImagePath = Path.Combine(readyDir, $"region_{regionId}_stitched.jpg"); - if (File.Exists(stitchedImagePath)) - { - try - { - File.Delete(stitchedImagePath); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to delete region stitched image: {FilePath}", stitchedImagePath); - } - } - } - } - - private async Task GenerateRouteCsvAsync( - string filePath, - IEnumerable tiles, - CancellationToken cancellationToken) - { - var rows = tiles.Select(t => new TileCsvRow(t.Latitude, t.Longitude, t.FilePath)).ToList(); - await new TileCsvWriter().WriteAsync(filePath, rows, cancellationToken); - _logger.LogInformation("Route CSV generated: {FilePath} with {Count} tiles", filePath, rows.Count); - } - - private async Task GenerateRouteSummaryAsync( - string filePath, - DataAccess.Models.RouteEntity route, - int uniqueTiles, - int totalTilesFromRegions, - int duplicateTiles, - string? tilesZipPath, - CancellationToken cancellationToken) - { - var summary = new System.Text.StringBuilder(); - summary.AppendLine("Route Maps Summary"); - summary.AppendLine("=================="); - summary.AppendLine($"Route ID: {route.Id}"); - summary.AppendLine($"Route Name: {route.Name}"); - if (!string.IsNullOrEmpty(route.Description)) - { - summary.AppendLine($"Description: {route.Description}"); - } - summary.AppendLine($"Total Points: {route.TotalPoints}"); - summary.AppendLine($"Total Distance: {route.TotalDistanceMeters:F2} meters"); - summary.AppendLine($"Region Size: {route.RegionSizeMeters:F0} meters"); - summary.AppendLine($"Zoom Level: {route.ZoomLevel}"); - summary.AppendLine(); - summary.AppendLine("Tile Statistics:"); - summary.AppendLine($"- Unique Tiles: {uniqueTiles}"); - summary.AppendLine($"- Total Tiles from Regions: {totalTilesFromRegions}"); - summary.AppendLine($"- Duplicate Tiles (overlap): {duplicateTiles}"); - summary.AppendLine(); - summary.AppendLine("Files Created:"); - summary.AppendLine($"- CSV: route_{route.Id}_ready.csv"); - summary.AppendLine($"- Summary: route_{route.Id}_summary.txt"); - if (route.RequestMaps) - { - summary.AppendLine($"- Stitched Map: route_{route.Id}_stitched.jpg"); - } - if (tilesZipPath != null) - { - summary.AppendLine($"- Tiles ZIP: route_{route.Id}_tiles.zip"); - } - summary.AppendLine(); - summary.AppendLine($"Completed: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"); - - await File.WriteAllTextAsync(filePath, summary.ToString(), cancellationToken); - - _logger.LogInformation("Route summary generated: {FilePath}", filePath); - } - - private async Task StitchRouteTilesAsync( - List tiles, - string outputPath, + private async Task> ComputeGeofencePolygonBoundsAsync( + Guid routeId, int zoomLevel, - List<(int MinX, int MinY, int MaxX, int MaxY)> geofencePolygonBounds, - List routePoints, CancellationToken cancellationToken) { - if (tiles.Count == 0) + var bounds = new List<(int MinX, int MinY, int MaxX, int MaxY)>(); + var geofencesByPolygon = await _routeRepository.GetGeofenceRegionsByPolygonAsync(routeId); + + foreach (var (_, polygonRegionIds) in geofencesByPolygon.OrderBy(kvp => kvp.Key)) { - _logger.LogWarning("No tiles to stitch for route map"); - return; - } + int? minX = null, minY = null, maxX = null, maxY = null; - const int tileSizePixels = 256; - - var placements = tiles - .Select(t => + foreach (var geofenceId in polygonRegionIds) { - var (tileX, tileY) = ExtractTileCoordinatesFromFilename(t.FilePath); - return new TilePlacement(tileX, tileY, t.FilePath); - }) - .Where(p => p.TileX >= 0 && p.TileY >= 0) - .ToList(); - - if (placements.Count == 0) - { - _logger.LogWarning("No tiles with extractable coordinates to stitch for route map"); - return; - } - - var stitcher = new TileGridStitcher(); - var result = await stitcher.StitchAsync( - placements, - tileSizePixels, - deduplicateByTileCoords: true, - swallowTileLoadErrors: true, - cancellationToken); - - using var stitchedImage = result.Image; - - foreach (var missing in result.MissingTiles) - { - if (missing.Reason == MissingTileReason.LoadFailed) - { - _logger.LogWarning(missing.Error, "Failed to load tile at {FilePath}, leaving black", missing.Tile.FilePath); - } - else - { - _logger.LogWarning("Tile file not found: {FilePath}, leaving black", missing.Tile.FilePath); - } - } - - var minX = result.MinX; - var minY = result.MinY; - var imageWidth = result.ImageWidth; - var imageHeight = result.ImageHeight; - var yellow = new Rgb24(255, 255, 0); - var red = new Rgb24(255, 0, 0); - - foreach (var (geoMinX, geoMinY, geoMaxX, geoMaxY) in geofencePolygonBounds) - { - var x1 = (geoMinX - minX) * tileSizePixels; - var y1 = (geoMinY - minY + 1) * tileSizePixels; - var x2 = (geoMaxX - minX + 2) * tileSizePixels - 1; - var y2 = (geoMaxY - minY + 1) * tileSizePixels - 1; - - x1 = Math.Max(0, Math.Min(x1, imageWidth - 1)); - y1 = Math.Max(0, Math.Min(y1, imageHeight - 1)); - x2 = Math.Max(0, Math.Min(x2, imageWidth - 1)); - y2 = Math.Max(0, Math.Min(y2, imageHeight - 1)); - - if (x1 >= 0 && y1 >= 0 && x2 < imageWidth && y2 < imageHeight && x2 > x1 && y2 > y1) - { - stitcher.DrawRectangleBorder( - stitchedImage, - new Rectangle(x1, y1, x2 - x1 + 1, y2 - y1 + 1), - yellow); - } - } - - foreach (var point in routePoints) - { - var geoPoint = new Common.DTO.GeoPoint { Lat = point.Latitude, Lon = point.Longitude }; - var (tileX, tileY) = GeoUtils.WorldToTilePos(geoPoint, zoomLevel); - - var pixelX = (tileX - minX) * tileSizePixels + tileSizePixels / 2; - var pixelY = (tileY - minY) * tileSizePixels + tileSizePixels / 2; - - if (pixelX >= 0 && pixelX < imageWidth && pixelY >= 0 && pixelY < imageHeight) - { - stitcher.DrawCross(stitchedImage, new Point(pixelX, pixelY), red, 50); - } - } - - await stitchedImage.SaveAsJpegAsync(outputPath, cancellationToken); - } - - private List MatchRegionsToRoutePoints( - List routePoints, - List regions, - Guid routeId) - { - var orderedRegions = new List(); - var availableRegions = new List(regions); - - foreach (var point in routePoints) - { - var pointGeo = new Common.DTO.GeoPoint { Lat = point.Latitude, Lon = point.Longitude }; - var matchedRegion = availableRegions - .OrderBy(r => GeoUtils.CalculateDistance(pointGeo, new Common.DTO.GeoPoint { Lat = r.Latitude, Lon = r.Longitude })) - .FirstOrDefault(); - - if (matchedRegion != null) - { - orderedRegions.Add(matchedRegion); - availableRegions.Remove(matchedRegion); - } - } - - return orderedRegions; - } - - internal (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath) - { - if (StorageConfig.TryExtractTileCoordinates(filePath, out var tileX, out var tileY)) - { - return (tileX, tileY); - } - - _logger.LogWarning( - "Could not extract tile coordinates from filename {FilePath}; expected pattern tile____", - filePath); - return (-1, -1); - } - - private Task CreateTilesZipAsync( - string zipFilePath, - IEnumerable tiles, - CancellationToken cancellationToken) - { - return Task.Run(() => - { - if (File.Exists(zipFilePath)) - { - File.Delete(zipFilePath); - } - - using var zipArchive = ZipFile.Open(zipFilePath, ZipArchiveMode.Create); - int addedFiles = 0; - int missingFiles = 0; - - var tilesBasePath = _storageConfig.TilesDirectory; - var normalizedBasePath = Path.GetFullPath(tilesBasePath).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - - foreach (var tile in tiles) - { - if (cancellationToken.IsCancellationRequested) - break; - - if (File.Exists(tile.FilePath)) + var region = await _regionRepository.GetByIdAsync(geofenceId); + if (region == null || string.IsNullOrEmpty(region.CsvFilePath) || !File.Exists(region.CsvFilePath)) { - try - { - var fullPath = Path.GetFullPath(tile.FilePath); - string entryName; - - if (fullPath.StartsWith(normalizedBasePath, StringComparison.OrdinalIgnoreCase)) - { - var relativePath = fullPath.Substring(normalizedBasePath.Length + 1); - relativePath = relativePath.Replace(Path.DirectorySeparatorChar, '/'); - entryName = "tiles/" + relativePath; - } - else - { - entryName = "tiles/" + Path.GetFileName(tile.FilePath); - } - - zipArchive.CreateEntryFromFile(tile.FilePath, entryName, CompressionLevel.Optimal); - addedFiles++; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to add tile to zip: {FilePath}", tile.FilePath); - } + continue; } - else + + var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken); + + foreach (var line in csvLines.Skip(1)) { - _logger.LogWarning("Tile file not found for zip: {FilePath}", tile.FilePath); - missingFiles++; + var parts = line.Split(','); + if (parts.Length < 3) continue; + if (!double.TryParse(parts[0], out var lat) || !double.TryParse(parts[1], out var lon)) + { + continue; + } + + var tile = GeoUtils.WorldToTilePos(new GeoPoint(lat, lon), zoomLevel); + minX = minX == null ? tile.x : Math.Min(minX.Value, tile.x); + minY = minY == null ? tile.y : Math.Min(minY.Value, tile.y); + maxX = maxX == null ? tile.x : Math.Max(maxX.Value, tile.x); + maxY = maxY == null ? tile.y : Math.Max(maxY.Value, tile.y); } } - _logger.LogInformation("Tiles zip created: {ZipPath} with {AddedFiles} tiles ({MissingFiles} missing)", - zipFilePath, addedFiles, missingFiles); - }, cancellationToken); + if (minX.HasValue && minY.HasValue && maxX.HasValue && maxY.HasValue) + { + bounds.Add((minX.Value, minY.Value, maxX.Value, maxY.Value)); + } + } + + return bounds; } } - -public class TileInfo -{ - public double Latitude { get; set; } - public double Longitude { get; set; } - public string FilePath { get; set; } = string.Empty; -} - diff --git a/SatelliteProvider.Services.RouteManagement/RouteRegionMatcher.cs b/SatelliteProvider.Services.RouteManagement/RouteRegionMatcher.cs new file mode 100644 index 0000000..23f2915 --- /dev/null +++ b/SatelliteProvider.Services.RouteManagement/RouteRegionMatcher.cs @@ -0,0 +1,39 @@ +using SatelliteProvider.Common.DTO; +using SatelliteProvider.Common.Utils; +using SatelliteProvider.DataAccess.Models; + +namespace SatelliteProvider.Services.RouteManagement; + +// AZ-364 / C11: extracted from RouteProcessingService.MatchRegionsToRoutePoints. +// Pure: given a list of route points and the regions known to be completed, +// return the regions ordered to match the route point sequence using nearest- +// neighbour Haversine distance. Each region is consumed at most once. +public class RouteRegionMatcher +{ + public List Match( + IReadOnlyList routePoints, + IReadOnlyList regions) + { + ArgumentNullException.ThrowIfNull(routePoints); + ArgumentNullException.ThrowIfNull(regions); + + var orderedRegions = new List(routePoints.Count); + var availableRegions = new List(regions); + + foreach (var point in routePoints) + { + var pointGeo = new GeoPoint(point.Latitude, point.Longitude); + var matchedRegion = availableRegions + .OrderBy(r => GeoUtils.CalculateDistance(pointGeo, new GeoPoint(r.Latitude, r.Longitude))) + .FirstOrDefault(); + + if (matchedRegion != null) + { + orderedRegions.Add(matchedRegion); + availableRegions.Remove(matchedRegion); + } + } + + return orderedRegions; + } +} diff --git a/SatelliteProvider.Services.RouteManagement/RouteSummaryWriter.cs b/SatelliteProvider.Services.RouteManagement/RouteSummaryWriter.cs new file mode 100644 index 0000000..4c83789 --- /dev/null +++ b/SatelliteProvider.Services.RouteManagement/RouteSummaryWriter.cs @@ -0,0 +1,76 @@ +using System.Text; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; +using SatelliteProvider.DataAccess.Models; + +namespace SatelliteProvider.Services.RouteManagement; + +// AZ-364 / C11: extracted from RouteProcessingService.GenerateRouteSummaryAsync. +// Owns the route__summary.txt path and the StringBuilder block that +// describes the route, tile statistics, and produced files. Output content +// preserved verbatim to satisfy AC-2 (byte-identical for existing scenarios). +public class RouteSummaryWriter +{ + private readonly StorageConfig _storageConfig; + private readonly ILogger _logger; + + public RouteSummaryWriter(IOptions storageConfig, ILogger logger) + { + ArgumentNullException.ThrowIfNull(storageConfig); + _storageConfig = storageConfig.Value; + _logger = logger; + } + + public async Task WriteAsync( + RouteEntity route, + int uniqueTiles, + int totalTilesFromRegions, + int duplicateTiles, + string? tilesZipPath, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(route); + + Directory.CreateDirectory(_storageConfig.ReadyDirectory); + var filePath = Path.Combine(_storageConfig.ReadyDirectory, $"route_{route.Id}_summary.txt"); + + var summary = new StringBuilder(); + summary.AppendLine("Route Maps Summary"); + summary.AppendLine("=================="); + summary.AppendLine($"Route ID: {route.Id}"); + summary.AppendLine($"Route Name: {route.Name}"); + if (!string.IsNullOrEmpty(route.Description)) + { + summary.AppendLine($"Description: {route.Description}"); + } + summary.AppendLine($"Total Points: {route.TotalPoints}"); + summary.AppendLine($"Total Distance: {route.TotalDistanceMeters:F2} meters"); + summary.AppendLine($"Region Size: {route.RegionSizeMeters:F0} meters"); + summary.AppendLine($"Zoom Level: {route.ZoomLevel}"); + summary.AppendLine(); + summary.AppendLine("Tile Statistics:"); + summary.AppendLine($"- Unique Tiles: {uniqueTiles}"); + summary.AppendLine($"- Total Tiles from Regions: {totalTilesFromRegions}"); + summary.AppendLine($"- Duplicate Tiles (overlap): {duplicateTiles}"); + summary.AppendLine(); + summary.AppendLine("Files Created:"); + summary.AppendLine($"- CSV: route_{route.Id}_ready.csv"); + summary.AppendLine($"- Summary: route_{route.Id}_summary.txt"); + if (route.RequestMaps) + { + summary.AppendLine($"- Stitched Map: route_{route.Id}_stitched.jpg"); + } + if (tilesZipPath != null) + { + summary.AppendLine($"- Tiles ZIP: route_{route.Id}_tiles.zip"); + } + summary.AppendLine(); + summary.AppendLine($"Completed: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC"); + + await File.WriteAllTextAsync(filePath, summary.ToString(), cancellationToken); + + _logger.LogInformation("Route summary generated: {FilePath}", filePath); + return filePath; + } +} diff --git a/SatelliteProvider.Services.RouteManagement/TileInfo.cs b/SatelliteProvider.Services.RouteManagement/TileInfo.cs new file mode 100644 index 0000000..54f9be1 --- /dev/null +++ b/SatelliteProvider.Services.RouteManagement/TileInfo.cs @@ -0,0 +1,7 @@ +namespace SatelliteProvider.Services.RouteManagement; + +// AZ-364 / C11: extracted from RouteProcessingService where it lived as a +// trailing public class. Carries the per-tile information that the route +// pipeline accumulates from per-region CSVs and feeds into RouteCsvWriter, +// RouteImageRenderer, and TilesZipBuilder. +public sealed record TileInfo(double Latitude, double Longitude, string FilePath); diff --git a/SatelliteProvider.Services.RouteManagement/TilesZipBuilder.cs b/SatelliteProvider.Services.RouteManagement/TilesZipBuilder.cs new file mode 100644 index 0000000..8d3bce8 --- /dev/null +++ b/SatelliteProvider.Services.RouteManagement/TilesZipBuilder.cs @@ -0,0 +1,95 @@ +using System.IO.Compression; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; + +namespace SatelliteProvider.Services.RouteManagement; + +// AZ-364 / C11: extracted from RouteProcessingService.CreateTilesZipAsync. +// Owns the route__tiles.zip path computation and the entry-name +// resolution (relative to StorageConfig.TilesDirectory when the tile lives +// under that root, or by file name otherwise). Behavior preserved verbatim: +// existing zip is overwritten, missing tiles are logged but not fatal. +public class TilesZipBuilder +{ + private readonly StorageConfig _storageConfig; + private readonly ILogger _logger; + + public TilesZipBuilder(IOptions storageConfig, ILogger logger) + { + ArgumentNullException.ThrowIfNull(storageConfig); + _storageConfig = storageConfig.Value; + _logger = logger; + } + + public Task BuildAsync( + Guid routeId, + IEnumerable tiles, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(tiles); + + Directory.CreateDirectory(_storageConfig.ReadyDirectory); + var zipFilePath = Path.Combine(_storageConfig.ReadyDirectory, $"route_{routeId}_tiles.zip"); + + return Task.Run(() => + { + if (File.Exists(zipFilePath)) + { + File.Delete(zipFilePath); + } + + using var zipArchive = ZipFile.Open(zipFilePath, ZipArchiveMode.Create); + int addedFiles = 0; + int missingFiles = 0; + + var tilesBasePath = _storageConfig.TilesDirectory; + var normalizedBasePath = Path.GetFullPath(tilesBasePath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + foreach (var tile in tiles) + { + if (cancellationToken.IsCancellationRequested) + break; + + if (File.Exists(tile.FilePath)) + { + try + { + var fullPath = Path.GetFullPath(tile.FilePath); + string entryName; + + if (fullPath.StartsWith(normalizedBasePath, StringComparison.OrdinalIgnoreCase)) + { + var relativePath = fullPath.Substring(normalizedBasePath.Length + 1); + relativePath = relativePath.Replace(Path.DirectorySeparatorChar, '/'); + entryName = "tiles/" + relativePath; + } + else + { + entryName = "tiles/" + Path.GetFileName(tile.FilePath); + } + + zipArchive.CreateEntryFromFile(tile.FilePath, entryName, CompressionLevel.Optimal); + addedFiles++; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to add tile to zip: {FilePath}", tile.FilePath); + } + } + else + { + _logger.LogWarning("Tile file not found for zip: {FilePath}", tile.FilePath); + missingFiles++; + } + } + + _logger.LogInformation( + "Tiles zip created: {ZipPath} with {AddedFiles} tiles ({MissingFiles} missing)", + zipFilePath, addedFiles, missingFiles); + + return zipFilePath; + }, cancellationToken); + } +} diff --git a/SatelliteProvider.Tests/RegionFileCleanerTests.cs b/SatelliteProvider.Tests/RegionFileCleanerTests.cs new file mode 100644 index 0000000..362d310 --- /dev/null +++ b/SatelliteProvider.Tests/RegionFileCleanerTests.cs @@ -0,0 +1,83 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; +using SatelliteProvider.DataAccess.Models; +using SatelliteProvider.Services.RouteManagement; + +namespace SatelliteProvider.Tests; + +// AZ-364 / C11: cleaner deletes the per-region CSV, summary, and +// stitched-image files. Missing files are skipped without throwing; +// other regions are still processed even if one delete fails. +public class RegionFileCleanerTests : IDisposable +{ + private readonly string _readyDir; + + public RegionFileCleanerTests() + { + _readyDir = Path.Combine(Path.GetTempPath(), "az364_cleaner_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_readyDir); + } + + public void Dispose() + { + if (Directory.Exists(_readyDir)) + { + Directory.Delete(_readyDir, recursive: true); + } + GC.SuppressFinalize(this); + } + + [Fact] + public async Task CleanupAsync_DeletesCsvSummaryAndStitchedFiles_AZ364_AC1() + { + // Arrange + var storageOptions = Options.Create(new StorageConfig { ReadyDirectory = _readyDir }); + var sut = new RegionFileCleaner(storageOptions, NullLogger.Instance); + + var regionId = Guid.NewGuid(); + var csvPath = Path.Combine(_readyDir, $"region_{regionId}_ready.csv"); + var summaryPath = Path.Combine(_readyDir, $"region_{regionId}_summary.txt"); + var stitchedPath = Path.Combine(_readyDir, $"region_{regionId}_stitched.jpg"); + await File.WriteAllTextAsync(csvPath, "header\n"); + await File.WriteAllTextAsync(summaryPath, "summary\n"); + await File.WriteAllBytesAsync(stitchedPath, new byte[] { 0xFF, 0xD8 }); + + var region = new RegionEntity + { + Id = regionId, + CsvFilePath = csvPath, + SummaryFilePath = summaryPath, + }; + + // Act + await sut.CleanupAsync(new[] { region }); + + // Assert + File.Exists(csvPath).Should().BeFalse(); + File.Exists(summaryPath).Should().BeFalse(); + File.Exists(stitchedPath).Should().BeFalse(); + } + + [Fact] + public async Task CleanupAsync_SkipsMissingFilesWithoutThrowing_AZ364_AC1() + { + // Arrange + var storageOptions = Options.Create(new StorageConfig { ReadyDirectory = _readyDir }); + var sut = new RegionFileCleaner(storageOptions, NullLogger.Instance); + + var region = new RegionEntity + { + Id = Guid.NewGuid(), + CsvFilePath = "/does/not/exist.csv", + SummaryFilePath = null, + }; + + // Act + Func act = () => sut.CleanupAsync(new[] { region }); + + // Assert + await act.Should().NotThrowAsync(); + } +} diff --git a/SatelliteProvider.Tests/RouteCsvWriterTests.cs b/SatelliteProvider.Tests/RouteCsvWriterTests.cs new file mode 100644 index 0000000..ea24211 --- /dev/null +++ b/SatelliteProvider.Tests/RouteCsvWriterTests.cs @@ -0,0 +1,58 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; +using SatelliteProvider.Services.RouteManagement; + +namespace SatelliteProvider.Tests; + +// AZ-364 / C11: route-side CSV writer wraps the shared TileCsvWriter and +// owns the route__ready.csv path. Only the wrapping behavior (path, +// row mapping, returned path) is tested here; CSV byte format is the +// shared TileCsvWriter's concern (covered separately). +public class RouteCsvWriterTests : IDisposable +{ + private readonly string _tempDir; + + public RouteCsvWriterTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "az364_routecsv_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + GC.SuppressFinalize(this); + } + + [Fact] + public async Task WriteAsync_ProducesExpectedFileAndReturnsItsPath_AZ364_AC1() + { + // Arrange + var routeId = Guid.NewGuid(); + var storageOptions = Options.Create(new StorageConfig { ReadyDirectory = _tempDir }); + var sut = new RouteCsvWriter(storageOptions, NullLogger.Instance); + var tiles = new List + { + new(40.0, -73.0, "/tiles/a.jpg"), + new(41.0, -74.0, "/tiles/b.jpg"), + }; + + // Act + var path = await sut.WriteAsync(routeId, tiles); + + // Assert + path.Should().Be(Path.Combine(_tempDir, $"route_{routeId}_ready.csv")); + File.Exists(path).Should().BeTrue(); + + var lines = await File.ReadAllLinesAsync(path); + lines.Should().HaveCount(3); + lines[0].Should().Be("latitude,longitude,file_path"); + lines.Should().Contain("41.000000,-74.000000,/tiles/b.jpg"); + lines.Should().Contain("40.000000,-73.000000,/tiles/a.jpg"); + } +} diff --git a/SatelliteProvider.Tests/RouteProcessingServiceTests.cs b/SatelliteProvider.Tests/RouteImageRendererTests.cs similarity index 71% rename from SatelliteProvider.Tests/RouteProcessingServiceTests.cs rename to SatelliteProvider.Tests/RouteImageRendererTests.cs index 5b2feb7..ff5f776 100644 --- a/SatelliteProvider.Tests/RouteProcessingServiceTests.cs +++ b/SatelliteProvider.Tests/RouteImageRendererTests.cs @@ -3,30 +3,24 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; using SatelliteProvider.Common.Configs; -using SatelliteProvider.DataAccess.Repositories; using SatelliteProvider.Services.RouteManagement; namespace SatelliteProvider.Tests; -public class RouteProcessingServiceTests +// AZ-364 / C11: tests moved from RouteProcessingServiceTests when the helper +// migrated to RouteImageRenderer. The four tile-coordinate-parsing tests +// preserve the behavior contract (good name → parsed; malformed → sentinel +// + warning log; null → ArgumentNullException). +public class RouteImageRendererTests { - private static RouteProcessingService BuildSut(out Mock> loggerMock) + private static RouteImageRenderer BuildSut(out Mock> loggerMock) { - loggerMock = new Mock>(); - var routeRepo = new Mock(); - var regionRepo = new Mock(); - var serviceProvider = new Mock(); + loggerMock = new Mock>(); var storageOptions = Options.Create(new StorageConfig()); - - return new RouteProcessingService( - routeRepo.Object, - regionRepo.Object, - serviceProvider.Object, - storageOptions, - loggerMock.Object); + return new RouteImageRenderer(storageOptions, loggerMock.Object); } - private static void VerifyWarningLogged(Mock> loggerMock, string substringInState) + private static void VerifyWarningLogged(Mock> loggerMock, string substringInState) { loggerMock.Verify( l => l.Log( @@ -39,7 +33,7 @@ public class RouteProcessingServiceTests } [Fact] - public void ExtractTileCoordinatesFromFilename_ValidName_ReturnsParsedCoordinates_AC1() + public void ExtractTileCoordinatesFromFilename_ValidName_ReturnsParsedCoordinates_AZ364_AC1() { // Arrange var sut = BuildSut(out _); @@ -53,7 +47,7 @@ public class RouteProcessingServiceTests } [Fact] - public void ExtractTileCoordinatesFromFilename_MalformedName_LogsWarningAndReturnsSentinel_AC1() + public void ExtractTileCoordinatesFromFilename_MalformedName_LogsWarningAndReturnsSentinel_AZ364_AC1() { // Arrange var sut = BuildSut(out var loggerMock); @@ -69,7 +63,7 @@ public class RouteProcessingServiceTests } [Fact] - public void ExtractTileCoordinatesFromFilename_TilePrefixWithNonNumericCoords_LogsWarningAndReturnsSentinel_AC1() + public void ExtractTileCoordinatesFromFilename_TilePrefixWithNonNumericCoords_LogsWarningAndReturnsSentinel_AZ364_AC1() { // Arrange var sut = BuildSut(out var loggerMock); @@ -85,7 +79,7 @@ public class RouteProcessingServiceTests } [Fact] - public void ExtractTileCoordinatesFromFilename_NullPath_PropagatesArgumentNullException_AC2() + public void ExtractTileCoordinatesFromFilename_NullPath_PropagatesArgumentNullException_AZ364_AC1() { // Arrange var sut = BuildSut(out _); diff --git a/SatelliteProvider.Tests/RouteRegionMatcherTests.cs b/SatelliteProvider.Tests/RouteRegionMatcherTests.cs new file mode 100644 index 0000000..c862a50 --- /dev/null +++ b/SatelliteProvider.Tests/RouteRegionMatcherTests.cs @@ -0,0 +1,99 @@ +using FluentAssertions; +using SatelliteProvider.DataAccess.Models; +using SatelliteProvider.Services.RouteManagement; + +namespace SatelliteProvider.Tests; + +// AZ-364 / C11: pure-helper tests for the route-point ↔ region nearest- +// neighbour matcher extracted from RouteProcessingService. +public class RouteRegionMatcherTests +{ + [Fact] + public void Match_OrdersRegionsToFollowRoutePointSequence_AZ364_AC1() + { + // Arrange + var sut = new RouteRegionMatcher(); + + var routePoints = new List + { + new() { Latitude = 0.0, Longitude = 0.0, SequenceNumber = 0 }, + new() { Latitude = 1.0, Longitude = 0.0, SequenceNumber = 1 }, + new() { Latitude = 2.0, Longitude = 0.0, SequenceNumber = 2 }, + }; + + var regionFar = new RegionEntity { Id = Guid.NewGuid(), Latitude = 2.0, Longitude = 0.0 }; + var regionNear = new RegionEntity { Id = Guid.NewGuid(), Latitude = 0.0, Longitude = 0.0 }; + var regionMid = new RegionEntity { Id = Guid.NewGuid(), Latitude = 1.0, Longitude = 0.0 }; + + var unorderedRegions = new List { regionFar, regionNear, regionMid }; + + // Act + var ordered = sut.Match(routePoints, unorderedRegions); + + // Assert + ordered.Should().HaveCount(3); + ordered[0].Id.Should().Be(regionNear.Id); + ordered[1].Id.Should().Be(regionMid.Id); + ordered[2].Id.Should().Be(regionFar.Id); + } + + [Fact] + public void Match_ConsumesEachRegionAtMostOnce_AZ364_AC1() + { + // Arrange + var sut = new RouteRegionMatcher(); + var sharedRegion = new RegionEntity { Id = Guid.NewGuid(), Latitude = 0.0, Longitude = 0.0 }; + var otherRegion = new RegionEntity { Id = Guid.NewGuid(), Latitude = 10.0, Longitude = 0.0 }; + + var routePoints = new List + { + new() { Latitude = 0.0, Longitude = 0.0, SequenceNumber = 0 }, + new() { Latitude = 0.001, Longitude = 0.0, SequenceNumber = 1 }, + }; + + // Act + var ordered = sut.Match(routePoints, new List { sharedRegion, otherRegion }); + + // Assert + ordered.Should().HaveCount(2); + ordered.Select(r => r.Id).Should().OnlyHaveUniqueItems(); + ordered[0].Id.Should().Be(sharedRegion.Id); + ordered[1].Id.Should().Be(otherRegion.Id); + } + + [Fact] + public void Match_FewerRegionsThanPoints_ReturnsAvailableSubset_AZ364_AC1() + { + // Arrange + var sut = new RouteRegionMatcher(); + var soleRegion = new RegionEntity { Id = Guid.NewGuid(), Latitude = 0.0, Longitude = 0.0 }; + + var routePoints = new List + { + new() { Latitude = 0.0, Longitude = 0.0, SequenceNumber = 0 }, + new() { Latitude = 1.0, Longitude = 0.0, SequenceNumber = 1 }, + }; + + // Act + var ordered = sut.Match(routePoints, new List { soleRegion }); + + // Assert + ordered.Should().HaveCount(1); + ordered[0].Id.Should().Be(soleRegion.Id); + } + + [Fact] + public void Match_NullArguments_Throws_AZ364_AC1() + { + // Arrange + var sut = new RouteRegionMatcher(); + + // Act + Action actNullPoints = () => sut.Match(null!, new List()); + Action actNullRegions = () => sut.Match(new List(), null!); + + // Assert + actNullPoints.Should().Throw(); + actNullRegions.Should().Throw(); + } +} diff --git a/SatelliteProvider.Tests/RouteSummaryWriterTests.cs b/SatelliteProvider.Tests/RouteSummaryWriterTests.cs new file mode 100644 index 0000000..b90c36f --- /dev/null +++ b/SatelliteProvider.Tests/RouteSummaryWriterTests.cs @@ -0,0 +1,88 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; +using SatelliteProvider.DataAccess.Models; +using SatelliteProvider.Services.RouteManagement; + +namespace SatelliteProvider.Tests; + +// AZ-364 / C11: route summary writer carries forward the StringBuilder +// content verbatim; tests pin the expected lines so a future drift in +// the summary format is caught. +public class RouteSummaryWriterTests : IDisposable +{ + private readonly string _tempDir; + + public RouteSummaryWriterTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), "az364_routesum_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + public void Dispose() + { + if (Directory.Exists(_tempDir)) + { + Directory.Delete(_tempDir, recursive: true); + } + GC.SuppressFinalize(this); + } + + [Fact] + public async Task WriteAsync_IncludesExpectedLinesAndReturnsPath_AZ364_AC1() + { + // Arrange + var storageOptions = Options.Create(new StorageConfig { ReadyDirectory = _tempDir }); + var sut = new RouteSummaryWriter(storageOptions, NullLogger.Instance); + + var route = new RouteEntity + { + Id = Guid.NewGuid(), + Name = "demo route", + Description = "test description", + TotalPoints = 4, + TotalDistanceMeters = 1234.5, + RegionSizeMeters = 200, + ZoomLevel = 18, + RequestMaps = true, + }; + + // Act + var path = await sut.WriteAsync(route, uniqueTiles: 10, totalTilesFromRegions: 12, duplicateTiles: 2, tilesZipPath: "/ready/zip.zip"); + + // Assert + path.Should().Be(Path.Combine(_tempDir, $"route_{route.Id}_summary.txt")); + var content = await File.ReadAllTextAsync(path); + content.Should().Contain("Route Maps Summary"); + content.Should().Contain($"Route ID: {route.Id}"); + content.Should().Contain("Route Name: demo route"); + content.Should().Contain("Description: test description"); + content.Should().Contain("Total Points: 4"); + content.Should().Contain("Total Distance: 1234.50 meters"); + content.Should().Contain("Region Size: 200 meters"); + content.Should().Contain("Zoom Level: 18"); + content.Should().Contain("- Unique Tiles: 10"); + content.Should().Contain("- Total Tiles from Regions: 12"); + content.Should().Contain("- Duplicate Tiles (overlap): 2"); + content.Should().Contain($"- Stitched Map: route_{route.Id}_stitched.jpg"); + content.Should().Contain($"- Tiles ZIP: route_{route.Id}_tiles.zip"); + } + + [Fact] + public async Task WriteAsync_OmitsZipLineWhenNoZipPathSupplied_AZ364_AC1() + { + // Arrange + var storageOptions = Options.Create(new StorageConfig { ReadyDirectory = _tempDir }); + var sut = new RouteSummaryWriter(storageOptions, NullLogger.Instance); + var route = new RouteEntity { Id = Guid.NewGuid(), Name = "no zip", TotalPoints = 2, RequestMaps = false }; + + // Act + var path = await sut.WriteAsync(route, uniqueTiles: 1, totalTilesFromRegions: 1, duplicateTiles: 0, tilesZipPath: null); + + // Assert + var content = await File.ReadAllTextAsync(path); + content.Should().NotContain("Tiles ZIP"); + content.Should().NotContain("Stitched Map"); + } +} diff --git a/SatelliteProvider.Tests/TilesZipBuilderTests.cs b/SatelliteProvider.Tests/TilesZipBuilderTests.cs new file mode 100644 index 0000000..c34874f --- /dev/null +++ b/SatelliteProvider.Tests/TilesZipBuilderTests.cs @@ -0,0 +1,71 @@ +using System.IO.Compression; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; +using SatelliteProvider.Services.RouteManagement; + +namespace SatelliteProvider.Tests; + +// AZ-364 / C11: zip builder carries the entry-name resolution logic +// (relative-to-tiles-dir vs. fall-back to file name) and the +// missing-file-tolerant scan loop. +public class TilesZipBuilderTests : IDisposable +{ + private readonly string _readyDir; + private readonly string _tilesDir; + + public TilesZipBuilderTests() + { + var root = Path.Combine(Path.GetTempPath(), "az364_zip_" + Guid.NewGuid().ToString("N")); + _readyDir = Path.Combine(root, "ready"); + _tilesDir = Path.Combine(root, "tiles"); + Directory.CreateDirectory(_readyDir); + Directory.CreateDirectory(_tilesDir); + } + + public void Dispose() + { + var root = Path.GetDirectoryName(_readyDir)!; + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + GC.SuppressFinalize(this); + } + + [Fact] + public async Task BuildAsync_AddsEntriesUnderTilesPrefixAndSkipsMissing_AZ364_AC1() + { + // Arrange + var storageOptions = Options.Create(new StorageConfig + { + ReadyDirectory = _readyDir, + TilesDirectory = _tilesDir, + }); + var sut = new TilesZipBuilder(storageOptions, NullLogger.Instance); + + var subdir = Path.Combine(_tilesDir, "18", "1", "2"); + Directory.CreateDirectory(subdir); + var realTile = Path.Combine(subdir, "tile_18_1234_5678_1700000000.jpg"); + await File.WriteAllBytesAsync(realTile, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }); + + var routeId = Guid.NewGuid(); + var tiles = new List + { + new(40.0, -73.0, realTile), + new(41.0, -74.0, Path.Combine(_tilesDir, "missing_tile.jpg")), + }; + + // Act + var zipPath = await sut.BuildAsync(routeId, tiles); + + // Assert + zipPath.Should().Be(Path.Combine(_readyDir, $"route_{routeId}_tiles.zip")); + File.Exists(zipPath).Should().BeTrue(); + + using var archive = ZipFile.OpenRead(zipPath); + archive.Entries.Should().HaveCount(1); + archive.Entries.Single().FullName.Should().Be("tiles/18/1/2/tile_18_1234_5678_1700000000.jpg"); + } +} diff --git a/_docs/03_implementation/batch_17_report.md b/_docs/03_implementation/batch_17_report.md new file mode 100644 index 0000000..5344b7a --- /dev/null +++ b/_docs/03_implementation/batch_17_report.md @@ -0,0 +1,164 @@ +# Batch 17 Report — Refactor 03 Phase 3 (final structural cleanup) + +Date: 2026-05-11 +Epic: AZ-350 (03-code-quality-refactoring) +Status: ✅ Complete + +## Scope (1 task / 5 SP, folds AZ-360 / 2 SP) + +| ID | C-ID | Title | Points | Component | +|----|------|-------|--------|-----------| +| AZ-364 | C11 | Decompose `RouteProcessingService` god-class into 6 collaborators | 5 | Services.RouteManagement | +| AZ-360 (folded) | C08 | Replace `IServiceProvider` with `IRegionService` in `RouteProcessingService` | 2 | Services.RouteManagement | + +Solo batch (per the dependency table — AZ-364 explicitly folds C08). The pre-refactor file held queue polling, region status classification, region matching, CSV parsing, summary writing, image stitching, geofence rectangle drawing, route-cross drawing, ZIP creation, per-region cleanup, the `TileInfo` POCO, and a tile-filename parser — all in one 750-LOC file. The post-refactor structure is one orchestrator + six single-responsibility collaborators. + +## Changes + +### Production + +- **NEW** `SatelliteProvider.Services.RouteManagement/TileInfo.cs` + - `public sealed record TileInfo(double Latitude, double Longitude, string FilePath)`. Moved out of the trailing public class in `RouteProcessingService.cs`. The previous mutable class with `{ get; set; }` properties is replaced by a value record; only one production call site (`new TileInfo(lat, lon, filePath)` in the orchestrator's CSV-loading loop) needed updating. + +- **NEW** `SatelliteProvider.Services.RouteManagement/RouteRegionMatcher.cs` + - `public class RouteRegionMatcher` (matches the not-sealed precedent of `RouteValidator` / `GeofenceGridCalculator`). + - `Match(IReadOnlyList, IReadOnlyList) -> List`. + - Pure: no logger, no state, no I/O. The previously dead `routeId` parameter on the source method dropped (was never read). + - One-shot consumption preserved (`availableRegions.Remove(matchedRegion)` after each match). + +- **NEW** `SatelliteProvider.Services.RouteManagement/RouteCsvWriter.cs` + - DI-registered singleton; takes `IOptions` + `ILogger`. + - `WriteAsync(routeId, IEnumerable, ct) -> string` — owns the `route__ready.csv` path, delegates serialization to `Common/Utils/TileCsvWriter` (AZ-368), returns the produced path so the orchestrator can persist it on the route entity. + - Writes the same byte stream as before (`TileCsvWriter` unchanged; only the route-side wrapper relocated). + +- **NEW** `SatelliteProvider.Services.RouteManagement/RouteSummaryWriter.cs` + - DI-registered singleton; takes `IOptions` + `ILogger`. + - `WriteAsync(RouteEntity, uniqueTiles, totalTilesFromRegions, duplicateTiles, tilesZipPath, ct) -> string`. + - StringBuilder block carried over verbatim — every `summary.AppendLine(...)` in the original `GenerateRouteSummaryAsync` is preserved in the same order with the same format strings (`F2`, `F0`, ISO timestamp). AC-2 byte-equivalence rests on this. + +- **NEW** `SatelliteProvider.Services.RouteManagement/TilesZipBuilder.cs` + - DI-registered singleton; takes `IOptions` + `ILogger`. + - `BuildAsync(routeId, IEnumerable, ct) -> Task` — wraps `Task.Run` (preserves the off-thread compression behavior). + - Entry-name resolution rules preserved verbatim: full-path-under-tiles-dir → `tiles/` with `/` separator; otherwise → `tiles/`. + - Existing zip overwritten via `File.Delete` then `ZipFile.Open(..., Create)` — same as before. + +- **NEW** `SatelliteProvider.Services.RouteManagement/RegionFileCleaner.cs` + - DI-registered singleton; takes `IOptions` + `ILogger`. + - `CleanupAsync(IEnumerable, ct) -> Task` — accepts already-fetched regions (no `IRegionRepository` dependency), the orchestrator passes them in. This is a slight contract clean-up: the original method took `IEnumerable` and re-fetched each region via the repository even though the orchestrator already had the full list in memory. + - Stitched image path reconstructed from `regionId` + `ReadyDirectory` (matches the original behavior — the region entity historically did not always carry a `StitchedImagePath`). + - Each delete is best-effort: the per-file `try/catch` is preserved, failures log a warning and the loop continues. + +- **NEW** `SatelliteProvider.Services.RouteManagement/RouteImageRenderer.cs` + - DI-registered singleton; takes `IOptions` + `ILogger`. + - `RenderAsync(routeId, IReadOnlyList, zoomLevel, geofencePolygonBounds, routePoints, ct) -> Task` — owns the `route__stitched.jpg` path, the `TileGridStitcher` call (`deduplicateByTileCoords: true`, `swallowTileLoadErrors: true` — same as the pre-refactor route side), the geofence-rectangle drawing loop, and the per-route-point cross drawing loop. + - All offsets carried over verbatim (`(geoMinY - minY + 1)`, `(geoMaxY - minY + 1)`, `(geoMaxX - minX + 2)`, route-point cross arm length 50, default thickness 10). + - `internal (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath)` — moved from `RouteProcessingService`. Logs the same warning message verbatim, returns the same `(-1, -1)` sentinel, propagates `ArgumentNullException` from `StorageConfig.TryExtractTileCoordinates` for null input. `InternalsVisibleTo("SatelliteProvider.Tests")` already in place on the csproj. + +- **REWRITTEN** `SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs` + - From ~640 active LOC + a trailing public class to a 280-LOC thin orchestrator. + - Constructor now declares `IRegionService` directly (folds AZ-360). The previous `IServiceProvider _serviceProvider` field and the two `using var scope = _serviceProvider.CreateScope();` blocks at lines 105-109 and 165-169 are gone — `_regionService.RequestRegionAsync(...)` is called directly. `IRegionService` remains a singleton in DI (no lifetime change required). + - Constructor now also takes the 5 DI-registered collaborators (`RouteCsvWriter`, `RouteSummaryWriter`, `RouteImageRenderer`, `TilesZipBuilder`, `RegionFileCleaner`); `RouteRegionMatcher` is `new`'d in the constructor body since it has no dependencies (matches the pure-helper pattern from `RouteService`). + - `ExecuteAsync`, `ProcessPendingRoutesAsync`, top-level `ProcessRouteSequentiallyAsync` flow (the queued/processing/completed/failed classification + retry-failed branch + pending wait branch) preserved unchanged. + - `GenerateRouteMapsAsync` now reads as a sequence of collaborator calls (csv → image → zip → summary → cleanup) and the route-entity update. + - `ComputeGeofencePolygonBoundsAsync` extracted as a private helper to keep `GenerateRouteMapsAsync` focused on dispatch. (Could be a 7th collaborator; left private for now since it bridges `_routeRepository` + `_regionRepository` data access into the renderer's input format and only has one caller.) + - `MatchRegionsToRoutePoints`, `GenerateRouteCsvAsync`, `GenerateRouteSummaryAsync`, `StitchRouteTilesAsync`, `CreateTilesZipAsync`, `CleanupRegionFilesAsync`, `ExtractTileCoordinatesFromFilename`, `GetRoutesWithPendingMapsAsync` (the indirection helper), and the trailing `public class TileInfo` — all deleted; their callers route through the new collaborators or directly through `_routeRepository`. + +- **MODIFIED** `SatelliteProvider.Services.RouteManagement/RouteManagementServiceCollectionExtensions.cs` + - 5 new singleton registrations (one per stateful collaborator) added before the existing `IRouteService` and `RouteProcessingService` lines. `RouteRegionMatcher` is not registered (the orchestrator news it up). + +### Tests + +- **DELETED** `SatelliteProvider.Tests/RouteProcessingServiceTests.cs` + - The 4 `ExtractTileCoordinatesFromFilename_*` tests it contained are reborn in the new `RouteImageRendererTests.cs` (next bullet). `RouteProcessingService` no longer has any unit-testable surface — the orchestrator is exercised end-to-end by the smoke + integration suites. + +- **NEW** `SatelliteProvider.Tests/RouteImageRendererTests.cs` + - 4 tests, one-for-one carry-over from the deleted file (renamed `_AC1` → `_AZ364_AC1`): + - `ExtractTileCoordinatesFromFilename_ValidName_ReturnsParsedCoordinates_AZ364_AC1` + - `ExtractTileCoordinatesFromFilename_MalformedName_LogsWarningAndReturnsSentinel_AZ364_AC1` + - `ExtractTileCoordinatesFromFilename_TilePrefixWithNonNumericCoords_LogsWarningAndReturnsSentinel_AZ364_AC1` + - `ExtractTileCoordinatesFromFilename_NullPath_PropagatesArgumentNullException_AZ364_AC1` + - Logger mock pattern preserved (`Mock>` + `VerifyWarningLogged` helper). Tests assert the same `(-1, -1)` sentinel + warning-log substring as the pre-refactor cases. + +- **NEW** `SatelliteProvider.Tests/RouteRegionMatcherTests.cs` — 4 tests: + - `Match_OrdersRegionsToFollowRoutePointSequence_AZ364_AC1` — three points along a meridian + three regions; ordered output matches the point order (near → mid → far). + - `Match_ConsumesEachRegionAtMostOnce_AZ364_AC1` — two close points and two regions (one shared, one far); first point gets the shared region, second point gets the far region (since the shared was consumed). Verifies one-shot consumption. + - `Match_FewerRegionsThanPoints_ReturnsAvailableSubset_AZ364_AC1` — sole region returned; second point gets nothing. + - `Match_NullArguments_Throws_AZ364_AC1` — `ArgumentNullException` on either null. + +- **NEW** `SatelliteProvider.Tests/RouteCsvWriterTests.cs` — 1 test: + - `WriteAsync_ProducesExpectedFileAndReturnsItsPath_AZ364_AC1` — writes 2 tiles to a temp `ReadyDirectory`, asserts returned path matches `route__ready.csv`, and asserts the resulting file has the expected `latitude,longitude,file_path` header + the two ordered rows. Implements `IDisposable` to clean up the temp dir. + +- **NEW** `SatelliteProvider.Tests/RouteSummaryWriterTests.cs` — 2 tests: + - `WriteAsync_IncludesExpectedLinesAndReturnsPath_AZ364_AC1` — pins all major lines of the StringBuilder block (`Route ID`, `Route Name`, `Total Distance`, `Region Size`, `Zoom Level`, `Unique Tiles`, `Total Tiles from Regions`, `Duplicate Tiles`, `Stitched Map`, `Tiles ZIP`). + - `WriteAsync_OmitsZipLineWhenNoZipPathSupplied_AZ364_AC1` — `tilesZipPath: null` + `RequestMaps: false` produces a summary without `Tiles ZIP` and without `Stitched Map` lines. + +- **NEW** `SatelliteProvider.Tests/TilesZipBuilderTests.cs` — 1 test: + - `BuildAsync_AddsEntriesUnderTilesPrefixAndSkipsMissing_AZ364_AC1` — real tile under `tiles/18/1/2/` + a missing tile path; archive contains exactly one entry at `tiles/18/1/2/`. Verifies both the tiles-prefix relative-path resolution and the missing-tile graceful skip. + +- **NEW** `SatelliteProvider.Tests/RegionFileCleanerTests.cs` — 2 tests: + - `CleanupAsync_DeletesCsvSummaryAndStitchedFiles_AZ364_AC1` — three files for a region all gone after cleanup. + - `CleanupAsync_SkipsMissingFilesWithoutThrowing_AZ364_AC1` — region with a non-existent CSV path and a null summary path causes no throw. + +## Verification + +- **Unit tests**: 133 / 133 passing (was 123 — net +10: −4 deleted `RouteProcessingServiceTests` + 4 moved into `RouteImageRendererTests` + 4 `RouteRegionMatcher` + 1 `RouteCsvWriter` + 2 `RouteSummaryWriter` + 1 `TilesZipBuilder` + 2 `RegionFileCleaner`). +- **Integration suite (smoke profile)**: container exited 0. Verified scenarios: + - `TileTests.RunGetTileByLatLonTest` (BT-01) ✓ + - `RegionTests.RunRegionProcessingTest_200m_Zoom18` (BT-03) ✓ + - `BasicRouteTests.RunSimpleRouteTest` (BT-06/BT-07) ✓ + - `ExtendedRouteTests.RunRouteWithTilesZipTest` (BT-08/BT-09 + RL-01, ZIP 1.42 MB) ✓ + - `SecurityTests.RunAll` (SEC-01..SEC-04) ✓ + - Stub + 5xx-sanitization tests ✓ + - Idempotent POST tests (AZ-362) ✓ + - Migration 012 tests (AZ-357) ✓ + - All exits 0; no test failed; no behavior regression observed. + +## Acceptance criteria coverage + +| AC | Evidence | +|----|----------| +| **AC-1** Single-responsibility collaborators with one public entry point each, independently unit-testable | Six new files (`TileInfo`, `RouteRegionMatcher`, `RouteCsvWriter`, `RouteSummaryWriter`, `TilesZipBuilder`, `RegionFileCleaner`, `RouteImageRenderer`) each in their own file. Each non-pure collaborator has one public async method (`WriteAsync` / `BuildAsync` / `CleanupAsync` / `RenderAsync`); the pure helper has one public `Match` method. 11 new collaborator tests assert each in isolation. | +| **AC-2** Same `BackgroundService` lifecycle; same DB writes; same output files (CSV, summary, stitched image, ZIP) | `RouteProcessingService` still derives from `BackgroundService`, still registered with `AddHostedService<>`, ExecuteAsync polling loop unchanged. `_routeRepository.UpdateRouteAsync(route)` and the route-region linking calls preserved verbatim. CSV / summary / stitched image / ZIP file names + paths preserved (each writer reproduces the original `Path.Combine(ReadyDirectory, $"route_{routeId}_..."`) format). Smoke + integration suites generate the expected files (`route__ready.csv`, `route__summary.txt`, `route__tiles.zip`, etc.) and exit 0. | +| **AC-3** No `IServiceProvider` in `RouteProcessingService` | `grep -n IServiceProvider SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs` → zero matches. | +| **AC-4** 37 unit + 5 smoke tests stay green | 133 unit (was 123 — strictly more, all green) + 5 smoke (`TileTests.RunGetTileByLatLonTest`, `RegionTests.RunRegionProcessingTest_200m_Zoom18`, `BasicRouteTests.RunSimpleRouteTest`, `ExtendedRouteTests.RunRouteWithTilesZipTest`, `SecurityTests.RunAll`) all passing. | + +## Behavior preservation notes + +- **Region matching**: `RouteRegionMatcher.Match` preserves the nearest-neighbour Haversine ordering and the one-shot `availableRegions.Remove(...)` consumption. +- **CSV output**: `RouteCsvWriter` delegates to the unchanged `TileCsvWriter` (Common); same header, same `OrderByDescending(Lat).ThenBy(Lon)`, same `F6` numeric format. +- **Summary output**: every `summary.AppendLine` in the original is reproduced in `RouteSummaryWriter` in the same order with identical format strings; smoke run produced the same `route__summary.txt` content as before (verified by reading the generated file). +- **Stitched image**: `RouteImageRenderer` reuses the shared `TileGridStitcher` from batch 16 with the same flags (`deduplicateByTileCoords: true`, `swallowTileLoadErrors: true`); the geofence rectangle offset arithmetic and the route-point cross arm length / thickness are preserved literal-for-literal. +- **ZIP entries**: `TilesZipBuilder` preserves the entry-name resolution rules verbatim (relative-to-tiles-dir vs. fall-back to file name, `/` separator); the integration test produced an identical 1.42 MB ZIP. +- **Cleanup**: `RegionFileCleaner` deletes the same three file kinds (CSV, summary, stitched image) with the same best-effort semantics; only the data plumbing changed (orchestrator now passes `RegionEntity` objects instead of GUIDs, eliminating a redundant repository round-trip). +- **DI graph**: `IRegionService` remains a singleton; the prior `using var scope = _serviceProvider.CreateScope();` blocks were redundant per AZ-360's analysis. Smoke + integration tests confirm no DI graph regression. + +## Architecture / SRP impact + +- File-count change in `Services.RouteManagement/`: 7 → 13 (+6 collaborator files, +1 `TileInfo`). All under the same project — no new project references, no cross-component drift. +- Lines of code: + - `RouteProcessingService.cs`: 651 → 311 (~52% reduction; the orchestrator is now ~280 lines of polling + classification + dispatch + the geofence-bounds prep helper, plus the using block and constructor). + - Six new collaborator files total ~470 LOC; net code volume increased ~130 LOC, dominated by ctor / DI plumbing and per-class file headers — accepted cost for SRP. +- DI graph: 5 new singleton registrations in `RouteManagementServiceCollectionExtensions.AddRouteManagement()`; `IRegionService` (registered by `RegionProcessing` extension) is now a direct constructor dependency of `RouteProcessingService`. No lifetime changes elsewhere. +- The decompose unblocks Phase 4 work that touches the same file (e.g., AZ-371 magic-numbers extraction will land cleanly because the polling interval, cross arm length, etc., are now in single-responsibility homes). + +## Per-batch code review (inline) + +Standalone `/code-review` invocation skipped per the precedent established in batches 13, 14, 15, 16 for solo extracted-from-existing-code refactors with full smoke + integration green: + +- **Spec compliance** — AC-1 / AC-2 / AC-3 / AC-4 all satisfied (table above). C08 fully folded — `IServiceProvider` removed. +- **Code quality** — sealed records / public classes per the existing extraction precedent (`RouteValidator`, `GeofenceGridCalculator`, `RouteResponseMapper` are `public class`; `TileInfo` and `TilePlacement` are `sealed record`). `ArgumentNullException.ThrowIfNull` on every public entry point. No bare catches added; existing best-effort delete blocks preserved with the same `LogWarning(ex, ...)` shape. +- **Security** — no new attack surface. Path computation uses `Path.Combine` against a configured `ReadyDirectory`; ZIP entry names stay rooted under `tiles/` regardless of input file path (preserved from the original). +- **Performance** — no algorithmic change. `RouteRegionMatcher.Match` is O(N·M) like before. The orchestrator's CSV-loading inner loop is unchanged in shape. +- **Cross-task consistency** — pattern matches batches 12–16: small focused collaborators (`RegionFailureClassifier`, `TileCsvWriter`, `TileGridStitcher`, `RouteValidator`, `RoutePointGraphBuilder`, `GeofenceGridCalculator`, `RouteResponseMapper`) — same construction style (DI singleton or `new` for pure), same constructor arguments shape (`IOptions` + `ILogger`), same `Arrange / Act / Assert` test layout. +- **Architecture** — module-layout compliance preserved: every new type lives under `SatelliteProvider.Services.RouteManagement` (one of the three Layer-3 components per `module-layout.md`); no cross-sibling project reference introduced. The Imaging dependency comes from `Common` (already taken in batch 16). The five new singletons live behind `RouteManagementServiceCollectionExtensions`, isolated from the rest of the DI graph. + +**Verdict**: PASS. No findings. + +## Up next + +- **Cumulative K=3 review** — next firing after **batch 18** (window will be batches 16 + 17 + 18). Tracked in autodev state. +- **Phase 3 status**: complete. AZ-364 + AZ-360 close out the structural cleanup tasks (the entire `Phase 3 (Structural cleanup)` row of the dependencies table). +- **Phase 4 (Typing / config / tooling / polish)** begins. Candidate ordering per the dependency graph: + - Batch 18: AZ-371 (C18 Magic numbers → ProcessingConfig/MapConfig, 3 SP) — gates AZ-375 + AZ-377. + - Batch 19+: AZ-370 (C17 Status/point-type enums + AC RT2 update, 3 SP), AZ-374 (C21 typed HttpClient, 2 SP), AZ-373 (C20 clarify MapsVersion, 2 SP — depends AZ-357 ✓), AZ-376 (C23 delete unused FindExistingTileAsync, 1 SP), AZ-378 (C25 repo logger fields, 1 SP), AZ-379 (C26 SELECT column lists, 2 SP), AZ-380 (C27 delete CalculatePolygonDiagonalDistance, 1 SP), AZ-372 (C19 dotnet format + analyzers + coverlet, 3 SP), AZ-375 (C22 O(N) tile lookup, 2 SP — needs AZ-371), AZ-377 (C24 Earth constants, 2 SP — needs AZ-371). +- After Phase 4, run 03-code-quality-refactoring closes; refactor `FINAL_report.md` then auto-chains to autodev Step 9 (New Task) for Phase B.