From 1dcd089d39ea6a72445cb9a815e0a15bb048200a Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Mon, 11 May 2026 03:30:07 +0300 Subject: [PATCH] [AZ-371] Refactor C18: magic numbers to ProcessingConfig/MapConfig Promotes 8 operational levers into config keys with defaults that match the prior source literals byte-for-byte: ProcessingConfig: RegionProcessingTimeoutSeconds (300), RouteProcessingPollIntervalSeconds (5), MaxRoutePointSpacingMeters (200), LatLonTolerance (0.0001). MapConfig: TileSizePixels (256), AllowedZoomLevels ([15..19]), RetryBaseDelaySeconds (1), RetryMaxDelaySeconds (30). Sites updated: RegionService, RouteProcessingService, RoutePointGraphBuilder, RouteValidator, RouteService 4-arg ctor, RouteImageRenderer, GoogleMapsDownloaderV2, TileService. Closes LF-2 by forwarding HttpContext.RequestAborted from GetTileByLatLon into the downloader. appsettings.json gains the 8 new keys at default values. Tests: 141 / 141 unit + 5 / 5 smoke green. New ConfigDefaultsTests pins defaults to original literals; new TileService unit test asserts CT identity from caller to downloader (AZ-371 AC-3). Co-authored-by: Cursor --- SatelliteProvider.Api/Program.cs | 4 +- SatelliteProvider.Api/appsettings.json | 12 +- SatelliteProvider.Common/Configs/MapConfig.cs | 8 +- .../Configs/ProcessingConfig.cs | 6 + .../RegionService.cs | 5 +- .../RouteImageRenderer.cs | 21 ++- .../RoutePointGraphBuilder.cs | 12 +- .../RouteProcessingService.cs | 6 +- .../RouteService.cs | 7 +- .../RouteValidator.cs | 16 +- .../GoogleMapsDownloaderV2.cs | 36 ++-- .../TileService.cs | 9 +- .../ConfigDefaultsTests.cs | 62 ++++++ .../InfrastructureTests.cs | 5 +- SatelliteProvider.Tests/RegionServiceTests.cs | 3 +- .../RouteImageRendererTests.cs | 3 +- .../RoutePointGraphBuilderTests.cs | 27 ++- SatelliteProvider.Tests/RouteServiceTests.cs | 4 +- .../RouteValidatorTests.cs | 25 ++- SatelliteProvider.Tests/TileServiceTests.cs | 23 +++ _docs/03_implementation/batch_18_report.md | 178 ++++++++++++++++++ 21 files changed, 404 insertions(+), 68 deletions(-) create mode 100644 SatelliteProvider.Tests/ConfigDefaultsTests.cs create mode 100644 _docs/03_implementation/batch_18_report.md diff --git a/SatelliteProvider.Api/Program.cs b/SatelliteProvider.Api/Program.cs index 82fa873..2ad3ef4 100644 --- a/SatelliteProvider.Api/Program.cs +++ b/SatelliteProvider.Api/Program.cs @@ -161,9 +161,9 @@ async Task ServeTile(int z, int x, int y, HttpContext httpContext, ITil return Results.Bytes(tile.Bytes, tile.ContentType); } -async Task GetTileByLatLon([FromQuery] double Latitude, [FromQuery] double Longitude, [FromQuery] int ZoomLevel, ITileService tileService) +async Task GetTileByLatLon([FromQuery] double Latitude, [FromQuery] double Longitude, [FromQuery] int ZoomLevel, HttpContext httpContext, ITileService tileService) { - var tile = await tileService.DownloadAndStoreSingleTileAsync(Latitude, Longitude, ZoomLevel); + var tile = await tileService.DownloadAndStoreSingleTileAsync(Latitude, Longitude, ZoomLevel, httpContext.RequestAborted); var response = new DownloadTileResponse { diff --git a/SatelliteProvider.Api/appsettings.json b/SatelliteProvider.Api/appsettings.json index 31eb32c..4ad9f73 100644 --- a/SatelliteProvider.Api/appsettings.json +++ b/SatelliteProvider.Api/appsettings.json @@ -25,7 +25,11 @@ }, "MapConfig": { "Service": "GoogleMaps", - "ApiKey": "" + "ApiKey": "", + "TileSizePixels": 256, + "AllowedZoomLevels": [ 15, 16, 17, 18, 19 ], + "RetryBaseDelaySeconds": 1, + "RetryMaxDelaySeconds": 30 }, "StorageConfig": { "TilesDirectory": "./tiles", @@ -37,7 +41,11 @@ "DefaultZoomLevel": 20, "QueueCapacity": 1000, "DelayBetweenRequestsMs": 50, - "SessionTokenReuseCount": 100 + "SessionTokenReuseCount": 100, + "RegionProcessingTimeoutSeconds": 300, + "RouteProcessingPollIntervalSeconds": 5, + "MaxRoutePointSpacingMeters": 200.0, + "LatLonTolerance": 0.0001 }, "CorsConfig": { "AllowedOrigins": [] diff --git a/SatelliteProvider.Common/Configs/MapConfig.cs b/SatelliteProvider.Common/Configs/MapConfig.cs index e53a0f5..79b2d85 100644 --- a/SatelliteProvider.Common/Configs/MapConfig.cs +++ b/SatelliteProvider.Common/Configs/MapConfig.cs @@ -4,4 +4,10 @@ public class MapConfig { public string Service { get; set; } = null!; public string ApiKey { get; set; } = null!; -} \ No newline at end of file + + // AZ-371 / C18 — Google Maps tile constants promoted from source literals. + public int TileSizePixels { get; set; } = 256; + public int[] AllowedZoomLevels { get; set; } = new[] { 15, 16, 17, 18, 19 }; + public int RetryBaseDelaySeconds { get; set; } = 1; + public int RetryMaxDelaySeconds { get; set; } = 30; +} diff --git a/SatelliteProvider.Common/Configs/ProcessingConfig.cs b/SatelliteProvider.Common/Configs/ProcessingConfig.cs index 7480f83..fa515e2 100644 --- a/SatelliteProvider.Common/Configs/ProcessingConfig.cs +++ b/SatelliteProvider.Common/Configs/ProcessingConfig.cs @@ -8,5 +8,11 @@ public class ProcessingConfig public int QueueCapacity { get; set; } = 100; public int DelayBetweenRequestsMs { get; set; } = 50; public int SessionTokenReuseCount { get; set; } = 100; + + // AZ-371 / C18 — operational levers promoted from source literals. + public int RegionProcessingTimeoutSeconds { get; set; } = 300; + public int RouteProcessingPollIntervalSeconds { get; set; } = 5; + public double MaxRoutePointSpacingMeters { get; set; } = 200.0; + public double LatLonTolerance { get; set; } = 0.0001; } diff --git a/SatelliteProvider.Services.RegionProcessing/RegionService.cs b/SatelliteProvider.Services.RegionProcessing/RegionService.cs index 8983307..220dcae 100644 --- a/SatelliteProvider.Services.RegionProcessing/RegionService.cs +++ b/SatelliteProvider.Services.RegionProcessing/RegionService.cs @@ -19,6 +19,7 @@ public class RegionService : IRegionService private readonly IRegionRequestQueue _queue; private readonly ITileService _tileService; private readonly StorageConfig _storageConfig; + private readonly ProcessingConfig _processingConfig; private readonly ILogger _logger; public RegionService( @@ -26,12 +27,14 @@ public class RegionService : IRegionService IRegionRequestQueue queue, ITileService tileService, IOptions storageConfig, + IOptions processingConfig, ILogger logger) { _regionRepository = regionRepository; _queue = queue; _tileService = tileService; _storageConfig = storageConfig.Value; + _processingConfig = processingConfig.Value; _logger = logger; } @@ -102,7 +105,7 @@ public class RegionService : IRegionService region.UpdatedAt = DateTime.UtcNow; await _regionRepository.UpdateAsync(region); - using var timeoutCts = new CancellationTokenSource(TimeSpan.FromMinutes(5)); + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(_processingConfig.RegionProcessingTimeoutSeconds)); using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); string? errorMessage = null; diff --git a/SatelliteProvider.Services.RouteManagement/RouteImageRenderer.cs b/SatelliteProvider.Services.RouteManagement/RouteImageRenderer.cs index 6e2a637..7b10196 100644 --- a/SatelliteProvider.Services.RouteManagement/RouteImageRenderer.cs +++ b/SatelliteProvider.Services.RouteManagement/RouteImageRenderer.cs @@ -18,15 +18,16 @@ namespace SatelliteProvider.Services.RouteManagement; // AC-2 (pixel-for-pixel identical output for existing scenarios). public class RouteImageRenderer { - private const int TileSizePixels = 256; - private readonly StorageConfig _storageConfig; + private readonly int _tileSizePixels; private readonly ILogger _logger; - public RouteImageRenderer(IOptions storageConfig, ILogger logger) + public RouteImageRenderer(IOptions storageConfig, IOptions mapConfig, ILogger logger) { ArgumentNullException.ThrowIfNull(storageConfig); + ArgumentNullException.ThrowIfNull(mapConfig); _storageConfig = storageConfig.Value; + _tileSizePixels = mapConfig.Value.TileSizePixels; _logger = logger; } @@ -69,7 +70,7 @@ public class RouteImageRenderer var stitcher = new TileGridStitcher(); var result = await stitcher.StitchAsync( placements, - TileSizePixels, + _tileSizePixels, deduplicateByTileCoords: true, swallowTileLoadErrors: true, cancellationToken); @@ -97,10 +98,10 @@ public class RouteImageRenderer 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; + 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)); @@ -121,8 +122,8 @@ public class RouteImageRenderer 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; + var pixelX = (tileX - minX) * _tileSizePixels + _tileSizePixels / 2; + var pixelY = (tileY - minY) * _tileSizePixels + _tileSizePixels / 2; if (pixelX >= 0 && pixelX < imageWidth && pixelY >= 0 && pixelY < imageHeight) { diff --git a/SatelliteProvider.Services.RouteManagement/RoutePointGraphBuilder.cs b/SatelliteProvider.Services.RouteManagement/RoutePointGraphBuilder.cs index d216d9e..e130b0d 100644 --- a/SatelliteProvider.Services.RouteManagement/RoutePointGraphBuilder.cs +++ b/SatelliteProvider.Services.RouteManagement/RoutePointGraphBuilder.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Common.Utils; @@ -5,7 +7,13 @@ namespace SatelliteProvider.Services.RouteManagement; public class RoutePointGraphBuilder { - public const double MaxPointSpacingMeters = 200.0; + private readonly double _maxPointSpacingMeters; + + public RoutePointGraphBuilder(IOptions processingConfig) + { + ArgumentNullException.ThrowIfNull(processingConfig); + _maxPointSpacingMeters = processingConfig.Value.MaxRoutePointSpacingMeters; + } public RoutePointGraph Build(IReadOnlyList userPoints) { @@ -53,7 +61,7 @@ public class RoutePointGraphBuilder var next = userPoints[segmentIndex + 1]; var nextGeo = new GeoPoint(next.Latitude, next.Longitude); - var intermediates = GeoUtils.CalculateIntermediatePoints(currentGeo, nextGeo, MaxPointSpacingMeters); + var intermediates = GeoUtils.CalculateIntermediatePoints(currentGeo, nextGeo, _maxPointSpacingMeters); foreach (var intermediateGeo in intermediates) { diff --git a/SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs b/SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs index 67aedf0..bb710bd 100644 --- a/SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs +++ b/SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs @@ -1,5 +1,7 @@ using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Common.Interfaces; using SatelliteProvider.Common.Utils; @@ -26,7 +28,7 @@ public class RouteProcessingService : BackgroundService private readonly RegionFileCleaner _regionFileCleaner; private readonly RouteRegionMatcher _routeRegionMatcher; private readonly ILogger _logger; - private readonly TimeSpan _checkInterval = TimeSpan.FromSeconds(5); + private readonly TimeSpan _checkInterval; public RouteProcessingService( IRouteRepository routeRepository, @@ -37,6 +39,7 @@ public class RouteProcessingService : BackgroundService RouteImageRenderer routeImageRenderer, TilesZipBuilder tilesZipBuilder, RegionFileCleaner regionFileCleaner, + IOptions processingConfig, ILogger logger) { _routeRepository = routeRepository; @@ -48,6 +51,7 @@ public class RouteProcessingService : BackgroundService _tilesZipBuilder = tilesZipBuilder; _regionFileCleaner = regionFileCleaner; _routeRegionMatcher = new RouteRegionMatcher(); + _checkInterval = TimeSpan.FromSeconds(processingConfig.Value.RouteProcessingPollIntervalSeconds); _logger = logger; } diff --git a/SatelliteProvider.Services.RouteManagement/RouteService.cs b/SatelliteProvider.Services.RouteManagement/RouteService.cs index 415f95e..21ecc41 100644 --- a/SatelliteProvider.Services.RouteManagement/RouteService.cs +++ b/SatelliteProvider.Services.RouteManagement/RouteService.cs @@ -1,4 +1,6 @@ using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Common.Interfaces; using SatelliteProvider.DataAccess.Models; @@ -19,10 +21,11 @@ public class RouteService : IRouteService public RouteService( IRouteRepository routeRepository, IRegionService regionService, + IOptions processingConfig, ILogger logger) : this(routeRepository, regionService, logger, - new RouteValidator(), - new RoutePointGraphBuilder(), + new RouteValidator(processingConfig), + new RoutePointGraphBuilder(processingConfig), new GeofenceGridCalculator(), new RouteResponseMapper()) { diff --git a/SatelliteProvider.Services.RouteManagement/RouteValidator.cs b/SatelliteProvider.Services.RouteManagement/RouteValidator.cs index e893687..1eecf1f 100644 --- a/SatelliteProvider.Services.RouteManagement/RouteValidator.cs +++ b/SatelliteProvider.Services.RouteManagement/RouteValidator.cs @@ -1,9 +1,19 @@ +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; namespace SatelliteProvider.Services.RouteManagement; public class RouteValidator { + private readonly double _latLonTolerance; + + public RouteValidator(IOptions processingConfig) + { + ArgumentNullException.ThrowIfNull(processingConfig); + _latLonTolerance = processingConfig.Value.LatLonTolerance; + } + public void Validate(CreateRouteRequest request) { ArgumentNullException.ThrowIfNull(request); @@ -39,7 +49,7 @@ public class RouteValidator } } - private static void ValidatePolygon(GeofencePolygon polygon, List errors) + private void ValidatePolygon(GeofencePolygon polygon, List errors) { if (polygon.NorthWest is null || polygon.SouthEast is null) { @@ -50,8 +60,8 @@ public class RouteValidator var nw = polygon.NorthWest; var se = polygon.SouthEast; - if ((Math.Abs(nw.Lat) < 0.0001 && Math.Abs(nw.Lon) < 0.0001) || - (Math.Abs(se.Lat) < 0.0001 && Math.Abs(se.Lon) < 0.0001)) + if ((Math.Abs(nw.Lat) < _latLonTolerance && Math.Abs(nw.Lon) < _latLonTolerance) || + (Math.Abs(se.Lat) < _latLonTolerance && Math.Abs(se.Lon) < _latLonTolerance)) { errors.Add("Geofence polygon coordinates cannot be (0,0)"); } diff --git a/SatelliteProvider.Services.TileDownloader/GoogleMapsDownloaderV2.cs b/SatelliteProvider.Services.TileDownloader/GoogleMapsDownloaderV2.cs index 2189d01..ec63c2f 100644 --- a/SatelliteProvider.Services.TileDownloader/GoogleMapsDownloaderV2.cs +++ b/SatelliteProvider.Services.TileDownloader/GoogleMapsDownloaderV2.cs @@ -15,15 +15,12 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader { private const string TILE_URL_TEMPLATE = "https://mt{0}.google.com/vt/lyrs=s&x={1}&y={2}&z={3}&token={4}"; private const string USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.0.0 Safari/537.36"; - private const int TILE_SIZE_PIXELS = 256; - private const int MAX_RETRY_DELAY_SECONDS = 30; - private const int BASE_RETRY_DELAY_SECONDS = 1; - private static readonly int[] ALLOWED_ZOOM_LEVELS = { 15, 16, 17, 18, 19 }; - + private readonly ILogger _logger; private readonly string _apiKey; private readonly StorageConfig _storageConfig; private readonly ProcessingConfig _processingConfig; + private readonly MapConfig _mapConfig; private readonly IHttpClientFactory _httpClientFactory; private readonly SemaphoreSlim _downloadSemaphore; private static readonly System.Collections.Concurrent.ConcurrentDictionary> _activeDownloads = new(); @@ -36,7 +33,8 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader IHttpClientFactory httpClientFactory) { _logger = logger; - _apiKey = mapConfig.Value.ApiKey; + _mapConfig = mapConfig.Value; + _apiKey = _mapConfig.ApiKey; _storageConfig = storageConfig.Value; _processingConfig = processingConfig.Value; _httpClientFactory = httpClientFactory; @@ -84,9 +82,9 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader public async Task DownloadSingleTileAsync(double latitude, double longitude, int zoomLevel, CancellationToken token = default) { - if (!ALLOWED_ZOOM_LEVELS.Contains(zoomLevel)) + if (!_mapConfig.AllowedZoomLevels.Contains(zoomLevel)) { - throw new ArgumentException($"Zoom level {zoomLevel} is not allowed. Allowed zoom levels are: {string.Join(", ", ALLOWED_ZOOM_LEVELS)}", nameof(zoomLevel)); + throw new ArgumentException($"Zoom level {zoomLevel} is not allowed. Allowed zoom levels are: {string.Join(", ", _mapConfig.AllowedZoomLevels)}", nameof(zoomLevel)); } var geoPoint = new GeoPoint(latitude, longitude); @@ -137,18 +135,19 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader ); } - private static double CalculateTileSizeInMeters(int zoomLevel, double latitude) + private double CalculateTileSizeInMeters(int zoomLevel, double latitude) { const double EARTH_CIRCUMFERENCE_METERS = 40075016.686; + var tileSizePixels = _mapConfig.TileSizePixels; var latRad = latitude * Math.PI / 180.0; - var metersPerPixel = (EARTH_CIRCUMFERENCE_METERS * Math.Cos(latRad)) / (Math.Pow(2, zoomLevel) * TILE_SIZE_PIXELS); - return metersPerPixel * TILE_SIZE_PIXELS; + var metersPerPixel = (EARTH_CIRCUMFERENCE_METERS * Math.Cos(latRad)) / (Math.Pow(2, zoomLevel) * tileSizePixels); + return metersPerPixel * tileSizePixels; } private async Task ExecuteWithRetryAsync(Func> action, int maxRetries = 5, CancellationToken cancellationToken = default) { int attempt = 0; - int delay = BASE_RETRY_DELAY_SECONDS; + int delay = _mapConfig.RetryBaseDelaySeconds; Exception? lastException = null; while (attempt < maxRetries) @@ -178,7 +177,7 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader throw new RateLimitException($"Rate limit exceeded after {maxRetries} attempts. Google Maps API is throttling requests."); } - delay = Math.Min(delay * 2, MAX_RETRY_DELAY_SECONDS); + delay = Math.Min(delay * 2, _mapConfig.RetryMaxDelaySeconds); _logger.LogWarning("Rate limited (429 Too Many Requests). Waiting {Delay}s before retry {Attempt}/{Max}", delay, attempt, maxRetries); await Task.Delay(TimeSpan.FromSeconds(delay), cancellationToken); } @@ -203,7 +202,7 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader throw; } - delay = Math.Min(delay * 2, MAX_RETRY_DELAY_SECONDS); + delay = Math.Min(delay * 2, _mapConfig.RetryMaxDelaySeconds); _logger.LogWarning("Server error ({StatusCode}). Waiting {Delay}s before retry {Attempt}/{Max}", ex.StatusCode, delay, attempt, maxRetries); await Task.Delay(TimeSpan.FromSeconds(delay), cancellationToken); } @@ -229,9 +228,9 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader IEnumerable existingTiles, CancellationToken token = default) { - if (!ALLOWED_ZOOM_LEVELS.Contains(zoomLevel)) + if (!_mapConfig.AllowedZoomLevels.Contains(zoomLevel)) { - throw new ArgumentException($"Zoom level {zoomLevel} is not allowed. Allowed zoom levels are: {string.Join(", ", ALLOWED_ZOOM_LEVELS)}", nameof(zoomLevel)); + throw new ArgumentException($"Zoom level {zoomLevel} is not allowed. Allowed zoom levels are: {string.Join(", ", _mapConfig.AllowedZoomLevels)}", nameof(zoomLevel)); } var (latMin, latMax, lonMin, lonMax) = GeoUtils.GetBoundingBox(centerGeoPoint, radiusM); @@ -248,9 +247,10 @@ public class GoogleMapsDownloaderV2 : ISatelliteDownloader { var tileCenter = GeoUtils.TileToWorldPos(x, y, zoomLevel); + var tolerance = _processingConfig.LatLonTolerance; var existingTile = existingTiles.FirstOrDefault(t => - Math.Abs(t.Latitude - tileCenter.Lat) < 0.0001 && - Math.Abs(t.Longitude - tileCenter.Lon) < 0.0001 && + Math.Abs(t.Latitude - tileCenter.Lat) < tolerance && + Math.Abs(t.Longitude - tileCenter.Lon) < tolerance && t.TileZoom == zoomLevel); if (existingTile != null) diff --git a/SatelliteProvider.Services.TileDownloader/TileService.cs b/SatelliteProvider.Services.TileDownloader/TileService.cs index 7d83b7f..0d9934b 100644 --- a/SatelliteProvider.Services.TileDownloader/TileService.cs +++ b/SatelliteProvider.Services.TileDownloader/TileService.cs @@ -1,5 +1,7 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Common.Interfaces; using SatelliteProvider.Common.Utils; @@ -18,17 +20,20 @@ public class TileService : ITileService private readonly ISatelliteDownloader _downloader; private readonly ITileRepository _tileRepository; private readonly IMemoryCache _cache; + private readonly MapConfig _mapConfig; private readonly ILogger _logger; public TileService( ISatelliteDownloader downloader, ITileRepository tileRepository, IMemoryCache cache, + IOptions mapConfig, ILogger logger) { _downloader = downloader; _tileRepository = tileRepository; _cache = cache; + _mapConfig = mapConfig.Value; _logger = logger; } @@ -135,7 +140,7 @@ public class TileService : ITileService return MapToMetadata(entity); } - private static TileEntity BuildTileEntity(DownloadedTileInfoV2 downloaded) + private TileEntity BuildTileEntity(DownloadedTileInfoV2 downloaded) { var now = DateTime.UtcNow; return new TileEntity @@ -147,7 +152,7 @@ public class TileService : ITileService Latitude = downloaded.CenterLatitude, Longitude = downloaded.CenterLongitude, TileSizeMeters = downloaded.TileSizeMeters, - TileSizePixels = 256, + TileSizePixels = _mapConfig.TileSizePixels, ImageType = "jpg", MapsVersion = $"downloaded_{now:yyyy-MM-dd}", Version = null, diff --git a/SatelliteProvider.Tests/ConfigDefaultsTests.cs b/SatelliteProvider.Tests/ConfigDefaultsTests.cs new file mode 100644 index 0000000..34e226b --- /dev/null +++ b/SatelliteProvider.Tests/ConfigDefaultsTests.cs @@ -0,0 +1,62 @@ +using FluentAssertions; +using SatelliteProvider.Common.Configs; + +namespace SatelliteProvider.Tests; + +// AZ-371 / C18 — verifies the config defaults preserve the original literal values +// that lived in source code prior to the magic-numbers-to-config refactor. +public class ConfigDefaultsTests +{ + [Fact] + public void ProcessingConfig_RegionProcessingTimeout_PreservesOriginal_AZ371_AC2() + { + // Assert + new ProcessingConfig().RegionProcessingTimeoutSeconds.Should().Be(300, "RegionService used TimeSpan.FromMinutes(5) before AZ-371"); + } + + [Fact] + public void ProcessingConfig_RouteProcessingPollInterval_PreservesOriginal_AZ371_AC2() + { + // Assert + new ProcessingConfig().RouteProcessingPollIntervalSeconds.Should().Be(5, "RouteProcessingService polled every 5s before AZ-371"); + } + + [Fact] + public void ProcessingConfig_MaxRoutePointSpacingMeters_PreservesOriginal_AZ371_AC2() + { + // Assert + new ProcessingConfig().MaxRoutePointSpacingMeters.Should().Be(200.0, "RoutePointGraphBuilder used 200m spacing before AZ-371"); + } + + [Fact] + public void ProcessingConfig_LatLonTolerance_PreservesOriginal_AZ371_AC2() + { + // Assert + new ProcessingConfig().LatLonTolerance.Should().Be(0.0001, "RouteValidator and GoogleMapsDownloaderV2 used 0.0001 before AZ-371"); + } + + [Fact] + public void MapConfig_TileSizePixels_PreservesOriginal_AZ371_AC2() + { + // Assert + new MapConfig().TileSizePixels.Should().Be(256, "TileService and GoogleMapsDownloaderV2 used 256 px before AZ-371"); + } + + [Fact] + public void MapConfig_AllowedZoomLevels_PreservesOriginal_AZ371_AC2() + { + // Assert + new MapConfig().AllowedZoomLevels.Should().Equal(15, 16, 17, 18, 19); + } + + [Fact] + public void MapConfig_RetryDelays_PreserveOriginal_AZ371_AC2() + { + // Arrange + var cfg = new MapConfig(); + + // Assert + cfg.RetryBaseDelaySeconds.Should().Be(1); + cfg.RetryMaxDelaySeconds.Should().Be(30); + } +} diff --git a/SatelliteProvider.Tests/InfrastructureTests.cs b/SatelliteProvider.Tests/InfrastructureTests.cs index ed930bc..c5ac010 100644 --- a/SatelliteProvider.Tests/InfrastructureTests.cs +++ b/SatelliteProvider.Tests/InfrastructureTests.cs @@ -1,7 +1,9 @@ using FluentAssertions; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using Moq; +using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Common.Interfaces; using SatelliteProvider.DataAccess.Models; @@ -65,7 +67,8 @@ public class InfrastructureTests var logger = NullLogger.Instance; // Act - var service = new TileService(downloader, tileRepo, cache, logger); + var mapConfig = Options.Create(new MapConfig { Service = "GoogleMaps", ApiKey = "" }); + var service = new TileService(downloader, tileRepo, cache, mapConfig, logger); // Assert service.Should().NotBeNull(); diff --git a/SatelliteProvider.Tests/RegionServiceTests.cs b/SatelliteProvider.Tests/RegionServiceTests.cs index e9e32a3..40cc808 100644 --- a/SatelliteProvider.Tests/RegionServiceTests.cs +++ b/SatelliteProvider.Tests/RegionServiceTests.cs @@ -36,7 +36,8 @@ public class RegionServiceTests : IDisposable Mock tileService) { var storage = Options.Create(new StorageConfig { ReadyDirectory = _readyDir, TilesDirectory = "/tiles" }); - return new RegionService(regionRepo.Object, queue.Object, tileService.Object, storage, NullLogger.Instance); + var processing = Options.Create(new ProcessingConfig()); + return new RegionService(regionRepo.Object, queue.Object, tileService.Object, storage, processing, NullLogger.Instance); } [Fact] diff --git a/SatelliteProvider.Tests/RouteImageRendererTests.cs b/SatelliteProvider.Tests/RouteImageRendererTests.cs index ff5f776..3d835ec 100644 --- a/SatelliteProvider.Tests/RouteImageRendererTests.cs +++ b/SatelliteProvider.Tests/RouteImageRendererTests.cs @@ -17,7 +17,8 @@ public class RouteImageRendererTests { loggerMock = new Mock>(); var storageOptions = Options.Create(new StorageConfig()); - return new RouteImageRenderer(storageOptions, loggerMock.Object); + var mapOptions = Options.Create(new MapConfig { Service = "GoogleMaps", ApiKey = "" }); + return new RouteImageRenderer(storageOptions, mapOptions, loggerMock.Object); } private static void VerifyWarningLogged(Mock> loggerMock, string substringInState) diff --git a/SatelliteProvider.Tests/RoutePointGraphBuilderTests.cs b/SatelliteProvider.Tests/RoutePointGraphBuilderTests.cs index 2d04daf..e1f5013 100644 --- a/SatelliteProvider.Tests/RoutePointGraphBuilderTests.cs +++ b/SatelliteProvider.Tests/RoutePointGraphBuilderTests.cs @@ -1,4 +1,6 @@ using FluentAssertions; +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Common.Utils; using SatelliteProvider.Services.RouteManagement; @@ -8,13 +10,18 @@ namespace SatelliteProvider.Tests; public class RoutePointGraphBuilderTests { + private static readonly ProcessingConfig DefaultProcessingConfig = new(); + + private static RoutePointGraphBuilder MakeBuilder() => + new(Options.Create(new ProcessingConfig())); + private static List ToRoutePoints(IEnumerable<(double Lat, double Lon)> points) => points.Select(p => new RoutePoint { Latitude = p.Lat, Longitude = p.Lon }).ToList(); [Fact] public void Build_TwoUserPoints_FirstIsStart_LastIsEnd_BetweenAreIntermediate() { - var sut = new RoutePointGraphBuilder(); + var sut = MakeBuilder(); var input = ToRoutePoints(TestCoordinates.Route.Route01Points); var graph = sut.Build(input); @@ -28,7 +35,7 @@ public class RoutePointGraphBuilderTests [Fact] public void Build_ConsecutivePointsRespectMaxSpacing() { - var sut = new RoutePointGraphBuilder(); + var sut = MakeBuilder(); var input = ToRoutePoints(TestCoordinates.Route.Route01Points); var graph = sut.Build(input); @@ -40,15 +47,15 @@ public class RoutePointGraphBuilderTests var distance = GeoUtils.CalculateDistance( new GeoPoint(prev.Latitude, prev.Longitude), new GeoPoint(cur.Latitude, cur.Longitude)); - distance.Should().BeLessThanOrEqualTo(RoutePointGraphBuilder.MaxPointSpacingMeters + 0.5, - $"point {i - 1}→{i} must be ≤{RoutePointGraphBuilder.MaxPointSpacingMeters}m"); + distance.Should().BeLessThanOrEqualTo(DefaultProcessingConfig.MaxRoutePointSpacingMeters + 0.5, + $"point {i - 1}→{i} must be ≤{DefaultProcessingConfig.MaxRoutePointSpacingMeters}m"); } } [Fact] public void Build_TenPointRoute_HasOneStartOneEndAndEightAction() { - var sut = new RoutePointGraphBuilder(); + var sut = MakeBuilder(); var input = ToRoutePoints(TestCoordinates.Route.Route04Points); var graph = sut.Build(input); @@ -62,7 +69,7 @@ public class RoutePointGraphBuilderTests [Fact] public void Build_TotalDistanceEqualsSumOfHaversineSegments() { - var sut = new RoutePointGraphBuilder(); + var sut = MakeBuilder(); var input = ToRoutePoints(TestCoordinates.Route.Route01Points); var graph = sut.Build(input); @@ -83,7 +90,7 @@ public class RoutePointGraphBuilderTests [Fact] public void Build_SequenceNumbersAreContiguousAndStartAtZero() { - var sut = new RoutePointGraphBuilder(); + var sut = MakeBuilder(); var input = ToRoutePoints(TestCoordinates.Route.Route04Points); var graph = sut.Build(input); @@ -95,7 +102,7 @@ public class RoutePointGraphBuilderTests [Fact] public void Build_FirstPointHasNullDistanceFromPrevious() { - var sut = new RoutePointGraphBuilder(); + var sut = MakeBuilder(); var input = ToRoutePoints(TestCoordinates.Route.Route01Points); var graph = sut.Build(input); @@ -107,7 +114,7 @@ public class RoutePointGraphBuilderTests [Fact] public void Build_FewerThanTwoPoints_Throws() { - var sut = new RoutePointGraphBuilder(); + var sut = MakeBuilder(); var input = new List { new() { Latitude = 47.46, Longitude = 37.64 } }; Action act = () => sut.Build(input); @@ -118,7 +125,7 @@ public class RoutePointGraphBuilderTests [Fact] public void Build_NullInput_Throws() { - var sut = new RoutePointGraphBuilder(); + var sut = MakeBuilder(); Action act = () => sut.Build(null!); diff --git a/SatelliteProvider.Tests/RouteServiceTests.cs b/SatelliteProvider.Tests/RouteServiceTests.cs index 45b7fd0..72d7fc4 100644 --- a/SatelliteProvider.Tests/RouteServiceTests.cs +++ b/SatelliteProvider.Tests/RouteServiceTests.cs @@ -1,6 +1,8 @@ using FluentAssertions; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using Moq; +using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Common.Interfaces; using SatelliteProvider.Common.Utils; @@ -17,7 +19,7 @@ public class RouteServiceTests Mock routeRepo, Mock regionService) { - return new RouteService(routeRepo.Object, regionService.Object, NullLogger.Instance); + return new RouteService(routeRepo.Object, regionService.Object, Options.Create(new ProcessingConfig()), NullLogger.Instance); } private static CreateRouteRequest BuildRequest(IEnumerable<(double Lat, double Lon)> points, double regionSize = 500, int zoom = 18, bool requestMaps = false, Geofences? geofences = null) diff --git a/SatelliteProvider.Tests/RouteValidatorTests.cs b/SatelliteProvider.Tests/RouteValidatorTests.cs index 0799dca..d0a961e 100644 --- a/SatelliteProvider.Tests/RouteValidatorTests.cs +++ b/SatelliteProvider.Tests/RouteValidatorTests.cs @@ -1,4 +1,6 @@ using FluentAssertions; +using Microsoft.Extensions.Options; +using SatelliteProvider.Common.Configs; using SatelliteProvider.Common.DTO; using SatelliteProvider.Services.RouteManagement; using SatelliteProvider.Tests.Fixtures; @@ -7,6 +9,9 @@ namespace SatelliteProvider.Tests; public class RouteValidatorTests { + private static RouteValidator MakeValidator() => + new(Options.Create(new ProcessingConfig())); + private static CreateRouteRequest BuildValidRequest() { return new CreateRouteRequest @@ -25,7 +30,7 @@ public class RouteValidatorTests [Fact] public void Validate_ValidRequest_DoesNotThrow_AZ365_AC2() { - var sut = new RouteValidator(); + var sut = MakeValidator(); var request = BuildValidRequest(); Action act = () => sut.Validate(request); @@ -36,7 +41,7 @@ public class RouteValidatorTests [Fact] public void Validate_FewerThanTwoPoints_Throws() { - var sut = new RouteValidator(); + var sut = MakeValidator(); var request = BuildValidRequest(); request.Points = new List { new() { Latitude = 47.46, Longitude = 37.64 } }; @@ -48,7 +53,7 @@ public class RouteValidatorTests [Fact] public void Validate_RegionSizeOutOfRange_Throws() { - var sut = new RouteValidator(); + var sut = MakeValidator(); var request = BuildValidRequest(); request.RegionSizeMeters = 50; @@ -61,7 +66,7 @@ public class RouteValidatorTests [Fact] public void Validate_BlankName_Throws() { - var sut = new RouteValidator(); + var sut = MakeValidator(); var request = BuildValidRequest(); request.Name = " "; @@ -73,7 +78,7 @@ public class RouteValidatorTests [Fact] public void Validate_GeofencePolygonZeroZero_Throws() { - var sut = new RouteValidator(); + var sut = MakeValidator(); var request = BuildValidRequest(); request.Geofences = new Geofences { @@ -92,7 +97,7 @@ public class RouteValidatorTests [Fact] public void Validate_GeofenceInvertedLatitudes_Throws() { - var sut = new RouteValidator(); + var sut = MakeValidator(); var request = BuildValidRequest(); request.Geofences = new Geofences { @@ -114,7 +119,7 @@ public class RouteValidatorTests [Fact] public void Validate_NullPolygonCorner_Throws() { - var sut = new RouteValidator(); + var sut = MakeValidator(); var request = BuildValidRequest(); request.Geofences = new Geofences { @@ -133,7 +138,7 @@ public class RouteValidatorTests [Fact] public void Validate_OutOfRangeLatitude_Throws() { - var sut = new RouteValidator(); + var sut = MakeValidator(); var request = BuildValidRequest(); request.Geofences = new Geofences { @@ -156,7 +161,7 @@ public class RouteValidatorTests [Fact] public void Validate_MultipleErrors_AggregatesIntoSingleException_AZ365_AC2() { - var sut = new RouteValidator(); + var sut = MakeValidator(); var request = BuildValidRequest(); request.Name = ""; request.RegionSizeMeters = 50; @@ -175,7 +180,7 @@ public class RouteValidatorTests [Fact] public void Validate_NullRequest_Throws() { - var sut = new RouteValidator(); + var sut = MakeValidator(); Action act = () => sut.Validate(null!); diff --git a/SatelliteProvider.Tests/TileServiceTests.cs b/SatelliteProvider.Tests/TileServiceTests.cs index f32ec2b..31c6f22 100644 --- a/SatelliteProvider.Tests/TileServiceTests.cs +++ b/SatelliteProvider.Tests/TileServiceTests.cs @@ -24,6 +24,7 @@ public class TileServiceTests downloader.Object, tileRepo.Object, cache ?? new MemoryCache(new MemoryCacheOptions()), + Options.Create(new MapConfig { Service = "GoogleMaps", ApiKey = "" }), NullLogger.Instance); } @@ -372,6 +373,28 @@ public class TileServiceTests tileRepo.Verify(r => r.InsertAsync(It.IsAny()), Times.Once); } + [Fact] + public async Task DownloadAndStoreSingleTileAsync_ForwardsCancellationTokenToDownloader_AZ371_AC3() + { + // Arrange + const int zoom = 18; + var downloader = new Mock(); + var tileRepo = new Mock(); + CancellationToken capturedToken = default; + downloader + .Setup(d => d.DownloadSingleTileAsync(It.IsAny(), It.IsAny(), zoom, It.IsAny())) + .Callback((_, _, _, ct) => capturedToken = ct) + .ReturnsAsync(new DownloadedTileInfoV2(1, 2, zoom, 0, 0, "p.jpg", 100.0)); + var service = BuildService(downloader, tileRepo); + using var cts = new CancellationTokenSource(); + + // Act + await service.DownloadAndStoreSingleTileAsync(0, 0, zoom, cts.Token); + + // Assert + capturedToken.Should().Be(cts.Token, "AZ-371 AC-3: caller-supplied CT must reach the downloader"); + } + [Fact] public async Task DownloadAndStoreSingleTileAsync_DownloaderThrows_DoesNotInsert_AZ311_AC2b() { diff --git a/_docs/03_implementation/batch_18_report.md b/_docs/03_implementation/batch_18_report.md new file mode 100644 index 0000000..e5c26c5 --- /dev/null +++ b/_docs/03_implementation/batch_18_report.md @@ -0,0 +1,178 @@ +# Batch 18 Report — Refactor 03 Phase 4 (kickoff) + +Date: 2026-05-11 +Epic: AZ-350 (03-code-quality-refactoring) +Status: ✅ Complete + +## Scope (1 task / 3 SP) + +| ID | C-ID | Title | Points | Component | +|----|------|-------|--------|-----------| +| AZ-371 | C18 | Move hardcoded magic numbers to ProcessingConfig / MapConfig | 3 | Common + Services.* (all) | + +Solo batch. First task of Phase 4 (typing/config/tooling). Promotes operational +literals scattered across 5 service files into explicit `ProcessingConfig` / +`MapConfig` keys. Defaults preserve every prior runtime value byte-for-byte. +Also closes the LF-2 finding by forwarding `HttpContext.RequestAborted` from +`GetTileByLatLon` into the downloader. + +## Changes + +### Production + +- **MODIFIED** `SatelliteProvider.Common/Configs/ProcessingConfig.cs` + - Added 4 keys with defaults that match prior source literals: + - `RegionProcessingTimeoutSeconds = 300` (was `TimeSpan.FromMinutes(5)`) + - `RouteProcessingPollIntervalSeconds = 5` (was `TimeSpan.FromSeconds(5)`) + - `MaxRoutePointSpacingMeters = 200.0` (was the `public const`) + - `LatLonTolerance = 0.0001` (was duplicated in 2 sites) + +- **MODIFIED** `SatelliteProvider.Common/Configs/MapConfig.cs` + - Added 4 keys with defaults that match prior source literals: + - `TileSizePixels = 256` + - `AllowedZoomLevels = [15, 16, 17, 18, 19]` + - `RetryBaseDelaySeconds = 1` + - `RetryMaxDelaySeconds = 30` + +- **MODIFIED** `SatelliteProvider.Api/appsettings.json` + - Added the 8 new keys under `MapConfig` and `ProcessingConfig`. Values match + `Common.Configs` defaults. `appsettings.Development.json` left untouched — + no environment-specific overrides for these levers yet. + +- **MODIFIED** `SatelliteProvider.Services.RegionProcessing/RegionService.cs` + - Constructor now takes `IOptions processingConfig`. + - The 5-minute region-processing timeout in `ProcessRegionAsync` is now + `TimeSpan.FromSeconds(_processingConfig.RegionProcessingTimeoutSeconds)`. + +- **MODIFIED** `SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs` + - Constructor now takes `IOptions processingConfig`. + - `_checkInterval` is no longer a `readonly` initializer literal; it's + set in the constructor from the config. + +- **MODIFIED** `SatelliteProvider.Services.RouteManagement/RoutePointGraphBuilder.cs` + - Single ctor `(IOptions)`; the previous + parameterless ctor + `public const MaxPointSpacingMeters` are gone. + - The const lived briefly to replace the inline literal in `RouteService` + (batch 15); promoting it the rest of the way to config is the natural + completion of that move. + +- **MODIFIED** `SatelliteProvider.Services.RouteManagement/RouteValidator.cs` + - Constructor now requires `IOptions`; reads + `LatLonTolerance` once and reuses it for the polygon (0,0) check. + - `ValidatePolygon` is no longer `static` because it reads instance state. + +- **MODIFIED** `SatelliteProvider.Services.RouteManagement/RouteService.cs` + - The 4-arg DI ctor (`IRouteRepository, IRegionService, IOptions, ILogger`) + is the production-callable ctor; the chained 8-arg ctor used by the + decomposition still accepts the helpers directly. The 4-arg ctor + constructs `RouteValidator` and `RoutePointGraphBuilder` with the same + `IOptions` reference. + +- **MODIFIED** `SatelliteProvider.Services.RouteManagement/RouteImageRenderer.cs` + - **Adjacent hygiene**: the `private const int TileSizePixels = 256` that + was extracted from `RouteProcessingService` in batch 17 is now read from + `_mapConfig.TileSizePixels`. Same operational lever as the `TileService` + site — leaving one configurable and the other hardcoded would be an + immediate cumulative-review finding (cross-task duplicate). + +- **MODIFIED** `SatelliteProvider.Services.TileDownloader/GoogleMapsDownloaderV2.cs` + - The 4 `private const`s (`TILE_SIZE_PIXELS`, `MAX_RETRY_DELAY_SECONDS`, + `BASE_RETRY_DELAY_SECONDS`, `ALLOWED_ZOOM_LEVELS`) are gone. + - `MapConfig` was already injected; now `_mapConfig` field stores the + resolved `MapConfig.Value` and the 4 sites consume `_mapConfig.*`. + - `CalculateTileSizeInMeters` is no longer `static` (reads `_mapConfig`). + - The `0.0001` lat/lon tolerance in `GetTilesWithMetadataAsync` now reads + `_processingConfig.LatLonTolerance` once per call and reuses it in the + closure. + +- **MODIFIED** `SatelliteProvider.Services.TileDownloader/TileService.cs` + - Constructor now takes `IOptions mapConfig`. + - `BuildTileEntity` becomes an instance method (reads `_mapConfig`); the + `TileSizePixels = 256` literal becomes `_mapConfig.TileSizePixels`. + +- **MODIFIED** `SatelliteProvider.Api/Program.cs` + - `GetTileByLatLon` now accepts `HttpContext httpContext` and forwards + `httpContext.RequestAborted` into `DownloadAndStoreSingleTileAsync`. + - This closes LF-2 (logical-flow finding from the discovery phase): client + cancellations now propagate through the `/api/satellite/tiles/latlon` + endpoint into the downloader. + +### Tests + +- **NEW** `SatelliteProvider.Tests/ConfigDefaultsTests.cs` — 7 tests that + pin the new config defaults to the original source literals (AC-2 evidence + — defaults preserve behavior is the load-bearing claim of this batch). +- **MODIFIED** `SatelliteProvider.Tests/TileServiceTests.cs` + - Added 1 new test `DownloadAndStoreSingleTileAsync_ForwardsCancellationTokenToDownloader_AZ371_AC3` + (AC-3 evidence — caller-supplied CT reaches the downloader). + - Updated `BuildService` to construct `TileService` with the new + `IOptions` argument. +- **MODIFIED** `SatelliteProvider.Tests/InfrastructureTests.cs` + - Updated the `TileService_ConstructsWithMockedDependencies` smoke test to + provide the new `IOptions`. +- **MODIFIED** `SatelliteProvider.Tests/RegionServiceTests.cs` + - `BuildService` now also threads `IOptions`. +- **MODIFIED** `SatelliteProvider.Tests/RouteServiceTests.cs` + - `BuildService` now also threads `IOptions`. +- **MODIFIED** `SatelliteProvider.Tests/RoutePointGraphBuilderTests.cs` + - All 8 `new RoutePointGraphBuilder()` calls go through a `MakeBuilder()` + helper that wraps the new options ctor. + - The two assertions that referenced `RoutePointGraphBuilder.MaxPointSpacingMeters` + now read `DefaultProcessingConfig.MaxRoutePointSpacingMeters`. +- **MODIFIED** `SatelliteProvider.Tests/RouteValidatorTests.cs` + - All 9 `new RouteValidator()` calls go through `MakeValidator()`. +- **MODIFIED** `SatelliteProvider.Tests/RouteImageRendererTests.cs` + - `BuildSut` now also threads `IOptions`. + +## Verification + +| Check | Result | +|-------|--------| +| Unit tests | ✅ 141 / 141 pass (was 133; +7 ConfigDefaultsTests, +1 AZ-371 AC-3 CT test) | +| Integration smoke | ✅ All scenarios pass; exit 0 | +| Behavior preservation | ✅ Defaults match prior literals byte-for-byte | +| Linter | ✅ No findings on any modified file | + +## AC Coverage + +| AC | Evidence | +|----|----------| +| AC-1: All listed magic numbers replaced by config-bound values | Grep for `FromMinutes(5)`, `TILE_SIZE_PIXELS`, `MAX_RETRY`, `BASE_RETRY`, `ALLOWED_ZOOM`, `200.0`, `0.0001`, `TileSizePixels = 256` across `SatelliteProvider.Services.*/**/*.cs` returns no matches. The remaining `_tileSizePixels` references are `MapConfig`-bound. | +| AC-2: Defaults preserve all existing behavior | `ConfigDefaultsTests` (7 tests) pins each new default to the original literal value. Smoke suite passes — region pipeline, route pipeline, tile downloads all behave identically. | +| AC-3: `GetTileByLatLon` request cancellation flows into the downloader | `Program.cs:165` now passes `httpContext.RequestAborted` into `DownloadAndStoreSingleTileAsync`. `TileServiceTests.DownloadAndStoreSingleTileAsync_ForwardsCancellationTokenToDownloader_AZ371_AC3` captures the CT through the mock and asserts identity. | + +## Inline Code Review + +This batch is a config-binding refactor. The risk surface: + +1. **Default-value drift** — every default in `ProcessingConfig` and `MapConfig` + was set side-by-side with the original literal in source and verified by + `ConfigDefaultsTests`. No drift possible. +2. **Constructor fan-out** — 5 production classes gained ctor params. Only the + already-DI-resolved consumers (`RouteService` 4-arg, `RegionService`, + `TileService`, `RouteProcessingService`, `GoogleMapsDownloaderV2`) needed + `IServiceCollection` calls — and those are unchanged because the existing + `services.AddSingleton(...)` lines bind via reflection. The two helper + classes that tests `new` directly (`RouteValidator`, `RoutePointGraphBuilder`) + now require `IOptions` — every call site is updated. +3. **Static → instance demotions** — three methods became instance methods + (`TileService.BuildTileEntity`, `GoogleMapsDownloaderV2.CalculateTileSizeInMeters`, + `RouteValidator.ValidatePolygon`). Each one now reads instance state, so + the demotion is correct per `coderule.mdc` ("Only use static methods for + pure, self-contained computations"). +4. **CT plumbing** (LF-2) — endpoint local function gained `HttpContext` + parameter; minimal API binding picks it up automatically. Smoke covers + the happy path; the new unit test pins the contract on `TileService`. + +**Verdict: PASS.** No findings. Behavior preserved end-to-end. + +## State + +`auto_push: true`. After this commit lands, transitions on AZ-371 → In Testing +in Jira and the task file moves from `todo/` to `done/`. Batch 18 closes the +window for the cumulative K=3 review covering batches 16–18 — that runs next. + +## Next Batch (preview) + +Phase 4 ordering: AZ-370 (status / point-type enums + AC RT2 update, 3 SP). +After the cumulative K=3 review for 16–18.