Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions GenHub/GenHub.Core/Constants/GeneralsOnlineConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public static class GeneralsOnlineConstants
public const string VersionDateFormat = "MMddyy";

/// <summary>Separator between date and QFE number in versions.</summary>
/// <remarks>Used by catalog parser for date extraction. Version comparison uses underscore split.</remarks>
public const string QfeSeparator = "_QFE";

/// <summary>Prefix for QFE markers in version strings.</summary>
Expand Down
21 changes: 16 additions & 5 deletions GenHub/GenHub.Core/Helpers/GameVersionHelper.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Linq;
using System.Text.RegularExpressions;
using GenHub.Core.Constants;

namespace GenHub.Core.Helpers;

Expand Down Expand Up @@ -122,7 +123,9 @@ public static int NormalizeVersion(string? version)
}

/// <summary>
/// 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.
/// </summary>
/// <param name="version">The version string to parse.</param>
/// <returns>A tuple containing the extracted date and QFE number, or null if parsing fails.</returns>
Expand All @@ -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))
{
Expand All @@ -164,7 +167,15 @@ public static (DateTime Date, int Qfe)? ParseGeneralsOnlineVersion(string? versi

/// <summary>
/// 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.
/// <para>
/// Note: The encoding uses <c>dateValue * 10 + QFE</c>, 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 <see cref="ParseGeneralsOnlineVersion"/> directly via
/// <c>VersionComparer.CompareGeneralsOnlineVersions</c>, which has no such limitation.
/// </para>
/// </summary>
/// <param name="version">The version string to convert.</param>
/// <returns>A sortable integer, or 0 if parsing fails.</returns>
Expand Down
34 changes: 32 additions & 2 deletions GenHub/GenHub.Core/Helpers/VersionComparer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,44 @@ 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);
}

/// <summary>
/// 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 <see cref="CompareNumericVersions"/> when parsing fails.
/// </summary>
/// <param name="version1">The first version.</param>
/// <param name="version2">The second version.</param>
/// <returns>Comparison result.</returns>
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);
}

/// <summary>
/// Compares two date-based version strings in YYYY-MM-DD format.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ namespace GenHub.Core.Models.GeneralsOnline;
public class GeneralsOnlineApiResponse
{
/// <summary>
/// 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.
/// </summary>
[JsonPropertyName("version")]
public string Version { get; set; } = string.Empty;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ namespace GenHub.Core.Models.GeneralsOnline;
public class GeneralsOnlineRelease
{
/// <summary>
/// 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").
/// </summary>
public required string Version { get; init; }

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -91,4 +92,51 @@ public async Task ParseAsync_WithCamelCaseJson_ParsesCorrectly()
var item = result.Data.First();
Assert.Equal("111825_QFE2", item.Version);
}

/// <summary>
/// Tests that ParseAsync correctly parses extended version format with build suffix.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous test operation.</returns>
[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<GeneralsOnlineRelease>()!.PortableUrl);
}

/// <summary>
/// Tests that ParseAsync correctly handles latest.txt fallback with extended version.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous test operation.</returns>
[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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
using GenHub.Core.Helpers;

namespace GenHub.Tests.Core.Helpers;

/// <summary>
/// Tests for <see cref="GameVersionHelper"/> Generals Online version parsing.
/// </summary>
public class GameVersionHelperTests
{
// ==== ParseGeneralsOnlineVersion — legacy format (MMDDYY_QFE#) ====

/// <summary>
/// Verifies that legacy two-segment versions parse correctly.
/// </summary>
[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) ====

/// <summary>
/// Verifies that extended format with build suffix parses date and QFE correctly.
/// </summary>
[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);
}

/// <summary>
/// Verifies that versions with multiple suffix segments still parse correctly.
/// </summary>
[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 ====

/// <summary>
/// Verifies that null or empty versions return null.
/// </summary>
/// <param name="version">The version string to test.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void ParseGeneralsOnlineVersion_NullOrEmpty_ReturnsNull(string? version)
{
var result = GameVersionHelper.ParseGeneralsOnlineVersion(version);
Assert.Null(result);
}

/// <summary>
/// Verifies that versions missing the underscore separator return null.
/// </summary>
[Fact]
public void ParseGeneralsOnlineVersion_NoUnderscore_ReturnsNull()
{
var result = GameVersionHelper.ParseGeneralsOnlineVersion("042826");
Assert.Null(result);
}

/// <summary>
/// Verifies that versions with malformed dates return null.
/// </summary>
[Fact]
public void ParseGeneralsOnlineVersion_InvalidDate_ReturnsNull()
{
var result = GameVersionHelper.ParseGeneralsOnlineVersion("ABCDEF_QFE1");
Assert.Null(result);
}

/// <summary>
/// Verifies that versions with non-numeric QFE return null.
/// </summary>
[Fact]
public void ParseGeneralsOnlineVersion_NonNumericQfe_ReturnsNull()
{
var result = GameVersionHelper.ParseGeneralsOnlineVersion("042826_QFEx");
Assert.Null(result);
}

// ==== GetGeneralsOnlineSortableVersion ====

/// <summary>
/// Verifies that sortable version is computed correctly for legacy format.
/// </summary>
[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);
}

/// <summary>
/// Verifies that sortable version ignores build suffixes in extended format.
/// </summary>
[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);
}

/// <summary>
/// Verifies that legacy and extended formats with same date+QFE produce the same sortable value.
/// </summary>
[Fact]
public void GetGeneralsOnlineSortableVersion_SameVersionDifferentFormat_ProducesEqualValue()
{
var legacy = GameVersionHelper.GetGeneralsOnlineSortableVersion("042826_QFE3");
var extended = GameVersionHelper.GetGeneralsOnlineSortableVersion("042826_QFE3_EAC");

Assert.Equal(legacy, extended);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,67 @@ public void CompareVersions_UnknownPublisher_NumericExtraction_ReturnsCorrectCom
// Assert
Assert.Equal(expected, Math.Sign(result));
}

/// <summary>
/// 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.
/// </summary>
/// <param name="version1">The first version string.</param>
/// <param name="version2">The second version string.</param>
/// <param name="expected">The expected comparison result.</param>
[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));
}

/// <summary>
/// Verifies that when only one GeneralsOnline version is parseable,
/// the unparseable version is always treated as older.
/// </summary>
/// <param name="version1">The first version string.</param>
/// <param name="version2">The second version string.</param>
/// <param name="expected">The expected comparison result.</param>
[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));
}

/// <summary>
/// Verifies that when both GeneralsOnline versions are unparseable,
/// the method falls back to numeric/string comparison.
/// </summary>
[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);
}
}
Loading
Loading