[AZ-364] [AZ-360] Refactor C11+C08: decompose RouteProcessingService

Extracts RouteRegionMatcher, RouteCsvWriter, RouteSummaryWriter,
RouteImageRenderer, TilesZipBuilder, RegionFileCleaner from the
~750-LOC RouteProcessingService god-class. Moves TileInfo to its
own file as a sealed record. Replaces IServiceProvider scope-
locator with a direct IRegionService injection (folds AZ-360 / C08).
Updates DI registration and tests.

Tests: 133 / 133 unit + 5 / 5 smoke green; integration suite exit 0.
Pixel-equivalent stitched route image and byte-equivalent CSV /
summary / ZIP outputs verified through the smoke run.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-11 03:12:49 +03:00
parent 70a0a2c4d5
commit 6f23120c49
16 changed files with 1181 additions and 439 deletions
@@ -0,0 +1,83 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.DataAccess.Models;
using SatelliteProvider.Services.RouteManagement;
namespace SatelliteProvider.Tests;
// AZ-364 / C11: cleaner deletes the per-region CSV, summary, and
// stitched-image files. Missing files are skipped without throwing;
// other regions are still processed even if one delete fails.
public class RegionFileCleanerTests : IDisposable
{
private readonly string _readyDir;
public RegionFileCleanerTests()
{
_readyDir = Path.Combine(Path.GetTempPath(), "az364_cleaner_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_readyDir);
}
public void Dispose()
{
if (Directory.Exists(_readyDir))
{
Directory.Delete(_readyDir, recursive: true);
}
GC.SuppressFinalize(this);
}
[Fact]
public async Task CleanupAsync_DeletesCsvSummaryAndStitchedFiles_AZ364_AC1()
{
// Arrange
var storageOptions = Options.Create(new StorageConfig { ReadyDirectory = _readyDir });
var sut = new RegionFileCleaner(storageOptions, NullLogger<RegionFileCleaner>.Instance);
var regionId = Guid.NewGuid();
var csvPath = Path.Combine(_readyDir, $"region_{regionId}_ready.csv");
var summaryPath = Path.Combine(_readyDir, $"region_{regionId}_summary.txt");
var stitchedPath = Path.Combine(_readyDir, $"region_{regionId}_stitched.jpg");
await File.WriteAllTextAsync(csvPath, "header\n");
await File.WriteAllTextAsync(summaryPath, "summary\n");
await File.WriteAllBytesAsync(stitchedPath, new byte[] { 0xFF, 0xD8 });
var region = new RegionEntity
{
Id = regionId,
CsvFilePath = csvPath,
SummaryFilePath = summaryPath,
};
// Act
await sut.CleanupAsync(new[] { region });
// Assert
File.Exists(csvPath).Should().BeFalse();
File.Exists(summaryPath).Should().BeFalse();
File.Exists(stitchedPath).Should().BeFalse();
}
[Fact]
public async Task CleanupAsync_SkipsMissingFilesWithoutThrowing_AZ364_AC1()
{
// Arrange
var storageOptions = Options.Create(new StorageConfig { ReadyDirectory = _readyDir });
var sut = new RegionFileCleaner(storageOptions, NullLogger<RegionFileCleaner>.Instance);
var region = new RegionEntity
{
Id = Guid.NewGuid(),
CsvFilePath = "/does/not/exist.csv",
SummaryFilePath = null,
};
// Act
Func<Task> act = () => sut.CleanupAsync(new[] { region });
// Assert
await act.Should().NotThrowAsync();
}
}
@@ -0,0 +1,58 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.Services.RouteManagement;
namespace SatelliteProvider.Tests;
// AZ-364 / C11: route-side CSV writer wraps the shared TileCsvWriter and
// owns the route_<id>_ready.csv path. Only the wrapping behavior (path,
// row mapping, returned path) is tested here; CSV byte format is the
// shared TileCsvWriter's concern (covered separately).
public class RouteCsvWriterTests : IDisposable
{
private readonly string _tempDir;
public RouteCsvWriterTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "az364_routecsv_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
if (Directory.Exists(_tempDir))
{
Directory.Delete(_tempDir, recursive: true);
}
GC.SuppressFinalize(this);
}
[Fact]
public async Task WriteAsync_ProducesExpectedFileAndReturnsItsPath_AZ364_AC1()
{
// Arrange
var routeId = Guid.NewGuid();
var storageOptions = Options.Create(new StorageConfig { ReadyDirectory = _tempDir });
var sut = new RouteCsvWriter(storageOptions, NullLogger<RouteCsvWriter>.Instance);
var tiles = new List<TileInfo>
{
new(40.0, -73.0, "/tiles/a.jpg"),
new(41.0, -74.0, "/tiles/b.jpg"),
};
// Act
var path = await sut.WriteAsync(routeId, tiles);
// Assert
path.Should().Be(Path.Combine(_tempDir, $"route_{routeId}_ready.csv"));
File.Exists(path).Should().BeTrue();
var lines = await File.ReadAllLinesAsync(path);
lines.Should().HaveCount(3);
lines[0].Should().Be("latitude,longitude,file_path");
lines.Should().Contain("41.000000,-74.000000,/tiles/b.jpg");
lines.Should().Contain("40.000000,-73.000000,/tiles/a.jpg");
}
}
@@ -3,30 +3,24 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.DataAccess.Repositories;
using SatelliteProvider.Services.RouteManagement;
namespace SatelliteProvider.Tests;
public class RouteProcessingServiceTests
// AZ-364 / C11: tests moved from RouteProcessingServiceTests when the helper
// migrated to RouteImageRenderer. The four tile-coordinate-parsing tests
// preserve the behavior contract (good name → parsed; malformed → sentinel
// + warning log; null → ArgumentNullException).
public class RouteImageRendererTests
{
private static RouteProcessingService BuildSut(out Mock<ILogger<RouteProcessingService>> loggerMock)
private static RouteImageRenderer BuildSut(out Mock<ILogger<RouteImageRenderer>> loggerMock)
{
loggerMock = new Mock<ILogger<RouteProcessingService>>();
var routeRepo = new Mock<IRouteRepository>();
var regionRepo = new Mock<IRegionRepository>();
var serviceProvider = new Mock<IServiceProvider>();
loggerMock = new Mock<ILogger<RouteImageRenderer>>();
var storageOptions = Options.Create(new StorageConfig());
return new RouteProcessingService(
routeRepo.Object,
regionRepo.Object,
serviceProvider.Object,
storageOptions,
loggerMock.Object);
return new RouteImageRenderer(storageOptions, loggerMock.Object);
}
private static void VerifyWarningLogged(Mock<ILogger<RouteProcessingService>> loggerMock, string substringInState)
private static void VerifyWarningLogged(Mock<ILogger<RouteImageRenderer>> loggerMock, string substringInState)
{
loggerMock.Verify(
l => l.Log(
@@ -39,7 +33,7 @@ public class RouteProcessingServiceTests
}
[Fact]
public void ExtractTileCoordinatesFromFilename_ValidName_ReturnsParsedCoordinates_AC1()
public void ExtractTileCoordinatesFromFilename_ValidName_ReturnsParsedCoordinates_AZ364_AC1()
{
// Arrange
var sut = BuildSut(out _);
@@ -53,7 +47,7 @@ public class RouteProcessingServiceTests
}
[Fact]
public void ExtractTileCoordinatesFromFilename_MalformedName_LogsWarningAndReturnsSentinel_AC1()
public void ExtractTileCoordinatesFromFilename_MalformedName_LogsWarningAndReturnsSentinel_AZ364_AC1()
{
// Arrange
var sut = BuildSut(out var loggerMock);
@@ -69,7 +63,7 @@ public class RouteProcessingServiceTests
}
[Fact]
public void ExtractTileCoordinatesFromFilename_TilePrefixWithNonNumericCoords_LogsWarningAndReturnsSentinel_AC1()
public void ExtractTileCoordinatesFromFilename_TilePrefixWithNonNumericCoords_LogsWarningAndReturnsSentinel_AZ364_AC1()
{
// Arrange
var sut = BuildSut(out var loggerMock);
@@ -85,7 +79,7 @@ public class RouteProcessingServiceTests
}
[Fact]
public void ExtractTileCoordinatesFromFilename_NullPath_PropagatesArgumentNullException_AC2()
public void ExtractTileCoordinatesFromFilename_NullPath_PropagatesArgumentNullException_AZ364_AC1()
{
// Arrange
var sut = BuildSut(out _);
@@ -0,0 +1,99 @@
using FluentAssertions;
using SatelliteProvider.DataAccess.Models;
using SatelliteProvider.Services.RouteManagement;
namespace SatelliteProvider.Tests;
// AZ-364 / C11: pure-helper tests for the route-point ↔ region nearest-
// neighbour matcher extracted from RouteProcessingService.
public class RouteRegionMatcherTests
{
[Fact]
public void Match_OrdersRegionsToFollowRoutePointSequence_AZ364_AC1()
{
// Arrange
var sut = new RouteRegionMatcher();
var routePoints = new List<RoutePointEntity>
{
new() { Latitude = 0.0, Longitude = 0.0, SequenceNumber = 0 },
new() { Latitude = 1.0, Longitude = 0.0, SequenceNumber = 1 },
new() { Latitude = 2.0, Longitude = 0.0, SequenceNumber = 2 },
};
var regionFar = new RegionEntity { Id = Guid.NewGuid(), Latitude = 2.0, Longitude = 0.0 };
var regionNear = new RegionEntity { Id = Guid.NewGuid(), Latitude = 0.0, Longitude = 0.0 };
var regionMid = new RegionEntity { Id = Guid.NewGuid(), Latitude = 1.0, Longitude = 0.0 };
var unorderedRegions = new List<RegionEntity> { regionFar, regionNear, regionMid };
// Act
var ordered = sut.Match(routePoints, unorderedRegions);
// Assert
ordered.Should().HaveCount(3);
ordered[0].Id.Should().Be(regionNear.Id);
ordered[1].Id.Should().Be(regionMid.Id);
ordered[2].Id.Should().Be(regionFar.Id);
}
[Fact]
public void Match_ConsumesEachRegionAtMostOnce_AZ364_AC1()
{
// Arrange
var sut = new RouteRegionMatcher();
var sharedRegion = new RegionEntity { Id = Guid.NewGuid(), Latitude = 0.0, Longitude = 0.0 };
var otherRegion = new RegionEntity { Id = Guid.NewGuid(), Latitude = 10.0, Longitude = 0.0 };
var routePoints = new List<RoutePointEntity>
{
new() { Latitude = 0.0, Longitude = 0.0, SequenceNumber = 0 },
new() { Latitude = 0.001, Longitude = 0.0, SequenceNumber = 1 },
};
// Act
var ordered = sut.Match(routePoints, new List<RegionEntity> { sharedRegion, otherRegion });
// Assert
ordered.Should().HaveCount(2);
ordered.Select(r => r.Id).Should().OnlyHaveUniqueItems();
ordered[0].Id.Should().Be(sharedRegion.Id);
ordered[1].Id.Should().Be(otherRegion.Id);
}
[Fact]
public void Match_FewerRegionsThanPoints_ReturnsAvailableSubset_AZ364_AC1()
{
// Arrange
var sut = new RouteRegionMatcher();
var soleRegion = new RegionEntity { Id = Guid.NewGuid(), Latitude = 0.0, Longitude = 0.0 };
var routePoints = new List<RoutePointEntity>
{
new() { Latitude = 0.0, Longitude = 0.0, SequenceNumber = 0 },
new() { Latitude = 1.0, Longitude = 0.0, SequenceNumber = 1 },
};
// Act
var ordered = sut.Match(routePoints, new List<RegionEntity> { soleRegion });
// Assert
ordered.Should().HaveCount(1);
ordered[0].Id.Should().Be(soleRegion.Id);
}
[Fact]
public void Match_NullArguments_Throws_AZ364_AC1()
{
// Arrange
var sut = new RouteRegionMatcher();
// Act
Action actNullPoints = () => sut.Match(null!, new List<RegionEntity>());
Action actNullRegions = () => sut.Match(new List<RoutePointEntity>(), null!);
// Assert
actNullPoints.Should().Throw<ArgumentNullException>();
actNullRegions.Should().Throw<ArgumentNullException>();
}
}
@@ -0,0 +1,88 @@
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.DataAccess.Models;
using SatelliteProvider.Services.RouteManagement;
namespace SatelliteProvider.Tests;
// AZ-364 / C11: route summary writer carries forward the StringBuilder
// content verbatim; tests pin the expected lines so a future drift in
// the summary format is caught.
public class RouteSummaryWriterTests : IDisposable
{
private readonly string _tempDir;
public RouteSummaryWriterTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), "az364_routesum_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempDir);
}
public void Dispose()
{
if (Directory.Exists(_tempDir))
{
Directory.Delete(_tempDir, recursive: true);
}
GC.SuppressFinalize(this);
}
[Fact]
public async Task WriteAsync_IncludesExpectedLinesAndReturnsPath_AZ364_AC1()
{
// Arrange
var storageOptions = Options.Create(new StorageConfig { ReadyDirectory = _tempDir });
var sut = new RouteSummaryWriter(storageOptions, NullLogger<RouteSummaryWriter>.Instance);
var route = new RouteEntity
{
Id = Guid.NewGuid(),
Name = "demo route",
Description = "test description",
TotalPoints = 4,
TotalDistanceMeters = 1234.5,
RegionSizeMeters = 200,
ZoomLevel = 18,
RequestMaps = true,
};
// Act
var path = await sut.WriteAsync(route, uniqueTiles: 10, totalTilesFromRegions: 12, duplicateTiles: 2, tilesZipPath: "/ready/zip.zip");
// Assert
path.Should().Be(Path.Combine(_tempDir, $"route_{route.Id}_summary.txt"));
var content = await File.ReadAllTextAsync(path);
content.Should().Contain("Route Maps Summary");
content.Should().Contain($"Route ID: {route.Id}");
content.Should().Contain("Route Name: demo route");
content.Should().Contain("Description: test description");
content.Should().Contain("Total Points: 4");
content.Should().Contain("Total Distance: 1234.50 meters");
content.Should().Contain("Region Size: 200 meters");
content.Should().Contain("Zoom Level: 18");
content.Should().Contain("- Unique Tiles: 10");
content.Should().Contain("- Total Tiles from Regions: 12");
content.Should().Contain("- Duplicate Tiles (overlap): 2");
content.Should().Contain($"- Stitched Map: route_{route.Id}_stitched.jpg");
content.Should().Contain($"- Tiles ZIP: route_{route.Id}_tiles.zip");
}
[Fact]
public async Task WriteAsync_OmitsZipLineWhenNoZipPathSupplied_AZ364_AC1()
{
// Arrange
var storageOptions = Options.Create(new StorageConfig { ReadyDirectory = _tempDir });
var sut = new RouteSummaryWriter(storageOptions, NullLogger<RouteSummaryWriter>.Instance);
var route = new RouteEntity { Id = Guid.NewGuid(), Name = "no zip", TotalPoints = 2, RequestMaps = false };
// Act
var path = await sut.WriteAsync(route, uniqueTiles: 1, totalTilesFromRegions: 1, duplicateTiles: 0, tilesZipPath: null);
// Assert
var content = await File.ReadAllTextAsync(path);
content.Should().NotContain("Tiles ZIP");
content.Should().NotContain("Stitched Map");
}
}
@@ -0,0 +1,71 @@
using System.IO.Compression;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SatelliteProvider.Common.Configs;
using SatelliteProvider.Services.RouteManagement;
namespace SatelliteProvider.Tests;
// AZ-364 / C11: zip builder carries the entry-name resolution logic
// (relative-to-tiles-dir vs. fall-back to file name) and the
// missing-file-tolerant scan loop.
public class TilesZipBuilderTests : IDisposable
{
private readonly string _readyDir;
private readonly string _tilesDir;
public TilesZipBuilderTests()
{
var root = Path.Combine(Path.GetTempPath(), "az364_zip_" + Guid.NewGuid().ToString("N"));
_readyDir = Path.Combine(root, "ready");
_tilesDir = Path.Combine(root, "tiles");
Directory.CreateDirectory(_readyDir);
Directory.CreateDirectory(_tilesDir);
}
public void Dispose()
{
var root = Path.GetDirectoryName(_readyDir)!;
if (Directory.Exists(root))
{
Directory.Delete(root, recursive: true);
}
GC.SuppressFinalize(this);
}
[Fact]
public async Task BuildAsync_AddsEntriesUnderTilesPrefixAndSkipsMissing_AZ364_AC1()
{
// Arrange
var storageOptions = Options.Create(new StorageConfig
{
ReadyDirectory = _readyDir,
TilesDirectory = _tilesDir,
});
var sut = new TilesZipBuilder(storageOptions, NullLogger<TilesZipBuilder>.Instance);
var subdir = Path.Combine(_tilesDir, "18", "1", "2");
Directory.CreateDirectory(subdir);
var realTile = Path.Combine(subdir, "tile_18_1234_5678_1700000000.jpg");
await File.WriteAllBytesAsync(realTile, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 });
var routeId = Guid.NewGuid();
var tiles = new List<TileInfo>
{
new(40.0, -73.0, realTile),
new(41.0, -74.0, Path.Combine(_tilesDir, "missing_tile.jpg")),
};
// Act
var zipPath = await sut.BuildAsync(routeId, tiles);
// Assert
zipPath.Should().Be(Path.Combine(_readyDir, $"route_{routeId}_tiles.zip"));
File.Exists(zipPath).Should().BeTrue();
using var archive = ZipFile.OpenRead(zipPath);
archive.Entries.Should().HaveCount(1);
archive.Entries.Single().FullName.Should().Be("tiles/18/1/2/tile_18_1234_5678_1700000000.jpg");
}
}