mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 05:41:14 +00:00
[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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
@@ -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);
|
||||
@@ -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<Common.Interfaces.IRegionService>();
|
||||
|
||||
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<DataAccess.Models.RegionEntity>();
|
||||
|
||||
var regions = new List<RegionEntity>();
|
||||
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<Common.Interfaces.IRegionService>();
|
||||
|
||||
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<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;
|
||||
|
||||
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<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;
|
||||
|
||||
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<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))
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
+13
-19
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 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<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.
|
||||
Reference in New Issue
Block a user