mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-22 17:41:15 +00:00
[AZ-368] Refactor C15: extract shared TileCsvWriter
Both RegionService.GenerateCsvFileAsync and RouteProcessingService.GenerateRouteCsvAsync wrote the same CSV shape: header "latitude,longitude,file_path", same OrderByDescending(Latitude).ThenBy(Longitude) ordering, same F6 numeric format. Two near-identical writers with no shared abstraction. Extracted TileCsvWriter (instance class, no DI dependencies) plus a TileCsvRow record bridging the per-pipeline DTOs (TileMetadata vs TileInfo) to a single contract. The header constant, ordering rule, and StreamWriter lifecycle now live in one place. Both call sites collapse to a one-line projection plus a delegated WriteAsync call. Region method becomes static (no longer references instance state). Route method preserves its existing logger line. Coverage: - 7 new unit tests including a byte-for-byte equivalence test that writes the same input via both the new TileCsvWriter and the inlined-original code path side by side and asserts file bytes are identical. - Integration smoke + full suite green; route + region CSV outputs unchanged across all existing scenarios (verified by extended-route CSV verification step in the integration suite). - 84/84 unit tests pass (was 77). Side improvement: writer now respects CancellationToken mid-loop. The pre-refactor inline code did not. Strict improvement; consistent with every other async API in the codebase. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
using FluentAssertions;
|
||||
using SatelliteProvider.Common.Utils;
|
||||
|
||||
namespace SatelliteProvider.Tests;
|
||||
|
||||
public class TileCsvWriterTests : IDisposable
|
||||
{
|
||||
private readonly string _tempDir;
|
||||
|
||||
public TileCsvWriterTests()
|
||||
{
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), $"tilecsv_tests_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
{
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_WritesHeaderAndRows_AZ368_AC1()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new TileCsvWriter();
|
||||
var path = Path.Combine(_tempDir, "tiles.csv");
|
||||
var rows = new[]
|
||||
{
|
||||
new TileCsvRow(47.461747, 37.647063, "/tiles/a.jpg"),
|
||||
new TileCsvRow(47.460000, 37.648000, "/tiles/b.jpg"),
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.WriteAsync(path, rows);
|
||||
|
||||
// Assert
|
||||
var lines = await File.ReadAllLinesAsync(path);
|
||||
lines.Should().HaveCount(3);
|
||||
lines[0].Should().Be("latitude,longitude,file_path");
|
||||
lines.Skip(1).Should().Contain("47.461747,37.647063,/tiles/a.jpg");
|
||||
lines.Skip(1).Should().Contain("47.460000,37.648000,/tiles/b.jpg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_OrdersByLatitudeDescThenLongitudeAsc_AZ368()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new TileCsvWriter();
|
||||
var path = Path.Combine(_tempDir, "ordered.csv");
|
||||
// Intentionally unsorted; expected order is lat DESC, then lon ASC.
|
||||
var rows = new[]
|
||||
{
|
||||
new TileCsvRow(10.0, 30.0, "/c.jpg"),
|
||||
new TileCsvRow(20.0, 20.0, "/b.jpg"),
|
||||
new TileCsvRow(20.0, 10.0, "/a.jpg"),
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.WriteAsync(path, rows);
|
||||
|
||||
// Assert
|
||||
var lines = await File.ReadAllLinesAsync(path);
|
||||
lines[0].Should().Be("latitude,longitude,file_path");
|
||||
lines[1].Should().Be("20.000000,10.000000,/a.jpg", "lat=20 wins over lat=10; lon=10 wins over lon=20");
|
||||
lines[2].Should().Be("20.000000,20.000000,/b.jpg");
|
||||
lines[3].Should().Be("10.000000,30.000000,/c.jpg", "lat=10 comes last in desc order");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_EmptyRows_WritesHeaderOnly_AZ368()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new TileCsvWriter();
|
||||
var path = Path.Combine(_tempDir, "empty.csv");
|
||||
|
||||
// Act
|
||||
await sut.WriteAsync(path, Array.Empty<TileCsvRow>());
|
||||
|
||||
// Assert
|
||||
var lines = await File.ReadAllLinesAsync(path);
|
||||
lines.Should().ContainSingle();
|
||||
lines[0].Should().Be("latitude,longitude,file_path");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_FormatsNumericValuesWithF6Precision_AZ368_AC2()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new TileCsvWriter();
|
||||
var path = Path.Combine(_tempDir, "format.csv");
|
||||
var rows = new[]
|
||||
{
|
||||
new TileCsvRow(1.0, 2.0, "/x.jpg"),
|
||||
new TileCsvRow(0.1234567890, 99.0000001, "/y.jpg"),
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.WriteAsync(path, rows);
|
||||
|
||||
// Assert — F6 trims/pads to exactly 6 decimal places
|
||||
var lines = await File.ReadAllLinesAsync(path);
|
||||
lines.Should().Contain("1.000000,2.000000,/x.jpg");
|
||||
lines.Should().Contain("0.123457,99.000000,/y.jpg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_ProducesByteForByteOutputMatchingOldInlineWriter_AZ368_AC2()
|
||||
{
|
||||
// Arrange — replicate the exact pre-refactor inline writer behavior to
|
||||
// prove the extracted class doesn't drift the on-disk format.
|
||||
var rows = new[]
|
||||
{
|
||||
new TileCsvRow(47.461747, 37.647063, "/tiles/a.jpg"),
|
||||
new TileCsvRow(47.460000, 37.648000, "/tiles/b.jpg"),
|
||||
new TileCsvRow(47.470000, 37.640000, "/tiles/c.jpg"),
|
||||
};
|
||||
|
||||
var newPath = Path.Combine(_tempDir, "new.csv");
|
||||
var oldPath = Path.Combine(_tempDir, "old.csv");
|
||||
|
||||
// Act — new path via TileCsvWriter
|
||||
await new TileCsvWriter().WriteAsync(newPath, rows);
|
||||
|
||||
// Act — old path via inlined original logic
|
||||
var ordered = rows.OrderByDescending(t => t.Latitude).ThenBy(t => t.Longitude).ToList();
|
||||
using (var writer = new StreamWriter(oldPath))
|
||||
{
|
||||
await writer.WriteLineAsync("latitude,longitude,file_path");
|
||||
foreach (var t in ordered)
|
||||
{
|
||||
await writer.WriteLineAsync($"{t.Latitude:F6},{t.Longitude:F6},{t.FilePath}");
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
var newBytes = await File.ReadAllBytesAsync(newPath);
|
||||
var oldBytes = await File.ReadAllBytesAsync(oldPath);
|
||||
newBytes.Should().Equal(oldBytes, "AZ-368 AC-2 requires byte-for-byte identical CSV output");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_NullPath_ThrowsArgumentNullException_AZ368()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new TileCsvWriter();
|
||||
|
||||
// Act
|
||||
Func<Task> act = () => sut.WriteAsync(null!, Array.Empty<TileCsvRow>());
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WriteAsync_NullRows_ThrowsArgumentNullException_AZ368()
|
||||
{
|
||||
// Arrange
|
||||
var sut = new TileCsvWriter();
|
||||
var path = Path.Combine(_tempDir, "nullrows.csv");
|
||||
|
||||
// Act
|
||||
Func<Task> act = () => sut.WriteAsync(path, null!);
|
||||
|
||||
// Assert
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user