diff --git a/TwitchDownloaderTests/GlobalUsings.cs b/TwitchDownloaderTests/GlobalUsings.cs new file mode 100644 index 00000000..8c927eb7 --- /dev/null +++ b/TwitchDownloaderTests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/TwitchDownloaderTests/ReadOnlySpanTryReplaceNonEscapedTests.cs b/TwitchDownloaderTests/ReadOnlySpanTryReplaceNonEscapedTests.cs new file mode 100644 index 00000000..ecf6d9b0 --- /dev/null +++ b/TwitchDownloaderTests/ReadOnlySpanTryReplaceNonEscapedTests.cs @@ -0,0 +1,95 @@ +using TwitchDownloaderCore.Extensions; + +namespace TwitchDownloaderTests +{ + // ReSharper disable StringLiteralTypo + public class ReadOnlySpanTryReplaceNonEscapedTests + { + [Fact] + public void ReturnsFalse_WhenDestinationTooShort() + { + ReadOnlySpan str = @"SORRY FOR TRAFFIC NaM"; + var destination = Array.Empty(); + + var success = str.TryReplaceNonEscaped(destination, 'r', 'w'); + + Assert.False(success); + } + + [Fact] + public void MatchesOriginalString_WhenOldCharNotFound() + { + ReadOnlySpan str = @"SORRY FOR TRAFFIC NaM"; + var destination = new char[str.Length]; + const string EXPECTED = @"SORRY FOR TRAFFIC NaM"; + + var success = str.TryReplaceNonEscaped(destination, 'r', 'w'); + + Assert.True(success); + Assert.Equal(EXPECTED.AsSpan(), destination); + } + + [Fact] + public void CorrectlyEscapeCharsPrependedByBackslash() + { + ReadOnlySpan str = @"SO\RRY FO\R TRAFFIC NaM"; + var destination = new char[str.Length]; + const string EXPECTED = @"SO\RWY FO\R TWAFFIC NaM"; + + var success = str.TryReplaceNonEscaped(destination, 'R', 'W'); + + Assert.True(success); + Assert.Equal(EXPECTED.AsSpan(), destination); + } + + [Fact] + public void CorrectlyEscapeSingleCharsContainedWithinQuotes() + { + ReadOnlySpan str = "SO\"R\"RY FO'R' TRAFFIC NaM"; + var destination = new char[str.Length]; + const string EXPECTED = "SO\"R\"WY FO'R' TWAFFIC NaM"; + + var success = str.TryReplaceNonEscaped(destination, 'R', 'W'); + + Assert.True(success); + Assert.Equal(EXPECTED.AsSpan(), destination); + } + + [Fact] + public void CorrectlyEscapeManyCharsContainedWithinQuotes() + { + ReadOnlySpan str = "SORRY \"FOR\" 'TRAFFIC' NaM"; + var destination = new char[str.Length]; + const string EXPECTED = "SOWWY \"FOR\" 'TRAFFIC' NaM"; + + var success = str.TryReplaceNonEscaped(destination, 'R', 'W'); + + Assert.True(success); + Assert.Equal(EXPECTED.AsSpan(), destination); + } + + [Fact] + public void CorrectlyEscapesEndQuotesPrependedByBackslashes() + { + ReadOnlySpan str = @"'It\'s finally over.' It truly is over."; + var destination = new char[str.Length]; + const string EXPECTED = @"'It\'s finally over.' It twuly is ovew."; + + var success = str.TryReplaceNonEscaped(destination, 'r', 'w'); + + Assert.True(success); + Assert.Equal(EXPECTED.AsSpan(), destination); + } + + [Fact] + public void DoesNotEscapeDifferingQuotes() + { + ReadOnlySpan str = "\"SORRY FOR TRAFFIC NaM.'"; + var destination = new char[str.Length]; + + var success = str.TryReplaceNonEscaped(destination, 'R', 'W'); + + Assert.False(success); + } + } +} \ No newline at end of file diff --git a/TwitchDownloaderTests/StringReplaceAnyTests.cs b/TwitchDownloaderTests/StringReplaceAnyTests.cs new file mode 100644 index 00000000..5689d839 --- /dev/null +++ b/TwitchDownloaderTests/StringReplaceAnyTests.cs @@ -0,0 +1,61 @@ +using TwitchDownloaderCore.Extensions; + +namespace TwitchDownloaderTests +{ + // ReSharper disable StringLiteralTypo + public class StringReplaceAnyTests + { + [Fact] + public void MatchesMultipleStringReplaceUses() + { + const string STRING = "SORRY FOR TRAFFIC NaM."; + const string OLD_CHARS = "FRM"; + const char NEW_CHAR = 'L'; + + var replaceResult1 = STRING.Replace(OLD_CHARS[0], NEW_CHAR); + var replaceResult2 = replaceResult1.Replace(OLD_CHARS[1], NEW_CHAR); + var replaceResult3 = replaceResult2.Replace(OLD_CHARS[2], NEW_CHAR); + + var replaceAnyResult = replaceResult2.ReplaceAny(OLD_CHARS, NEW_CHAR); + + Assert.Equal(replaceResult3, replaceAnyResult); + } + + [Fact] + public void CorrectlyReplacesAnyCharacter() + { + const string STRING = "SORRY FOR TRAFFIC NaM."; + const string OLD_CHARS = "FRM"; + const char NEW_CHAR = 'L'; + const string EXPECTED = "SOLLY LOL TLALLIC NaL."; + + var result = STRING.ReplaceAny(OLD_CHARS, NEW_CHAR); + + Assert.Equal(EXPECTED, result); + } + + [Fact] + public void ReturnsOriginalString_WhenEmpty() + { + const string STRING = ""; + const string OLD_CHARS = ""; + const char NEW_CHAR = 'L'; + + var result = STRING.ReplaceAny(OLD_CHARS, NEW_CHAR); + + Assert.Same(STRING, result); + } + + [Fact] + public void ReturnsOriginalString_WhenOldCharsNotPresent() + { + const string STRING = "SORRY FOR TRAFFIC NaM."; + const string OLD_CHARS = "PogU"; + const char NEW_CHAR = 'L'; + + var result = STRING.ReplaceAny(OLD_CHARS, NEW_CHAR); + + Assert.Same(STRING, result); + } + } +} \ No newline at end of file diff --git a/TwitchDownloaderTests/TimeSpanHFormatTests.cs b/TwitchDownloaderTests/TimeSpanHFormatTests.cs new file mode 100644 index 00000000..4aa17709 --- /dev/null +++ b/TwitchDownloaderTests/TimeSpanHFormatTests.cs @@ -0,0 +1,248 @@ +using TwitchDownloaderCore.Tools; + +namespace TwitchDownloaderTests +{ + // Important notes: When a TimeSpan less than 24 hours in length is passed to TimeSpanHFormat.Format, TimeSpan.ToString is used instead. + // ReSharper disable StringLiteralTypo + public class TimeSpanHFormatTests + { + [Fact] + public void GetFormatWithICustomFormatterReturnsSelf() + { + var type = typeof(ICustomFormatter); + var formatter = TimeSpanHFormat.ReusableInstance; + + var result = formatter.GetFormat(type); + + Assert.Same(formatter, result); + } + + [Fact] + public void GetFormatWithNotICustomFormatterReturnsNull() + { + var type = typeof(TimeSpanHFormat); + var formatter = TimeSpanHFormat.ReusableInstance; + + var result = formatter.GetFormat(type); + + Assert.Null(result); + } + + [Fact] + public void CustomFormatOverloadMatchesICustomFormatter() + { + var timeSpan = new TimeSpan(17, 49, 12); + const string FORMAT_STRING = @"HH\:mm\:ss"; + + var resultICustomFormatter = ((ICustomFormatter)TimeSpanHFormat.ReusableInstance).Format(FORMAT_STRING, timeSpan,null); + var resultCustom = TimeSpanHFormat.ReusableInstance.Format(FORMAT_STRING, timeSpan); + + Assert.Equal(resultICustomFormatter, resultCustom); + } + + [Fact] + public void CorrectlyFormatsNonTimeSpanFormattable() + { + const float FLOAT = 3.14159f; + const string FORMAT_STRING = @"F2"; + const string EXPECTED = @"3.14"; + + var resultCustom = TimeSpanHFormat.ReusableInstance.Format(FORMAT_STRING, FLOAT); + + Assert.Equal(EXPECTED, resultCustom); + } + + [Fact] + public void CorrectlyFormatsNonTimeSpanNonFormattable() + { + var obj = new object(); + const string FORMAT_STRING = ""; + const string EXPECTED = "System.Object"; + + var resultCustom = TimeSpanHFormat.ReusableInstance.Format(FORMAT_STRING, obj); + + Assert.Equal(EXPECTED, resultCustom); + } + + [Fact] + public void CorrectlyFormatsNull() + { + object? obj = null; + const string FORMAT_STRING = ""; + const string EXPECTED = ""; + + var resultCustom = TimeSpanHFormat.ReusableInstance.Format(FORMAT_STRING, obj); + + Assert.Equal(EXPECTED, resultCustom); + } + + [Fact] + public void ReturnsEmptyString_WhenFormatIsEmpty() + { + var timeSpan = new TimeSpan(17, 49, 12); + const string FORMAT_STRING = ""; + const string EXPECTED = ""; + + var resultCustom = TimeSpanHFormat.ReusableInstance.Format(FORMAT_STRING, timeSpan); + + Assert.Equal(EXPECTED, resultCustom); + } + + [Fact] + public void MatchesTimeSpanToString_WhenBigHInFormat() + { + var timeSpan = new TimeSpan(17, 49, 12); + const string FORMAT_STRING = @"HH\:mm\:ss"; + const string EXPECTED = "17:49:12"; + + var result = TimeSpanHFormat.ReusableInstance.Format(FORMAT_STRING, timeSpan); + + Assert.Equal(EXPECTED, result); + } + + [Fact] + public void MatchesTimeSpanToString_WhenNoBigHInFormat() + { + var timeSpan = new TimeSpan(17, 49, 12); + const string FORMAT_STRING = @"hh\:mm\:ss"; + const string EXPECTED = "17:49:12"; + + var result = TimeSpanHFormat.ReusableInstance.Format(FORMAT_STRING, timeSpan); + + Assert.Equal(EXPECTED, result); + } + + [Fact] + public void CorrectlyEscapesCharsPrependedByBackslash() + { + var timeSpan = new TimeSpan(25, 37, 43); + const string FORMAT_STRING = @"HH\:mm\:ss"; + const string EXPECTED = "25:37:43"; + + var result = TimeSpanHFormat.ReusableInstance.Format(FORMAT_STRING, timeSpan); + + Assert.Equal(EXPECTED, result); + } + + [Fact] + public void CorrectlyEscapesSingleCharsContainedWithinQuotes() + { + var timeSpan = new TimeSpan(25, 37, 43); + const string FORMAT_STRING = @"HH'-'mm'-'ss"; + const string EXPECTED = "25-37-43"; + + var result = TimeSpanHFormat.ReusableInstance.Format(FORMAT_STRING, timeSpan); + + Assert.Equal(EXPECTED, result); + } + + [Fact] + public void CorrectlyEscapesManyCharsContainedWithinQuotes() + { + var timeSpan = new TimeSpan(25, 37, 43); + const string FORMAT_STRING = @"'It has been 'HH' hours.'"; + const string EXPECTED = "It has been 25 hours."; + + var result = TimeSpanHFormat.ReusableInstance.Format(FORMAT_STRING, timeSpan); + + Assert.Equal(EXPECTED, result); + } + + [Fact] + public void CorrectlyEscapesEndQuotesPrependedByBackslashes() + { + var timeSpan = new TimeSpan(25, 37, 43); + const string FORMAT_STRING = @"'I\'ll be back in 'H' Hours.'"; + const string EXPECTED = "I'll be back in 25 Hours."; + + var result = TimeSpanHFormat.ReusableInstance.Format(FORMAT_STRING, timeSpan); + + Assert.Equal(EXPECTED, result); + } + + [Fact] + public void CorrectlyFormatsTrailingBigH() + { + var timeSpan = new TimeSpan(25, 37, 43); + const string FORMAT_STRING = @"ssmmHH"; + const string EXPECTED = "433725"; + + var result = TimeSpanHFormat.ReusableInstance.Format(FORMAT_STRING, timeSpan); + + Assert.Equal(EXPECTED, result); + } + + [Fact] + public void CorrectlyFormatsMoreThan2BigH_WhenHoursUnder24() + { + var timeSpan = new TimeSpan(23, 37, 43); + const string FORMAT_STRING = @"HHH"; + const string EXPECTED = "023"; + + var result = TimeSpanHFormat.ReusableInstance.Format(FORMAT_STRING, timeSpan); + + Assert.Equal(EXPECTED, result); + } + + [Fact] + public void CorrectlyFormatsAbsurdlyLongSequentialBigHFormat() + { + var timeSpan = new TimeSpan(25, 37, 43); + const string FORMAT_STRING = @"HHHHHHHHHHHHHHHHHHHH"; + const string EXPECTED = "00000000000000000025"; + + var result = TimeSpanHFormat.ReusableInstance.Format(FORMAT_STRING, timeSpan); + + Assert.Equal(EXPECTED, result); + } + + [Fact] + public void CorrectlyFormatsAbsurdlyLongFormatString() + { + var timeSpan = new TimeSpan(25, 37, 43); + const string FORMAT_STRING = "'This is a really long format string. You should never put messages in your format string, but this is a unit test " + + "designed to ensure the unit functions as expected in extreme cases. This class could be exposed to user input someday. " + + "The format string should be at least 256 characters in length, as that is the size of the stackalloc for appending " + + "a regular format string as of writing the test but it could change in the future, we never know. SORRY FOR THE TRAFFIC " + + @"NaM. It is now time to include a big H character to ensure we don\'t fall back on TimeSpan.ToString(). 'H"; + const string EXPECTED = "This is a really long format string. You should never put messages in your format string, but this is a unit test " + + "designed to ensure the unit functions as expected in extreme cases. This class could be exposed to user input someday. " + + "The format string should be at least 256 characters in length, as that is the size of the stackalloc for appending " + + "a regular format string as of writing the test but it could change in the future, we never know. SORRY FOR THE TRAFFIC " + + "NaM. It is now time to include a big H character to ensure we don't fall back on TimeSpan.ToString(). 25"; + + var result = TimeSpanHFormat.ReusableInstance.Format(FORMAT_STRING, timeSpan); + + Assert.Equal(EXPECTED, result); + } + + [Fact] + public void ThrowsOnImbalancedQuoteMarkEscaping_WhenHoursUnder24() + { + var timeSpan = new TimeSpan(23, 37, 43); + const string FORMAT_STRING = "H\" Imbalanced quote escaping."; + var expectedExceptionType = typeof(FormatException); + const string EXPECTED_SOURCE_NAME = nameof(TwitchDownloaderCore); + + var exception = Assert.Throws(expectedExceptionType, () => TimeSpanHFormat.ReusableInstance.Format(FORMAT_STRING, timeSpan)); + + // Ensure the FormatException originated from TimeSpanHFormat and not TimeSpan.ToString() + Assert.Equal(EXPECTED_SOURCE_NAME, exception.Source); + Assert.Equal($"Invalid character escaping in the format string: {FORMAT_STRING}", exception.Message); + } + + [Fact] + public void ThrowsOnImbalancedQuoteMarkEscaping_When24HoursOrMore() + { + var timeSpan = new TimeSpan(24, 37, 43); + const string FORMAT_STRING = "H\" Imbalanced quote escaping."; + var expectedExceptionType = typeof(FormatException); + const string EXPECTED_SOURCE_NAME = nameof(TwitchDownloaderCore); + + var exception = Assert.Throws(expectedExceptionType, () => TimeSpanHFormat.ReusableInstance.Format(FORMAT_STRING, timeSpan)); + + // Ensure the FormatException originated from TimeSpanHFormat and not TimeSpan.ToString() + Assert.Equal(EXPECTED_SOURCE_NAME, exception.Source); + } + } +} \ No newline at end of file diff --git a/TwitchDownloaderTests/TwitchDownloaderTests.csproj b/TwitchDownloaderTests/TwitchDownloaderTests.csproj new file mode 100644 index 00000000..19757736 --- /dev/null +++ b/TwitchDownloaderTests/TwitchDownloaderTests.csproj @@ -0,0 +1,29 @@ + + + + net6.0;net6.0-windows + enable + enable + + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/TwitchDownloaderTests/UrlTimeCodeTests.cs b/TwitchDownloaderTests/UrlTimeCodeTests.cs new file mode 100644 index 00000000..302a3589 --- /dev/null +++ b/TwitchDownloaderTests/UrlTimeCodeTests.cs @@ -0,0 +1,33 @@ +using TwitchDownloaderCore.Tools; + +namespace TwitchDownloaderTests +{ + public class UrlTimeCodeTests + { + [Theory] + [InlineData("12s",0, 0, 0, 12)] + [InlineData("13m12s", 0, 0, 13 ,12)] + [InlineData("14h13m12s", 0, 14, 13, 12)] + [InlineData("15d14h13m12s", 15, 14, 13, 12)] + public void ParsesTimeCodeCorrectly(string timeCode, int days, int hours, int minutes, int seconds) + { + var result = UrlTimeCode.Parse(timeCode); + + Assert.Equal(days, result.Days); + Assert.Equal(hours, result.Hours); + Assert.Equal(minutes, result.Minutes); + Assert.Equal(seconds, result.Seconds); + } + + [Fact] + public void ReturnsZeroForInvalidTimeCode() + { + const string INVALID_TIME_CODE = "123abc"; + var expected = TimeSpan.Zero; + + var result = UrlTimeCode.Parse(INVALID_TIME_CODE); + + Assert.Equal(expected, result); + } + } +} \ No newline at end of file diff --git a/TwitchDownloaderWPF.sln b/TwitchDownloaderWPF.sln index aaf1c650..e7122828 100644 --- a/TwitchDownloaderWPF.sln +++ b/TwitchDownloaderWPF.sln @@ -9,6 +9,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TwitchDownloaderCore", "Twi EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TwitchDownloaderCLI", "TwitchDownloaderCLI\TwitchDownloaderCLI.csproj", "{F14A9B02-36A4-4FFF-9AD0-7475E2CF2D5C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TwitchDownloaderTests", "TwitchDownloaderTests\TwitchDownloaderTests.csproj", "{FE8F0DC2-6750-4956-9208-9CEE9B524184}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,6 +43,14 @@ Global {F14A9B02-36A4-4FFF-9AD0-7475E2CF2D5C}.Release|Any CPU.Build.0 = Release|Any CPU {F14A9B02-36A4-4FFF-9AD0-7475E2CF2D5C}.Release|x64.ActiveCfg = Release|x64 {F14A9B02-36A4-4FFF-9AD0-7475E2CF2D5C}.Release|x64.Build.0 = Release|x64 + {FE8F0DC2-6750-4956-9208-9CEE9B524184}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FE8F0DC2-6750-4956-9208-9CEE9B524184}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FE8F0DC2-6750-4956-9208-9CEE9B524184}.Debug|x64.ActiveCfg = Debug|Any CPU + {FE8F0DC2-6750-4956-9208-9CEE9B524184}.Debug|x64.Build.0 = Debug|Any CPU + {FE8F0DC2-6750-4956-9208-9CEE9B524184}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FE8F0DC2-6750-4956-9208-9CEE9B524184}.Release|Any CPU.Build.0 = Release|Any CPU + {FE8F0DC2-6750-4956-9208-9CEE9B524184}.Release|x64.ActiveCfg = Release|Any CPU + {FE8F0DC2-6750-4956-9208-9CEE9B524184}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE