[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 <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 03:12:49 +03:00
parent 70a0a2c4d5
commit 6f23120c49
16 changed files with 1181 additions and 439 deletions
@@ -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<RegionFileCleaner> _logger;
public RegionFileCleaner(IOptions<StorageConfig> storageConfig, ILogger<RegionFileCleaner> logger)
{
ArgumentNullException.ThrowIfNull(storageConfig);
_storageConfig = storageConfig.Value;
_logger = logger;
}
public Task CleanupAsync(IEnumerable<RegionEntity> 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);
}
}
}
@@ -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_<id>_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<RouteCsvWriter> _logger;
public RouteCsvWriter(IOptions<StorageConfig> storageConfig, ILogger<RouteCsvWriter> logger)
{
ArgumentNullException.ThrowIfNull(storageConfig);
_storageConfig = storageConfig.Value;
_logger = logger;
}
public async Task<string> WriteAsync(
Guid routeId,
IEnumerable<TileInfo> 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;
}
}
@@ -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_<id>_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<RouteImageRenderer> _logger;
public RouteImageRenderer(IOptions<StorageConfig> storageConfig, ILogger<RouteImageRenderer> logger)
{
ArgumentNullException.ThrowIfNull(storageConfig);
_storageConfig = storageConfig.Value;
_logger = logger;
}
public async Task<string?> RenderAsync(
Guid routeId,
IReadOnlyList<TileInfo> tiles,
int zoomLevel,
IReadOnlyList<(int MinX, int MinY, int MaxX, int MaxY)> geofencePolygonBounds,
IReadOnlyList<RoutePointEntity> 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_<zoom>_<x>_<y>_<timestamp>.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_<zoom>_<x>_<y>_<timestamp>",
filePath);
return (-1, -1);
}
}
@@ -8,6 +8,11 @@ public static class RouteManagementServiceCollectionExtensions
{
public static IServiceCollection AddRouteManagement(this IServiceCollection services)
{
services.AddSingleton<RouteCsvWriter>();
services.AddSingleton<RouteSummaryWriter>();
services.AddSingleton<RouteImageRenderer>();
services.AddSingleton<TilesZipBuilder>();
services.AddSingleton<RegionFileCleaner>();
services.AddSingleton<IRouteService, RouteService>();
services.AddHostedService<RouteProcessingService>();
return services;
@@ -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<RouteProcessingService> _logger;
private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(5);
public RouteProcessingService(
IRouteRepository routeRepository,
IRegionRepository regionRepository,
IServiceProvider serviceProvider,
IOptions<StorageConfig> storageConfig,
IRegionService regionService,
RouteCsvWriter routeCsvWriter,
RouteSummaryWriter routeSummaryWriter,
RouteImageRenderer routeImageRenderer,
TilesZipBuilder tilesZipBuilder,
RegionFileCleaner regionFileCleaner,
ILogger<RouteProcessingService> 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<List<(Guid Id, bool RequestMaps)>> 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);
@@ -104,14 +114,11 @@ public class RouteProcessingService : BackgroundService
if (regionIdsList.Count == 0 && routePointsList.Count > 0)
{
using var scope = _serviceProvider.CreateScope();
var regionService = scope.ServiceProvider.GetRequiredService<Common.Interfaces.IRegionService>();
foreach (var point in routePointsList)
{
var regionId = Guid.NewGuid();
await regionService.RequestRegionAsync(
await _regionService.RequestRegionAsync(
regionId,
point.Latitude,
point.Longitude,
@@ -125,7 +132,7 @@ public class RouteProcessingService : BackgroundService
return;
}
var regions = new List<DataAccess.Models.RegionEntity>();
var regions = new List<RegionEntity>();
foreach (var regionId in allRegionIds)
{
var region = await _regionRepository.GetByIdAsync(regionId);
@@ -152,25 +159,23 @@ public class RouteProcessingService : BackgroundService
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();
var orderedRouteRegions = _routeRegionMatcher.Match(routePointsList, completedRoutePointRegions);
var regionsForOutput = orderedRouteRegions.Concat(completedGeofenceRegions)
.GroupBy(r => r.Id)
.Select(g => g.First())
.ToList();
await GenerateRouteMapsAsync(routeId, route, allRegionIdsForStitching, geofenceRegionIdsForBorders, routePointsList, cancellationToken);
await GenerateRouteMapsAsync(routeId, route, regionsForOutput, completedGeofenceRegions, routePointsList, cancellationToken);
return;
}
if (shouldRetryFailed)
{
using var scope = _serviceProvider.CreateScope();
var regionService = scope.ServiceProvider.GetRequiredService<Common.Interfaces.IRegionService>();
foreach (var failedRegion in failedRegions)
{
var newRegionId = Guid.NewGuid();
await regionService.RequestRegionAsync(
await _regionService.RequestRegionAsync(
newRegionId,
failedRegion.Latitude,
failedRegion.Longitude,
@@ -190,42 +195,37 @@ public class RouteProcessingService : BackgroundService
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<Guid> regionIds,
List<Guid> geofenceRegionIds,
List<DataAccess.Models.RoutePointEntity> routePoints,
RouteEntity route,
IReadOnlyList<RegionEntity> regionsForOutput,
IReadOnlyList<RegionEntity> geofenceRegions,
IReadOnlyList<RoutePointEntity> routePoints,
CancellationToken cancellationToken)
{
try
{
var readyDir = _storageConfig.ReadyDirectory;
Directory.CreateDirectory(readyDir);
var allTiles = new Dictionary<string, TileInfo>();
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;
@@ -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<Guid> 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<TileInfo> 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<TileInfo> tiles,
string outputPath,
private async Task<List<(int MinX, int MinY, int MaxX, int MaxY)>> ComputeGeofencePolygonBoundsAsync(
Guid routeId,
int zoomLevel,
List<(int MinX, int MinY, int MaxX, int MaxY)> geofencePolygonBounds,
List<DataAccess.Models.RoutePointEntity> 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;
foreach (var geofenceId in polygonRegionIds)
{
var region = await _regionRepository.GetByIdAsync(geofenceId);
if (region == null || string.IsNullOrEmpty(region.CsvFilePath) || !File.Exists(region.CsvFilePath))
{
continue;
}
const int tileSizePixels = 256;
var csvLines = await File.ReadAllLinesAsync(region.CsvFilePath, cancellationToken);
var placements = tiles
.Select(t =>
foreach (var line in csvLines.Skip(1))
{
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)
var parts = line.Split(',');
if (parts.Length < 3) continue;
if (!double.TryParse(parts[0], out var lat) || !double.TryParse(parts[1], out var lon))
{
_logger.LogWarning("No tiles with extractable coordinates to stitch for route map");
return;
continue;
}
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 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);
}
}
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)
if (minX.HasValue && minY.HasValue && maxX.HasValue && maxY.HasValue)
{
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);
bounds.Add((minX.Value, minY.Value, maxX.Value, maxY.Value));
}
}
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<DataAccess.Models.RegionEntity> MatchRegionsToRoutePoints(
List<DataAccess.Models.RoutePointEntity> routePoints,
List<DataAccess.Models.RegionEntity> regions,
Guid routeId)
{
var orderedRegions = new List<DataAccess.Models.RegionEntity>();
var availableRegions = new List<DataAccess.Models.RegionEntity>(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_<zoom>_<x>_<y>_<timestamp>",
filePath);
return (-1, -1);
}
private Task CreateTilesZipAsync(
string zipFilePath,
IEnumerable<TileInfo> 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))
{
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);
}, cancellationToken);
return bounds;
}
}
public class TileInfo
{
public double Latitude { get; set; }
public double Longitude { get; set; }
public string FilePath { get; set; } = string.Empty;
}
@@ -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<RegionEntity> Match(
IReadOnlyList<RoutePointEntity> routePoints,
IReadOnlyList<RegionEntity> regions)
{
ArgumentNullException.ThrowIfNull(routePoints);
ArgumentNullException.ThrowIfNull(regions);
var orderedRegions = new List<RegionEntity>(routePoints.Count);
var availableRegions = new List<RegionEntity>(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;
}
}
@@ -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_<id>_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<RouteSummaryWriter> _logger;
public RouteSummaryWriter(IOptions<StorageConfig> storageConfig, ILogger<RouteSummaryWriter> logger)
{
ArgumentNullException.ThrowIfNull(storageConfig);
_storageConfig = storageConfig.Value;
_logger = logger;
}
public async Task<string> 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;
}
}
@@ -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);
@@ -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_<id>_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<TilesZipBuilder> _logger;
public TilesZipBuilder(IOptions<StorageConfig> storageConfig, ILogger<TilesZipBuilder> logger)
{
ArgumentNullException.ThrowIfNull(storageConfig);
_storageConfig = storageConfig.Value;
_logger = logger;
}
public Task<string> BuildAsync(
Guid routeId,
IEnumerable<TileInfo> 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);
}
}
@@ -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<RegionFileCleaner>.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<RegionFileCleaner>.Instance);
var region = new RegionEntity
{
Id = Guid.NewGuid(),
CsvFilePath = "/does/not/exist.csv",
SummaryFilePath = null,
};
// Act
Func<Task> act = () => sut.CleanupAsync(new[] { region });
// Assert
await act.Should().NotThrowAsync();
}
}
@@ -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_<id>_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<RouteCsvWriter>.Instance);
var tiles = new List<TileInfo>
{
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");
}
}
@@ -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<ILogger<RouteProcessingService>> loggerMock)
private static RouteImageRenderer BuildSut(out Mock<ILogger<RouteImageRenderer>> loggerMock)
{
loggerMock = new Mock<ILogger<RouteProcessingService>>();
var routeRepo = new Mock<IRouteRepository>();
var regionRepo = new Mock<IRegionRepository>();
var serviceProvider = new Mock<IServiceProvider>();
loggerMock = new Mock<ILogger<RouteImageRenderer>>();
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<ILogger<RouteProcessingService>> loggerMock, string substringInState)
private static void VerifyWarningLogged(Mock<ILogger<RouteImageRenderer>> 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 _);
@@ -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<RoutePointEntity>
{
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<RegionEntity> { 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<RoutePointEntity>
{
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<RegionEntity> { 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<RoutePointEntity>
{
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<RegionEntity> { 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<RegionEntity>());
Action actNullRegions = () => sut.Match(new List<RoutePointEntity>(), null!);
// Assert
actNullPoints.Should().Throw<ArgumentNullException>();
actNullRegions.Should().Throw<ArgumentNullException>();
}
}
@@ -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<RouteSummaryWriter>.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<RouteSummaryWriter>.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");
}
}
@@ -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<TilesZipBuilder>.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<TileInfo>
{
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");
}
}
+164
View File
@@ -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<RoutePointEntity>, IReadOnlyList<RegionEntity>) -> List<RegionEntity>`.
- 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<StorageConfig>` + `ILogger<RouteCsvWriter>`.
- `WriteAsync(routeId, IEnumerable<TileInfo>, ct) -> string` — owns the `route_<id>_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<StorageConfig>` + `ILogger<RouteSummaryWriter>`.
- `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<StorageConfig>` + `ILogger<TilesZipBuilder>`.
- `BuildAsync(routeId, IEnumerable<TileInfo>, ct) -> Task<string>` — wraps `Task.Run` (preserves the off-thread compression behavior).
- Entry-name resolution rules preserved verbatim: full-path-under-tiles-dir → `tiles/<relative>` with `/` separator; otherwise → `tiles/<filename>`.
- Existing zip overwritten via `File.Delete` then `ZipFile.Open(..., Create)` — same as before.
- **NEW** `SatelliteProvider.Services.RouteManagement/RegionFileCleaner.cs`
- DI-registered singleton; takes `IOptions<StorageConfig>` + `ILogger<RegionFileCleaner>`.
- `CleanupAsync(IEnumerable<RegionEntity>, 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<Guid>` 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<StorageConfig>` + `ILogger<RouteImageRenderer>`.
- `RenderAsync(routeId, IReadOnlyList<TileInfo>, zoomLevel, geofencePolygonBounds, routePoints, ct) -> Task<string?>` — owns the `route_<id>_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<ILogger<RouteImageRenderer>>` + `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_<id>_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/<filename>`. 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_<id>_ready.csv`, `route_<id>_summary.txt`, `route_<id>_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_<id>_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 1216: small focused collaborators (`RegionFailureClassifier`, `TileCsvWriter`, `TileGridStitcher`, `RouteValidator`, `RoutePointGraphBuilder`, `GeofenceGridCalculator`, `RouteResponseMapper`) — same construction style (DI singleton or `new` for pure), same constructor arguments shape (`IOptions<StorageConfig>` + `ILogger<T>`), 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.