From 78bcdaa4b86627f0079e5a18b5e4fa32da0d675a Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:22:45 +1100 Subject: [PATCH] test: add ColorPickerHelpers bitmap accuracy tests Verify that CreateComponentBitmapAsync with isPerceptive=false produces pixel output that reflects the actual HSV selection values. Covers all three component types (Hue, Saturation, Value) with assertions derived from HSV color math invariants. Also adds InternalsVisibleTo and project reference to enable testing the internal ColorPickerHelpers from Avalonia.Controls.UnitTests. --- .../Avalonia.Controls.ColorPicker.csproj | 1 + .../Avalonia.Controls.UnitTests.csproj | 1 + .../ColorPickerHelpersTests.cs | 110 ++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 tests/Avalonia.Controls.UnitTests/ColorPickerTests/ColorPickerHelpersTests.cs diff --git a/src/Avalonia.Controls.ColorPicker/Avalonia.Controls.ColorPicker.csproj b/src/Avalonia.Controls.ColorPicker/Avalonia.Controls.ColorPicker.csproj index 473190fe59..b6aa1fc9a4 100644 --- a/src/Avalonia.Controls.ColorPicker/Avalonia.Controls.ColorPicker.csproj +++ b/src/Avalonia.Controls.ColorPicker/Avalonia.Controls.ColorPicker.csproj @@ -23,6 +23,7 @@ + diff --git a/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj b/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj index a92ba0dc83..948c413c82 100644 --- a/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj +++ b/tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj @@ -23,6 +23,7 @@ + diff --git a/tests/Avalonia.Controls.UnitTests/ColorPickerTests/ColorPickerHelpersTests.cs b/tests/Avalonia.Controls.UnitTests/ColorPickerTests/ColorPickerHelpersTests.cs new file mode 100644 index 0000000000..605c48caf6 --- /dev/null +++ b/tests/Avalonia.Controls.UnitTests/ColorPickerTests/ColorPickerHelpersTests.cs @@ -0,0 +1,110 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Collections.Pooled; +using Avalonia.Controls.Primitives; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.UnitTests; +using Xunit; + +namespace Avalonia.Controls.UnitTests.ColorPickerTests; + +/// +/// Tests that produces +/// accurate pixel output when isPerceptive=false — the mode the ColorSpectrum's +/// third-component slider should use. +/// +/// Regression tests for https://github.com/AvaloniaUI/Avalonia/issues/20925 +/// +public class ColorPickerHelpersTests : ScopedTestBase +{ + /// + /// When sweeping Hue at a fixed desaturated selection (S=0.2, V=0.5), + /// every pixel's brightest channel should be bounded by V — not boosted to 255. + /// + [Fact] + public async Task Hue_Slider_Bitmap_Reflects_Actual_Saturation_And_Value() + { + var selection = new HsvColor(alpha: 1.0, hue: 180, saturation: 0.2, value: 0.5); + + using var pixels = await GenerateSliderBitmap(ColorComponent.Component1, selection, height: 360); + + // Sample mid-strip. In HSV, max RGB channel = V for any hue. + var (r, g, b) = ReadPixel(pixels, row: 180); + var maxChannel = Math.Max(r, Math.Max(g, b)); + double expectedMax = selection.V * 255; + + Assert.InRange(maxChannel, expectedMax - 10, expectedMax + 10); + } + + /// + /// When sweeping Saturation, the brightest channel at full saturation is bounded + /// by the current Value — not forced to 255 by perceptive V=1.0 override. + /// + [Theory] + [InlineData(0.1)] + [InlineData(0.5)] + [InlineData(0.9)] + public async Task Saturation_Slider_Bitmap_Respects_Current_Value(double currentValue) + { + var selection = new HsvColor(alpha: 1.0, hue: 200, saturation: 0.8, value: currentValue); + + using var pixels = await GenerateSliderBitmap(ColorComponent.Component2, selection); + + // Row 0 = highest saturation (bitmap sweeps high→low top-to-bottom). + var (r, g, b) = ReadPixel(pixels, row: 0); + var maxChannel = Math.Max(r, Math.Max(g, b)); + double expectedMax = currentValue * 255; + + Assert.InRange(maxChannel, expectedMax - 20, expectedMax + 20); + } + + /// + /// When sweeping Value at low Saturation (S=0.1), the top pixel should be + /// near-grey (low chroma) — not vivid from a perceptive S=1.0 override. + /// + [Fact] + public async Task Value_Slider_Bitmap_Respects_Current_Saturation() + { + var selection = new HsvColor(alpha: 1.0, hue: 30, saturation: 0.1, value: 0.8); + + using var pixels = await GenerateSliderBitmap(ColorComponent.Component3, selection); + + // Row 0 = highest Value. In HSV, chroma = V * S * 255 ≤ S * 255. + var (r, g, b) = ReadPixel(pixels, row: 0); + int chroma = Math.Max(r, Math.Max(g, b)) - Math.Min(r, Math.Min(g, b)); + + // At S=0.1 the max possible chroma is 25.5 — near-grey for any V. + // With perceptive S=1.0 override, chroma would be ~252. + Assert.InRange(chroma, 0, selection.S * 255 + 10); + } + + private static async Task> GenerateSliderBitmap( + ColorComponent component, + HsvColor baseColor, + int height = 100) + { + const int width = 1; + var buffer = new PooledList(width * height * 4, ClearMode.Never, sizeToCapacity: true); + + await ColorPickerHelpers.CreateComponentBitmapAsync( + buffer, width, height, + Orientation.Vertical, + ColorModel.Hsva, + component, + baseColor, + isAlphaVisible: false, + isPerceptive: false); + + return buffer; + } + + /// + /// Reads a pixel from a 1-pixel-wide vertical BGRA bitmap at the given row. + /// + private static (byte R, byte G, byte B) ReadPixel(PooledList bgraData, int row) + { + int offset = row * 4; + return (R: bgraData[offset + 2], G: bgraData[offset + 1], B: bgraData[offset]); + } +}