diff --git a/Common.Tests/Common.Tests.csproj b/Common.Tests/Common.Tests.csproj new file mode 100644 index 00000000..54b53c71 --- /dev/null +++ b/Common.Tests/Common.Tests.csproj @@ -0,0 +1,21 @@ + + + + net8.0 + enable + enable + OpenShock.Common.Tests + OpenShock.Common.Tests + OpenShock + OpenShock.Common.Tests + + + + + + + + + + + diff --git a/Common.Tests/Geo/Alpha2CountryCodeTests.cs b/Common.Tests/Geo/Alpha2CountryCodeTests.cs new file mode 100644 index 00000000..e20ed8ab --- /dev/null +++ b/Common.Tests/Geo/Alpha2CountryCodeTests.cs @@ -0,0 +1,159 @@ +using OpenShock.Common.Geo; + +namespace OpenShock.Common.Tests.Geo; + +public class Alpha2CountryCodeTests +{ + [Test] + [Arguments("US", 'U', 'S')] + [Arguments("DE", 'D', 'E')] + public async Task ValidCode_ShouldParse(string str, char char1, char char2) + { + // Act + Alpha2CountryCode c = str; + + // Assert + await Assert.That(c.Char1).IsEqualTo(char1); + await Assert.That(c.Char2).IsEqualTo(char2); + } + + [Test] + [Arguments("E")] + [Arguments("INVALID")] + public async Task InvalidCharCount_ShouldThrow_InvalidLength(string str) + { + // Act + var ex = await Assert.ThrowsAsync(() => + { + Alpha2CountryCode c = str; + return Task.CompletedTask; + }); + + // Assert + await Assert.That(ex.Message).IsEqualTo("Country code must be exactly 2 characters long (Parameter 'str')"); + } + + [Test] + [Arguments("us")] + [Arguments("Us")] + [Arguments("uS")] + [Arguments("12")] + [Arguments("U1")] + [Arguments("1U")] + [Arguments("ÆØ")] + [Arguments(":D")] + public async Task InvalidCharTypes_ShouldThrow(string str) + { + // Act + var ex = await Assert.ThrowsAsync(() => { Alpha2CountryCode c = str; return Task.CompletedTask; }); + + // Assert + await Assert.That(ex.Message).IsEqualTo("Country code must be uppercase ASCII characters only (Parameter 'str')"); + } + + [Test] + [Arguments("US", 'U', 'S')] + [Arguments("DE", 'D', 'E')] + public async Task TryParseAndValidate_ValidCode_ShouldReturnTrue(string str, char char1, char char2) + { + // Act + var result = Alpha2CountryCode.TryParseAndValidate(str, out var c); + + // Assert + await Assert.That(result).IsTrue(); + await Assert.That(c.Char1).IsEqualTo(char1); + await Assert.That(c.Char2).IsEqualTo(char2); + } + + [Test] + [Arguments("E")] + [Arguments("INVALID")] + [Arguments("us")] + [Arguments("Us")] + [Arguments("uS")] + [Arguments("12")] + [Arguments("U1")] + [Arguments("1U")] + [Arguments("ÆØ")] + [Arguments(":D")] + public async Task TryParseAndValidate_InvalidCode_ShouldReturnFalse(string str) + { + // Act + var result = Alpha2CountryCode.TryParseAndValidate(str, out var c); + + // Assert + await Assert.That(result).IsFalse(); + await Assert.That(c).IsEqualTo(Alpha2CountryCode.UnknownCountry); + } + + [Test] + [Arguments("US", "US", 0x5553_5553)] + [Arguments("US", "DE", 0x4445_5553)] + [Arguments("DE", "US", 0x4445_5553)] + public async Task GetCombinedHashCode_ShouldReturnCombined(string str1, string str2, int expected) + { + // Act + var result = Alpha2CountryCode.GetCombinedHashCode(str1, str2); + + // Assert + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("US", 0x5553)] + [Arguments("DE", 0x4445)] + [Arguments("NO", 0x4E4F)] + public async Task GetHashcode_ShouldReturnHash(string str, int expected) + { + // Arrange + Alpha2CountryCode code = str; + + // Act + var result = code.GetHashCode(); + + // Assert + await Assert.That(result).IsEqualTo(expected); // "US" + } + + [Test] + public async Task IsUnknown_ShouldReturnTrue() + { + // Arrange + Alpha2CountryCode code = Alpha2CountryCode.UnknownCountry; + + // Act + var result = code.IsUnknown(); + + // Assert + await Assert.That(result).IsTrue(); + } + + [Test] + public async Task UnknownCountry_IsEqualTo_XX() + { + // Arrange + Alpha2CountryCode code = "XX"; + + // Act + var result = code == Alpha2CountryCode.UnknownCountry; + + // Assert + await Assert.That(result).IsTrue(); + } + + [Test] + [Arguments("US")] + [Arguments("DE")] + [Arguments("NO")] + public async Task IsUnknown_Known_ShouldReturnFalse(string str) + { + // Arrange + Alpha2CountryCode code = str; + + // Act + var result = code.IsUnknown(); + + // Assert + await Assert.That(result).IsFalse(); + } +} diff --git a/Common.Tests/Geo/DistanceLookupTests.cs b/Common.Tests/Geo/DistanceLookupTests.cs new file mode 100644 index 00000000..d8ee4675 --- /dev/null +++ b/Common.Tests/Geo/DistanceLookupTests.cs @@ -0,0 +1,35 @@ +using OpenShock.Common.Geo; +using TUnit.Assertions.Extensions.Numbers; + +namespace OpenShock.Common.Tests.Geo; + +public class DistanceLookupTests +{ + [Test] + [Arguments("US", "US", 0f)] + [Arguments("US", "DE", 7861.5f)] + public async Task TryGetDistanceBetween_ValidCountries(string str1, string str2, float expectedDistance) + { + // Act + var result = DistanceLookup.TryGetDistanceBetween(str1, str2, out var distance); + + // Assert + await Assert.That(result).IsTrue(); + await Assert.That(distance).IsEqualToWithTolerance(expectedDistance, 0.1f); + } + + [Test] + [Arguments("US", "XX")] + [Arguments("XX", "US")] + [Arguments("XX", "XX")] + [Arguments("EZ", "PZ")] + public async Task TryGetDistanceBetween_UnknownCountry(string str1, string str2) + { + // Act + var result = DistanceLookup.TryGetDistanceBetween(str1, str2, out var distance); + + // Assert + await Assert.That(result).IsFalse(); + await Assert.That(distance).IsEqualTo(0f); + } +} diff --git a/Common.Tests/Utils/HashingUtilsTests.cs b/Common.Tests/Utils/HashingUtilsTests.cs new file mode 100644 index 00000000..271969dd --- /dev/null +++ b/Common.Tests/Utils/HashingUtilsTests.cs @@ -0,0 +1,17 @@ +using OpenShock.Common.Utils; + +namespace OpenShock.Common.Tests.Utils; + +public class HashingUtilsTests +{ + [Test] + [Arguments("test", "9F86D081884C7D659A2FEAA0C55AD015A3BF4F1B2B0B822CD15D6C15B0F00A08")] + public async Task HashSha256(string str, string expectedHash) + { + // Act + var result = HashingUtils.HashSha256(str); + + // Assert + await Assert.That(result).IsEqualTo(expectedHash); + } +} \ No newline at end of file diff --git a/Common/Constants.cs b/Common/Constants.cs index 6e643950..9bf6bc4f 100644 --- a/Common/Constants.cs +++ b/Common/Constants.cs @@ -10,5 +10,5 @@ public static class Constants public static readonly TimeSpan PasswordResetRequestLifetime = TimeSpan.FromHours(1); - public const double DistanceToAndromedaGalaxyInKm = 2.401E19; + public const float DistanceToAndromedaGalaxyInKm = 2.401E19f; } diff --git a/Common/Geo/Alpha2CountryCode.cs b/Common/Geo/Alpha2CountryCode.cs index 56f7b60a..1d3c0c8f 100644 --- a/Common/Geo/Alpha2CountryCode.cs +++ b/Common/Geo/Alpha2CountryCode.cs @@ -10,7 +10,7 @@ public static bool TryParseAndValidate(string str, [MaybeNullWhen(false)] out Al { if (str.Length != 2 || !char.IsAsciiLetterUpper(str[0]) || !char.IsAsciiLetterUpper(str[1])) { - code = default; + code = UnknownCountry; return false; } @@ -22,15 +22,28 @@ public static bool TryParseAndValidate(string str, [MaybeNullWhen(false)] out Al public static implicit operator Alpha2CountryCode(string str) { if (str.Length != 2) - throw new ArgumentOutOfRangeException(nameof(str), "String input must be exactly 2 chars"); + throw new ArgumentOutOfRangeException(nameof(str), "Country code must be exactly 2 characters long"); if (!char.IsAsciiLetterUpper(str[0]) || !char.IsAsciiLetterUpper(str[1])) - throw new ArgumentOutOfRangeException(nameof(str), "String input must be upper characters only"); + throw new ArgumentOutOfRangeException(nameof(str), "Country code must be uppercase ASCII characters only"); return new Alpha2CountryCode(str[0], str[1]); } + public static int GetCombinedHashCode(Alpha2CountryCode code1, Alpha2CountryCode code2) + { + int a = code1.GetHashCode(); + int b = code2.GetHashCode(); + + int v = (a << 16) | b; + + if (a > b) v = int.RotateLeft(v, 16); + + return v; + } + public bool IsUnknown() => this == UnknownCountry; + public override int GetHashCode() => (Char1 << 8) | Char2; public override string ToString() => new([Char1, Char2]); } \ No newline at end of file diff --git a/Common/Geo/DistanceLookup.cs b/Common/Geo/DistanceLookup.cs index 68624cfd..1e52c308 100644 --- a/Common/Geo/DistanceLookup.cs +++ b/Common/Geo/DistanceLookup.cs @@ -5,24 +5,6 @@ namespace OpenShock.Common.Geo; public static class DistanceLookup { - /// - /// Generates a unique ID for a pair of countries, regardless of order - /// - /// - /// - /// - private static int CreateId(Alpha2CountryCode code1, Alpha2CountryCode code2) - { - int a = (code1.Char1 << 8) | code1.Char2; - int b = (code2.Char1 << 8) | code2.Char2; - - int v = (a << 16) | b; - - if (a > b) v = int.RotateLeft(v, 16); - - return v; - } - /// /// Generates all distances between all countries in the world, along with their unique ID /// @@ -35,11 +17,14 @@ private static IEnumerable> GetAllDistances() { var first = CountryInfo.Countries[i]; - for (int j = i; j < CountryInfo.Countries.Length; j++) + // Same country, no need to calculate distance + yield return new KeyValuePair(Alpha2CountryCode.GetCombinedHashCode(first.CountryCode, first.CountryCode), 0f); + + for (int j = i + 1; j < CountryInfo.Countries.Length; j++) { var second = CountryInfo.Countries[j]; - var id = CreateId(first.CountryCode, second.CountryCode); + var id = Alpha2CountryCode.GetCombinedHashCode(first.CountryCode, second.CountryCode); var dist = MathUtils.CalculateHaversineDistance(first.Latitude, first.Longitude, second.Latitude, second.Longitude); yield return new KeyValuePair(id, dist); @@ -52,6 +37,20 @@ private static IEnumerable> GetAllDistances() /// private static readonly FrozenDictionary Distances = GetAllDistances().ToFrozenDictionary(); // Create a frozen dictionary for fast lookups - public static bool TryGetDistanceBetween(Alpha2CountryCode alpha2CountryA, Alpha2CountryCode alpha2CountryB, out float distance) => - Distances.TryGetValue(CreateId(alpha2CountryA, alpha2CountryB), out distance); + public static bool TryGetDistanceBetween(Alpha2CountryCode alpha2CountryA, Alpha2CountryCode alpha2CountryB, out float distance) + { + if (alpha2CountryA.IsUnknown() || alpha2CountryB.IsUnknown()) + { + distance = 0f; + return false; + } + + if (!Distances.TryGetValue(Alpha2CountryCode.GetCombinedHashCode(alpha2CountryA, alpha2CountryB), out distance)) + { + distance = 0f; + return false; + } + + return true; + } } \ No newline at end of file diff --git a/OpenShockBackend.sln b/OpenShockBackend.sln index adb3f8ac..a7b6f305 100644 --- a/OpenShockBackend.sln +++ b/OpenShockBackend.sln @@ -1,14 +1,19 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "API", "API\API.csproj", "{185193B9-9A3D-4DDA-9750-A3CE83717FD3}" +# Visual Studio Version 17 +VisualStudioVersion = 17.11.35208.52 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "API", "API\API.csproj", "{185193B9-9A3D-4DDA-9750-A3CE83717FD3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common", "Common\Common.csproj", "{61A32805-02D5-44BA-A0E5-0B46C99896DD}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Common", "Common\Common.csproj", "{61A32805-02D5-44BA-A0E5-0B46C99896DD}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LiveControlGateway", "LiveControlGateway\LiveControlGateway.csproj", "{83BB2593-B344-42DB-8C61-D1A331054279}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LiveControlGateway", "LiveControlGateway\LiveControlGateway.csproj", "{83BB2593-B344-42DB-8C61-D1A331054279}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cron", "Cron\Cron.csproj", "{1A5D3B84-AF07-4884-B317-50BC600F33A8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MigrationHelper", "MigrationHelper\MigrationHelper.csproj", "{7396B01D-5463-4794-B2ED-4A5AE3B89320}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MigrationHelper", "MigrationHelper\MigrationHelper.csproj", "{7396B01D-5463-4794-B2ED-4A5AE3B89320}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Tests", "Common.Tests\Common.Tests.csproj", "{7AED9D47-F0B0-4644-A154-EE386A81C85D}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -36,5 +41,12 @@ Global {7396B01D-5463-4794-B2ED-4A5AE3B89320}.Debug|Any CPU.Build.0 = Debug|Any CPU {7396B01D-5463-4794-B2ED-4A5AE3B89320}.Release|Any CPU.ActiveCfg = Release|Any CPU {7396B01D-5463-4794-B2ED-4A5AE3B89320}.Release|Any CPU.Build.0 = Release|Any CPU + {7AED9D47-F0B0-4644-A154-EE386A81C85D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7AED9D47-F0B0-4644-A154-EE386A81C85D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7AED9D47-F0B0-4644-A154-EE386A81C85D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7AED9D47-F0B0-4644-A154-EE386A81C85D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal