mirror of
https://github.com/azaion/satellite-provider.git
synced 2026-06-21 19:51:14 +00:00
10d31b4c1c
Co-authored-by: Cursor <cursoragent@cursor.com>
408 lines
13 KiB
C#
408 lines
13 KiB
C#
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<Rgb24>(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<Task> act = () => new TileGridStitcher().StitchAsync(
|
|
tiles,
|
|
tileSizePixels: size,
|
|
deduplicateByTileCoords: false,
|
|
swallowTileLoadErrors: false);
|
|
|
|
// Assert
|
|
await act.Should().ThrowAsync<Exception>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StitchAsync_EmptyTiles_ThrowsInvalidOperation_AZ367()
|
|
{
|
|
// Arrange
|
|
var sut = new TileGridStitcher();
|
|
|
|
// Act
|
|
Func<Task> act = () => sut.StitchAsync(
|
|
Array.Empty<TilePlacement>(),
|
|
tileSizePixels: 256,
|
|
deduplicateByTileCoords: false,
|
|
swallowTileLoadErrors: false);
|
|
|
|
// Assert
|
|
await act.Should().ThrowAsync<InvalidOperationException>();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StitchAsync_NonPositiveTileSize_ThrowsArgumentOutOfRange_AZ367()
|
|
{
|
|
// Arrange
|
|
var sut = new TileGridStitcher();
|
|
var tiles = new[] { new TilePlacement(0, 0, "/nope") };
|
|
|
|
// Act
|
|
Func<Task> act = () => sut.StitchAsync(tiles, 0, false, false);
|
|
|
|
// Assert
|
|
await act.Should().ThrowAsync<ArgumentOutOfRangeException>();
|
|
}
|
|
|
|
[Fact]
|
|
public void DrawCross_PaintsHorizontalAndVerticalArms_AtCenter_AZ367_AC1()
|
|
{
|
|
// Arrange
|
|
const int width = 20;
|
|
using var img = new Image<Rgb24>(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<Rgb24>(width, height);
|
|
using var oldImg = new Image<Rgb24>(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<Rgb24>(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<Rgb24>(width, height);
|
|
using var oldImg = new Image<Rgb24>(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<Rgb24>(imageWidth, imageHeight);
|
|
foreach (var (x, y, filePath) in coords)
|
|
{
|
|
using var tileImg = await Image.LoadAsync<Rgb24>(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");
|
|
}
|
|
}
|
|
}
|
|
}
|