diff --git a/CaseConverter.Test/CaseConverterTests.cs b/CaseConverter.Test/CaseConverterTests.cs
index 1457aa2..c72694e 100644
--- a/CaseConverter.Test/CaseConverterTests.cs
+++ b/CaseConverter.Test/CaseConverterTests.cs
@@ -43,7 +43,9 @@ public void ToTitleCaseShouldHandleMultipleSpaces()
{
string input = " the quick brown FOX ";
string result = input.ToTitleCase();
- Assert.AreEqual("The Quick Brown FOX", result);
+
+ // "FOX" is normalized rather than kept as an acronym, the same as it would be on its own.
+ Assert.AreEqual("The Quick Brown Fox", result);
}
[TestMethod]
@@ -83,7 +85,53 @@ public void ToTitleCaseShouldConvertToTitleCase()
{
string input = "the quick Brown FOX";
string result = input.ToTitleCase();
- Assert.AreEqual("The Quick Brown FOX", result);
+
+ // "FOX" is normalized rather than kept as an acronym, the same as it would be on its own.
+ Assert.AreEqual("The Quick Brown Fox", result);
+ }
+
+ // An all-caps word used to be normalized only when the entire string was all caps, because
+ // ToTitleCase tested IsAllCaps against the whole string. The same token therefore converted two
+ // different ways depending on its neighbours: "MAX_SIZE" gave "MaxSize" but "set MAX_SIZE" gave
+ // "SetMAXSIZE". Each pair below measures a word alone and beside a lowercase one, so a regression
+ // to whole-string reasoning fails the second assertion of the pair while the first still passes.
+
+ [TestMethod]
+ public void ToTitleCaseShouldNormalizeAnAllCapsWordIndependentlyOfItsNeighbours()
+ {
+ Assert.AreEqual("Http", "HTTP".ToTitleCase());
+ Assert.AreEqual("Parse Http Header", "parse HTTP header".ToTitleCase());
+ }
+
+ [TestMethod]
+ public void ToPascalCaseShouldNormalizeAnAllCapsWordIndependentlyOfItsNeighbours()
+ {
+ Assert.AreEqual("MaxSize", "MAX_SIZE".ToPascalCase());
+ Assert.AreEqual("SetMaxSize", "set MAX_SIZE".ToPascalCase());
+ }
+
+ [TestMethod]
+ public void ToCamelCaseShouldNormalizeAnAllCapsWordIndependentlyOfItsNeighbours()
+ {
+ Assert.AreEqual("url", "URL".ToCamelCase());
+ Assert.AreEqual("myUrlHandler", "my URL handler".ToCamelCase());
+ }
+
+ [TestMethod]
+ public void ToPascalCaseShouldMatchTheAcronymExampleInTheReadme()
+ {
+ // README.md has documented this result since before the neighbour dependence was found, while
+ // the library actually produced "APIResponseURL". Pinning it keeps the two from drifting again.
+ Assert.AreEqual("ApiResponseUrl", "API_response_URL".ToPascalCase());
+ }
+
+ [TestMethod]
+ public void ToPascalCaseShouldAgreeWithToMacroCaseOnWordBoundaries()
+ {
+ // The macro and snake paths never had the neighbour dependence, so they are the reference the
+ // title-case-derived converters are brought back into agreement with.
+ Assert.AreEqual("SET_MAX_SIZE", "set MAX_SIZE".ToMacroCase());
+ Assert.AreEqual("SetMaxSize", "set MAX_SIZE".ToPascalCase());
}
[TestMethod]
diff --git a/CaseConverter/CaseConverter.cs b/CaseConverter/CaseConverter.cs
index 33c437b..01e9877 100644
--- a/CaseConverter/CaseConverter.cs
+++ b/CaseConverter/CaseConverter.cs
@@ -155,11 +155,47 @@ public static string ToUppercaseFirstChar(this string input)
#endif
}
+ ///
+ /// Lowercases every space separated word whose letters are all uppercase, leaving the rest alone.
+ ///
+ /// The string to process, already split into space separated words.
+ /// A new string with each all-caps word lowercased.
+ ///
+ /// preserves a word that is all caps, on the assumption
+ /// that it is an acronym. Lowering such a word first is what normalizes it instead, and deciding
+ /// this per word rather than for the whole string is what keeps a word's result independent of its
+ /// neighbours.
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Lowercasing is the point: TextInfo.ToTitleCase then capitalizes the first letter of each word.")]
+ private static string LowercaseAllCapsWords(string input)
+ {
+ string[] words = input.Split(' ');
+ StringBuilder builder = new(input.Length);
+
+ for (int i = 0; i < words.Length; i++)
+ {
+ if (i > 0)
+ {
+ builder.Append(' ');
+ }
+
+ string word = words[i];
+ builder.Append(IsAllCaps(word) ? word.ToLowerInvariant() : word);
+ }
+
+ return builder.ToString();
+ }
+
///
/// Returns a copy of this string converted to Title Case. Example: "the quick brown fox" becomes "The Quick Brown Fox".
///
/// The string to convert.
/// A new string in Title Case.
+ ///
+ /// An all-caps word is normalized rather than preserved as an acronym, so "HTTP" becomes
+ /// "Http" and "parse HTTP header" becomes "Parse Http Header". The decision is
+ /// made per word, so a word converts the same way whatever else is in the string.
+ ///
public static string ToTitleCase(this string input)
{
Ensure.NotNull(input);
@@ -168,12 +204,10 @@ public static string ToTitleCase(this string input)
output = SplitOnCaseChange(output);
output = CollapseSpaces(output).Trim();
- // If the input is all caps, we want to convert it to lowercase before converting to title case,
- // as TextInfo.ToTitleCase preserves words that are all caps assuming they are acronyms.
- if (IsAllCaps(output))
- {
- output = output.ToLowerInvariant();
- }
+ // TextInfo.ToTitleCase preserves words that are all caps assuming they are acronyms, so lowercase
+ // them first. This is done per word rather than only when the whole string is all caps, because
+ // otherwise the same word converts two different ways depending on its neighbours.
+ output = LowercaseAllCapsWords(output);
return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(output);
}
@@ -230,6 +264,12 @@ private static string CollapseSpaces(string output)
///
/// The string to convert.
/// A new string in PascalCase.
+ ///
+ /// An all-caps word is normalized rather than preserved as an acronym, so "MAX_SIZE" and
+ /// "set MAX_SIZE" become "MaxSize" and "SetMaxSize". The decision is made per
+ /// word by , so a word converts the same way whatever else is in
+ /// the string.
+ ///
public static string ToPascalCase(this string input)
{
Ensure.NotNull(input);
@@ -252,6 +292,12 @@ public static string ToPascalCase(this string input)
///
/// The string to convert.
/// A new string in camelCase.
+ ///
+ /// An all-caps word is normalized rather than preserved as an acronym, so "URL" and
+ /// "my URL handler" become "url" and "myUrlHandler". The decision is made per
+ /// word by , so a word converts the same way whatever else is in
+ /// the string.
+ ///
public static string ToCamelCase(this string input)
{
Ensure.NotNull(input);