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