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>
19 KiB
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.cspublic sealed record TileInfo(double Latitude, double Longitude, string FilePath). Moved out of the trailing public class inRouteProcessingService.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.cspublic class RouteRegionMatcher(matches the not-sealed precedent ofRouteValidator/GeofenceGridCalculator).Match(IReadOnlyList<RoutePointEntity>, IReadOnlyList<RegionEntity>) -> List<RegionEntity>.- Pure: no logger, no state, no I/O. The previously dead
routeIdparameter 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 theroute_<id>_ready.csvpath, delegates serialization toCommon/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 (
TileCsvWriterunchanged; only the route-side wrapper relocated).
- DI-registered singleton; takes
-
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 originalGenerateRouteSummaryAsyncis preserved in the same order with the same format strings (F2,F0, ISO timestamp). AC-2 byte-equivalence rests on this.
- DI-registered singleton; takes
-
NEW
SatelliteProvider.Services.RouteManagement/TilesZipBuilder.cs- DI-registered singleton; takes
IOptions<StorageConfig>+ILogger<TilesZipBuilder>. BuildAsync(routeId, IEnumerable<TileInfo>, ct) -> Task<string>— wrapsTask.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.DeletethenZipFile.Open(..., Create)— same as before.
- DI-registered singleton; takes
-
NEW
SatelliteProvider.Services.RouteManagement/RegionFileCleaner.cs- DI-registered singleton; takes
IOptions<StorageConfig>+ILogger<RegionFileCleaner>. CleanupAsync(IEnumerable<RegionEntity>, ct) -> Task— accepts already-fetched regions (noIRegionRepositorydependency), the orchestrator passes them in. This is a slight contract clean-up: the original method tookIEnumerable<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 aStitchedImagePath). - Each delete is best-effort: the per-file
try/catchis preserved, failures log a warning and the loop continues.
- DI-registered singleton; takes
-
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 theroute_<id>_stitched.jpgpath, theTileGridStitchercall (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 fromRouteProcessingService. Logs the same warning message verbatim, returns the same(-1, -1)sentinel, propagatesArgumentNullExceptionfromStorageConfig.TryExtractTileCoordinatesfor null input.InternalsVisibleTo("SatelliteProvider.Tests")already in place on the csproj.
- DI-registered singleton; takes
-
REWRITTEN
SatelliteProvider.Services.RouteManagement/RouteProcessingService.cs- From ~640 active LOC + a trailing public class to a 280-LOC thin orchestrator.
- Constructor now declares
IRegionServicedirectly (folds AZ-360). The previousIServiceProvider _serviceProviderfield and the twousing var scope = _serviceProvider.CreateScope();blocks at lines 105-109 and 165-169 are gone —_regionService.RequestRegionAsync(...)is called directly.IRegionServiceremains a singleton in DI (no lifetime change required). - Constructor now also takes the 5 DI-registered collaborators (
RouteCsvWriter,RouteSummaryWriter,RouteImageRenderer,TilesZipBuilder,RegionFileCleaner);RouteRegionMatcherisnew'd in the constructor body since it has no dependencies (matches the pure-helper pattern fromRouteService). ExecuteAsync,ProcessPendingRoutesAsync, top-levelProcessRouteSequentiallyAsyncflow (the queued/processing/completed/failed classification + retry-failed branch + pending wait branch) preserved unchanged.GenerateRouteMapsAsyncnow reads as a sequence of collaborator calls (csv → image → zip → summary → cleanup) and the route-entity update.ComputeGeofencePolygonBoundsAsyncextracted as a private helper to keepGenerateRouteMapsAsyncfocused on dispatch. (Could be a 7th collaborator; left private for now since it bridges_routeRepository+_regionRepositorydata 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 trailingpublic 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
IRouteServiceandRouteProcessingServicelines.RouteRegionMatcheris not registered (the orchestrator news it up).
- 5 new singleton registrations (one per stateful collaborator) added before the existing
Tests
-
DELETED
SatelliteProvider.Tests/RouteProcessingServiceTests.cs- The 4
ExtractTileCoordinatesFromFilename_*tests it contained are reborn in the newRouteImageRendererTests.cs(next bullet).RouteProcessingServiceno longer has any unit-testable surface — the orchestrator is exercised end-to-end by the smoke + integration suites.
- The 4
-
NEW
SatelliteProvider.Tests/RouteImageRendererTests.cs- 4 tests, one-for-one carry-over from the deleted file (renamed
_AC1→_AZ364_AC1):ExtractTileCoordinatesFromFilename_ValidName_ReturnsParsedCoordinates_AZ364_AC1ExtractTileCoordinatesFromFilename_MalformedName_LogsWarningAndReturnsSentinel_AZ364_AC1ExtractTileCoordinatesFromFilename_TilePrefixWithNonNumericCoords_LogsWarningAndReturnsSentinel_AZ364_AC1ExtractTileCoordinatesFromFilename_NullPath_PropagatesArgumentNullException_AZ364_AC1
- Logger mock pattern preserved (
Mock<ILogger<RouteImageRenderer>>+VerifyWarningLoggedhelper). Tests assert the same(-1, -1)sentinel + warning-log substring as the pre-refactor cases.
- 4 tests, one-for-one carry-over from the deleted file (renamed
-
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—ArgumentNullExceptionon either null.
-
NEW
SatelliteProvider.Tests/RouteCsvWriterTests.cs— 1 test:WriteAsync_ProducesExpectedFileAndReturnsItsPath_AZ364_AC1— writes 2 tiles to a tempReadyDirectory, asserts returned path matchesroute_<id>_ready.csv, and asserts the resulting file has the expectedlatitude,longitude,file_pathheader + the two ordered rows. ImplementsIDisposableto 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: falseproduces a summary withoutTiles ZIPand withoutStitched Maplines.
-
NEW
SatelliteProvider.Tests/TilesZipBuilderTests.cs— 1 test:BuildAsync_AddsEntriesUnderTilesPrefixAndSkipsMissing_AZ364_AC1— real tile undertiles/18/1/2/+ a missing tile path; archive contains exactly one entry attiles/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 intoRouteImageRendererTests+ 4RouteRegionMatcher+ 1RouteCsvWriter+ 2RouteSummaryWriter+ 1TilesZipBuilder+ 2RegionFileCleaner). - 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.Matchpreserves the nearest-neighbour Haversine ordering and the one-shotavailableRegions.Remove(...)consumption. - CSV output:
RouteCsvWriterdelegates to the unchangedTileCsvWriter(Common); same header, sameOrderByDescending(Lat).ThenBy(Lon), sameF6numeric format. - Summary output: every
summary.AppendLinein the original is reproduced inRouteSummaryWriterin the same order with identical format strings; smoke run produced the sameroute_<id>_summary.txtcontent as before (verified by reading the generated file). - Stitched image:
RouteImageRendererreuses the sharedTileGridStitcherfrom 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:
TilesZipBuilderpreserves 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:
RegionFileCleanerdeletes the same three file kinds (CSV, summary, stitched image) with the same best-effort semantics; only the data plumbing changed (orchestrator now passesRegionEntityobjects instead of GUIDs, eliminating a redundant repository round-trip). - DI graph:
IRegionServiceremains a singleton; the priorusing 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, +1TileInfo). 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 byRegionProcessingextension) is now a direct constructor dependency ofRouteProcessingService. 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 —
IServiceProviderremoved. - Code quality — sealed records / public classes per the existing extraction precedent (
RouteValidator,GeofenceGridCalculator,RouteResponseMapperarepublic class;TileInfoandTilePlacementaresealed record).ArgumentNullException.ThrowIfNullon every public entry point. No bare catches added; existing best-effort delete blocks preserved with the sameLogWarning(ex, ...)shape. - Security — no new attack surface. Path computation uses
Path.Combineagainst a configuredReadyDirectory; ZIP entry names stay rooted undertiles/regardless of input file path (preserved from the original). - Performance — no algorithmic change.
RouteRegionMatcher.Matchis 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 ornewfor pure), same constructor arguments shape (IOptions<StorageConfig>+ILogger<T>), sameArrange / Act / Asserttest layout. - Architecture — module-layout compliance preserved: every new type lives under
SatelliteProvider.Services.RouteManagement(one of the three Layer-3 components permodule-layout.md); no cross-sibling project reference introduced. The Imaging dependency comes fromCommon(already taken in batch 16). The five new singletons live behindRouteManagementServiceCollectionExtensions, 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.mdthen auto-chains to autodev Step 9 (New Task) for Phase B.