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

@@ -108,7 +108,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor.
- Steps (`SPEC.md` §3.2): decode → resize (cover/center-crop to oriented resolution) → dither against display palette → remap pixels display→device → rotate 90° CW if portrait → encode indexed PNG (`PngColorType.Palette`, smallest bit depth fitting the palette, `PaletteQuantizer(deviceColors)`).
- Output dimensions always match the frame's *declared* `width × height` regardless of orientation.
### [ ] 3.4 `ImagePipelineTests` golden fixtures
### [x] 3.4 `ImagePipelineTests` golden fixtures
- Check in `tests/FrameProcessor.Tests/Fixtures/inputs/` (landscape.jpg, portrait.jpg, tiny-1x1.png, non-divisible.jpg).
- Generate expected outputs once, commit under `Fixtures/expected/<algo>/<input>.png`.
- Assert byte-equality of produced PNG vs golden.

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 B

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