using FluentAssertions; using SatelliteProvider.Common.Imaging; using SixLabors.ImageSharp; using SixLabors.ImageSharp.PixelFormats; using SixLabors.ImageSharp.Processing; namespace SatelliteProvider.Tests; public class TileGridStitcherTests : IDisposable { private readonly string _tempDir; public TileGridStitcherTests() { _tempDir = Path.Combine(Path.GetTempPath(), $"stitcher_tests_{Guid.NewGuid():N}"); Directory.CreateDirectory(_tempDir); } public void Dispose() { if (Directory.Exists(_tempDir)) { Directory.Delete(_tempDir, recursive: true); } GC.SuppressFinalize(this); } private string CreateSolidTile(string name, int sizePixels, Rgb24 color) { var path = Path.Combine(_tempDir, $"{name}.png"); using var img = new Image(sizePixels, sizePixels); for (int y = 0; y < sizePixels; y++) { for (int x = 0; x < sizePixels; x++) { img[x, y] = color; } } img.SaveAsPng(path); return path; } [Fact] public async Task StitchAsync_PlacesEachTileAtCorrectGridOffset_AZ367_AC1() { // Arrange const int size = 8; var red = new Rgb24(200, 10, 10); var green = new Rgb24(10, 200, 10); var blue = new Rgb24(10, 10, 200); var redTile = CreateSolidTile("red", size, red); var greenTile = CreateSolidTile("green", size, green); var blueTile = CreateSolidTile("blue", size, blue); var tiles = new[] { new TilePlacement(10, 20, redTile), new TilePlacement(11, 20, greenTile), new TilePlacement(10, 21, blueTile), }; // Act var result = await new TileGridStitcher().StitchAsync( tiles, tileSizePixels: size, deduplicateByTileCoords: false, swallowTileLoadErrors: false); // Assert using var img = result.Image; result.MinX.Should().Be(10); result.MinY.Should().Be(20); result.MaxX.Should().Be(11); result.MaxY.Should().Be(21); result.ImageWidth.Should().Be(2 * size); result.ImageHeight.Should().Be(2 * size); result.PlacedTiles.Should().HaveCount(3); result.MissingTiles.Should().BeEmpty(); img[1, 1].Should().Be(red); img[size + 1, 1].Should().Be(green); img[1, size + 1].Should().Be(blue); img[size + 1, size + 1].Should().Be(default(Rgb24)); } [Fact] public async Task StitchAsync_DeduplicatesAndSortsWhenRequested_AZ367() { // Arrange const int size = 4; var color = new Rgb24(123, 45, 67); var sameCellTileA = CreateSolidTile("dupA", size, color); var sameCellTileB = CreateSolidTile("dupB", size, color); var other = CreateSolidTile("other", size, color); var tiles = new[] { new TilePlacement(5, 5, sameCellTileA), new TilePlacement(5, 5, sameCellTileB), new TilePlacement(6, 5, other), }; // Act var result = await new TileGridStitcher().StitchAsync( tiles, tileSizePixels: size, deduplicateByTileCoords: true, swallowTileLoadErrors: false); // Assert using var img = result.Image; result.PlacedTiles.Should().HaveCount(2); result.PlacedTiles.Select(p => (p.TileX, p.TileY)) .Should().BeEquivalentTo(new[] { (5, 5), (6, 5) }); } [Fact] public async Task StitchAsync_SwallowsLoadErrors_WhenFlagSet_AZ367() { // Arrange const int size = 4; var good = CreateSolidTile("good", size, new Rgb24(1, 2, 3)); var ghost = Path.Combine(_tempDir, "missing.png"); var sut = new TileGridStitcher(); var tiles = new[] { new TilePlacement(0, 0, good), new TilePlacement(1, 0, ghost), }; // Act var result = await sut.StitchAsync( tiles, tileSizePixels: size, deduplicateByTileCoords: false, swallowTileLoadErrors: true); // Assert using var img = result.Image; result.PlacedTiles.Should().HaveCount(1); result.MissingTiles.Should().ContainSingle() .Which.Reason.Should().Be(MissingTileReason.FileNotFound); } [Fact] public async Task StitchAsync_PropagatesLoadErrors_WhenFlagFalse_AZ367() { // Arrange — corrupt file (text bytes pretending to be PNG) const int size = 4; var good = CreateSolidTile("good", size, new Rgb24(1, 2, 3)); var corrupt = Path.Combine(_tempDir, "corrupt.png"); await File.WriteAllTextAsync(corrupt, "this is not a png file"); var tiles = new[] { new TilePlacement(0, 0, good), new TilePlacement(1, 0, corrupt), }; // Act Func act = () => new TileGridStitcher().StitchAsync( tiles, tileSizePixels: size, deduplicateByTileCoords: false, swallowTileLoadErrors: false); // Assert await act.Should().ThrowAsync(); } [Fact] public async Task StitchAsync_EmptyTiles_ThrowsInvalidOperation_AZ367() { // Arrange var sut = new TileGridStitcher(); // Act Func act = () => sut.StitchAsync( Array.Empty(), tileSizePixels: 256, deduplicateByTileCoords: false, swallowTileLoadErrors: false); // Assert await act.Should().ThrowAsync(); } [Fact] public async Task StitchAsync_NonPositiveTileSize_ThrowsArgumentOutOfRange_AZ367() { // Arrange var sut = new TileGridStitcher(); var tiles = new[] { new TilePlacement(0, 0, "/nope") }; // Act Func act = () => sut.StitchAsync(tiles, 0, false, false); // Assert await act.Should().ThrowAsync(); } [Fact] public void DrawCross_PaintsHorizontalAndVerticalArms_AtCenter_AZ367_AC1() { // Arrange const int width = 20; using var img = new Image(width, width); var red = new Rgb24(255, 0, 0); var sut = new TileGridStitcher(); // Act sut.DrawCross(img, new Point(10, 10), red, armLength: 4, thickness: 2); // Assert — horizontal arm pixels img[6, 10].Should().Be(red); img[14, 10].Should().Be(red); img[10, 6].Should().Be(red); img[10, 14].Should().Be(red); // Pixel outside the arm length stays default img[5, 10].Should().Be(default(Rgb24)); img[10, 5].Should().Be(default(Rgb24)); } [Fact] public void DrawCross_ProducesIdenticalOutputToInlinedPreRefactorCross_AZ367_AC2() { // Arrange — replicate the route stitcher's pre-refactor DrawCross const int width = 200; const int height = 200; var color = new Rgb24(255, 0, 0); const int armLength = 50; const int thickness = 10; var center = new Point(100, 100); using var newImg = new Image(width, height); using var oldImg = new Image(width, height); // Act — new path through TileGridStitcher new TileGridStitcher().DrawCross(newImg, center, color, armLength, thickness); // Act — old path inlined int halfThickness = thickness / 2; for (int dx = -armLength; dx <= armLength; dx++) { for (int t = -halfThickness; t <= halfThickness; t++) { int x = center.X + dx; int y = center.Y + t; if (x >= 0 && x < width && y >= 0 && y < height) oldImg[x, y] = color; } } for (int dy = -armLength; dy <= armLength; dy++) { for (int t = -halfThickness; t <= halfThickness; t++) { int x = center.X + t; int y = center.Y + dy; if (x >= 0 && x < width && y >= 0 && y < height) oldImg[x, y] = color; } } // Assert — every pixel identical for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { newImg[x, y].Should().Be(oldImg[x, y], $"pixel ({x},{y}) must match pre-refactor cross"); } } } [Fact] public void DrawRectangleBorder_DrawsTopBottomLeftRightWalls_AZ367_AC1() { // Arrange const int size = 40; using var img = new Image(size, size); var yellow = new Rgb24(255, 255, 0); var sut = new TileGridStitcher(); // Act sut.DrawRectangleBorder(img, new Rectangle(5, 5, 30, 30), yellow, thickness: 3); // Assert — corners are yellow img[5, 5].Should().Be(yellow); img[34, 5].Should().Be(yellow); img[5, 34].Should().Be(yellow); img[34, 34].Should().Be(yellow); // Interior pixel (beyond 3-px wall) stays untouched img[20, 20].Should().Be(default(Rgb24)); } [Fact] public void DrawRectangleBorder_ProducesIdenticalOutputToInlinedPreRefactorBorder_AZ367_AC2() { // Arrange — replicate the route stitcher's pre-refactor DrawRectangleBorder const int width = 100; const int height = 100; var color = new Rgb24(255, 255, 0); const int thickness = 5; int x1 = 10, y1 = 15, x2 = 80, y2 = 70; using var newImg = new Image(width, height); using var oldImg = new Image(width, height); // Act — new path uses Rectangle (inclusive bounds (x1,y1)..(x2,y2)) new TileGridStitcher().DrawRectangleBorder( newImg, new Rectangle(x1, y1, x2 - x1 + 1, y2 - y1 + 1), color, thickness); // Act — old path inlined for (int t = 0; t < thickness; t++) { for (int x = x1; x <= x2; x++) { int topY = y1 + t; int bottomY = y2 - t; if (x >= 0 && x < width && topY >= 0 && topY < height) oldImg[x, topY] = color; if (x >= 0 && x < width && bottomY >= 0 && bottomY < height) oldImg[x, bottomY] = color; } for (int y = y1; y <= y2; y++) { int leftX = x1 + t; int rightX = x2 - t; if (leftX >= 0 && leftX < width && y >= 0 && y < height) oldImg[leftX, y] = color; if (rightX >= 0 && rightX < width && y >= 0 && y < height) oldImg[rightX, y] = color; } } // Assert — every pixel identical for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { newImg[x, y].Should().Be(oldImg[x, y], $"pixel ({x},{y}) must match pre-refactor border"); } } } [Fact] public async Task StitchAsync_OutputMatchesInlinedPreRefactorRegionStitch_AZ367_AC2() { // Arrange — three solid tiles at the same coords pre/post refactor const int size = 8; var c1 = new Rgb24(10, 20, 30); var c2 = new Rgb24(40, 50, 60); var c3 = new Rgb24(70, 80, 90); var p1 = CreateSolidTile("p1", size, c1); var p2 = CreateSolidTile("p2", size, c2); var p3 = CreateSolidTile("p3", size, c3); var coords = new[] { (TileX: 100, TileY: 200, Path: p1), (TileX: 101, TileY: 200, Path: p2), (TileX: 100, TileY: 201, Path: p3), }; var placements = coords.Select(c => new TilePlacement(c.TileX, c.TileY, c.Path)).ToList(); // Act — new path var result = await new TileGridStitcher().StitchAsync( placements, tileSizePixels: size, deduplicateByTileCoords: false, swallowTileLoadErrors: false); // Act — old path inlined (region stitcher pre-refactor) var minX = coords.Min(t => t.TileX); var maxX = coords.Max(t => t.TileX); var minY = coords.Min(t => t.TileY); var maxY = coords.Max(t => t.TileY); var imageWidth = (maxX - minX + 1) * size; var imageHeight = (maxY - minY + 1) * size; using var oldImg = new Image(imageWidth, imageHeight); foreach (var (x, y, filePath) in coords) { using var tileImg = await Image.LoadAsync(filePath); var destX = (x - minX) * size; var destY = (y - minY) * size; oldImg.Mutate(ctx => ctx.DrawImage(tileImg, new Point(destX, destY), 1f)); } // Assert using var newImg = result.Image; newImg.Width.Should().Be(oldImg.Width); newImg.Height.Should().Be(oldImg.Height); for (int y = 0; y < newImg.Height; y++) { for (int x = 0; x < newImg.Width; x++) { newImg[x, y].Should().Be(oldImg[x, y], $"pixel ({x},{y}) must match pre-refactor stitch"); } } } }