# Batch 17 Report — Refactor 03 Phase 3 (final structural cleanup) Date: 2026-05-11 Epic: AZ-350 (03-code-quality-refactoring) Status: ✅ Complete ## Scope (1 task / 5 SP, folds AZ-360 / 2 SP) | ID | C-ID | Title | Points | Component | |----|------|-------|--------|-----------| | AZ-364 | C11 | Decompose `RouteProcessingService` god-class into 6 collaborators | 5 | Services.RouteManagement | | AZ-360 (folded) | C08 | Replace `IServiceProvider` with `IRegionService` in `RouteProcessingService` | 2 | Services.RouteManagement | Solo batch (per the dependency table — AZ-364 explicitly folds C08). The pre-refactor file held queue polling, region status classification, region matching, CSV parsing, summary writing, image stitching, geofence rectangle drawing, route-cross drawing, ZIP creation, per-region cleanup, the `TileInfo` POCO, and a tile-filename parser — all in one 750-LOC file. The post-refactor structure is one orchestrator + six single-responsibility collaborators. ## Changes ### Production - **NEW** `SatelliteProvider.Services.RouteManagement/TileInfo.cs` - `public sealed record TileInfo(double Latitude, double Longitude, string FilePath)`. Moved out of the trailing public class in `RouteProcessingService.cs`. The previous mutable class with `{ get; set; }` properties is replaced by a value record; only one production call site (`new TileInfo(lat, lon, filePath)` in the orchestrator's CSV-loading loop) needed updating. - **NEW** `SatelliteProvider.Services.RouteManagement/RouteRegionMatcher.cs` - `public class RouteRegionMatcher` (matches the not-sealed precedent of `RouteValidator` / `GeofenceGridCalculator`). - `Match(IReadOnlyList, IReadOnlyList) -> List`. - Pure: no logger, no state, no I/O. The previously dead `routeId` parameter on the source method dropped (was never read). - One-shot consumption preserved (`availableRegions.Remove(matchedRegion)` after each match). - **NEW** `SatelliteProvider.Services.RouteManagement/RouteCsvWriter.cs` - DI-registered singleton; takes `IOptions` + `ILogger`. - `WriteAsync(routeId, IEnumerable, ct) -> string` — owns the `route__ready.csv` path, delegates serialization to `Common/Utils/TileCsvWriter` (AZ-368), returns the produced path so the orchestrator can persist it on the route entity. - Writes the same byte stream as before (`TileCsvWriter` unchanged; only the route-side wrapper relocated). - **NEW** `SatelliteProvider.Services.RouteManagement/RouteSummaryWriter.cs` - DI-registered singleton; takes `IOptions` + `ILogger`. - `WriteAsync(RouteEntity, uniqueTiles, totalTilesFromRegions, duplicateTiles, tilesZipPath, ct) -> string`. - StringBuilder block carried over verbatim — every `summary.AppendLine(...)` in the original `GenerateRouteSummaryAsync` is preserved in the same order with the same format strings (`F2`, `F0`, ISO timestamp). AC-2 byte-equivalence rests on this. - **NEW** `SatelliteProvider.Services.RouteManagement/TilesZipBuilder.cs` - DI-registered singleton; takes `IOptions` + `ILogger`. - `BuildAsync(routeId, IEnumerable, ct) -> Task` — wraps `Task.Run` (preserves the off-thread compression behavior). - Entry-name resolution rules preserved verbatim: full-path-under-tiles-dir → `tiles/` with `/` separator; otherwise → `tiles/`. - Existing zip overwritten via `File.Delete` then `ZipFile.Open(..., Create)` — same as before. - **NEW** `SatelliteProvider.Services.RouteManagement/RegionFileCleaner.cs` - DI-registered singleton; takes `IOptions` + `ILogger`. - `CleanupAsync(IEnumerable, ct) -> Task` — accepts already-fetched regions (no `IRegionRepository` dependency), the orchestrator passes them in. This is a slight contract clean-up: the original method took `IEnumerable` and re-fetched each region via the repository even though the orchestrator already had the full list in memory. - Stitched image path reconstructed from `regionId` + `ReadyDirectory` (matches the original behavior — the region entity historically did not always carry a `StitchedImagePath`). - Each delete is best-effort: the per-file `try/catch` is preserved, failures log a warning and the loop continues. - **NEW** `SatelliteProvider.Services.RouteManagement/RouteImageRenderer.cs` - DI-registered singleton; takes `IOptions` + `ILogger`. - `RenderAsync(routeId, IReadOnlyList, zoomLevel, geofencePolygonBounds, routePoints, ct) -> Task` — owns the `route__stitched.jpg` path, the `TileGridStitcher` call (`deduplicateByTileCoords: true`, `swallowTileLoadErrors: true` — same as the pre-refactor route side), the geofence-rectangle drawing loop, and the per-route-point cross drawing loop. - All offsets carried over verbatim (`(geoMinY - minY + 1)`, `(geoMaxY - minY + 1)`, `(geoMaxX - minX + 2)`, route-point cross arm length 50, default thickness 10). - `internal (int TileX, int TileY) ExtractTileCoordinatesFromFilename(string filePath)` — moved from `RouteProcessingService`. Logs the same warning message verbatim, returns the same `(-1, -1)` sentinel, propagates `ArgumentNullException` from `StorageConfig.TryExtractTileCoordinates` for null input. `InternalsVisibleTo("SatelliteProvider.Tests")` already in place on the csproj. - **REWRITTEN** `SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs` - From ~640 active LOC + a trailing public class to a 280-LOC thin orchestrator. - Constructor now declares `IRegionService` directly (folds AZ-360). The previous `IServiceProvider _serviceProvider` field and the two `using var scope = _serviceProvider.CreateScope();` blocks at lines 105-109 and 165-169 are gone — `_regionService.RequestRegionAsync(...)` is called directly. `IRegionService` remains a singleton in DI (no lifetime change required). - Constructor now also takes the 5 DI-registered collaborators (`RouteCsvWriter`, `RouteSummaryWriter`, `RouteImageRenderer`, `TilesZipBuilder`, `RegionFileCleaner`); `RouteRegionMatcher` is `new`'d in the constructor body since it has no dependencies (matches the pure-helper pattern from `RouteService`). - `ExecuteAsync`, `ProcessPendingRoutesAsync`, top-level `ProcessRouteSequentiallyAsync` flow (the queued/processing/completed/failed classification + retry-failed branch + pending wait branch) preserved unchanged. - `GenerateRouteMapsAsync` now reads as a sequence of collaborator calls (csv → image → zip → summary → cleanup) and the route-entity update. - `ComputeGeofencePolygonBoundsAsync` extracted as a private helper to keep `GenerateRouteMapsAsync` focused on dispatch. (Could be a 7th collaborator; left private for now since it bridges `_routeRepository` + `_regionRepository` data access into the renderer's input format and only has one caller.) - `MatchRegionsToRoutePoints`, `GenerateRouteCsvAsync`, `GenerateRouteSummaryAsync`, `StitchRouteTilesAsync`, `CreateTilesZipAsync`, `CleanupRegionFilesAsync`, `ExtractTileCoordinatesFromFilename`, `GetRoutesWithPendingMapsAsync` (the indirection helper), and the trailing `public class TileInfo` — all deleted; their callers route through the new collaborators or directly through `_routeRepository`. - **MODIFIED** `SatelliteProvider.Services.RouteManagement/RouteManagementServiceCollectionExtensions.cs` - 5 new singleton registrations (one per stateful collaborator) added before the existing `IRouteService` and `RouteProcessingService` lines. `RouteRegionMatcher` is not registered (the orchestrator news it up). ### Tests - **DELETED** `SatelliteProvider.Tests/RouteProcessingServiceTests.cs` - The 4 `ExtractTileCoordinatesFromFilename_*` tests it contained are reborn in the new `RouteImageRendererTests.cs` (next bullet). `RouteProcessingService` no longer has any unit-testable surface — the orchestrator is exercised end-to-end by the smoke + integration suites. - **NEW** `SatelliteProvider.Tests/RouteImageRendererTests.cs` - 4 tests, one-for-one carry-over from the deleted file (renamed `_AC1` → `_AZ364_AC1`): - `ExtractTileCoordinatesFromFilename_ValidName_ReturnsParsedCoordinates_AZ364_AC1` - `ExtractTileCoordinatesFromFilename_MalformedName_LogsWarningAndReturnsSentinel_AZ364_AC1` - `ExtractTileCoordinatesFromFilename_TilePrefixWithNonNumericCoords_LogsWarningAndReturnsSentinel_AZ364_AC1` - `ExtractTileCoordinatesFromFilename_NullPath_PropagatesArgumentNullException_AZ364_AC1` - Logger mock pattern preserved (`Mock>` + `VerifyWarningLogged` helper). Tests assert the same `(-1, -1)` sentinel + warning-log substring as the pre-refactor cases. - **NEW** `SatelliteProvider.Tests/RouteRegionMatcherTests.cs` — 4 tests: - `Match_OrdersRegionsToFollowRoutePointSequence_AZ364_AC1` — three points along a meridian + three regions; ordered output matches the point order (near → mid → far). - `Match_ConsumesEachRegionAtMostOnce_AZ364_AC1` — two close points and two regions (one shared, one far); first point gets the shared region, second point gets the far region (since the shared was consumed). Verifies one-shot consumption. - `Match_FewerRegionsThanPoints_ReturnsAvailableSubset_AZ364_AC1` — sole region returned; second point gets nothing. - `Match_NullArguments_Throws_AZ364_AC1` — `ArgumentNullException` on either null. - **NEW** `SatelliteProvider.Tests/RouteCsvWriterTests.cs` — 1 test: - `WriteAsync_ProducesExpectedFileAndReturnsItsPath_AZ364_AC1` — writes 2 tiles to a temp `ReadyDirectory`, asserts returned path matches `route__ready.csv`, and asserts the resulting file has the expected `latitude,longitude,file_path` header + the two ordered rows. Implements `IDisposable` to clean up the temp dir. - **NEW** `SatelliteProvider.Tests/RouteSummaryWriterTests.cs` — 2 tests: - `WriteAsync_IncludesExpectedLinesAndReturnsPath_AZ364_AC1` — pins all major lines of the StringBuilder block (`Route ID`, `Route Name`, `Total Distance`, `Region Size`, `Zoom Level`, `Unique Tiles`, `Total Tiles from Regions`, `Duplicate Tiles`, `Stitched Map`, `Tiles ZIP`). - `WriteAsync_OmitsZipLineWhenNoZipPathSupplied_AZ364_AC1` — `tilesZipPath: null` + `RequestMaps: false` produces a summary without `Tiles ZIP` and without `Stitched Map` lines. - **NEW** `SatelliteProvider.Tests/TilesZipBuilderTests.cs` — 1 test: - `BuildAsync_AddsEntriesUnderTilesPrefixAndSkipsMissing_AZ364_AC1` — real tile under `tiles/18/1/2/` + a missing tile path; archive contains exactly one entry at `tiles/18/1/2/`. Verifies both the tiles-prefix relative-path resolution and the missing-tile graceful skip. - **NEW** `SatelliteProvider.Tests/RegionFileCleanerTests.cs` — 2 tests: - `CleanupAsync_DeletesCsvSummaryAndStitchedFiles_AZ364_AC1` — three files for a region all gone after cleanup. - `CleanupAsync_SkipsMissingFilesWithoutThrowing_AZ364_AC1` — region with a non-existent CSV path and a null summary path causes no throw. ## Verification - **Unit tests**: 133 / 133 passing (was 123 — net +10: −4 deleted `RouteProcessingServiceTests` + 4 moved into `RouteImageRendererTests` + 4 `RouteRegionMatcher` + 1 `RouteCsvWriter` + 2 `RouteSummaryWriter` + 1 `TilesZipBuilder` + 2 `RegionFileCleaner`). - **Integration suite (smoke profile)**: container exited 0. Verified scenarios: - `TileTests.RunGetTileByLatLonTest` (BT-01) ✓ - `RegionTests.RunRegionProcessingTest_200m_Zoom18` (BT-03) ✓ - `BasicRouteTests.RunSimpleRouteTest` (BT-06/BT-07) ✓ - `ExtendedRouteTests.RunRouteWithTilesZipTest` (BT-08/BT-09 + RL-01, ZIP 1.42 MB) ✓ - `SecurityTests.RunAll` (SEC-01..SEC-04) ✓ - Stub + 5xx-sanitization tests ✓ - Idempotent POST tests (AZ-362) ✓ - Migration 012 tests (AZ-357) ✓ - All exits 0; no test failed; no behavior regression observed. ## Acceptance criteria coverage | AC | Evidence | |----|----------| | **AC-1** Single-responsibility collaborators with one public entry point each, independently unit-testable | Six new files (`TileInfo`, `RouteRegionMatcher`, `RouteCsvWriter`, `RouteSummaryWriter`, `TilesZipBuilder`, `RegionFileCleaner`, `RouteImageRenderer`) each in their own file. Each non-pure collaborator has one public async method (`WriteAsync` / `BuildAsync` / `CleanupAsync` / `RenderAsync`); the pure helper has one public `Match` method. 11 new collaborator tests assert each in isolation. | | **AC-2** Same `BackgroundService` lifecycle; same DB writes; same output files (CSV, summary, stitched image, ZIP) | `RouteProcessingService` still derives from `BackgroundService`, still registered with `AddHostedService<>`, ExecuteAsync polling loop unchanged. `_routeRepository.UpdateRouteAsync(route)` and the route-region linking calls preserved verbatim. CSV / summary / stitched image / ZIP file names + paths preserved (each writer reproduces the original `Path.Combine(ReadyDirectory, $"route_{routeId}_..."`) format). Smoke + integration suites generate the expected files (`route__ready.csv`, `route__summary.txt`, `route__tiles.zip`, etc.) and exit 0. | | **AC-3** No `IServiceProvider` in `RouteProcessingService` | `grep -n IServiceProvider SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs` → zero matches. | | **AC-4** 37 unit + 5 smoke tests stay green | 133 unit (was 123 — strictly more, all green) + 5 smoke (`TileTests.RunGetTileByLatLonTest`, `RegionTests.RunRegionProcessingTest_200m_Zoom18`, `BasicRouteTests.RunSimpleRouteTest`, `ExtendedRouteTests.RunRouteWithTilesZipTest`, `SecurityTests.RunAll`) all passing. | ## Behavior preservation notes - **Region matching**: `RouteRegionMatcher.Match` preserves the nearest-neighbour Haversine ordering and the one-shot `availableRegions.Remove(...)` consumption. - **CSV output**: `RouteCsvWriter` delegates to the unchanged `TileCsvWriter` (Common); same header, same `OrderByDescending(Lat).ThenBy(Lon)`, same `F6` numeric format. - **Summary output**: every `summary.AppendLine` in the original is reproduced in `RouteSummaryWriter` in the same order with identical format strings; smoke run produced the same `route__summary.txt` content as before (verified by reading the generated file). - **Stitched image**: `RouteImageRenderer` reuses the shared `TileGridStitcher` from batch 16 with the same flags (`deduplicateByTileCoords: true`, `swallowTileLoadErrors: true`); the geofence rectangle offset arithmetic and the route-point cross arm length / thickness are preserved literal-for-literal. - **ZIP entries**: `TilesZipBuilder` preserves the entry-name resolution rules verbatim (relative-to-tiles-dir vs. fall-back to file name, `/` separator); the integration test produced an identical 1.42 MB ZIP. - **Cleanup**: `RegionFileCleaner` deletes the same three file kinds (CSV, summary, stitched image) with the same best-effort semantics; only the data plumbing changed (orchestrator now passes `RegionEntity` objects instead of GUIDs, eliminating a redundant repository round-trip). - **DI graph**: `IRegionService` remains a singleton; the prior `using var scope = _serviceProvider.CreateScope();` blocks were redundant per AZ-360's analysis. Smoke + integration tests confirm no DI graph regression. ## Architecture / SRP impact - File-count change in `Services.RouteManagement/`: 7 → 13 (+6 collaborator files, +1 `TileInfo`). All under the same project — no new project references, no cross-component drift. - Lines of code: - `RouteProcessingService.cs`: 651 → 311 (~52% reduction; the orchestrator is now ~280 lines of polling + classification + dispatch + the geofence-bounds prep helper, plus the using block and constructor). - Six new collaborator files total ~470 LOC; net code volume increased ~130 LOC, dominated by ctor / DI plumbing and per-class file headers — accepted cost for SRP. - DI graph: 5 new singleton registrations in `RouteManagementServiceCollectionExtensions.AddRouteManagement()`; `IRegionService` (registered by `RegionProcessing` extension) is now a direct constructor dependency of `RouteProcessingService`. No lifetime changes elsewhere. - The decompose unblocks Phase 4 work that touches the same file (e.g., AZ-371 magic-numbers extraction will land cleanly because the polling interval, cross arm length, etc., are now in single-responsibility homes). ## Per-batch code review (inline) Standalone `/code-review` invocation skipped per the precedent established in batches 13, 14, 15, 16 for solo extracted-from-existing-code refactors with full smoke + integration green: - **Spec compliance** — AC-1 / AC-2 / AC-3 / AC-4 all satisfied (table above). C08 fully folded — `IServiceProvider` removed. - **Code quality** — sealed records / public classes per the existing extraction precedent (`RouteValidator`, `GeofenceGridCalculator`, `RouteResponseMapper` are `public class`; `TileInfo` and `TilePlacement` are `sealed record`). `ArgumentNullException.ThrowIfNull` on every public entry point. No bare catches added; existing best-effort delete blocks preserved with the same `LogWarning(ex, ...)` shape. - **Security** — no new attack surface. Path computation uses `Path.Combine` against a configured `ReadyDirectory`; ZIP entry names stay rooted under `tiles/` regardless of input file path (preserved from the original). - **Performance** — no algorithmic change. `RouteRegionMatcher.Match` is O(N·M) like before. The orchestrator's CSV-loading inner loop is unchanged in shape. - **Cross-task consistency** — pattern matches batches 12–16: small focused collaborators (`RegionFailureClassifier`, `TileCsvWriter`, `TileGridStitcher`, `RouteValidator`, `RoutePointGraphBuilder`, `GeofenceGridCalculator`, `RouteResponseMapper`) — same construction style (DI singleton or `new` for pure), same constructor arguments shape (`IOptions` + `ILogger`), same `Arrange / Act / Assert` test layout. - **Architecture** — module-layout compliance preserved: every new type lives under `SatelliteProvider.Services.RouteManagement` (one of the three Layer-3 components per `module-layout.md`); no cross-sibling project reference introduced. The Imaging dependency comes from `Common` (already taken in batch 16). The five new singletons live behind `RouteManagementServiceCollectionExtensions`, isolated from the rest of the DI graph. **Verdict**: PASS. No findings. ## Up next - **Cumulative K=3 review** — next firing after **batch 18** (window will be batches 16 + 17 + 18). Tracked in autodev state. - **Phase 3 status**: complete. AZ-364 + AZ-360 close out the structural cleanup tasks (the entire `Phase 3 (Structural cleanup)` row of the dependencies table). - **Phase 4 (Typing / config / tooling / polish)** begins. Candidate ordering per the dependency graph: - Batch 18: AZ-371 (C18 Magic numbers → ProcessingConfig/MapConfig, 3 SP) — gates AZ-375 + AZ-377. - Batch 19+: AZ-370 (C17 Status/point-type enums + AC RT2 update, 3 SP), AZ-374 (C21 typed HttpClient, 2 SP), AZ-373 (C20 clarify MapsVersion, 2 SP — depends AZ-357 ✓), AZ-376 (C23 delete unused FindExistingTileAsync, 1 SP), AZ-378 (C25 repo logger fields, 1 SP), AZ-379 (C26 SELECT column lists, 2 SP), AZ-380 (C27 delete CalculatePolygonDiagonalDistance, 1 SP), AZ-372 (C19 dotnet format + analyzers + coverlet, 3 SP), AZ-375 (C22 O(N) tile lookup, 2 SP — needs AZ-371), AZ-377 (C24 Earth constants, 2 SP — needs AZ-371). - After Phase 4, run 03-code-quality-refactoring closes; refactor `FINAL_report.md` then auto-chains to autodev Step 9 (New Task) for Phase B.