3.4 ImagePipelineTests golden fixtures
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@@ -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)`).
|
- 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.
|
- 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).
|
- 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`.
|
- Generate expected outputs once, commit under `Fixtures/expected/<algo>/<input>.png`.
|
||||||
- Assert byte-equality of produced PNG vs golden.
|
- Assert byte-equality of produced PNG vs golden.
|
||||||
|
|||||||
|
After Width: | Height: | Size: 252 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 274 KiB |
BIN
tests/FrameProcessor.Tests/Fixtures/expected/jarvis/portrait.png
Normal file
|
After Width: | Height: | Size: 288 KiB |
BIN
tests/FrameProcessor.Tests/Fixtures/expected/jarvis/tiny-1x1.png
Normal file
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 268 KiB |
BIN
tests/FrameProcessor.Tests/Fixtures/expected/stucki/portrait.png
Normal file
|
After Width: | Height: | Size: 280 KiB |
BIN
tests/FrameProcessor.Tests/Fixtures/expected/stucki/tiny-1x1.png
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
tests/FrameProcessor.Tests/Fixtures/inputs/landscape.jpg
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
tests/FrameProcessor.Tests/Fixtures/inputs/non-divisible.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
tests/FrameProcessor.Tests/Fixtures/inputs/portrait.jpg
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
tests/FrameProcessor.Tests/Fixtures/inputs/tiny-1x1.png
Normal file
|
After Width: | Height: | Size: 91 B |
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");
|
||||||
|
}
|
||||||