diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index f3779da..dae5d44 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -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//.png`. - Assert byte-equality of produced PNG vs golden. diff --git a/tests/FrameProcessor.Tests/Fixtures/expected/atkinson/landscape.png b/tests/FrameProcessor.Tests/Fixtures/expected/atkinson/landscape.png new file mode 100644 index 0000000..3f562b1 Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/expected/atkinson/landscape.png differ diff --git a/tests/FrameProcessor.Tests/Fixtures/expected/atkinson/non-divisible.png b/tests/FrameProcessor.Tests/Fixtures/expected/atkinson/non-divisible.png new file mode 100644 index 0000000..3822d8f Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/expected/atkinson/non-divisible.png differ diff --git a/tests/FrameProcessor.Tests/Fixtures/expected/atkinson/portrait.png b/tests/FrameProcessor.Tests/Fixtures/expected/atkinson/portrait.png new file mode 100644 index 0000000..92903fe Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/expected/atkinson/portrait.png differ diff --git a/tests/FrameProcessor.Tests/Fixtures/expected/atkinson/tiny-1x1.png b/tests/FrameProcessor.Tests/Fixtures/expected/atkinson/tiny-1x1.png new file mode 100644 index 0000000..e781477 Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/expected/atkinson/tiny-1x1.png differ diff --git a/tests/FrameProcessor.Tests/Fixtures/expected/floyd-steinberg/landscape.png b/tests/FrameProcessor.Tests/Fixtures/expected/floyd-steinberg/landscape.png new file mode 100644 index 0000000..7e0daee Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/expected/floyd-steinberg/landscape.png differ diff --git a/tests/FrameProcessor.Tests/Fixtures/expected/floyd-steinberg/non-divisible.png b/tests/FrameProcessor.Tests/Fixtures/expected/floyd-steinberg/non-divisible.png new file mode 100644 index 0000000..1454e2b Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/expected/floyd-steinberg/non-divisible.png differ diff --git a/tests/FrameProcessor.Tests/Fixtures/expected/floyd-steinberg/portrait.png b/tests/FrameProcessor.Tests/Fixtures/expected/floyd-steinberg/portrait.png new file mode 100644 index 0000000..cf260b4 Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/expected/floyd-steinberg/portrait.png differ diff --git a/tests/FrameProcessor.Tests/Fixtures/expected/floyd-steinberg/tiny-1x1.png b/tests/FrameProcessor.Tests/Fixtures/expected/floyd-steinberg/tiny-1x1.png new file mode 100644 index 0000000..d1a28b3 Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/expected/floyd-steinberg/tiny-1x1.png differ diff --git a/tests/FrameProcessor.Tests/Fixtures/expected/jarvis/landscape.png b/tests/FrameProcessor.Tests/Fixtures/expected/jarvis/landscape.png new file mode 100644 index 0000000..9d9e352 Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/expected/jarvis/landscape.png differ diff --git a/tests/FrameProcessor.Tests/Fixtures/expected/jarvis/non-divisible.png b/tests/FrameProcessor.Tests/Fixtures/expected/jarvis/non-divisible.png new file mode 100644 index 0000000..c1cd0eb Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/expected/jarvis/non-divisible.png differ diff --git a/tests/FrameProcessor.Tests/Fixtures/expected/jarvis/portrait.png b/tests/FrameProcessor.Tests/Fixtures/expected/jarvis/portrait.png new file mode 100644 index 0000000..edf479a Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/expected/jarvis/portrait.png differ diff --git a/tests/FrameProcessor.Tests/Fixtures/expected/jarvis/tiny-1x1.png b/tests/FrameProcessor.Tests/Fixtures/expected/jarvis/tiny-1x1.png new file mode 100644 index 0000000..031c609 Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/expected/jarvis/tiny-1x1.png differ diff --git a/tests/FrameProcessor.Tests/Fixtures/expected/stucki/landscape.png b/tests/FrameProcessor.Tests/Fixtures/expected/stucki/landscape.png new file mode 100644 index 0000000..a94c2eb Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/expected/stucki/landscape.png differ diff --git a/tests/FrameProcessor.Tests/Fixtures/expected/stucki/non-divisible.png b/tests/FrameProcessor.Tests/Fixtures/expected/stucki/non-divisible.png new file mode 100644 index 0000000..3d3555d Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/expected/stucki/non-divisible.png differ diff --git a/tests/FrameProcessor.Tests/Fixtures/expected/stucki/portrait.png b/tests/FrameProcessor.Tests/Fixtures/expected/stucki/portrait.png new file mode 100644 index 0000000..face582 Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/expected/stucki/portrait.png differ diff --git a/tests/FrameProcessor.Tests/Fixtures/expected/stucki/tiny-1x1.png b/tests/FrameProcessor.Tests/Fixtures/expected/stucki/tiny-1x1.png new file mode 100644 index 0000000..b019cc6 Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/expected/stucki/tiny-1x1.png differ diff --git a/tests/FrameProcessor.Tests/Fixtures/inputs/landscape.jpg b/tests/FrameProcessor.Tests/Fixtures/inputs/landscape.jpg new file mode 100644 index 0000000..d092130 Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/inputs/landscape.jpg differ diff --git a/tests/FrameProcessor.Tests/Fixtures/inputs/non-divisible.jpg b/tests/FrameProcessor.Tests/Fixtures/inputs/non-divisible.jpg new file mode 100644 index 0000000..77097cc Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/inputs/non-divisible.jpg differ diff --git a/tests/FrameProcessor.Tests/Fixtures/inputs/portrait.jpg b/tests/FrameProcessor.Tests/Fixtures/inputs/portrait.jpg new file mode 100644 index 0000000..c7eb196 Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/inputs/portrait.jpg differ diff --git a/tests/FrameProcessor.Tests/Fixtures/inputs/tiny-1x1.png b/tests/FrameProcessor.Tests/Fixtures/inputs/tiny-1x1.png new file mode 100644 index 0000000..8aec21a Binary files /dev/null and b/tests/FrameProcessor.Tests/Fixtures/inputs/tiny-1x1.png differ diff --git a/tests/FrameProcessor.Tests/ImagePipelineTests.cs b/tests/FrameProcessor.Tests/ImagePipelineTests.cs new file mode 100644 index 0000000..2ec7604 --- /dev/null +++ b/tests/FrameProcessor.Tests/ImagePipelineTests.cs @@ -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 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 AllCombinations() + { + foreach (var algo in Enum.GetValues()) + { + 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()) + { + 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(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(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 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"); +}