diff --git a/CHANGELOG.md b/CHANGELOG.md index 6851ca2..6c4bed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,5 +14,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `IEnumerable` extension methods - `IConfiguration` extension methods - `IServiceCollection` extension methods +- `String` extension methods - `IFormFile` extension method (AspNetCore package only) - `DbSet` extension methods for ISortableEntity interface (EntityFrameworkCore package only) diff --git a/Neolution.Utilities.UnitTests/Extensions/StringExtensionsTests.cs b/Neolution.Utilities.UnitTests/Extensions/StringExtensionsTests.cs new file mode 100644 index 0000000..3addce3 --- /dev/null +++ b/Neolution.Utilities.UnitTests/Extensions/StringExtensionsTests.cs @@ -0,0 +1,131 @@ +namespace Neolution.Utilities.UnitTests.Extensions; + +using Neolution.Utilities.Extensions; +using Xunit; + +/// +/// Unit tests for . +/// +public class StringExtensionsTests +{ + /// + /// Truncate returns the expected result. + /// + /// The value. + /// The maximum length. + /// The expected. + [Theory] + [InlineData(null, 5, null)] + [InlineData("", 5, "")] + [InlineData("abc", 5, "abc")] + [InlineData("abcdef", 3, "abc")] + [InlineData("abcdef", 6, "abcdef")] + public void Truncate_ReturnsExpectedResult(string? value, int maxLength, string? expected) + { + // Act + var result = value.Truncate(maxLength); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Truncate with suffix returns the expected result when suffix length does not exceed maxLength. + /// maxLength represents the total length of the resulting string. + /// + /// The value. + /// The maximum (final) length. + /// The suffix. + /// The expected. + [Theory] + [InlineData(null, 5, "...", null)] + [InlineData("", 5, "...", "")] + [InlineData("abc", 5, "...", "abc")] // no truncation because length <= maxLength + [InlineData("abcdef", 6, "...", "abcdef")] // no truncation + [InlineData("abcdef", 3, "...", "...")] // suffix length == maxLength => only suffix + [InlineData("abcdef", 4, "-", "abc-")] // keep 3 chars + '-' + [InlineData("abcdef", 4, "", "abcd")] // empty suffix behaves like plain truncate + [InlineData("abcdef", 0, "", "")] // zero length with empty suffix + public void Truncate_WithSuffix_ReturnsExpectedResult(string? value, int maxLength, string suffix, string? expected) + { + // Act + var result = value.Truncate(maxLength, suffix); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Truncating with a negative maxLength throws an . + /// + [Fact] + public void Truncate_NegativeMaxLength_Throws() + { + Assert.Throws(() => "abc".Truncate(-1)); + Assert.Throws(() => "abc".Truncate(-5, "..")); + } + + /// + /// Truncating with a null suffix throws an . + /// + [Fact] + public void Truncate_NullSuffix_Throws() + { + Assert.Throws(() => "abc".Truncate(2, null!)); + } + + /// + /// Truncating with a suffix longer than maxLength throws an . + /// + [Fact] + public void Truncate_SuffixLongerThanMaxLength_Throws() + { + Assert.Throws(() => "abcdef".Truncate(3, "....")); + Assert.Throws(() => "abcdef".Truncate(0, ".")); + Assert.Throws(() => "abcdef".Truncate(2, "...")); + } + + /// + /// When the value length equals maxLength and a suffix is provided (shorter than maxLength), the value is returned unchanged. + /// + [Fact] + public void Truncate_ValueLengthEqualsMaxLength_WithSuffix_ReturnsUnchanged() + { + var value = "12345"; + var result = value.Truncate(5, "..."); + Assert.Equal("12345", result); + } + + /// + /// Result equals the suffix when suffix length == maxLength. + /// + [Fact] + public void Truncate_SuffixLengthEqualsMaxLength_ReturnsOnlySuffix() + { + var result = "abcdef".Truncate(3, "..."); + Assert.Equal("...", result); + } + + /// + /// Truncation works with Unicode surrogate pairs (keeping whole emoji, not splitting) when maxLength accommodates suffix + complete emoji. + /// + [Fact] + public void Truncate_UnicodeCharacters() + { + var value = "😀😃😄😁"; // 4 emoji (each surrogate pair) + var result = value.Truncate(5, "..."); // keep one emoji (2 code units) + '...' + Assert.Equal("😀...", result); + } + + /// + /// Truncation of a long string returns the expected prefix plus suffix with total length == maxLength. + /// + [Fact] + public void Truncate_LongString() + { + var value = new string('x', 1000); + var result = value.Truncate(10, "..."); // keep 7 + '...' + Assert.Equal(new string('x', 7) + "...", result); + Assert.Equal(10, result!.Length); + } +} diff --git a/Neolution.Utilities/Extensions/StringExtensions.cs b/Neolution.Utilities/Extensions/StringExtensions.cs new file mode 100644 index 0000000..7d4c5a6 --- /dev/null +++ b/Neolution.Utilities/Extensions/StringExtensions.cs @@ -0,0 +1,46 @@ +namespace Neolution.Utilities.Extensions; + +/// +/// String extensions. +/// +public static class StringExtensions +{ + /// + /// Truncates the string to the specified maximum length. + /// + /// The value. + /// The maximum length. + /// The truncated string + public static string? Truncate(this string? value, int maxLength) + { + return value.Truncate(maxLength, string.Empty); + } + + /// + /// Truncates the string to the specified maximum length, appending a suffix if necessary. + /// + /// The value. + /// The maximum length. + /// The suffix to append if the string is truncated. The suffix length must not exceed . + /// The truncated string. + /// Thrown when is negative. + /// Thrown when is null. + /// Thrown when is longer than . + public static string? Truncate(this string? value, int maxLength, string suffix) + { + ArgumentOutOfRangeException.ThrowIfNegative(maxLength); + ArgumentNullException.ThrowIfNull(suffix); + + if (suffix.Length > maxLength) + { + throw new ArgumentException("Suffix length must not exceed maxLength.", nameof(suffix)); + } + + if (string.IsNullOrEmpty(value) || value.Length <= maxLength) + { + return value; + } + + return $"{value[..(maxLength - suffix.Length)]}{suffix}"; + } +}