From a01919b5343910b95c89ba5a206d44172550b165 Mon Sep 17 00:00:00 2001
From: ScrubN <72096833+ScrubN@users.noreply.github.com>
Date: Sun, 12 Nov 2023 03:32:18 -0500
Subject: [PATCH 1/2] Add unit test project
---
TwitchDownloaderTests/GlobalUsings.cs | 1 +
.../TwitchDownloaderTests.csproj | 29 +++++++++++++++++++
TwitchDownloaderWPF.sln | 10 +++++++
3 files changed, 40 insertions(+)
create mode 100644 TwitchDownloaderTests/GlobalUsings.cs
create mode 100644 TwitchDownloaderTests/TwitchDownloaderTests.csproj
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/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/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
From 4a9d8504b0777cd5908e07f568752e06804aff05 Mon Sep 17 00:00:00 2001
From: ScrubN <72096833+ScrubN@users.noreply.github.com>
Date: Sun, 12 Nov 2023 03:32:33 -0500
Subject: [PATCH 2/2] Add initial unit tests
---
.../ReadOnlySpanTryReplaceNonEscapedTests.cs | 95 +++++++
.../StringReplaceAnyTests.cs | 61 +++++
TwitchDownloaderTests/TimeSpanHFormatTests.cs | 248 ++++++++++++++++++
TwitchDownloaderTests/UrlTimeCodeTests.cs | 33 +++
4 files changed, 437 insertions(+)
create mode 100644 TwitchDownloaderTests/ReadOnlySpanTryReplaceNonEscapedTests.cs
create mode 100644 TwitchDownloaderTests/StringReplaceAnyTests.cs
create mode 100644 TwitchDownloaderTests/TimeSpanHFormatTests.cs
create mode 100644 TwitchDownloaderTests/UrlTimeCodeTests.cs
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/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