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