using System; using System.Numerics; using Content.Shared.Humanoid; using NUnit.Framework; using Robust.Shared.Maths; using Robust.Shared.Random; namespace Content.Tests.Shared.Preferences.Humanoid; [TestFixture] [TestOf(typeof(HumanTonedSkinColoration))] [TestOf(typeof(ClampedHslColoration))] [TestOf(typeof(ClampedHsvColoration))] public sealed class SkinTonesTest { // These fields will track the maximum observed floating-point drift across all tests. // This is for monitoring, even if tests pass due to a sufficiently large Epsilon in production code. private static float _maxHslDrift; private static float _maxHsvDrift; [OneTimeTearDown] public void OneTimeTearDown() { // After all tests in this fixture run, print the final results. // This gives insight into the actual precision loss, even if VerifySkinColor passes. TestContext.Out.WriteLine("\n--- FINAL DRIFT SUMMARY FOR ALL CLAMPING TESTS ---"); TestContext.Out.WriteLine($"Maximum observed HSL drift: {_maxHslDrift:E}"); // Scientific notation for precision TestContext.Out.WriteLine($"Maximum observed HSV drift: {_maxHsvDrift:E}"); TestContext.Out.WriteLine("This indicates the actual max floating-point error observed. Production code's Epsilon should be >= this value."); TestContext.Out.WriteLine("--------------------------------------------------"); } /// /// Checks that colors generated by HumanTonedSkinColoration.FromUnary pass verification. /// [Test] public void TestHumanSkinTonesFromUnaryAreValid() { var strategy = new HumanTonedSkinColoration(); // Testing across a finer range to hit more edge cases for (var i = 0; i <= 10000; i++) { var unaryInput = i / 100f; // Test values like 0.0, 0.01, ..., 100.0 var color = strategy.FromUnary(unaryInput); Assert.That(strategy.VerifySkinColor(color), $"Color {color} from unary value {unaryInput} failed verification."); } } /// /// Checks that converting a unary value to a color and back results in a similar unary value. /// [Test] public void TestHumanTonedSkinColoration_RoundTrip() { var strategy = new HumanTonedSkinColoration(); // Test values across the full range, including transition points for (var i = 0; i <= 10000; i++) { var originalUnary = i / 100f; var color = strategy.FromUnary(originalUnary); var resultUnary = strategy.ToUnary(color); // A small tolerance is expected due to float precision and the nature of HSV conversions // as well as the rounding logic in ToUnary. Assert.That(resultUnary, Is.EqualTo(originalUnary).Within(1e-2f), // 1e-2f (0.01) is 1% of the unary range, which is reasonable for rounding and float error. $"Round trip failed for unary {originalUnary}. Got {resultUnary} back."); } } /// /// Checks that the default human skin tone is considered valid. /// [Test] public void TestDefaultHumanSkinToneValid() { var strategy = new HumanTonedSkinColoration(); Assert.That(strategy.VerifySkinColor(strategy.ValidHumanSkinTone)); } /// /// Checks that clamping random colors with a low-saturation, high-lightness HSL strategy produces valid colors. /// This was the primary test case that originally revealed the precision bug. /// [Test] public void TestTintedHuesValidHsl() { var random = new RobustRandom(); var strategy = new ClampedHslColoration() { Saturation = (0.0f, 0.1f), Lightness = (0.85f, 1.0f), }; for (var i = 0; i <= 10000; i++) { var color = new Color(random.NextFloat(), random.NextFloat(), random.NextFloat()); var skinColor = strategy.ClosestSkinColor(color); LogDriftIfGreater(strategy, color, skinColor, TestContext.CurrentContext.Test.Name); // Monitor drift Assert.That(strategy.VerifySkinColor(skinColor), $"Color {skinColor} (from input {color}) failed verification in {TestContext.CurrentContext.Test.Name} on iteration {i}"); } } /// /// Checks that clamping random colors with a low-saturation, high-value HSV strategy produces valid colors. /// [Test] public void TestTintedHuesValidHsv() { var random = new RobustRandom(); var strategy = new ClampedHsvColoration() { Saturation = (0.0f, 0.1f), Value = (0.85f, 1.0f), }; for (var i = 0; i <= 10000; i++) { var color = new Color(random.NextFloat(), random.NextFloat(), random.NextFloat()); var skinColor = strategy.ClosestSkinColor(color); LogDriftIfGreater(strategy, color, skinColor, TestContext.CurrentContext.Test.Name); // Monitor drift Assert.That(strategy.VerifySkinColor(skinColor), $"Color {skinColor} (from input {color}) failed verification in {TestContext.CurrentContext.Test.Name} on iteration {i}"); } } /// /// Checks that clamping random colors with an HSL strategy that limits all three channels produces valid colors. /// [Test] public void TestClampedHslWithAllChannels() { var random = new RobustRandom(); var strategy = new ClampedHslColoration() { Hue = (0.1f, 0.3f), Saturation = (0.2f, 0.8f), Lightness = (0.3f, 0.7f), }; for (var i = 0; i <= 10000; i++) { var color = new Color(random.NextFloat(), random.NextFloat(), random.NextFloat()); var skinColor = strategy.ClosestSkinColor(color); LogDriftIfGreater(strategy, color, skinColor, TestContext.CurrentContext.Test.Name); // Monitor drift Assert.That(strategy.VerifySkinColor(skinColor), $"Color {skinColor} (from input {color}) failed verification in {TestContext.CurrentContext.Test.Name} on iteration {i}"); } } /// /// Checks that clamping random colors with an HSV strategy that limits all three channels produces valid colors. /// [Test] public void TestClampedHsvWithAllChannels() { var random = new RobustRandom(); var strategy = new ClampedHsvColoration() { Hue = (0.1f, 0.3f), Saturation = (0.2f, 0.8f), Value = (0.3f, 0.7f), }; for (var i = 0; i <= 10000; i++) { var color = new Color(random.NextFloat(), random.NextFloat(), random.NextFloat()); var skinColor = strategy.ClosestSkinColor(color); LogDriftIfGreater(strategy, color, skinColor, TestContext.CurrentContext.Test.Name); // Monitor drift Assert.That(strategy.VerifySkinColor(skinColor), $"Color {skinColor} (from input {color}) failed verification in {TestContext.CurrentContext.Test.Name} on iteration {i}"); } } /// /// Checks that clamping works correctly for HSL strategies where the hue range wraps around the 0-1 boundary. /// [Test] public void TestClampedHslWithCircularHue() { var random = new RobustRandom(); var strategy = new ClampedHslColoration() { Hue = (0.9f, 0.1f), // A range that wraps around 1.0 (e.g., reds) Saturation = (0.5f, 1.0f), Lightness = (0.5f, 1.0f), }; for (var i = 0; i <= 10000; i++) { var color = new Color(random.NextFloat(), random.NextFloat(), random.NextFloat()); var skinColor = strategy.ClosestSkinColor(color); LogDriftIfGreater(strategy, color, skinColor, TestContext.CurrentContext.Test.Name); // Monitor drift Assert.That(strategy.VerifySkinColor(skinColor), $"Color {skinColor} (from input {color}) with circular hue failed verification in {TestContext.CurrentContext.Test.Name} on iteration {i}"); } } /// /// Checks that a color that is already valid is not modified. /// [Test] public void TestClosestSkinColorReturnsValidColor() { var strategy = new ClampedHslColoration() { Saturation = (0.0f, 1.0f), Lightness = (0.0f, 1.0f), }; var validColor = Color.FromHsl(new Vector4(0.5f, 0.5f, 0.5f, 1.0f)); var result = strategy.ClosestSkinColor(validColor); Assert.That(strategy.VerifySkinColor(result), Is.True); } /// /// Checks that a color outside the valid range is correctly clamped to a valid color. /// [Test] public void TestClosestSkinColorClampsInvalidColor() { var strategy = new ClampedHslColoration() { Saturation = (0.0f, 0.1f), Lightness = (0.85f, 1.0f), }; // This color has high saturation and low lightness, should be clamped var invalidColor = Color.FromHsl(new Vector4(0.5f, 0.9f, 0.2f, 1.0f)); var result = strategy.ClosestSkinColor(invalidColor); Assert.That(strategy.VerifySkinColor(result), Is.True); Assert.That(result, Is.Not.EqualTo(invalidColor)); } /// /// Helper method to calculate and log the maximum floating-point drift observed during clamping. /// This is for monitoring the behavior of the clamping, not for causing test failures directly. /// private void LogDriftIfGreater(ISkinColorationStrategy strategy, Color original, Color clamped, string testName) { if (strategy is ClampedHslColoration hslStrategy) { var hsl = Color.ToHsl(clamped); var (minSat, maxSat) = hslStrategy.Saturation ?? (0f, 1f); var (minLight, maxLight) = hslStrategy.Lightness ?? (0f, 1f); // Re-calculate the drift from the original bounds *without* applying Epsilon // This shows the pure floating-point error relative to the intended boundaries. var satDrift = Math.Max(minSat - hsl.Y, hsl.Y - maxSat); var lightDrift = Math.Max(minLight - hsl.Z, hsl.Z - maxLight); var currentDrift = Math.Max(satDrift, lightDrift); if (currentDrift > _maxHslDrift) { TestContext.Out.WriteLine($"--- NEW MAX HSL DRIFT DETECTED in {testName} ---"); TestContext.Out.WriteLine($"Max HSL Drift: {currentDrift:E} (previously {_maxHslDrift:E})"); TestContext.Out.WriteLine($"Original RGB: {original}"); TestContext.Out.WriteLine($"Clamped RGB: {clamped}"); TestContext.Out.WriteLine($"Result HSL: H={hsl.X:F8}, S={hsl.Y:F8}, L={hsl.Z:F8}"); TestContext.Out.WriteLine($"Bounds: S=({minSat:F8}, {maxSat:F8}), L=({minLight:F8}, {maxLight:F8})"); _maxHslDrift = currentDrift; } } else if (strategy is ClampedHsvColoration hsvStrategy) { var hsv = Color.ToHsv(clamped); var (minSat, maxSat) = hsvStrategy.Saturation ?? (0f, 1f); var (minValue, maxValue) = hsvStrategy.Value ?? (0f, 1f); var satDrift = Math.Max(minSat - hsv.Y, hsv.Y - maxSat); var valueDrift = Math.Max(minValue - hsv.Z, hsv.Z - maxValue); var currentDrift = Math.Max(satDrift, valueDrift); if (currentDrift > _maxHsvDrift) { TestContext.Out.WriteLine($"--- NEW MAX HSV DRIFT DETECTED in {testName} ---"); TestContext.Out.WriteLine($"Max HSV Drift: {currentDrift:E} (previously {_maxHsvDrift:E})"); TestContext.Out.WriteLine($"Original RGB: {original}"); TestContext.Out.WriteLine($"Clamped RGB: {clamped}"); TestContext.Out.WriteLine($"Result HSV: H={hsv.X:F8}, S={hsv.Y:F8}, V={hsv.Z:F8}"); TestContext.Out.WriteLine($"Bounds: S=({minSat:F8}, {maxSat:F8}), V=({minValue:F8}, {maxValue:F8})"); _maxHsvDrift = currentDrift; } } } }