3.4 ImagePipelineTests golden fixtures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
168
tests/FrameProcessor.Tests/ImagePipelineTests.cs
Normal file
168
tests/FrameProcessor.Tests/ImagePipelineTests.cs
Normal file
@@ -0,0 +1,168 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using FrameProcessor.Domain;
|
||||
using FrameProcessor.ImagePipeline;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Jpeg;
|
||||
using SixLabors.ImageSharp.Formats.Png;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
|
||||
namespace FrameProcessor.Tests;
|
||||
|
||||
public class ImagePipelineTests
|
||||
{
|
||||
// Canonical Spectra 6 palette from SPEC §6.2.
|
||||
private static readonly IReadOnlyList<PaletteEntry> Spectra6 = new[]
|
||||
{
|
||||
new PaletteEntry("black", Color.FromPixel(new Rgba32(0x1F, 0x22, 0x26, 0xFF)), Color.FromPixel(new Rgba32(0x00, 0x00, 0x00, 0xFF))),
|
||||
new PaletteEntry("white", Color.FromPixel(new Rgba32(0xB9, 0xC7, 0xC9, 0xFF)), Color.FromPixel(new Rgba32(0xFF, 0xFF, 0xFF, 0xFF))),
|
||||
new PaletteEntry("blue", Color.FromPixel(new Rgba32(0x23, 0x3F, 0x8E, 0xFF)), Color.FromPixel(new Rgba32(0x00, 0x00, 0xFF, 0xFF))),
|
||||
new PaletteEntry("green", Color.FromPixel(new Rgba32(0x35, 0x56, 0x3A, 0xFF)), Color.FromPixel(new Rgba32(0x00, 0xFF, 0x00, 0xFF))),
|
||||
new PaletteEntry("red", Color.FromPixel(new Rgba32(0x62, 0x20, 0x1E, 0xFF)), Color.FromPixel(new Rgba32(0xFF, 0x00, 0x00, 0xFF))),
|
||||
new PaletteEntry("yellow", Color.FromPixel(new Rgba32(0xC1, 0xBB, 0x1E, 0xFF)), Color.FromPixel(new Rgba32(0xFF, 0xFF, 0x00, 0xFF))),
|
||||
};
|
||||
|
||||
private static readonly Frame LandscapeFrame = new(
|
||||
FrameName.Parse("test"),
|
||||
MacAddress.Parse("aabbccddeeff"),
|
||||
new Resolution(1600, 1200),
|
||||
Orientation.Landscape,
|
||||
DitherAlgorithm.FloydSteinberg,
|
||||
Spectra6);
|
||||
|
||||
private static readonly string[] InputNames =
|
||||
{
|
||||
"landscape.jpg",
|
||||
"portrait.jpg",
|
||||
"tiny-1x1.png",
|
||||
"non-divisible.jpg",
|
||||
};
|
||||
|
||||
public static IEnumerable<object[]> AllCombinations()
|
||||
{
|
||||
foreach (var algo in Enum.GetValues<DitherAlgorithm>())
|
||||
{
|
||||
foreach (var input in InputNames)
|
||||
{
|
||||
yield return new object[] { algo, input };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllCombinations))]
|
||||
public void Process_MatchesGoldenFixture(DitherAlgorithm algorithm, string inputName)
|
||||
{
|
||||
var inputPath = FixturePath("inputs", inputName);
|
||||
var expectedPath = FixturePath("expected", AlgorithmKey(algorithm), Path.ChangeExtension(inputName, ".png"));
|
||||
|
||||
Assert.True(File.Exists(inputPath), $"Missing input fixture: {inputPath}");
|
||||
Assert.True(
|
||||
File.Exists(expectedPath),
|
||||
$"Missing golden fixture: {expectedPath}. Regenerate by running 'UPDATE_GOLDEN=1 dotnet test'.");
|
||||
|
||||
var frame = LandscapeFrame with { Dithering = algorithm };
|
||||
var pipeline = new FrameProcessor.ImagePipeline.ImagePipeline();
|
||||
using var stream = File.OpenRead(inputPath);
|
||||
var actual = pipeline.Process(stream, frame);
|
||||
var expected = File.ReadAllBytes(expectedPath);
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GenerateFixtures()
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("UPDATE_GOLDEN") != "1")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
WriteInputFixtures();
|
||||
|
||||
var pipeline = new FrameProcessor.ImagePipeline.ImagePipeline();
|
||||
foreach (var algorithm in Enum.GetValues<DitherAlgorithm>())
|
||||
{
|
||||
foreach (var input in InputNames)
|
||||
{
|
||||
var frame = LandscapeFrame with { Dithering = algorithm };
|
||||
using var stream = File.OpenRead(FixturePath("inputs", input));
|
||||
var bytes = pipeline.Process(stream, frame);
|
||||
var outputPath = FixturePath(
|
||||
"expected",
|
||||
AlgorithmKey(algorithm),
|
||||
Path.ChangeExtension(input, ".png"));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
|
||||
File.WriteAllBytes(outputPath, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteInputFixtures()
|
||||
{
|
||||
var inputsDir = FixturePath("inputs");
|
||||
Directory.CreateDirectory(inputsDir);
|
||||
|
||||
WriteJpeg(Path.Combine(inputsDir, "landscape.jpg"), 2000, 1000);
|
||||
WriteJpeg(Path.Combine(inputsDir, "portrait.jpg"), 1200, 1600);
|
||||
WriteJpeg(Path.Combine(inputsDir, "non-divisible.jpg"), 1234, 789);
|
||||
WriteTinyPng(Path.Combine(inputsDir, "tiny-1x1.png"));
|
||||
}
|
||||
|
||||
private static void WriteJpeg(string path, int width, int height)
|
||||
{
|
||||
using var image = new Image<Rgba32>(width, height);
|
||||
FillGradient(image);
|
||||
var encoder = new JpegEncoder { Quality = 90 };
|
||||
using var fs = File.Create(path);
|
||||
image.SaveAsJpeg(fs, encoder);
|
||||
}
|
||||
|
||||
private static void WriteTinyPng(string path)
|
||||
{
|
||||
using var image = new Image<Rgba32>(1, 1);
|
||||
image[0, 0] = new Rgba32(0x80, 0x40, 0xC0, 0xFF);
|
||||
var encoder = new PngEncoder();
|
||||
using var fs = File.Create(path);
|
||||
image.SaveAsPng(fs, encoder);
|
||||
}
|
||||
|
||||
private static void FillGradient(Image<Rgba32> image)
|
||||
{
|
||||
var w = image.Width;
|
||||
var h = image.Height;
|
||||
image.ProcessPixelRows(accessor =>
|
||||
{
|
||||
for (var y = 0; y < accessor.Height; y++)
|
||||
{
|
||||
var row = accessor.GetRowSpan(y);
|
||||
for (var x = 0; x < row.Length; x++)
|
||||
{
|
||||
var r = (byte)(x * 255 / Math.Max(1, w - 1));
|
||||
var g = (byte)(y * 255 / Math.Max(1, h - 1));
|
||||
var b = (byte)(((x + y) * 255) / Math.Max(1, w + h - 2));
|
||||
row[x] = new Rgba32(r, g, b, 0xFF);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static string AlgorithmKey(DitherAlgorithm algorithm) => algorithm switch
|
||||
{
|
||||
DitherAlgorithm.FloydSteinberg => "floyd-steinberg",
|
||||
DitherAlgorithm.Atkinson => "atkinson",
|
||||
DitherAlgorithm.Stucki => "stucki",
|
||||
DitherAlgorithm.Jarvis => "jarvis",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(algorithm)),
|
||||
};
|
||||
|
||||
private static string FixturePath(params string[] parts)
|
||||
{
|
||||
var combined = new string[parts.Length + 1];
|
||||
combined[0] = FixturesRoot();
|
||||
Array.Copy(parts, 0, combined, 1, parts.Length);
|
||||
return Path.Combine(combined);
|
||||
}
|
||||
|
||||
private static string FixturesRoot([CallerFilePath] string callerPath = "")
|
||||
=> Path.Combine(Path.GetDirectoryName(callerPath)!, "Fixtures");
|
||||
}
|
||||
Reference in New Issue
Block a user