diff --git a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs index 242cb02ef..0179d126c 100644 --- a/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs +++ b/GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs @@ -52,6 +52,7 @@ public static class GeneralsOnlineConstants public const string VersionDateFormat = "MMddyy"; /// Separator between date and QFE number in versions. + /// Used by catalog parser for date extraction. Version comparison uses underscore split. public const string QfeSeparator = "_QFE"; /// Prefix for QFE markers in version strings. diff --git a/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs b/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs index 60061eff6..4a991bd20 100644 --- a/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs +++ b/GenHub/GenHub.Core/Helpers/GameVersionHelper.cs @@ -1,5 +1,6 @@ using System.Linq; using System.Text.RegularExpressions; +using GenHub.Core.Constants; namespace GenHub.Core.Helpers; @@ -122,7 +123,9 @@ public static int NormalizeVersion(string? version) } /// - /// Parses a version string (MMDDYY_QFE#) used by Generals Online. + /// Parses a version string (MMDDYY_QFE#[_SUFFIX]) used by Generals Online. + /// Supports extended formats like "042826_QFE3_EAC" — extra segments after + /// the QFE number are build metadata and do not affect version ordering. /// /// The version string to parse. /// A tuple containing the extracted date and QFE number, or null if parsing fails. @@ -135,15 +138,15 @@ public static (DateTime Date, int Qfe)? ParseGeneralsOnlineVersion(string? versi try { - // Format: MMDDYY_QFE# or DDMMYY_QFE# (General Online CDN uses MMDDYY) + // Format: MMDDYY_QFE#[_SUFFIX...] (Generals Online CDN uses MMDDYY) var parts = version.Split('_', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length != 2) + if (parts.Length < 2) { return null; } var datePart = parts[0]; - var qfePart = parts[1].Replace("QFE", string.Empty, StringComparison.OrdinalIgnoreCase); + var qfePart = parts[1].Replace(GeneralsOnlineConstants.QfeMarkerPrefix, string.Empty, StringComparison.OrdinalIgnoreCase); if (datePart.Length != 6 || !int.TryParse(qfePart, out var qfe)) { @@ -164,7 +167,15 @@ public static (DateTime Date, int Qfe)? ParseGeneralsOnlineVersion(string? versi /// /// Gets a sortable integer version for Generals Online versions. - /// Converts "101525_QFE2" to 1015252. + /// Converts "101525_QFE2" to 1015252, "042826_QFE3_EAC" to 428263. + /// Build suffixes (e.g. _EAC) are ignored — only date and QFE affect ordering. + /// + /// Note: The encoding uses dateValue * 10 + QFE, which limits QFE to 0–9 + /// without colliding with the next date. This is intentional — the value is embedded + /// in manifest IDs and changing the encoding would break installed content references. + /// Version ordering uses directly via + /// VersionComparer.CompareGeneralsOnlineVersions, which has no such limitation. + /// /// /// The version string to convert. /// A sortable integer, or 0 if parsing fails. diff --git a/GenHub/GenHub.Core/Helpers/VersionComparer.cs b/GenHub/GenHub.Core/Helpers/VersionComparer.cs index a3d1dc208..0b979e9e5 100644 --- a/GenHub/GenHub.Core/Helpers/VersionComparer.cs +++ b/GenHub/GenHub.Core/Helpers/VersionComparer.cs @@ -22,11 +22,19 @@ public static int CompareVersions(string? version1, string? version2, string? pu { // Handle null/empty cases if (string.IsNullOrWhiteSpace(version1) && string.IsNullOrWhiteSpace(version2)) + { return 0; + } + if (string.IsNullOrWhiteSpace(version1)) + { return -1; + } + if (string.IsNullOrWhiteSpace(version2)) + { return 1; + } // Determine comparison strategy based on publisher type if (string.Equals(publisherType, CommunityOutpostConstants.PublisherType, StringComparison.OrdinalIgnoreCase)) @@ -41,14 +49,53 @@ public static int CompareVersions(string? version1, string? version2, string? pu } else if (string.Equals(publisherType, PublisherTypeConstants.GeneralsOnline, StringComparison.OrdinalIgnoreCase)) { - // GeneralsOnline uses numeric versions - return CompareNumericVersions(version1, version2); + // GeneralsOnline uses MMDDYY_QFE#[_SUFFIX] format — parse for accurate comparison + return CompareGeneralsOnlineVersions(version1, version2); } // Default: Use the robust numeric/semantic comparison logic return CompareNumericVersions(version1, version2); } + /// + /// Compares two GeneralsOnline version strings (MMDDYY_QFE#[_SUFFIX]). + /// Extra suffix segments (e.g. _EAC) are build metadata and do not affect ordering. + /// Falls back to when parsing fails. + /// + /// The first version. + /// The second version. + /// Comparison result. + private static int CompareGeneralsOnlineVersions(string version1, string version2) + { + var parsed1 = GameVersionHelper.ParseGeneralsOnlineVersion(version1); + var parsed2 = GameVersionHelper.ParseGeneralsOnlineVersion(version2); + + // Both unparseable — fall back to generic numeric comparison + if (parsed1 == null && parsed2 == null) + { + return CompareNumericVersions(version1, version2); + } + + // Exactly one unparseable — treat unparseable as always older + if (parsed1 == null) + { + return -1; + } + + if (parsed2 == null) + { + return 1; + } + + int dateCompare = parsed1.Value.Date.CompareTo(parsed2.Value.Date); + if (dateCompare != 0) + { + return dateCompare; + } + + return parsed1.Value.Qfe.CompareTo(parsed2.Value.Qfe); + } + /// /// Compares two date-based version strings in YYYY-MM-DD format. /// @@ -170,22 +217,30 @@ private static int CompareNumericVersions(string ver1, string ver2) private static string NormalizeVersionString(string version) { if (string.IsNullOrWhiteSpace(version)) + { return string.Empty; + } // Remove common prefixes that publishers use var normalized = version; // Remove weekly- prefix (SuperHackers) if (normalized.StartsWith("weekly-", StringComparison.OrdinalIgnoreCase)) + { normalized = normalized.Substring(8); // Remove "weekly-" + } // Remove release- prefix if (normalized.StartsWith("release-", StringComparison.OrdinalIgnoreCase)) + { normalized = normalized.Substring(9); // Remove "release-" + } // Remove version- prefix if (normalized.StartsWith("version-", StringComparison.OrdinalIgnoreCase)) + { normalized = normalized.Substring(9); // Remove "version-" + } // Remove 'v' or 'V' prefix normalized = normalized.TrimStart('v', 'V'); @@ -237,13 +292,19 @@ private static int CompareSemanticVersions(string ver1, string ver2) if (isDigit1 && isDigit2 && long.TryParse(segment1, out var num1) && long.TryParse(segment2, out var num2)) { - if (num1 != num2) return num1.CompareTo(num2); + if (num1 != num2) + { + return num1.CompareTo(num2); + } } else { // For non-pure-numeric segments, compare the full original strings (case-insensitive) var strCompare = string.Compare(rawSegment1, rawSegment2, StringComparison.OrdinalIgnoreCase); - if (strCompare != 0) return strCompare; + if (strCompare != 0) + { + return strCompare; + } } } @@ -277,7 +338,9 @@ private static bool TryParseDateVersion(string dateStr, out DateTime result) private static long? ExtractNumericFromDate(string dateStr) { if (string.IsNullOrWhiteSpace(dateStr)) + { return null; + } // Remove common date separators var digits = dateStr.Replace("-", string.Empty) @@ -298,7 +361,9 @@ private static bool TryParseDateVersion(string dateStr, out DateTime result) private static string ExtractDigits(string version) { if (string.IsNullOrWhiteSpace(version)) + { return string.Empty; + } var result = new System.Text.StringBuilder(); foreach (var c in version) diff --git a/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineApiResponse.cs b/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineApiResponse.cs index a4cb03730..a8ce32557 100644 --- a/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineApiResponse.cs +++ b/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineApiResponse.cs @@ -8,8 +8,9 @@ namespace GenHub.Core.Models.GeneralsOnline; public class GeneralsOnlineApiResponse { /// - /// Gets or sets the version string (e.g., "111825_QFE2" for November 18, 2025). - /// Format: MMDDYY_QFE# where MM=month, DD=day, YY=year, #=QFE number. + /// Gets or sets the version string (e.g., "111825_QFE2" or "042826_QFE3_EAC"). + /// Format: MMDDYY_QFE#[_SUFFIX] where MM=month, DD=day, YY=year, #=QFE number. + /// The optional suffix (e.g. "_EAC") is build metadata that does not affect version ordering. /// [JsonPropertyName("version")] public string Version { get; set; } = string.Empty; diff --git a/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs b/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs index fd42ee2b2..8027f564e 100644 --- a/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs +++ b/GenHub/GenHub.Core/Models/GeneralsOnline/GeneralsOnlineRelease.cs @@ -10,7 +10,7 @@ namespace GenHub.Core.Models.GeneralsOnline; public class GeneralsOnlineRelease { /// - /// Gets version string in format: MMDDYY_QFE# (e.g., "101525_QFE5"). + /// Gets version string in format: MMDDYY_QFE#[_SUFFIX] (e.g., "101525_QFE5", "042826_QFE3_EAC"). /// public required string Version { get; init; } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs index e2e10254f..ef1e6dd44 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/GeneralsOnline/GeneralsOnlineJsonCatalogParserTests.cs @@ -1,5 +1,6 @@ using GenHub.Core.Constants; using GenHub.Core.Interfaces.Providers; +using GenHub.Core.Models.GeneralsOnline; using GenHub.Core.Models.Providers; using GenHub.Features.Content.Services.GeneralsOnline; using Microsoft.Extensions.Logging.Abstractions; @@ -91,4 +92,51 @@ public async Task ParseAsync_WithCamelCaseJson_ParsesCorrectly() var item = result.Data.First(); Assert.Equal("111825_QFE2", item.Version); } + + /// + /// Tests that ParseAsync correctly parses extended version format with build suffix. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task ParseAsync_WithExtendedVersion_ParsesCorrectly() + { + // Arrange + var json = @"{ + ""version"": ""042826_QFE3_EAC"", + ""download_url"": ""https://cdn.playgenerals.online/GeneralsOnline_portable_042826_QFE3_EAC.zip"", + ""size"": 28277693, + ""release_notes"": ""www.playgenerals.online/updates"", + ""sha256"": ""abc123"" + }"; + + var wrapper = $"{{\"source\":\"manifest\",\"data\":{json}}}"; + + // Act + var result = await _parser.ParseAsync(wrapper, _provider); + + // Assert + Assert.True(result.Success); + var item = result.Data!.First(); + Assert.Equal("042826_QFE3_EAC", item.Version); + Assert.Equal("https://cdn.playgenerals.online/GeneralsOnline_portable_042826_QFE3_EAC.zip", item.GetData()!.PortableUrl); + } + + /// + /// Tests that ParseAsync correctly handles latest.txt fallback with extended version. + /// + /// A representing the asynchronous test operation. + [Fact] + public async Task ParseAsync_LatestFallbackExtendedVersion_ParsesCorrectly() + { + // Arrange + var wrapper = "{\"source\":\"latest\",\"version\":\"042826_QFE3_EAC\"}"; + + // Act + var result = await _parser.ParseAsync(wrapper, _provider); + + // Assert + Assert.True(result.Success); + var item = result.Data!.First(); + Assert.Equal("042826_QFE3_EAC", item.Version); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameVersionHelperTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameVersionHelperTests.cs new file mode 100644 index 000000000..bc614fccd --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/GameVersionHelperTests.cs @@ -0,0 +1,134 @@ +using GenHub.Core.Helpers; + +namespace GenHub.Tests.Core.Helpers; + +/// +/// Tests for Generals Online version parsing. +/// +public class GameVersionHelperTests +{ + // ==== ParseGeneralsOnlineVersion — legacy format (MMDDYY_QFE#) ==== + + /// + /// Verifies that legacy two-segment versions parse correctly. + /// + [Fact] + public void ParseGeneralsOnlineVersion_LegacyFormat_ParsesCorrectly() + { + var result = GameVersionHelper.ParseGeneralsOnlineVersion("101525_QFE2"); + + Assert.NotNull(result); + Assert.Equal(new DateTime(2025, 10, 15), result.Value.Date); + Assert.Equal(2, result.Value.Qfe); + } + + // ==== ParseGeneralsOnlineVersion — extended format (MMDDYY_QFE#_SUFFIX) ==== + + /// + /// Verifies that extended format with build suffix parses date and QFE correctly. + /// + [Fact] + public void ParseGeneralsOnlineVersion_ExtendedFormat_ParsesDateAndQfe() + { + var result = GameVersionHelper.ParseGeneralsOnlineVersion("042826_QFE3_EAC"); + + Assert.NotNull(result); + Assert.Equal(new DateTime(2026, 4, 28), result.Value.Date); + Assert.Equal(3, result.Value.Qfe); + } + + /// + /// Verifies that versions with multiple suffix segments still parse correctly. + /// + [Fact] + public void ParseGeneralsOnlineVersion_MultipleSuffixes_ParsesDateAndQfe() + { + var result = GameVersionHelper.ParseGeneralsOnlineVersion("011526_QFE1_EAC_X86"); + + Assert.NotNull(result); + Assert.Equal(new DateTime(2026, 1, 15), result.Value.Date); + Assert.Equal(1, result.Value.Qfe); + } + + // ==== ParseGeneralsOnlineVersion — edge cases ==== + + /// + /// Verifies that null or empty versions return null. + /// + /// The version string to test. + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ParseGeneralsOnlineVersion_NullOrEmpty_ReturnsNull(string? version) + { + var result = GameVersionHelper.ParseGeneralsOnlineVersion(version); + Assert.Null(result); + } + + /// + /// Verifies that versions missing the underscore separator return null. + /// + [Fact] + public void ParseGeneralsOnlineVersion_NoUnderscore_ReturnsNull() + { + var result = GameVersionHelper.ParseGeneralsOnlineVersion("042826"); + Assert.Null(result); + } + + /// + /// Verifies that versions with malformed dates return null. + /// + [Fact] + public void ParseGeneralsOnlineVersion_InvalidDate_ReturnsNull() + { + var result = GameVersionHelper.ParseGeneralsOnlineVersion("ABCDEF_QFE1"); + Assert.Null(result); + } + + /// + /// Verifies that versions with non-numeric QFE return null. + /// + [Fact] + public void ParseGeneralsOnlineVersion_NonNumericQfe_ReturnsNull() + { + var result = GameVersionHelper.ParseGeneralsOnlineVersion("042826_QFEx"); + Assert.Null(result); + } + + // ==== GetGeneralsOnlineSortableVersion ==== + + /// + /// Verifies that sortable version is computed correctly for legacy format. + /// + [Fact] + public void GetGeneralsOnlineSortableVersion_LegacyFormat_ReturnsCorrectValue() + { + // "101525_QFE2" -> date=101525, qfe=2 -> 101525*10 + 2 = 1015252 + var result = GameVersionHelper.GetGeneralsOnlineSortableVersion("101525_QFE2"); + Assert.Equal(1015252, result); + } + + /// + /// Verifies that sortable version ignores build suffixes in extended format. + /// + [Fact] + public void GetGeneralsOnlineSortableVersion_ExtendedFormat_IgnoresSuffix() + { + // "042826_QFE3_EAC" -> date=042826, qfe=3 -> 42826*10 + 3 = 428263 + var result = GameVersionHelper.GetGeneralsOnlineSortableVersion("042826_QFE3_EAC"); + Assert.Equal(428263, result); + } + + /// + /// Verifies that legacy and extended formats with same date+QFE produce the same sortable value. + /// + [Fact] + public void GetGeneralsOnlineSortableVersion_SameVersionDifferentFormat_ProducesEqualValue() + { + var legacy = GameVersionHelper.GetGeneralsOnlineSortableVersion("042826_QFE3"); + var extended = GameVersionHelper.GetGeneralsOnlineSortableVersion("042826_QFE3_EAC"); + + Assert.Equal(legacy, extended); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/VersionComparerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/VersionComparerTests.cs index ba59c408e..d3d393eea 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/VersionComparerTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Helpers/VersionComparerTests.cs @@ -169,4 +169,67 @@ public void CompareVersions_UnknownPublisher_NumericExtraction_ReturnsCorrectCom // Assert Assert.Equal(expected, Math.Sign(result)); } + + /// + /// Verifies that GeneralsOnline versions with extended format (build suffix) compare correctly. + /// Versions like "042826_QFE3_EAC" should compare by date+QFE only, ignoring the suffix. + /// + /// The first version string. + /// The second version string. + /// The expected comparison result. + [Theory] + [InlineData("042826_QFE3_EAC", "042826_QFE3_EAC", 0)] // Same extended version + [InlineData("042826_QFE4_EAC", "042826_QFE3_EAC", 1)] // Same date, higher QFE + [InlineData("042826_QFE3_EAC", "042826_QFE2", 1)] // Extended vs legacy, higher QFE + [InlineData("042826_QFE2", "042826_QFE2_EAC", 0)] // Legacy vs extended, same date+QFE + [InlineData("042926_QFE1_EAC", "042826_QFE3_EAC", 1)] // Newer date wins regardless of QFE + public void CompareVersions_GeneralsOnline_ExtendedFormat_ReturnsCorrectComparison( + string version1, + string version2, + int expected) + { + // Act + var result = VersionComparer.CompareVersions(version1, version2, PublisherTypeConstants.GeneralsOnline); + + // Assert + Assert.Equal(expected, Math.Sign(result)); + } + + /// + /// Verifies that when only one GeneralsOnline version is parseable, + /// the unparseable version is always treated as older. + /// + /// The first version string. + /// The second version string. + /// The expected comparison result. + [Theory] + [InlineData("042826_QFE3_EAC", "Unknown", 1)] // Parseable vs unparseable + [InlineData("Unknown", "042826_QFE3_EAC", -1)] // Unparseable vs parseable + [InlineData("042826_QFE2", "garbage", 1)] // Legacy parseable vs unparseable + [InlineData("garbage", "042826_QFE2", -1)] // Unparseable vs legacy parseable + public void CompareVersions_GeneralsOnline_MixedParseable_UnparseableIsOlder( + string version1, + string version2, + int expected) + { + // Act + var result = VersionComparer.CompareVersions(version1, version2, PublisherTypeConstants.GeneralsOnline); + + // Assert + Assert.Equal(expected, Math.Sign(result)); + } + + /// + /// Verifies that when both GeneralsOnline versions are unparseable, + /// the method falls back to numeric/string comparison. + /// + [Fact] + public void CompareVersions_GeneralsOnline_BothUnparseable_FallsBackToNumeric() + { + // Act + var result = VersionComparer.CompareVersions("Unknown", "Unknown", PublisherTypeConstants.GeneralsOnline); + + // Assert — same unparseable strings should compare as equal + Assert.Equal(0, result); + } } diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs index 3afbac6c3..8417cfea3 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineManifestFactory.cs @@ -408,6 +408,8 @@ private async Task> UpdateManifestsWithExtractedFiles( var allFiles = Directory.GetFiles(extractPath, "*", SearchOption.AllDirectories); logger.LogInformation("Processing {Count} files", allFiles.Length); + // Track file metadata for manifest generation. Maps are routed to the MapPack + // manifest; all other files (executables, shared files) go to the GameClient manifest. List<(string RelativePath, FileInfo FileInfo, string Hash, bool IsMap)> filesWithHashes = []; // Detect Maps directory (case-insensitive) @@ -462,8 +464,8 @@ private async Task> UpdateManifestsWithExtractedFiles( } else { - // Game client manifest: include executables, shared files, AND map files - // Map files are included with UserMapsDirectory install target so they install to Documents + // Game client manifest: include executables and shared files, but NOT maps + // Map files are handled by the MapPack manifest var targetExecutable = GameClientConstants.GeneralsOnline60HzExecutable; foreach (var (relativePath, fileInfo, hash, isMap) in filesWithHashes) diff --git a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineUpdateService.cs b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineUpdateService.cs index 15c24c1d0..2a186c4a8 100644 --- a/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineUpdateService.cs +++ b/GenHub/GenHub/Features/Content/Services/GeneralsOnline/GeneralsOnlineUpdateService.cs @@ -91,7 +91,7 @@ private static bool IsNewerVersion(string latestVersion, string? currentVersion) return true; // Any version is newer than nothing } - // Parse MMddyy_QFE# format + // Parse MMddyy_QFE#[_SUFFIX] format (suffixes like _EAC are build metadata, ignored for comparison) var latest = GameVersionHelper.ParseGeneralsOnlineVersion(latestVersion); var current = GameVersionHelper.ParseGeneralsOnlineVersion(currentVersion);