3.4 ImagePipelineTests golden fixtures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 14:41:32 +02:00
parent 5d0d5ed185
commit ad62ce00a5
22 changed files with 169 additions and 1 deletions

View 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");
}