295 lines
12 KiB
C#
295 lines
12 KiB
C#
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("--------------------------------------------------");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks that colors generated by HumanTonedSkinColoration.FromUnary pass verification.
|
|
/// </summary>
|
|
[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.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks that converting a unary value to a color and back results in a similar unary value.
|
|
/// </summary>
|
|
[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.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks that the default human skin tone is considered valid.
|
|
/// </summary>
|
|
[Test]
|
|
public void TestDefaultHumanSkinToneValid()
|
|
{
|
|
var strategy = new HumanTonedSkinColoration();
|
|
Assert.That(strategy.VerifySkinColor(strategy.ValidHumanSkinTone));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks that clamping random colors with a low-saturation, high-value HSV strategy produces valid colors.
|
|
/// </summary>
|
|
[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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks that clamping random colors with an HSL strategy that limits all three channels produces valid colors.
|
|
/// </summary>
|
|
[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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks that clamping random colors with an HSV strategy that limits all three channels produces valid colors.
|
|
/// </summary>
|
|
[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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks that clamping works correctly for HSL strategies where the hue range wraps around the 0-1 boundary.
|
|
/// </summary>
|
|
[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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks that a color that is already valid is not modified.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks that a color outside the valid range is correctly clamped to a valid color.
|
|
/// </summary>
|
|
[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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|