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);