diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 323290891..93b5f3ea9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -264,11 +264,6 @@ jobs:
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- - name: Install Linux Dependencies
- run: |
- sudo apt-get update
- sudo apt-get install -y libgtk-3-dev libx11-dev
-
- name: Cache NuGet Packages
uses: actions/cache@v3
with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 89588dd19..8d424e2c1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -234,9 +234,6 @@ jobs:
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- - name: Install Linux Dependencies
- run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libx11-dev
-
- name: Extract Build Info
id: buildinfo
run: |
diff --git a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs
index e7a543023..5ee0581e1 100644
--- a/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs
+++ b/GenHub/GenHub.Core/Constants/AppUpdateConstants.cs
@@ -10,6 +10,21 @@ public static class AppUpdateConstants
///
public const int MaxHttpRetries = 3;
+ ///
+ /// Index for the Update tab in update notification views.
+ ///
+ public const int UpdateTabIndex = 0;
+
+ ///
+ /// Index for the Browse Builds tab in update notification views.
+ ///
+ public const int BrowseBuildsTabIndex = 1;
+
+ ///
+ /// Maximum valid tab index in update notification views.
+ ///
+ public const int MaxTabIndex = 1;
+
///
/// Velopack directory name.
///
@@ -153,6 +168,101 @@ public static class AppUpdateConstants
"3. Launch the installed version (will be in %LOCALAPPDATA%\\GenHub)\n\n" +
"Update available: v{1}";
+ ///
+ /// Update available notification title for release channel.
+ ///
+ public const string UpdateAvailableNotificationTitle = "Update Available";
+
+ ///
+ /// Update available notification title for branch subscriptions.
+ ///
+ public const string BranchUpdateAvailableNotificationTitle = "Branch Update Available";
+
+ ///
+ /// Update available notification title for PR subscriptions.
+ ///
+ public const string PrUpdateAvailableNotificationTitle = "PR Update Available";
+
+ ///
+ /// Update action button text.
+ ///
+ public const string UpdateAction = "Update";
+
+ ///
+ /// Title for the update in progress notification.
+ ///
+ public const string UpdatingAppNotificationTitle = "Updating GenHub";
+
+ ///
+ /// Starting update progress message.
+ ///
+ public const string UpdateStartingMessage = "Starting update...";
+
+ ///
+ /// Title for update failed notification.
+ ///
+ public const string UpdateFailedNotificationTitle = "Update Failed";
+
+ ///
+ /// Update failed notification body format string ({0}: error message).
+ ///
+ public const string UpdateFailedNotificationFormat = "Failed to install update: {0}";
+
+ ///
+ /// View updates action button text.
+ ///
+ public const string ViewUpdatesAction = "View Updates";
+
+ ///
+ /// Release update notification body format string ({0}: version).
+ ///
+ public const string ReleaseUpdateNotificationFormat = "A new version ({0}) is available.";
+
+ ///
+ /// Branch update notification body format string ({0}: version, {1}: branch name).
+ ///
+ public const string BranchUpdateNotificationFormat = "A new build ({0}) is available on branch '{1}'.";
+
+ ///
+ /// PR update notification body format string ({0}: version, {1}: PR number).
+ ///
+ public const string PrUpdateNotificationFormat = "A new build ({0}) is available for PR #{1}.";
+
+ ///
+ /// Sort option: sort by last updated date descending.
+ ///
+ public const string SortOptionLastUpdated = "Last Updated";
+
+ ///
+ /// Sort option: sort by pull request number descending.
+ ///
+ public const string SortOptionPrNumberDesc = "PR Number (Highest)";
+
+ ///
+ /// Sort option: sort by pull request number ascending.
+ ///
+ public const string SortOptionPrNumberAsc = "PR Number (Lowest)";
+
+ ///
+ /// Default interval in minutes for periodic update checks (30 minutes).
+ ///
+ public const int DefaultPeriodicUpdateCheckIntervalMinutes = 30;
+
+ ///
+ /// Minimum interval in minutes for periodic update checks (5 minutes).
+ ///
+ public const int MinPeriodicUpdateCheckIntervalMinutes = 5;
+
+ ///
+ /// Maximum interval in minutes for periodic update checks (10080 minutes / 7 days).
+ ///
+ public const int MaxPeriodicUpdateCheckIntervalMinutes = 10080;
+
+ ///
+ /// Increment step in minutes for periodic update check interval setting (5 minutes).
+ ///
+ public const int PeriodicUpdateCheckIntervalIncrementMinutes = 5;
+
///
/// Delay before exit after applying update (5 seconds).
///
diff --git a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs
index 4b0821443..30cd69c4f 100644
--- a/GenHub/GenHub.Core/Constants/CommandLineConstants.cs
+++ b/GenHub/GenHub.Core/Constants/CommandLineConstants.cs
@@ -1,8 +1,13 @@
namespace GenHub.Core.Constants;
///
-/// Constants for command line arguments and URI schemes.
+/// Constants for command line arguments and the genhub:// URI scheme.
///
+///
+/// Subscription links use genhub://subscribe?url=<absolute-url>.
+/// Today url is a hosted GenHub catalog.json. Publisher Studio will also share
+/// Provider Definition URLs via the same scheme; GenHub will detect payload type at fetch time.
+///
public static class CommandLineConstants
{
///
@@ -16,22 +21,27 @@ public static class CommandLineConstants
public const string LaunchProfileInlinePrefix = "--launch-profile=";
///
- /// URI scheme used for protocol handling.
+ /// Scheme name for custom protocol registration.
///
- public const string UriScheme = "genhub://";
+ public const string SchemeName = "genhub";
///
- /// Command for subscribing to a catalog via URI.
+ /// Custom URI scheme registered so OS/browser links can open GenHub.
+ ///
+ public const string UriScheme = SchemeName + "://";
+
+ ///
+ /// URI path segment for content subscription (genhub://subscribe?url=...).
///
public const string SubscribeCommand = "subscribe";
///
- /// Full prefix for subscription URI.
+ /// Full prefix for subscription URIs (genhub://subscribe).
///
public const string SubscribeUriPrefix = UriScheme + SubscribeCommand;
///
- /// Query parameter name for the catalog URL in a subscription URI.
+ /// Query parameter carrying the absolute URL of a catalog (or future provider definition).
///
public const string SubscribeUrlParam = "?url=";
}
diff --git a/GenHub/GenHub.Core/Constants/IpcCommands.cs b/GenHub/GenHub.Core/Constants/IpcCommands.cs
index 1a66630f8..4096fd317 100644
--- a/GenHub/GenHub.Core/Constants/IpcCommands.cs
+++ b/GenHub/GenHub.Core/Constants/IpcCommands.cs
@@ -11,7 +11,8 @@ public static class IpcCommands
public const string LaunchProfilePrefix = "launch-profile:";
///
- /// Command prefix used to subscribe to a catalog via IPC.
+ /// Command prefix used to forward a subscribe URL to the primary instance
+ /// (subscribe:<absolute-url>). Same payload as genhub://subscribe?url=....
///
public const string SubscribePrefix = "subscribe:";
}
diff --git a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs
index b15606e59..d5d3dffce 100644
--- a/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs
+++ b/GenHub/GenHub.Core/Constants/SuperHackersConstants.cs
@@ -60,6 +60,21 @@ public static class SuperHackersConstants
///
public const string GeneralsGameCodeRepo = "GeneralsGameCode";
+ ///
+ /// GitHub owner for Generals game patch 2.
+ ///
+ public const string GeneralsGamePatch2Owner = "TheSuperHackers";
+
+ ///
+ /// GitHub repo for Generals game patch 2.
+ ///
+ public const string GeneralsGamePatch2Repo = "GeneralsGamePatch2";
+
+ ///
+ /// Display name for Generals game patch 2.
+ ///
+ public const string GeneralsGamePatch2DisplayName = "Community Patch 2";
+
// ===== Service Configuration =====
///
diff --git a/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs b/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs
new file mode 100644
index 000000000..b44ff9c53
--- /dev/null
+++ b/GenHub/GenHub.Core/Helpers/AppUpdateVersionHelper.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Linq;
+using System.Text.RegularExpressions;
+
+namespace GenHub.Core.Helpers;
+
+///
+/// Helper class for application update version comparison and parsing.
+///
+public static partial class AppUpdateVersionHelper
+{
+ ///
+ /// Extracts the workflow run number from a version string (e.g., "0.0.641-pr241" -> 641).
+ /// Returns 0 for plain semantic versions without CI run markers.
+ ///
+ /// The version string to extract the run number from.
+ /// The extracted run number, or 0 if extraction fails or not a CI build.
+ public static int ExtractRunNumber(string? version)
+ {
+ if (string.IsNullOrWhiteSpace(version))
+ {
+ return 0;
+ }
+
+ var match = CiRunNumberRegex().Match(version);
+ if (match.Success && int.TryParse(match.Groups[1].Value, out var runNumber) && runNumber > 0)
+ {
+ return runNumber;
+ }
+
+ var ciMatch = CiMarkerRegex().Match(version);
+ if (ciMatch.Success && int.TryParse(ciMatch.Groups[1].Value, out var ciRunNumber) && ciRunNumber > 0)
+ {
+ return ciRunNumber;
+ }
+
+ return 0;
+ }
+
+ ///
+ /// Checks whether an available artifact version is newer than the currently installed version.
+ ///
+ /// The new artifact version string.
+ /// The current version string.
+ /// True if newVersion is newer than currentVersion; otherwise false.
+ public static bool IsArtifactVersionNewer(string? newVersion, string? currentVersion)
+ {
+ if (string.IsNullOrWhiteSpace(newVersion))
+ {
+ return false;
+ }
+
+ if (string.IsNullOrWhiteSpace(currentVersion))
+ {
+ return true;
+ }
+
+ var newVersionBase = newVersion.Split('+')[0].Trim();
+ var currentVersionBase = currentVersion.Split('+')[0].Trim();
+
+ var newRun = ExtractRunNumber(newVersionBase);
+ var currentRun = ExtractRunNumber(currentVersionBase);
+
+ if (newRun > 0 && currentRun > 0)
+ {
+ return newRun > currentRun;
+ }
+
+ if (newRun == 0 && currentRun > 0)
+ {
+ return false;
+ }
+
+ if (newRun > 0 && currentRun == 0)
+ {
+ return true;
+ }
+
+ var newClean = newVersionBase.Split('-')[0];
+ var currentClean = currentVersionBase.Split('-')[0];
+ if (Version.TryParse(newClean, out var newVer) && Version.TryParse(currentClean, out var currentVer))
+ {
+ return newVer > currentVer;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Regex for extracting workflow run number from a 0.0.X CI version string.
+ /// Matches patterns like "0.0.1282-pr265", "0.0.1282-main", "0.0.1282".
+ ///
+ [GeneratedRegex(@"^0\.0\.(\d+)(?:-[a-zA-Z0-9_.-]+)?$", RegexOptions.IgnoreCase)]
+ private static partial Regex CiRunNumberRegex();
+
+ ///
+ /// Regex for extracting workflow run number from a -ci.X marker.
+ ///
+ [GeneratedRegex(@"-ci\.(\d+)", RegexOptions.IgnoreCase)]
+ private static partial Regex CiMarkerRegex();
+}
diff --git a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs
index f6b570af0..d5c595d36 100644
--- a/GenHub/GenHub.Core/Helpers/CommandLineParser.cs
+++ b/GenHub/GenHub.Core/Helpers/CommandLineParser.cs
@@ -15,9 +15,9 @@ public static class CommandLineParser
/// The extracted profile identifier if present; otherwise, null.
public static string? ExtractProfileId(string[] args)
{
- for (var i = 0; i < args.Length; i++)
+ for (int i = 0; i < args.Length; i++)
{
- var arg = args[i];
+ string arg = args[i];
if (arg.Equals(CommandLineConstants.LaunchProfileArg, StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
@@ -34,23 +34,48 @@ public static class CommandLineParser
}
///
- /// Extracts a subscription URL from command line arguments.
- /// Supports the URI scheme format: genhub://subscribe?url=<url>.
+ /// Extracts the absolute URL from a genhub://subscribe?url=... startup argument.
///
+ ///
+ /// The returned value is the url query value only (not the genhub:// wrapper).
+ /// Callers treat it as a GenHub catalog JSON URL today; later it may also be a Provider
+ /// Definition URL without changing this parser.
+ ///
/// The command line arguments.
- /// The extracted catalog URL if present; otherwise, null.
+ /// The decoded absolute URL if present; otherwise, null.
public static string? ExtractSubscriptionUrl(string[] args)
{
- foreach (var arg in args)
+ foreach (string arg in args)
{
if (arg.StartsWith(CommandLineConstants.SubscribeUriPrefix, StringComparison.OrdinalIgnoreCase))
{
- // Simple parsing for ?url=...
- var queryStart = arg.IndexOf(CommandLineConstants.SubscribeUrlParam, StringComparison.OrdinalIgnoreCase);
+ string remainder = arg[CommandLineConstants.SubscribeUriPrefix.Length..];
+ if (!remainder.StartsWith('?') && !remainder.StartsWith("/?", StringComparison.Ordinal))
+ {
+ continue;
+ }
+
+ int queryStart = arg.IndexOf(CommandLineConstants.SubscribeUrlParam, StringComparison.OrdinalIgnoreCase);
if (queryStart != -1)
{
- var url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..];
- return Uri.UnescapeDataString(url).Trim('"');
+ string url = arg[(queryStart + CommandLineConstants.SubscribeUrlParam.Length)..];
+ string unescaped = Uri.UnescapeDataString(url)
+ .Replace("\r", string.Empty)
+ .Replace("\n", string.Empty)
+ .Trim('"', '\'', ' ', '\t');
+
+ if (string.IsNullOrWhiteSpace(unescaped))
+ {
+ return null;
+ }
+
+ if (Uri.TryCreate(unescaped, UriKind.Absolute, out var uri) &&
+ (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
+ {
+ return unescaped;
+ }
+
+ return null;
}
}
}
diff --git a/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs b/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs
index d97f2f8cf..93a3536b0 100644
--- a/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs
+++ b/GenHub/GenHub.Core/Interfaces/Common/IConfigurationProviderService.cs
@@ -64,6 +64,18 @@ public interface IConfigurationProviderService
/// True if auto-check is enabled; otherwise, false.
bool GetAutoCheckForUpdatesOnStartup();
+ ///
+ /// Gets whether to automatically check for updates periodically.
+ ///
+ /// True if periodic auto-check is enabled; otherwise, false.
+ bool GetAutoCheckForUpdatesPeriodically();
+
+ ///
+ /// Gets the interval in minutes for periodic update checks.
+ ///
+ /// The update check interval in minutes.
+ int GetPeriodicUpdateCheckIntervalMinutes();
+
///
/// Gets whether detailed logging is enabled.
///
diff --git a/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs b/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs
index 84a4d3773..9b26f26b2 100644
--- a/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs
+++ b/GenHub/GenHub.Core/Interfaces/Content/ILocalContentService.cs
@@ -20,6 +20,7 @@ public interface ILocalContentService
/// Optional original source path of the content.
/// Optional progress reporter for tracking manifest creation.
/// Cancellation token.
+ /// Optional relative path of the main executable entry point.
/// A result containing the created manifest or errors.
Task> CreateLocalContentManifestAsync(
string directoryPath,
@@ -28,7 +29,8 @@ Task> CreateLocalContentManifestAsync(
GameType targetGame,
string? sourcePath = null,
IProgress? progress = null,
- CancellationToken cancellationToken = default);
+ CancellationToken cancellationToken = default,
+ string? entryPoint = null);
///
/// Adds local content by creating and storing a manifest.
@@ -66,6 +68,7 @@ Task> AddLocalContentAsync(
/// Optional original source path of the content.
/// Optional progress reporter.
/// Cancellation token.
+ /// Optional relative path of the main executable entry point.
/// A result containing the updated manifest.
Task> UpdateLocalContentManifestAsync(
string existingManifestId,
@@ -75,7 +78,8 @@ Task> UpdateLocalContentManifestAsync(
GameType targetGame,
string? sourcePath = null,
IProgress? progress = null,
- CancellationToken cancellationToken = default);
+ CancellationToken cancellationToken = default,
+ string? entryPoint = null);
///
/// Gets the allowed content types for local content creation.
diff --git a/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs b/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs
index 34a88df40..55800dd3e 100644
--- a/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs
+++ b/GenHub/GenHub.Core/Interfaces/Notifications/INotificationService.cs
@@ -28,6 +28,11 @@ public interface INotificationService
///
IObservable NotificationHistory { get; }
+ ///
+ /// Gets the observable stream of notification update requests.
+ ///
+ IObservable<(Guid Id, string? Title, string Message)> UpdateRequests { get; }
+
///
/// Shows an informational notification.
///
@@ -70,6 +75,14 @@ public interface INotificationService
/// The notification to show.
void Show(NotificationMessage notification);
+ ///
+ /// Updates the message and optionally the title of an active notification.
+ ///
+ /// The ID of the notification to update.
+ /// The new message content.
+ /// Optional new title. If null, the existing title is preserved.
+ void Update(Guid notificationId, string message, string? title = null);
+
///
/// Dismisses a specific notification.
///
diff --git a/GenHub/GenHub.Core/Messages/UpdateSettingsChangedMessage.cs b/GenHub/GenHub.Core/Messages/UpdateSettingsChangedMessage.cs
new file mode 100644
index 000000000..e199ff5b3
--- /dev/null
+++ b/GenHub/GenHub.Core/Messages/UpdateSettingsChangedMessage.cs
@@ -0,0 +1,12 @@
+namespace GenHub.Core.Messages;
+
+///
+/// Message sent when update settings have changed.
+///
+/// Whether to check for updates on startup.
+/// Whether to check for updates periodically.
+/// Interval in minutes between periodic update checks.
+public record UpdateSettingsChangedMessage(
+ bool AutoCheckForUpdatesOnStartup,
+ bool AutoCheckForUpdatesPeriodically,
+ int PeriodicUpdateCheckIntervalMinutes);
diff --git a/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs b/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs
index a8d2a9693..e9fc4bb56 100644
--- a/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs
+++ b/GenHub/GenHub.Core/Models/AppUpdate/PullRequestInfo.cs
@@ -50,6 +50,11 @@ public record PullRequestInfo
///
public string DisplayVersion => LatestArtifact?.DisplayVersion ?? $"0.0.{Number}";
+ ///
+ /// Gets the display title formatted with the PR number (e.g., "#123 - PR Title").
+ ///
+ public string DisplayTitle => $"#{Number} - {Title}";
+
///
/// Gets a value indicating whether this PR is still open.
///
diff --git a/GenHub/GenHub.Core/Models/Common/UserSettings.cs b/GenHub/GenHub.Core/Models/Common/UserSettings.cs
index 4b77fe175..c33263307 100644
--- a/GenHub/GenHub.Core/Models/Common/UserSettings.cs
+++ b/GenHub/GenHub.Core/Models/Common/UserSettings.cs
@@ -39,6 +39,12 @@ public class UserSettings
/// Gets or sets a value indicating whether to automatically check for updates on startup.
public bool AutoCheckForUpdatesOnStartup { get; set; } = true;
+ /// Gets or sets a value indicating whether to automatically check for updates periodically.
+ public bool AutoCheckForUpdatesPeriodically { get; set; } = true;
+
+ /// Gets or sets the interval in minutes between periodic update checks.
+ public int PeriodicUpdateCheckIntervalMinutes { get; set; } = GenHub.Core.Constants.AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes;
+
/// Gets or sets the timestamp of the last update check in ISO 8601 format.
public string? LastUpdateCheckTimestamp { get; set; }
@@ -151,6 +157,8 @@ public UserSettings Clone()
MaxConcurrentDownloads = MaxConcurrentDownloads,
AllowBackgroundDownloads = AllowBackgroundDownloads,
AutoCheckForUpdatesOnStartup = AutoCheckForUpdatesOnStartup,
+ AutoCheckForUpdatesPeriodically = AutoCheckForUpdatesPeriodically,
+ PeriodicUpdateCheckIntervalMinutes = PeriodicUpdateCheckIntervalMinutes,
LastUpdateCheckTimestamp = LastUpdateCheckTimestamp,
EnableDetailedLogging = EnableDetailedLogging,
DefaultWorkspaceStrategy = DefaultWorkspaceStrategy,
diff --git a/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs b/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs
index 83d681312..7a93f17cf 100644
--- a/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs
+++ b/GenHub/GenHub.Core/Models/Manifest/ManifestIdJsonConverter.cs
@@ -9,7 +9,7 @@ namespace GenHub.Core.Models.Manifest;
public sealed class ManifestIdJsonConverter : JsonConverter
{
///
- public override ManifestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ public override ManifestId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) // skipcq: CS-R1138
{
var s = reader.GetString() ?? string.Empty;
return ManifestId.Create(s);
diff --git a/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs b/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs
index ddb22794a..fc609db20 100644
--- a/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs
+++ b/GenHub/GenHub.Core/Models/Manifest/ManifestVariantResolver.cs
@@ -166,7 +166,13 @@ public static EntryPointResolution ResolveEntryPoint(
files);
}
- private static bool PathsMatch(string left, string right) =>
+ ///
+ /// Determines whether two relative file paths match, normalizing directory separators and leading slashes.
+ ///
+ /// The first relative path.
+ /// The second relative path.
+ /// true if the paths match; otherwise, false.
+ public static bool PathsMatch(string left, string right) =>
string.Equals(
left.Replace('\\', '/').TrimStart('/'),
right.Replace('\\', '/').TrimStart('/'),
diff --git a/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs b/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs
index 24058e7d9..04375f0ea 100644
--- a/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs
+++ b/GenHub/GenHub.Core/Serialization/JsonWorkspaceStrategyConverter.cs
@@ -15,7 +15,8 @@ public class JsonWorkspaceStrategyConverter : JsonConverter
///
[SuppressMessage("Maintainability", "CS-R1138:Inappropriate ordering of parameters", Justification = "Signature is defined by System.Text.Json.Serialization.JsonConverter.Read")]
[SuppressMessage("DeepSource", "CS-R1138", Justification = "Signature is defined by System.Text.Json.Serialization.JsonConverter.Read")]
- public override WorkspaceStrategy Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ [SuppressMessage("csharp", "CS-R1138", Justification = "Signature is defined by System.Text.Json.Serialization.JsonConverter.Read")]
+ public override WorkspaceStrategy Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) // skipcq: CS-R1138
{
if (reader.TokenType == JsonTokenType.Number)
{
diff --git a/GenHub/GenHub.Core/Services/Content/LocalContentService.cs b/GenHub/GenHub.Core/Services/Content/LocalContentService.cs
index 8f544eec5..2912aa9a5 100644
--- a/GenHub/GenHub.Core/Services/Content/LocalContentService.cs
+++ b/GenHub/GenHub.Core/Services/Content/LocalContentService.cs
@@ -57,7 +57,8 @@ public async Task> CreateLocalContentManifestAs
GameType targetGame,
string? sourcePath = null,
IProgress? progress = null,
- CancellationToken cancellationToken = default)
+ CancellationToken cancellationToken = default,
+ string? entryPoint = null)
{
try
{
@@ -102,6 +103,29 @@ public async Task> CreateLocalContentManifestAs
var manifest = builder.Build();
manifest.SourcePath = !string.IsNullOrEmpty(sourcePath) ? sourcePath : directoryPath;
+ if (!string.IsNullOrWhiteSpace(entryPoint))
+ {
+ var normalizedEntryPoint = entryPoint.Replace('\\', '/').TrimStart('/');
+
+ var segments = normalizedEntryPoint.Split('/', StringSplitOptions.RemoveEmptyEntries);
+ if (Path.IsPathRooted(entryPoint) || segments.Any(s => s == ".."))
+ {
+ return OperationResult.CreateFailure(
+ $"Entry point '{entryPoint}' is invalid. It must be a relative path without parent directory traversal ('..').");
+ }
+
+ var matchedFile = manifest.Files.FirstOrDefault(f =>
+ ManifestVariantResolver.PathsMatch(f.RelativePath, normalizedEntryPoint));
+
+ if (matchedFile == null)
+ {
+ return OperationResult.CreateFailure(
+ $"Entry point '{entryPoint}' was not found among the files in the directory.");
+ }
+
+ manifest.EntryPoint = matchedFile.RelativePath.Replace('\\', '/');
+ }
+
// Auto-add GameInstallation dependency for GameClient content types
// This ensures auto-resolution logic works correctly for locally added clients
if (contentType == ContentType.GameClient)
@@ -195,13 +219,14 @@ public async Task> UpdateLocalContentManifestAs
GameType targetGame,
string? sourcePath = null,
IProgress? progress = null,
- CancellationToken cancellationToken = default)
+ CancellationToken cancellationToken = default,
+ string? entryPoint = null)
{
try
{
// 1. Create the new manifest/content
// We do this FIRST to ensure the new content is valid before deleting the old one
- var createResult = await CreateLocalContentManifestAsync(directoryPath, name, contentType, targetGame, sourcePath, progress, cancellationToken);
+ var createResult = await CreateLocalContentManifestAsync(directoryPath, name, contentType, targetGame, sourcePath, progress, cancellationToken, entryPoint);
if (!createResult.Success)
{
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs
index 49836a2dc..7cd571b77 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/ConfigurationProviderServiceTests.cs
@@ -549,6 +549,58 @@ public void GetAutoCheckForUpdatesOnStartup_ReturnsUserSetting(bool userValue)
Assert.Equal(userValue, result);
}
+ ///
+ /// Verifies that GetAutoCheckForUpdatesPeriodically returns user setting when explicitly set.
+ ///
+ /// The value to set for AutoCheckForUpdatesPeriodically in user settings.
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void GetAutoCheckForUpdatesPeriodically_ReturnsUserSetting(bool userValue)
+ {
+ // Arrange
+ var userSettings = new UserSettings { AutoCheckForUpdatesPeriodically = userValue };
+ userSettings.MarkAsExplicitlySet(nameof(UserSettings.AutoCheckForUpdatesPeriodically));
+ _mockUserSettings.Setup(x => x.Get()).Returns(userSettings);
+
+ var provider = CreateProvider();
+
+ // Act
+ var result = provider.GetAutoCheckForUpdatesPeriodically();
+
+ // Assert
+ Assert.Equal(userValue, result);
+ }
+
+ ///
+ /// Verifies that GetPeriodicUpdateCheckIntervalMinutes returns user setting when explicitly set.
+ ///
+ /// The interval to set in user settings.
+ /// The expected clamped interval.
+ [Theory]
+ [InlineData(60, 60)]
+ [InlineData(0, AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes)]
+ [InlineData(20000, AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes)]
+ public void GetPeriodicUpdateCheckIntervalMinutes_ReturnsUserSetting(int intervalMinutes, int expectedMinutes)
+ {
+ // Arrange
+ var userSettings = new UserSettings { PeriodicUpdateCheckIntervalMinutes = intervalMinutes };
+ if (intervalMinutes > 0)
+ {
+ userSettings.MarkAsExplicitlySet(nameof(UserSettings.PeriodicUpdateCheckIntervalMinutes));
+ }
+
+ _mockUserSettings.Setup(x => x.Get()).Returns(userSettings);
+
+ var provider = CreateProvider();
+
+ // Act
+ var result = provider.GetPeriodicUpdateCheckIntervalMinutes();
+
+ // Assert
+ Assert.Equal(expectedMinutes, result);
+ }
+
///
/// Verifies that GetEnableDetailedLogging returns user setting when explicitly set.
///
@@ -784,7 +836,8 @@ public void GetGitHubDiscoveryRepositories_WithNullUserSetting_ReturnsDefaults()
// Assert
Assert.Contains("TheSuperHackers/GeneralsGameCode", result);
- Assert.Single(result);
+ Assert.Contains("TheSuperHackers/GeneralsGamePatch2", result);
+ Assert.Equal(2, result.Count);
}
///
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs
index 9b37967c3..1e105e2ef 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Common/Services/UserSettingsServiceTests.cs
@@ -66,6 +66,8 @@ public void Get_WhenNoFileExists_ReturnsDefaultUserSettings()
Assert.Equal(DownloadDefaults.MaxConcurrentDownloads, settings.MaxConcurrentDownloads);
Assert.True(settings.AllowBackgroundDownloads);
Assert.True(settings.AutoCheckForUpdatesOnStartup);
+ Assert.True(settings.AutoCheckForUpdatesPeriodically);
+ Assert.Equal(AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes, settings.PeriodicUpdateCheckIntervalMinutes);
Assert.Equal(WorkspaceConstants.DefaultWorkspaceStrategy, settings.DefaultWorkspaceStrategy);
}
@@ -364,6 +366,25 @@ public void UpdateSettings_EnableDetailedLogging_CanBeSetAndRetrieved(bool enabl
Assert.Equal(enableLogging, currentSettings.EnableDetailedLogging);
}
+ ///
+ /// Verifies that periodic update settings can be set and retrieved correctly.
+ ///
+ [Fact]
+ public void UpdateSettings_PeriodicUpdateSettings_CanBeSetAndRetrieved()
+ {
+ var service = CreateService();
+
+ service.Update(settings =>
+ {
+ settings.AutoCheckForUpdatesPeriodically = false;
+ settings.PeriodicUpdateCheckIntervalMinutes = 15;
+ });
+ var currentSettings = service.Get();
+
+ Assert.False(currentSettings.AutoCheckForUpdatesPeriodically);
+ Assert.Equal(15, currentSettings.PeriodicUpdateCheckIntervalMinutes);
+ }
+
private static IAppConfiguration CreateAppConfigMock()
{
var appConfig = new Mock();
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs
new file mode 100644
index 000000000..be8cebf08
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Constants/AppUpdateConstantsTests.cs
@@ -0,0 +1,95 @@
+using System;
+using GenHub.Core.Constants;
+using Xunit;
+
+namespace GenHub.Tests.Core.Constants;
+
+///
+/// Unit tests for .
+///
+public class AppUpdateConstantsTests
+{
+ ///
+ /// Tests that tab index constants have expected values.
+ ///
+ [Fact]
+ public void TabIndex_Constants_ShouldHaveExpectedValues()
+ {
+ Assert.Equal(0, AppUpdateConstants.UpdateTabIndex);
+ Assert.Equal(1, AppUpdateConstants.BrowseBuildsTabIndex);
+ Assert.Equal(1, AppUpdateConstants.MaxTabIndex);
+ }
+
+ ///
+ /// Tests that platform and artifact prefix constants have expected values.
+ ///
+ [Fact]
+ public void ArtifactAndPlatform_Constants_ShouldHaveExpectedValues()
+ {
+ Assert.Equal("velopack", AppUpdateConstants.VelopackDirectory);
+ Assert.Equal("genhub-velopack-windows-", AppUpdateConstants.ArtifactPrefixWindows);
+ Assert.Equal("genhub-velopack-linux-", AppUpdateConstants.ArtifactPrefixLinux);
+ Assert.Equal("GenHub-Release", AppUpdateConstants.ArtifactNameRelease);
+ Assert.Equal("windows", AppUpdateConstants.PlatformWindows);
+ Assert.Equal("linux", AppUpdateConstants.PlatformLinux);
+ }
+
+ ///
+ /// Tests that periodic update check interval constants have expected values.
+ ///
+ [Fact]
+ public void PeriodicUpdateCheckInterval_Constants_ShouldHaveExpectedValues()
+ {
+ Assert.Equal(30, AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes);
+ Assert.Equal(5, AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes);
+ Assert.Equal(10080, AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes);
+ Assert.Equal(5, AppUpdateConstants.PeriodicUpdateCheckIntervalIncrementMinutes);
+ Assert.True(AppUpdateConstants.MinPeriodicUpdateCheckIntervalMinutes <= AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes);
+ Assert.True(AppUpdateConstants.DefaultPeriodicUpdateCheckIntervalMinutes <= AppUpdateConstants.MaxPeriodicUpdateCheckIntervalMinutes);
+ }
+
+ ///
+ /// Tests that timespan constants have expected durations.
+ ///
+ [Fact]
+ public void TimeSpan_Constants_ShouldHaveExpectedValues()
+ {
+ Assert.Equal(TimeSpan.FromSeconds(5), AppUpdateConstants.PostUpdateExitDelay);
+ Assert.Equal(TimeSpan.FromHours(1), AppUpdateConstants.CacheDuration);
+ Assert.Equal(3, AppUpdateConstants.MaxHttpRetries);
+ }
+
+ ///
+ /// Tests that notification title and format constants are non-empty strings.
+ ///
+ [Fact]
+ public void NotificationAndFormat_Constants_ShouldBeValid()
+ {
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdateAvailableNotificationTitle));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.BranchUpdateAvailableNotificationTitle));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.PrUpdateAvailableNotificationTitle));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdatingAppNotificationTitle));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdateFailedNotificationTitle));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.UpdateAction));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.ViewUpdatesAction));
+ Assert.Contains("{0}", AppUpdateConstants.ReleaseUpdateNotificationFormat);
+ Assert.Contains("{0}", AppUpdateConstants.BranchUpdateNotificationFormat);
+ Assert.Contains("{1}", AppUpdateConstants.BranchUpdateNotificationFormat);
+ Assert.Contains("{0}", AppUpdateConstants.PrUpdateNotificationFormat);
+ Assert.Contains("{1}", AppUpdateConstants.PrUpdateNotificationFormat);
+ Assert.Contains("{0}", AppUpdateConstants.UpdateFailedNotificationFormat);
+ }
+
+ ///
+ /// Tests that sort option constants are distinct non-empty strings.
+ ///
+ [Fact]
+ public void SortOption_Constants_ShouldBeDistinctAndNonEmpty()
+ {
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.SortOptionLastUpdated));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.SortOptionPrNumberDesc));
+ Assert.False(string.IsNullOrWhiteSpace(AppUpdateConstants.SortOptionPrNumberAsc));
+ Assert.NotEqual(AppUpdateConstants.SortOptionLastUpdated, AppUpdateConstants.SortOptionPrNumberDesc);
+ Assert.NotEqual(AppUpdateConstants.SortOptionPrNumberDesc, AppUpdateConstants.SortOptionPrNumberAsc);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs
index fed2373b8..b4ea11d25 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/ViewModels/UpdateNotificationViewModelTests.cs
@@ -1,8 +1,15 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
using GenHub.Core.Interfaces.Common;
+using GenHub.Core.Models.AppUpdate;
+using GenHub.Core.Models.Common;
using GenHub.Features.AppUpdate.Interfaces;
using GenHub.Features.AppUpdate.ViewModels;
using Microsoft.Extensions.Logging;
using Moq;
+using Xunit;
namespace GenHub.Tests.Core.Features.AppUpdate.ViewModels;
@@ -23,7 +30,7 @@ public async Task CheckForUpdatesCommand_WhenNoUpdateAvailable_UpdatesStatusAsyn
.ReturnsAsync((Velopack.UpdateInfo?)null);
var mockUserSettings = new Mock();
- mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings());
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
var vm = new UpdateNotificationViewModel(
mockVelopack.Object,
@@ -43,7 +50,7 @@ public async Task CheckForUpdatesCommand_WhenNoUpdateAvailable_UpdatesStatusAsyn
public void Constructor_InitializesSuccessfully()
{
var mockUserSettings = new Mock();
- mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings());
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
var vm = new UpdateNotificationViewModel(
Mock.Of(),
@@ -63,7 +70,7 @@ public void Constructor_InitializesSuccessfully()
public void IsCheckButtonEnabled_ReflectsCheckingState()
{
var mockUserSettings = new Mock();
- mockUserSettings.Setup(x => x.Get()).Returns(new GenHub.Core.Models.Common.UserSettings());
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
var vm = new UpdateNotificationViewModel(
Mock.Of(),
@@ -72,4 +79,493 @@ public void IsCheckButtonEnabled_ReflectsCheckingState()
Assert.True(vm.IsCheckButtonEnabled);
}
+
+ ///
+ /// Verifies that pull request display title formats properly with PR number and title.
+ ///
+ [Fact]
+ public void PullRequestInfo_DisplayTitle_ShouldIncludePrNumberAndTitle()
+ {
+ var prInfo = new PullRequestInfo
+ {
+ Number = 265,
+ Title = "feat: UI Downloads",
+ BranchName = "feat/ui-downloads",
+ Author = "developer",
+ State = "open",
+ UpdatedAt = DateTimeOffset.UtcNow,
+ };
+
+ Assert.Equal("#265 - feat: UI Downloads", prInfo.DisplayTitle);
+ }
+
+ ///
+ /// Verifies that subscribing to a PR loads artifacts and auto-selects the latest version.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SubscribeToPr_LoadsArtifactsAndAutoSelectsLatestVersionAsync()
+ {
+ var mockVelopack = new Mock();
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var artifacts = new List
+ {
+ new("0.0.1316-pr389", "e1212a5", 389, 1001, "https://github.com/test/run/1", 501, "genhub-velopack-linux-0.0.1316-pr389", DateTime.UtcNow, "https://github.com/test/art/1", 1024),
+ new("0.0.1315-pr389", "a1b2c3d", 389, 1000, "https://github.com/test/run/0", 500, "genhub-velopack-linux-0.0.1315-pr389", DateTime.UtcNow.AddMinutes(-10), "https://github.com/test/art/0", 1024),
+ };
+
+ var loadTcs = new TaskCompletionSource>();
+ mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(389, It.IsAny()))
+ .Returns(async (int _, CancellationToken ct) =>
+ {
+ ct.Register(() => loadTcs.TrySetCanceled(ct));
+ return await loadTcs.Task;
+ });
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ vm.SubscribeToPrCommand.Execute(389);
+
+ Assert.True(vm.IsLoadingVersions);
+ loadTcs.SetResult(artifacts);
+
+ // wait briefly for async continuation
+ var timeout = DateTime.UtcNow.AddSeconds(2);
+ while (vm.IsLoadingVersions && DateTime.UtcNow < timeout)
+ {
+ await Task.Delay(10);
+ }
+
+ Assert.False(vm.IsLoadingVersions);
+ Assert.Equal(2, vm.AvailableVersions.Count);
+ Assert.NotNull(vm.SelectedVersion);
+ Assert.Equal("0.0.1316-pr389", vm.SelectedVersion.Version);
+ Assert.Equal("e1212a5", vm.SelectedVersion.GitHash);
+ Assert.True(vm.CanDownloadUpdate);
+ }
+
+ ///
+ /// Verifies that subscribing to a branch loads artifacts and auto-selects the latest version.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SubscribeToBranch_LoadsArtifactsAndAutoSelectsLatestVersionAsync()
+ {
+ var mockVelopack = new Mock();
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var artifacts = new List
+ {
+ new("0.0.1320-development", "f4e3d2c", null, 2001, "https://github.com/test/run/2", 601, "genhub-velopack-linux-0.0.1320-development", DateTime.UtcNow, "https://github.com/test/art/2", 2048),
+ };
+
+ mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("development", It.IsAny()))
+ .ReturnsAsync(artifacts);
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ vm.SubscribeToBranchCommand.Execute("development");
+
+ var timeout = DateTime.UtcNow.AddSeconds(2);
+ while (vm.IsLoadingVersions && DateTime.UtcNow < timeout)
+ {
+ await Task.Delay(10);
+ }
+
+ Assert.False(vm.IsLoadingVersions);
+ Assert.Single(vm.AvailableVersions);
+ Assert.NotNull(vm.SelectedVersion);
+ Assert.Equal("0.0.1320-development", vm.SelectedVersion.Version);
+ }
+
+ ///
+ /// Verifies that when switching PR subscriptions while a previous load is in flight, the old request is cancelled and only the new subscription artifacts are applied.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SubscribeToPr_WhenSwitchedImmediately_CancelsPreviousLoadAndLoadsNewSubscriptionAsync()
+ {
+ var mockVelopack = new Mock();
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var pr391Tcs = new TaskCompletionSource>();
+ var pr389Tcs = new TaskCompletionSource>();
+
+ mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(391, It.IsAny()))
+ .Returns(async (int _, CancellationToken ct) =>
+ {
+ ct.Register(() => pr391Tcs.TrySetCanceled(ct));
+ return await pr391Tcs.Task;
+ });
+
+ mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(389, It.IsAny()))
+ .Returns(async (int _, CancellationToken ct) =>
+ {
+ ct.Register(() => pr389Tcs.TrySetCanceled(ct));
+ return await pr389Tcs.Task;
+ });
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ // subscribe to 391 first
+ vm.SubscribeToPrCommand.Execute(391);
+ Assert.True(vm.IsLoadingVersions);
+
+ // immediately switch to 389 while 391 is loading
+ vm.SubscribeToPrCommand.Execute(389);
+
+ // resolve 389 artifacts
+ var pr389Artifacts = new List
+ {
+ new("0.0.1316-pr389", "e1212a5", 389, 1001, "https://github.com/test/run/1", 501, "genhub-velopack-linux-0.0.1316-pr389", DateTime.UtcNow, "https://github.com/test/art/1", 1024),
+ };
+ pr389Tcs.TrySetResult(pr389Artifacts);
+
+ var timeout = DateTime.UtcNow.AddSeconds(2);
+ while (vm.IsLoadingVersions && DateTime.UtcNow < timeout)
+ {
+ await Task.Delay(10);
+ }
+
+ Assert.True(pr391Tcs.Task.IsCanceled);
+ Assert.False(vm.IsLoadingVersions);
+ Assert.Single(vm.AvailableVersions);
+ Assert.NotNull(vm.SelectedVersion);
+ Assert.Equal("0.0.1316-pr389", vm.SelectedVersion.Version);
+ Assert.Equal(389, vm.SelectedVersion.PullRequestNumber);
+ }
+
+ ///
+ /// Verifies that switching from a branch to another branch cancels the previous load and populates the new branch artifacts.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SubscribeToBranch_WhenSwitchedImmediately_CancelsPreviousLoadAndLoadsNewBranchAsync()
+ {
+ var mockVelopack = new Mock();
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var branchOldTcs = new TaskCompletionSource>();
+ var branchNewTcs = new TaskCompletionSource>();
+
+ mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("old-branch", It.IsAny()))
+ .Returns(async (string _, CancellationToken ct) =>
+ {
+ ct.Register(() => branchOldTcs.TrySetCanceled(ct));
+ return await branchOldTcs.Task;
+ });
+
+ mockVelopack.Setup(x => x.GetArtifactsForBranchAsync("new-branch", It.IsAny()))
+ .Returns(async (string _, CancellationToken ct) =>
+ {
+ ct.Register(() => branchNewTcs.TrySetCanceled(ct));
+ return await branchNewTcs.Task;
+ });
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ vm.SubscribeToBranchCommand.Execute("old-branch");
+ Assert.True(vm.IsLoadingVersions);
+
+ vm.SubscribeToBranchCommand.Execute("new-branch");
+
+ var newArtifacts = new List
+ {
+ new("0.0.1400-new-branch", "9998887", null, 3001, "https://github.com/test/run/3", 701, "genhub-velopack-linux-0.0.1400-new-branch", DateTime.UtcNow, "https://github.com/test/art/3", 2048),
+ };
+ branchNewTcs.TrySetResult(newArtifacts);
+
+ var timeout = DateTime.UtcNow.AddSeconds(2);
+ while (vm.IsLoadingVersions && DateTime.UtcNow < timeout)
+ {
+ await Task.Delay(10);
+ }
+
+ Assert.True(branchOldTcs.Task.IsCanceled);
+ Assert.False(vm.IsLoadingVersions);
+ Assert.Single(vm.AvailableVersions);
+ Assert.NotNull(vm.SelectedVersion);
+ Assert.Equal("0.0.1400-new-branch", vm.SelectedVersion.Version);
+ }
+
+ ///
+ /// Verifies that unsubscribing cancels in-flight loads and clears available versions and selection.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task Unsubscribe_CancelsInFlightLoadsAndClearsAvailableVersionsAsync()
+ {
+ var mockVelopack = new Mock();
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var prTcs = new TaskCompletionSource>();
+ mockVelopack.Setup(x => x.GetArtifactsForPullRequestAsync(391, It.IsAny()))
+ .Returns(async (int _, CancellationToken ct) =>
+ {
+ ct.Register(() => prTcs.TrySetCanceled(ct));
+ return await prTcs.Task;
+ });
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ vm.SubscribeToPrCommand.Execute(391);
+ Assert.True(vm.IsLoadingVersions);
+
+ vm.UnsubscribeCommand.Execute(null);
+
+ var timeout = DateTime.UtcNow.AddSeconds(2);
+ while ((vm.IsLoadingVersions || vm.AvailableVersions.Count > 0) && DateTime.UtcNow < timeout)
+ {
+ await Task.Delay(10);
+ }
+
+ Assert.True(prTcs.Task.IsCanceled);
+ Assert.False(vm.IsLoadingVersions);
+ Assert.Empty(vm.AvailableVersions);
+ Assert.Null(vm.SelectedVersion);
+ }
+
+ ///
+ /// Verifies that OpenPullRequestUrlCommand executes without error for valid and invalid PR numbers.
+ ///
+ /// The PR number under test.
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void OpenPullRequestUrlCommand_ExecutesWithoutException(int prNumber)
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var vm = new UpdateNotificationViewModel(
+ Mock.Of(),
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ // verify command execution does not throw
+ vm.OpenPullRequestUrlCommand.Execute(prNumber);
+ Assert.NotNull(vm);
+ }
+
+ ///
+ /// Verifies that changing the sort option reorders available pull requests accordingly.
+ ///
+ [Fact]
+ public void SelectedSortOption_ReordersAvailablePullRequests()
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var vm = new UpdateNotificationViewModel(
+ Mock.Of(),
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ var now = DateTimeOffset.UtcNow;
+ var pr100 = new PullRequestInfo { Number = 100, Title = "PR 100", BranchName = "b1", Author = "a1", State = "open", UpdatedAt = now.AddDays(-2) };
+ var pr200 = new PullRequestInfo { Number = 200, Title = "PR 200", BranchName = "b2", Author = "a2", State = "open", UpdatedAt = now.AddDays(-10) };
+ var pr300 = new PullRequestInfo { Number = 300, Title = "PR 300", BranchName = "b3", Author = "a3", State = "open", UpdatedAt = now };
+
+ vm.AvailablePullRequests.Add(pr100);
+ vm.AvailablePullRequests.Add(pr200);
+ vm.AvailablePullRequests.Add(pr300);
+
+ // sort by PR number descending
+ vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionPrNumberDesc;
+ Assert.Equal(300, vm.AvailablePullRequests[0].Number);
+ Assert.Equal(200, vm.AvailablePullRequests[1].Number);
+ Assert.Equal(100, vm.AvailablePullRequests[2].Number);
+
+ // sort by PR number ascending
+ vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionPrNumberAsc;
+ Assert.Equal(100, vm.AvailablePullRequests[0].Number);
+ Assert.Equal(200, vm.AvailablePullRequests[1].Number);
+ Assert.Equal(300, vm.AvailablePullRequests[2].Number);
+
+ // sort by last updated (newest first)
+ vm.SelectedSortOption = GenHub.Core.Constants.AppUpdateConstants.SortOptionLastUpdated;
+ Assert.Equal(300, vm.AvailablePullRequests[0].Number);
+ Assert.Equal(100, vm.AvailablePullRequests[1].Number);
+ Assert.Equal(200, vm.AvailablePullRequests[2].Number);
+ }
+
+ ///
+ /// Verifies that tab commands correctly switch between Update and Browse Builds tabs.
+ ///
+ [Fact]
+ public void TabCommands_UpdatesSelectedTabIndexAndIsBrowseTabSelected()
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var vm = new UpdateNotificationViewModel(
+ Mock.Of(),
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ Assert.Equal(0, vm.SelectedTabIndex);
+ Assert.False(vm.IsBrowseTabSelected);
+
+ vm.ShowBrowseBuildsTabCommand.Execute(null);
+ Assert.Equal(1, vm.SelectedTabIndex);
+ Assert.True(vm.IsBrowseTabSelected);
+
+ vm.ShowUpdateTabCommand.Execute(null);
+ Assert.Equal(0, vm.SelectedTabIndex);
+ Assert.False(vm.IsBrowseTabSelected);
+
+ vm.SelectTabCommand.Execute("1");
+ Assert.Equal(1, vm.SelectedTabIndex);
+ Assert.True(vm.IsBrowseTabSelected);
+
+ vm.SelectTabCommand.Execute(0);
+ Assert.Equal(0, vm.SelectedTabIndex);
+ Assert.False(vm.IsBrowseTabSelected);
+
+ // Clamping out-of-range inputs
+ vm.SelectTabCommand.Execute(-1);
+ Assert.Equal(0, vm.SelectedTabIndex);
+
+ vm.SelectTabCommand.Execute(5);
+ Assert.Equal(1, vm.SelectedTabIndex);
+
+ vm.SelectTabCommand.Execute("99");
+ Assert.Equal(1, vm.SelectedTabIndex);
+ }
+
+ ///
+ /// Verifies that DisplayCurrentVersion and InstalledVersionDisplay return a valid non-empty version string.
+ ///
+ [Fact]
+ public void DisplayCurrentVersion_ReturnsNonEmptyVersion()
+ {
+ var displayVersion = UpdateNotificationViewModel.DisplayCurrentVersion;
+ Assert.False(string.IsNullOrWhiteSpace(displayVersion));
+ Assert.StartsWith("v", displayVersion);
+
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var vm = new UpdateNotificationViewModel(
+ Mock.Of(),
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ Assert.Equal(displayVersion, vm.InstalledVersionDisplay);
+ }
+
+ ///
+ /// Verifies that setting SelectedVersion to a newer artifact updates StatusMessage and sets IsUpdateAvailable to true.
+ ///
+ [Fact]
+ public void SelectedVersion_WhenNewer_UpdatesStatusMessageAndIsUpdateAvailable()
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings());
+
+ var vm = new UpdateNotificationViewModel(
+ Mock.Of(),
+ Mock.Of>(),
+ mockUserSettings.Object);
+
+ var newerArtifact = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024);
+ vm.SelectedVersion = newerArtifact;
+
+ Assert.True(vm.IsUpdateAvailable);
+ Assert.Equal("0.0.99999-pr389", vm.LatestVersion);
+ Assert.Contains("0.0.99999-pr389", vm.StatusMessage);
+ }
+
+ ///
+ /// Verifies that selecting an artifact matching dismissed version clears IsUpdateAvailable, LatestVersion, and ReleaseNotesUrl.
+ ///
+ [Fact]
+ public void SelectedVersion_WhenDismissed_ClearsUpdateAvailableState()
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { DismissedUpdateVersion = "0.0.99999-pr389" });
+
+ var vm = new UpdateNotificationViewModel(
+ Mock.Of(),
+ Mock.Of>(),
+ mockUserSettings.Object)
+ {
+ IsUpdateAvailable = true,
+ LatestVersion = "0.0.88888",
+ ReleaseNotesUrl = "https://example.com/notes",
+ };
+
+ var dismissedArtifact = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024);
+ vm.SelectedVersion = dismissedArtifact;
+
+ Assert.False(vm.IsUpdateAvailable);
+ Assert.Empty(vm.LatestVersion);
+ Assert.Empty(vm.ReleaseNotesUrl);
+ Assert.Contains("dismissed", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Verifies that Unsubscribe resets subscription fields, clears update available state, and updates status message.
+ ///
+ [Fact]
+ public void Unsubscribe_ClearsArtifactUpdateStateAndSwitchesToMain()
+ {
+ var mockUserSettings = new Mock();
+ mockUserSettings.Setup(x => x.Get()).Returns(new UserSettings { SubscribedPrNumber = 389 });
+
+ var mockVelopack = new Mock();
+ mockVelopack.SetupProperty(x => x.SubscribedPrNumber, 389);
+ mockVelopack.SetupProperty(x => x.SubscribedBranch, null);
+
+ var vm = new UpdateNotificationViewModel(
+ mockVelopack.Object,
+ Mock.Of>(),
+ mockUserSettings.Object)
+ {
+ SubscribedPr = new PullRequestInfo
+ {
+ Number = 389,
+ Title = "Test PR",
+ BranchName = "feature/test",
+ Author = "testuser",
+ State = "open",
+ },
+ SelectedVersion = new ArtifactUpdateInfo("0.0.99999-pr389", "abcdef1", 389, 9999, "https://github.com/test/run/9999", 501, "genhub-linux", DateTime.UtcNow, "https://github.com/test/art/1", 1024),
+ IsUpdateAvailable = true,
+ LatestVersion = "0.0.99999-pr389",
+ ReleaseNotesUrl = "https://example.com/notes",
+ };
+
+ vm.UnsubscribeCommand.Execute(null);
+
+ Assert.Null(vm.SubscribedPr);
+ Assert.Null(vm.SubscribedBranch);
+ Assert.Null(vm.SelectedVersion);
+ Assert.False(vm.IsUpdateAvailable);
+ Assert.Empty(vm.LatestVersion);
+ Assert.Empty(vm.ReleaseNotesUrl);
+ Assert.False(string.IsNullOrEmpty(vm.StatusMessage));
+ Assert.Null(mockVelopack.Object.SubscribedPrNumber);
+ }
}
\ No newline at end of file
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs
index 5422334ce..33d4f7d2a 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/ContentOrchestratorTests.cs
@@ -475,4 +475,62 @@ public async Task AcquireContentAsync_WhenInstallationDetectionCancels_Propagate
await Assert.ThrowsAnyAsync(
() => orchestrator.AcquireContentAsync(searchResult, progress: null, cts.Token));
}
+
+ ///
+ /// Verifies that SearchAsync deduplicates results by manifest ID, preferring specialized providers.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_DeduplicatesResultsById_PrefersSpecializedProviderOverGitHubAsync()
+ {
+ // Arrange
+ var specializedProviderMock = new Mock();
+ var githubProviderMock = new Mock();
+
+ const string duplicateId = "1.0.thesuperhackers.patch.generalsgamepatch2";
+
+ var specializedResult = new ContentSearchResult
+ {
+ Id = duplicateId,
+ Name = "TheSuperHackers Patch 2",
+ ProviderName = "thesuperhackers",
+ };
+
+ var githubResult = new ContentSearchResult
+ {
+ Id = duplicateId,
+ Name = "GeneralsGamePatch2",
+ ProviderName = "GitHub",
+ };
+
+ specializedProviderMock.Setup(p => p.IsEnabled).Returns(true);
+ specializedProviderMock.Setup(p => p.SearchAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([specializedResult]));
+
+ githubProviderMock.Setup(p => p.IsEnabled).Returns(true);
+ githubProviderMock.Setup(p => p.SearchAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([githubResult]));
+
+ var orchestrator = new ContentOrchestrator(
+ _loggerMock.Object,
+ [githubProviderMock.Object, specializedProviderMock.Object],
+ [],
+ [],
+ _cacheMock.Object,
+ _contentValidatorMock.Object,
+ _manifestPoolMock.Object,
+ _installationServiceMock.Object,
+ _installationCasPoolServiceMock.Object);
+
+ // Act
+ var result = await orchestrator.SearchAsync(new ContentSearchQuery());
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Single(items);
+ Assert.Equal("thesuperhackers", items[0].ProviderName);
+ Assert.Equal("TheSuperHackers Patch 2", items[0].Name);
+ }
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/LocalContentServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/LocalContentServiceTests.cs
new file mode 100644
index 000000000..d8f2d1692
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/LocalContentServiceTests.cs
@@ -0,0 +1,286 @@
+using System;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Interfaces.Content;
+using GenHub.Core.Interfaces.Manifest;
+using GenHub.Core.Models.Content;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Manifest;
+using GenHub.Core.Models.Results;
+using GenHub.Core.Services.Content;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
+
+namespace GenHub.Tests.Core.Features.Content.Services;
+
+///
+/// Contains tests for .
+///
+public class LocalContentServiceTests : IDisposable
+{
+ private readonly Mock _manifestGenServiceMock;
+ private readonly Mock _contentStorageServiceMock;
+ private readonly Mock _reconciliationServiceMock;
+ private readonly LocalContentService _service;
+ private readonly string _tempDir;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public LocalContentServiceTests()
+ {
+ _manifestGenServiceMock = new Mock();
+ _contentStorageServiceMock = new Mock();
+ _reconciliationServiceMock = new Mock();
+
+ _service = new LocalContentService(
+ _manifestGenServiceMock.Object,
+ _contentStorageServiceMock.Object,
+ _reconciliationServiceMock.Object,
+ NullLogger.Instance);
+
+ _tempDir = Path.Combine(Path.GetTempPath(), "LocalContentServiceTests_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(_tempDir);
+ }
+
+ ///
+ /// Cleans up temporary resources.
+ ///
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(_tempDir))
+ {
+ Directory.Delete(_tempDir, recursive: true);
+ }
+ }
+ catch
+ {
+ // Ignore cleanup failures
+ }
+
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Verifies that CreateLocalContentManifestAsync sets EntryPoint when provided.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task CreateLocalContentManifestAsync_WithEntryPoint_SetsManifestEntryPoint()
+ {
+ SetupManifestBuilder(ContentType.ModdingTool, GameType.ZeroHour, "FinalBIG", "FinalBIG.exe");
+
+ var result = await _service.CreateLocalContentManifestAsync(
+ directoryPath: _tempDir,
+ name: "FinalBIG",
+ contentType: ContentType.ModdingTool,
+ targetGame: GameType.ZeroHour,
+ entryPoint: "FinalBIG.exe");
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.Equal("FinalBIG.exe", result.Data!.EntryPoint);
+ }
+
+ ///
+ /// Verifies that CreateLocalContentManifestAsync normalizes backslashes to forward slashes in EntryPoint.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task CreateLocalContentManifestAsync_NormalizesBackslashesInEntryPoint()
+ {
+ SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", "bin/sub/tool.exe");
+
+ var result = await _service.CreateLocalContentManifestAsync(
+ directoryPath: _tempDir,
+ name: "Tool",
+ contentType: ContentType.Executable,
+ targetGame: GameType.ZeroHour,
+ entryPoint: "bin\\sub\\tool.exe");
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.Equal("bin/sub/tool.exe", result.Data!.EntryPoint);
+ }
+
+ ///
+ /// Verifies that CreateLocalContentManifestAsync leaves EntryPoint null when passed a whitespace-only value.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task CreateLocalContentManifestAsync_WithWhitespaceOnlyEntryPoint_LeavesEntryPointNull()
+ {
+ SetupManifestBuilder(ContentType.ModdingTool, GameType.ZeroHour, "FinalBIG", "FinalBIG.exe");
+
+ var result = await _service.CreateLocalContentManifestAsync(
+ directoryPath: _tempDir,
+ name: "FinalBIG",
+ contentType: ContentType.ModdingTool,
+ targetGame: GameType.ZeroHour,
+ entryPoint: " ");
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.Null(result.Data!.EntryPoint);
+ }
+
+ ///
+ /// Verifies that CreateLocalContentManifestAsync rejects rooted or parent-traversal entry points.
+ ///
+ /// The invalid entry point path to test.
+ /// A task representing the asynchronous test.
+ [Theory]
+ [InlineData("/usr/bin/tool.exe")]
+ [InlineData("../tool.exe")]
+ [InlineData("bin/../../tool.exe")]
+ public async Task CreateLocalContentManifestAsync_WithInvalidEntryPointPath_ReturnsFailure(string invalidEntryPoint)
+ {
+ SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", "tool.exe");
+
+ var result = await _service.CreateLocalContentManifestAsync(
+ directoryPath: _tempDir,
+ name: "Tool",
+ contentType: ContentType.Executable,
+ targetGame: GameType.ZeroHour,
+ entryPoint: invalidEntryPoint);
+
+ Assert.False(result.Success);
+ Assert.Contains("invalid", result.FirstError, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Verifies that CreateLocalContentManifestAsync accepts entry points with double dots in file or folder names.
+ ///
+ /// The valid entry point path with dots in name.
+ /// A task representing the asynchronous test.
+ [Theory]
+ [InlineData("game..exe")]
+ [InlineData("backup..old/tool.exe")]
+ public async Task CreateLocalContentManifestAsync_WithDoubleDotsInName_ReturnsSuccess(string validEntryPoint)
+ {
+ SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", validEntryPoint);
+
+ var result = await _service.CreateLocalContentManifestAsync(
+ directoryPath: _tempDir,
+ name: "Tool",
+ contentType: ContentType.Executable,
+ targetGame: GameType.ZeroHour,
+ entryPoint: validEntryPoint);
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.Equal(validEntryPoint, result.Data!.EntryPoint);
+ }
+
+ ///
+ /// Verifies that CreateLocalContentManifestAsync rejects an entry point that does not exist in manifest files.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task CreateLocalContentManifestAsync_WithNonExistentEntryPoint_ReturnsFailure()
+ {
+ SetupManifestBuilder(ContentType.Executable, GameType.ZeroHour, "Tool", "tool.exe");
+
+ var result = await _service.CreateLocalContentManifestAsync(
+ directoryPath: _tempDir,
+ name: "Tool",
+ contentType: ContentType.Executable,
+ targetGame: GameType.ZeroHour,
+ entryPoint: "missing.exe");
+
+ Assert.False(result.Success);
+ Assert.Contains("not found", result.FirstError, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Verifies that CreateLocalContentManifestAsync leaves EntryPoint null when not provided.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task CreateLocalContentManifestAsync_WithoutEntryPoint_LeavesEntryPointNull()
+ {
+ SetupManifestBuilder(ContentType.Mod, GameType.ZeroHour, "MyMod");
+
+ var result = await _service.CreateLocalContentManifestAsync(
+ directoryPath: _tempDir,
+ name: "MyMod",
+ contentType: ContentType.Mod,
+ targetGame: GameType.ZeroHour);
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.Null(result.Data!.EntryPoint);
+ }
+
+ ///
+ /// Verifies that UpdateLocalContentManifestAsync passes entryPoint through to the created manifest.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task UpdateLocalContentManifestAsync_WithEntryPoint_SetsEntryPointOnUpdatedManifest()
+ {
+ SetupManifestBuilder(ContentType.GameClient, GameType.ZeroHour, "GeneralsClient", "generals.exe");
+
+ _reconciliationServiceMock
+ .Setup(x => x.OrchestrateLocalUpdateAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(new ContentUpdateResult()));
+
+ var result = await _service.UpdateLocalContentManifestAsync(
+ existingManifestId: "1.0.local.gameclient.old",
+ name: "GeneralsClient",
+ directoryPath: _tempDir,
+ contentType: ContentType.GameClient,
+ targetGame: GameType.ZeroHour,
+ entryPoint: "generals.exe");
+
+ Assert.True(result.Success);
+ Assert.NotNull(result.Data);
+ Assert.Equal("generals.exe", result.Data!.EntryPoint);
+ }
+
+ private void SetupManifestBuilder(ContentType contentType, GameType targetGame, string contentName, params string[] filePaths)
+ {
+ var files = filePaths.Length > 0
+ ? filePaths.Select(f => new ManifestFile { RelativePath = f, IsExecutable = f.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) }).ToList()
+ : new List();
+
+ var manifest = new ContentManifest
+ {
+ Id = ManifestId.Create($"1.0.local.{contentType.ToString().ToLowerInvariant()}.{contentName.ToLowerInvariant()}"),
+ Name = contentName,
+ ContentType = contentType,
+ TargetGame = targetGame,
+ Files = files,
+ };
+
+ var builderMock = new Mock();
+ builderMock.Setup(b => b.Build()).Returns(manifest);
+
+ _manifestGenServiceMock
+ .Setup(x => x.CreateContentManifestAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(builderMock.Object);
+
+ _contentStorageServiceMock
+ .Setup(x => x.StoreContentAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny?>(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(manifest));
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/PublisherManifestFactoryResolverTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/PublisherManifestFactoryResolverTests.cs
new file mode 100644
index 000000000..522569cd5
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/PublisherManifestFactoryResolverTests.cs
@@ -0,0 +1,173 @@
+using System;
+using System.Collections.Generic;
+using GenHub.Core.Constants;
+using GenHub.Core.Interfaces.Common;
+using GenHub.Core.Interfaces.Content;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Manifest;
+using GenHub.Features.Content.Services.Publishers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
+
+namespace GenHub.Tests.Core.Features.Content.Services.Publishers;
+
+///
+/// Unit tests for .
+///
+public class PublisherManifestFactoryResolverTests
+{
+ private readonly Mock _hashProviderMock;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public PublisherManifestFactoryResolverTests()
+ {
+ _hashProviderMock = new Mock();
+ }
+
+ ///
+ /// Verifies that ResolveFactory returns the specialized factory when CanHandle matches.
+ ///
+ [Fact]
+ public void ResolveFactory_ReturnsSpecializedFactory_WhenCanHandleMatches()
+ {
+ // Arrange
+ var superHackersFactory = new SuperHackersManifestFactory(
+ NullLogger.Instance,
+ _hashProviderMock.Object);
+
+ var gitHubFactory = new GitHubManifestFactory(
+ NullLogger.Instance,
+ _hashProviderMock.Object);
+
+ var resolver = new PublisherManifestFactoryResolver(
+ [superHackersFactory, gitHubFactory],
+ NullLogger.Instance);
+
+ var manifest = new ContentManifest
+ {
+ Id = ManifestId.Create("1.0.thesuperhackers.gameclient.generals"),
+ ContentType = ContentType.GameClient,
+ Publisher = new PublisherInfo
+ {
+ Name = "TheSuperHackers",
+ PublisherType = PublisherTypeConstants.TheSuperHackers,
+ },
+ };
+
+ // Act
+ var result = resolver.ResolveFactory(manifest);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.IsType(result);
+ }
+
+ ///
+ /// Verifies that ResolveFactory falls back to GitHubManifestFactory for non-GameClient publisher content.
+ ///
+ [Fact]
+ public void ResolveFactory_FallsBackToGitHubFactory_WhenSpecializedFactoryCannotHandle()
+ {
+ // Arrange
+ var superHackersFactory = new SuperHackersManifestFactory(
+ NullLogger.Instance,
+ _hashProviderMock.Object);
+
+ var gitHubFactory = new GitHubManifestFactory(
+ NullLogger.Instance,
+ _hashProviderMock.Object);
+
+ var resolver = new PublisherManifestFactoryResolver(
+ [superHackersFactory, gitHubFactory],
+ NullLogger.Instance);
+
+ var patchManifest = new ContentManifest
+ {
+ Id = ManifestId.Create("1.0.thesuperhackers.patch.generalsgamepatch2"),
+ ContentType = ContentType.Patch,
+ Publisher = new PublisherInfo
+ {
+ Name = "TheSuperHackers",
+ PublisherType = PublisherTypeConstants.TheSuperHackers,
+ },
+ };
+
+ // Act
+ var result = resolver.ResolveFactory(patchManifest);
+
+ // Assert
+ Assert.NotNull(result);
+ Assert.IsType(result);
+ }
+
+ ///
+ /// Verifies that ResolveFactory returns null when no specialized or fallback factory is available.
+ ///
+ [Fact]
+ public void ResolveFactory_ReturnsNull_WhenNoFactoryMatchesAndNoFallbackAvailable()
+ {
+ // Arrange
+ var superHackersFactory = new SuperHackersManifestFactory(
+ NullLogger.Instance,
+ _hashProviderMock.Object);
+
+ var resolver = new PublisherManifestFactoryResolver(
+ [superHackersFactory],
+ NullLogger.Instance);
+
+ var patchManifest = new ContentManifest
+ {
+ Id = ManifestId.Create("1.0.testpublisher.mod.sample"),
+ ContentType = ContentType.Mod,
+ Publisher = new PublisherInfo
+ {
+ Name = "Unknown",
+ PublisherType = "unknown",
+ },
+ };
+
+ // Act
+ var result = resolver.ResolveFactory(patchManifest);
+
+ // Assert
+ Assert.Null(result);
+ }
+
+ ///
+ /// Verifies that ResolveFactory returns null when a GameClient manifest has no specialized factory,
+ /// rather than falling back to GitHubManifestFactory.
+ ///
+ [Fact]
+ public void ResolveFactory_ReturnsNull_WhenGameClientHasNoSpecializedFactory()
+ {
+ // Arrange
+ var gitHubFactory = new GitHubManifestFactory(
+ NullLogger.Instance,
+ _hashProviderMock.Object);
+
+ var resolver = new PublisherManifestFactoryResolver(
+ [gitHubFactory],
+ NullLogger.Instance);
+
+ var gameClientManifest = new ContentManifest
+ {
+ Id = ManifestId.Create("1.0.unknownpublisher.gameclient.generals"),
+ ContentType = ContentType.GameClient,
+ Publisher = new PublisherInfo
+ {
+ Name = "UnknownPublisher",
+ PublisherType = "unknownpublisher",
+ },
+ };
+
+ // Act
+ var result = resolver.ResolveFactory(gameClientManifest);
+
+ // Assert
+ Assert.Null(result);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs
new file mode 100644
index 000000000..2b645c6e5
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Content/Services/Publishers/SuperHackersProviderTests.cs
@@ -0,0 +1,497 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Constants;
+using GenHub.Core.Interfaces.Content;
+using GenHub.Core.Interfaces.GitHub;
+using GenHub.Core.Interfaces.Providers;
+using GenHub.Core.Models.Content;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.GitHub;
+using GenHub.Core.Models.Manifest;
+using GenHub.Core.Models.Results;
+using GenHub.Features.Content.Services.Publishers;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
+
+namespace GenHub.Tests.Core.Features.Content.Services.Publishers;
+
+///
+/// Unit tests for .
+///
+public class SuperHackersProviderTests
+{
+ private readonly Mock _providerDefinitionLoaderMock;
+ private readonly Mock _gitHubApiClientMock;
+ private readonly Mock _resolverMock;
+ private readonly Mock _delivererMock;
+ private readonly Mock _validatorMock;
+ private readonly SuperHackersProvider _provider;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public SuperHackersProviderTests()
+ {
+ _providerDefinitionLoaderMock = new Mock();
+ _gitHubApiClientMock = new Mock();
+ _resolverMock = new Mock();
+ _delivererMock = new Mock();
+ _validatorMock = new Mock();
+
+ _resolverMock.Setup(r => r.ResolverId).Returns(SuperHackersConstants.ResolverId);
+ _delivererMock.Setup(d => d.SourceName).Returns(ContentSourceNames.GitHubDeliverer);
+
+ _validatorMock.Setup(v => v.ValidateManifestAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new ValidationResult("test", []));
+
+ _provider = new SuperHackersProvider(
+ _providerDefinitionLoaderMock.Object,
+ _gitHubApiClientMock.Object,
+ [_resolverMock.Object],
+ [_delivererMock.Object],
+ _validatorMock.Object,
+ NullLogger.Instance);
+ }
+
+ ///
+ /// Verifies that SearchAsync returns both GeneralsGameCode and GeneralsGamePatch2 releases when available.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_DiscoversBothGameCodeAndGamePatch2_WhenBothAvailableAsync()
+ {
+ // Arrange
+ var gameCodeRelease = new GitHubRelease
+ {
+ TagName = "weekly-2026-08-01",
+ Name = "Weekly Release 2026-08-01",
+ Body = "Generals and Zero Hour game code updates",
+ HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGameCode/releases/tag/weekly-2026-08-01",
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+
+ var gamePatch2Release = new GitHubRelease
+ {
+ TagName = "1.0.0",
+ Name = "Release 1.0.0",
+ Body = "Community Patch 2 to fix and improve Generals and Zero Hour",
+ HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/1.0.0",
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ReturnsAsync(gameCodeRelease);
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ReturnsAsync(gamePatch2Release);
+
+ var query = new ContentSearchQuery();
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Equal(2, items.Count);
+
+ var gameCodeItem = items.FirstOrDefault(i => i.ContentType == ContentType.GameClient);
+ Assert.NotNull(gameCodeItem);
+ Assert.Equal("weekly-2026-08-01", gameCodeItem.Version);
+ Assert.Equal(SuperHackersConstants.GeneralsGameCodeRepo, gameCodeItem.ResolverMetadata[GitHubConstants.RepoMetadataKey]);
+
+ var gamePatch2Item = items.FirstOrDefault(i => i.ContentType == ContentType.Patch);
+ Assert.NotNull(gamePatch2Item);
+ Assert.Equal("1.0.0", gamePatch2Item.Version);
+ Assert.Equal(SuperHackersConstants.GeneralsGamePatch2Repo, gamePatch2Item.ResolverMetadata[GitHubConstants.RepoMetadataKey]);
+ }
+
+ ///
+ /// Verifies that SearchAsync filters properly by repository search term.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_FiltersBySearchTerm_CorrectlyAsync()
+ {
+ // Arrange
+ var gamePatch2Release = new GitHubRelease
+ {
+ TagName = "1.0.0",
+ Name = "Release 1.0.0",
+ Body = "Community Patch 2",
+ HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/1.0.0",
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ReturnsAsync(new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1" });
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ReturnsAsync(gamePatch2Release);
+
+ var query = new ContentSearchQuery { SearchTerm = "GeneralsGamePatch2" };
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Single(items);
+ Assert.Equal(ContentType.Patch, items[0].ContentType);
+ }
+
+ ///
+ /// Verifies that SearchAsync filters by ContentType correctly.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_FiltersByContentType_ReturnsOnlyMatchingReleasesAsync()
+ {
+ // Arrange
+ var gameCodeRelease = new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1" };
+ var gamePatch2Release = new GitHubRelease { TagName = "1.0.0", Name = "Release 1.0.0" };
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ReturnsAsync(gameCodeRelease);
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ReturnsAsync(gamePatch2Release);
+
+ var query = new ContentSearchQuery { ContentType = ContentType.Patch };
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Single(items);
+ Assert.Equal(ContentType.Patch, items[0].ContentType);
+ Assert.Equal("1.0.0", items[0].Version);
+ }
+
+ ///
+ /// Verifies that SearchAsync filters by TargetGame correctly.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_FiltersByTargetGame_ReturnsMatchingReleasesAsync()
+ {
+ // Arrange
+ var gameCodeRelease = new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1" };
+ var gamePatch2Release = new GitHubRelease { TagName = "1.0.0", Name = "Release 1.0.0" };
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ReturnsAsync(gameCodeRelease);
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ReturnsAsync(gamePatch2Release);
+
+ var zeroHourQuery = new ContentSearchQuery { TargetGame = GameType.ZeroHour };
+
+ // Act
+ var result = await _provider.SearchAsync(zeroHourQuery);
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Single(items);
+ Assert.Equal(ContentType.Patch, items[0].ContentType);
+ Assert.Equal(GameType.ZeroHour, items[0].TargetGame);
+ }
+
+ ///
+ /// Verifies that SearchAsync filters by author name and github author correctly.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_FiltersByAuthor_ReturnsEmptyWhenAuthorDoesNotMatchAsync()
+ {
+ // Arrange
+ var query = new ContentSearchQuery { AuthorName = "NonExistentAuthor" };
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Empty(items);
+ }
+
+ ///
+ /// Verifies that SearchAsync matches on display name and body text.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_MatchesSearchTerm_OnDisplayNameAndBodyAsync()
+ {
+ // Arrange
+ var gamePatch2Release = new GitHubRelease
+ {
+ TagName = "1.0.0",
+ Name = "Patch Release",
+ Body = "Community patch details",
+ HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/1.0.0",
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ReturnsAsync(new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1", Body = "Engine updates" });
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ReturnsAsync(gamePatch2Release);
+
+ var query = new ContentSearchQuery { SearchTerm = SuperHackersConstants.GeneralsGamePatch2DisplayName };
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Single(items);
+ Assert.Equal(ContentType.Patch, items[0].ContentType);
+ }
+
+ ///
+ /// Verifies that SearchAsync returns failure when one target returns null release and the other throws an error.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_WhenOneTargetReturnsNullAndOtherErrors_ReturnsFailureAsync()
+ {
+ // Arrange
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ReturnsAsync((GitHubRelease)null!);
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("API rate limit"));
+
+ var query = new ContentSearchQuery();
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.Contains("Search failed for SuperHackers targets", result.FirstError);
+ }
+
+ ///
+ /// Verifies that SearchAsync returns successful results when one repository fails.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_ReturnsRemainingReleases_WhenOneRepositoryFailsAsync()
+ {
+ // Arrange
+ var gameCodeRelease = new GitHubRelease { TagName = "weekly-1", Name = "Weekly 1" };
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ReturnsAsync(gameCodeRelease);
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("API error"));
+
+ var query = new ContentSearchQuery();
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Single(items);
+ Assert.Equal(ContentType.GameClient, items[0].ContentType);
+ }
+
+ ///
+ /// Verifies that SearchAsync returns failure when all matching repositories fail.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_ReturnsFailure_WhenAllRepositoriesFailAsync()
+ {
+ // Arrange
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("Network failure 1"));
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ThrowsAsync(new InvalidOperationException("Network failure 2"));
+
+ var query = new ContentSearchQuery();
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.Contains("Search failed for SuperHackers targets", result.FirstError);
+ }
+
+ ///
+ /// Verifies that SearchAsync propagates cancellation.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_PropagatesCancellation_WhenCancellationRequestedAsync()
+ {
+ // Arrange
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ // Act & Assert
+ await Assert.ThrowsAnyAsync(
+ () => _provider.SearchAsync(new ContentSearchQuery(), cts.Token));
+ }
+
+ ///
+ /// Verifies that SearchAsync falls back to display name and tag name when release name is blank.
+ ///
+ /// The candidate release name to test.
+ /// A representing the asynchronous operation.
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task SearchAsync_UsesFallbackName_WhenReleaseNameIsBlankAsync(string? releaseName)
+ {
+ // Arrange
+ var release = new GitHubRelease
+ {
+ TagName = "alpha-4",
+ Name = releaseName ?? string.Empty,
+ Body = "Patch notes",
+ HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/alpha-4",
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ReturnsAsync(release);
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ReturnsAsync((GitHubRelease)null!);
+
+ var query = new ContentSearchQuery { ContentType = ContentType.Patch };
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Single(items);
+ Assert.Equal($"{SuperHackersConstants.GeneralsGamePatch2DisplayName} alpha-4", items[0].Name);
+ }
+
+ ///
+ /// Verifies that SearchAsync preserves the original release name when it is not blank.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task SearchAsync_PreservesReleaseName_WhenReleaseNameIsNonBlankAsync()
+ {
+ // Arrange
+ var release = new GitHubRelease
+ {
+ TagName = "alpha-4",
+ Name = "Community Patch 2.0 Alpha 4",
+ Body = "Patch notes",
+ HtmlUrl = "https://github.com/TheSuperHackers/GeneralsGamePatch2/releases/tag/alpha-4",
+ CreatedAt = DateTimeOffset.UtcNow,
+ };
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGamePatch2Owner,
+ SuperHackersConstants.GeneralsGamePatch2Repo,
+ It.IsAny()))
+ .ReturnsAsync(release);
+
+ _gitHubApiClientMock.Setup(c => c.GetLatestReleaseAsync(
+ SuperHackersConstants.GeneralsGameCodeOwner,
+ SuperHackersConstants.GeneralsGameCodeRepo,
+ It.IsAny()))
+ .ReturnsAsync((GitHubRelease)null!);
+
+ var query = new ContentSearchQuery { ContentType = ContentType.Patch };
+
+ // Act
+ var result = await _provider.SearchAsync(query);
+
+ // Assert
+ Assert.True(result.Success);
+ var items = result.Data?.ToList();
+ Assert.NotNull(items);
+ Assert.Single(items);
+ Assert.Equal("Community Patch 2.0 Alpha 4", items[0].Name);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs
index 3afee1b7b..23aea4a92 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs
@@ -195,7 +195,7 @@ public async Task StartProcessAsync_WhenLauncherExitsCleanlyWithoutChild_Reports
Assert.False(result.Success);
var errors = string.Join(", ", result.Errors);
- Assert.Contains("without starting", errors);
+ Assert.True(errors.Contains("without starting") || errors.Contains("did not start"), $"Expected start failure message, but got: {errors}");
Assert.Contains(complaint, errors);
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/AddLocalContentViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/AddLocalContentViewModelTests.cs
new file mode 100644
index 000000000..c7490ef60
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/AddLocalContentViewModelTests.cs
@@ -0,0 +1,799 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Interfaces.Common;
+using GenHub.Core.Interfaces.Content;
+using GenHub.Core.Models.Content;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Manifest;
+using GenHub.Core.Models.Results;
+using GenHub.Features.GameProfiles.ViewModels;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
+
+namespace GenHub.Tests.Core.Features.GameProfiles.ViewModels;
+
+///
+/// Contains tests for .
+///
+public class AddLocalContentViewModelTests : IDisposable
+{
+ private readonly Mock _localContentServiceMock;
+ private readonly Mock _contentStorageServiceMock;
+ private readonly Mock _normalizationServiceMock;
+ private readonly Mock _dialogServiceMock;
+ private readonly List _tempDirectories = [];
+ private readonly List _viewModels = [];
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public AddLocalContentViewModelTests()
+ {
+ _localContentServiceMock = new Mock();
+ _contentStorageServiceMock = new Mock();
+ _normalizationServiceMock = new Mock();
+ _dialogServiceMock = new Mock();
+
+ _localContentServiceMock
+ .Setup(x => x.AllowedContentTypes)
+ .Returns(AddLocalContentViewModel.AllowedContentTypes);
+
+ _normalizationServiceMock
+ .Setup(x => x.DetectGenLauncherFilesAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new GenLauncherDetectionResult());
+ }
+
+ ///
+ /// Cleans up temporary test directories and viewmodels.
+ ///
+ public void Dispose()
+ {
+ foreach (var vm in _viewModels)
+ {
+ vm.Dispose();
+ }
+
+ foreach (var dir in _tempDirectories)
+ {
+ try
+ {
+ if (Directory.Exists(dir))
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+ catch
+ {
+ // Ignore cleanup errors
+ }
+ }
+
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Verifies that the ViewModel initializes with proper defaults.
+ ///
+ [Fact]
+ public void Constructor_InitializesWithDefaultValues()
+ {
+ var vm = CreateViewModel();
+
+ Assert.NotNull(vm);
+ Assert.Equal(ContentType.Mod, vm.SelectedContentType);
+ Assert.Equal(GameType.ZeroHour, vm.SelectedGameType);
+ Assert.Empty(vm.ContentName);
+ Assert.Empty(vm.SourcePath);
+ Assert.Empty(vm.FileTree);
+ Assert.False(vm.IsEditing);
+ Assert.False(vm.CanAdd);
+ Assert.False(vm.ShowExecutableSelection);
+ Assert.Null(vm.SelectedExecutableItem);
+ Assert.Equal(0, vm.ExecutableCount);
+ Assert.Equal("Add Local Content", vm.DialogTitle);
+ Assert.Equal("Add to Library", vm.ActionButtonText);
+ Assert.Contains(ContentType.GameClient, AddLocalContentViewModel.AllowedContentTypes);
+ Assert.Contains(ContentType.ModdingTool, AddLocalContentViewModel.AllowedContentTypes);
+ Assert.Contains(ContentType.Executable, AddLocalContentViewModel.AllowedContentTypes);
+ }
+
+ ///
+ /// Verifies that PreviewIdleText changes based on SelectedContentType.
+ ///
+ /// The content type under test.
+ /// The expected idle description text.
+ [Theory]
+ [InlineData(ContentType.Mod, "Import mod content (e.g. .big, .zip)")]
+ [InlineData(ContentType.GameClient, "Import GameClient")]
+ [InlineData(ContentType.Executable, "Import executable")]
+ [InlineData(ContentType.ModdingTool, "Import tool executable")]
+ [InlineData(ContentType.Patch, "Import patch")]
+ [InlineData(ContentType.Addon, "Import addon content")]
+ [InlineData(ContentType.Map, "Import map files")]
+ [InlineData(ContentType.MapPack, "Import map pack files")]
+ [InlineData(ContentType.Mission, "Import mission content")]
+ public void PreviewIdleText_ReturnsExpectedDescriptions(ContentType type, string expectedText)
+ {
+ var vm = CreateViewModel();
+ vm.SelectedContentType = type;
+
+ Assert.Equal(expectedText, vm.PreviewIdleText);
+ }
+
+ ///
+ /// Verifies that ShowExecutableSelection is true when ExecutableCount > 0 for GameClient, ModdingTool, and Executable.
+ ///
+ /// The content type under test.
+ /// The number of detected executables.
+ /// The expected boolean indicating whether executable selection is shown.
+ [Theory]
+ [InlineData(ContentType.GameClient, 1, true)]
+ [InlineData(ContentType.GameClient, 2, true)]
+ [InlineData(ContentType.ModdingTool, 1, true)]
+ [InlineData(ContentType.ModdingTool, 2, true)]
+ [InlineData(ContentType.Executable, 1, true)]
+ [InlineData(ContentType.Executable, 2, true)]
+ [InlineData(ContentType.GameClient, 0, false)]
+ [InlineData(ContentType.ModdingTool, 0, false)]
+ [InlineData(ContentType.Executable, 0, false)]
+ [InlineData(ContentType.Mod, 1, false)]
+ [InlineData(ContentType.Mod, 2, false)]
+ [InlineData(ContentType.Patch, 1, false)]
+ [InlineData(ContentType.Map, 1, false)]
+ public void ShowExecutableSelection_EvaluatesCorrectly_BasedOnContentTypeAndExecutableCount(
+ ContentType contentType,
+ int executableCount,
+ bool expectedShow)
+ {
+ var vm = CreateViewModel();
+ vm.SelectedContentType = contentType;
+ vm.ExecutableCount = executableCount;
+
+ Assert.Equal(expectedShow, vm.ShowExecutableSelection);
+ }
+
+ ///
+ /// Verifies that importing a directory with an executable auto-selects the executable for GameClient.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ImportContentAsync_WithSingleExecutable_ForGameClient_AutoSelectsExecutable()
+ {
+ var tempDir = CreateTempDirectory();
+ var exePath = Path.Combine(tempDir, "generals.exe");
+ var dataPath = Path.Combine(tempDir, "data.ini");
+ File.WriteAllText(exePath, "fake-exe-content");
+ File.WriteAllText(dataPath, "fake-data");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.GameClient;
+
+ await vm.ImportContentAsync(tempDir);
+
+ Assert.Equal(1, vm.ExecutableCount);
+ Assert.True(vm.ShowExecutableSelection);
+ Assert.NotNull(vm.SelectedExecutableItem);
+ Assert.Equal("generals.exe", vm.SelectedExecutableItem!.Name);
+ Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable);
+ }
+
+ ///
+ /// Verifies that importing a directory with an executable auto-selects the executable for ModdingTool.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ImportContentAsync_WithSingleExecutable_ForModdingTool_AutoSelectsExecutable()
+ {
+ var tempDir = CreateTempDirectory();
+ var exePath = Path.Combine(tempDir, "FinalBIG.exe");
+ var dataPath = Path.Combine(tempDir, "readme.txt");
+ File.WriteAllText(exePath, "fake-exe-content");
+ File.WriteAllText(dataPath, "read me");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.ModdingTool;
+
+ await vm.ImportContentAsync(tempDir);
+
+ Assert.Equal(1, vm.ExecutableCount);
+ Assert.True(vm.ShowExecutableSelection);
+ Assert.NotNull(vm.SelectedExecutableItem);
+ Assert.Equal("FinalBIG.exe", vm.SelectedExecutableItem!.Name);
+ Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable);
+ }
+
+ ///
+ /// Verifies that importing a directory with an executable auto-selects the executable for Executable.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ImportContentAsync_WithSingleExecutable_ForExecutable_AutoSelectsExecutable()
+ {
+ var tempDir = CreateTempDirectory();
+ var exePath = Path.Combine(tempDir, "WorldBuilder.exe");
+ File.WriteAllText(exePath, "fake-exe-content");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.Executable;
+
+ await vm.ImportContentAsync(tempDir);
+
+ Assert.Equal(1, vm.ExecutableCount);
+ Assert.True(vm.ShowExecutableSelection);
+ Assert.NotNull(vm.SelectedExecutableItem);
+ Assert.Equal("WorldBuilder.exe", vm.SelectedExecutableItem!.Name);
+ Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable);
+ }
+
+ ///
+ /// Verifies that switching to an executable content type triggers auto-selection if an executable is in the tree.
+ ///
+ /// The executable content type to switch to.
+ /// A task representing the asynchronous test.
+ [Theory]
+ [InlineData(ContentType.GameClient)]
+ [InlineData(ContentType.ModdingTool)]
+ [InlineData(ContentType.Executable)]
+ public async Task SelectedContentTypeChanged_ToExecutableType_AutoSelectsFirstExecutable(ContentType newType)
+ {
+ var tempDir = CreateTempDirectory();
+ var exePath = Path.Combine(tempDir, "Launcher.exe");
+ File.WriteAllText(exePath, "fake-exe-content");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.Mod;
+
+ await vm.ImportContentAsync(tempDir);
+
+ // When imported as Mod, no auto-selection happened
+ Assert.Null(vm.SelectedExecutableItem);
+ Assert.False(vm.ShowExecutableSelection);
+
+ // Switch to executable type
+ vm.SelectedContentType = newType;
+
+ Assert.NotNull(vm.SelectedExecutableItem);
+ Assert.Equal("Launcher.exe", vm.SelectedExecutableItem!.Name);
+ Assert.True(vm.SelectedExecutableItem.IsSelectedExecutable);
+ Assert.True(vm.ShowExecutableSelection);
+ }
+
+ ///
+ /// Verifies manual selection of an executable via SelectExecutableCommand.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task SelectExecutableCommand_SwitchesSelectedExecutable()
+ {
+ var tempDir = CreateTempDirectory();
+ var exe1Path = Path.Combine(tempDir, "Primary.exe");
+ var exe2Path = Path.Combine(tempDir, "Secondary.exe");
+ File.WriteAllText(exe1Path, "fake-exe-1");
+ File.WriteAllText(exe2Path, "fake-exe-2");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.ModdingTool;
+
+ await vm.ImportContentAsync(tempDir);
+
+ Assert.Equal(2, vm.ExecutableCount);
+ Assert.NotNull(vm.SelectedExecutableItem);
+
+ var initialSelected = vm.SelectedExecutableItem!;
+ var otherItem = FindInTree(vm.FileTree, f => f != initialSelected && f.IsExecutable);
+ Assert.NotNull(otherItem);
+ Assert.False(otherItem!.IsSelectedExecutable);
+
+ // Select the other executable
+ vm.SelectExecutableCommand.Execute(otherItem);
+
+ Assert.Equal(otherItem.Name, vm.SelectedExecutableItem.Name);
+ Assert.True(otherItem.IsSelectedExecutable);
+ Assert.False(initialSelected.IsSelectedExecutable);
+ }
+
+ ///
+ /// Verifies that SelectExecutableCommand ignores non-executable files.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task SelectExecutableCommand_IgnoresNonExecutableItem()
+ {
+ var tempDir = CreateTempDirectory();
+ var exePath = Path.Combine(tempDir, "Tool.exe");
+ var txtPath = Path.Combine(tempDir, "Doc.txt");
+ File.WriteAllText(exePath, "fake-exe");
+ File.WriteAllText(txtPath, "text");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.Executable;
+
+ await vm.ImportContentAsync(tempDir);
+
+ Assert.Equal("Tool.exe", vm.SelectedExecutableItem?.Name);
+
+ var txtItem = FindInTree(vm.FileTree, f => f.Name == "Doc.txt");
+ Assert.NotNull(txtItem);
+ Assert.False(txtItem!.IsExecutable);
+
+ vm.SelectExecutableCommand.Execute(txtItem);
+
+ // Should still be Tool.exe
+ Assert.Equal("Tool.exe", vm.SelectedExecutableItem?.Name);
+ Assert.False(txtItem.IsSelectedExecutable);
+ }
+
+ ///
+ /// Verifies that CanAdd validation requires an executable for GameClient, ModdingTool, and Executable.
+ ///
+ /// The executable content type under test.
+ /// A task representing the asynchronous test.
+ [Theory]
+ [InlineData(ContentType.GameClient)]
+ [InlineData(ContentType.ModdingTool)]
+ [InlineData(ContentType.Executable)]
+ public async Task Validation_CanAdd_RequiresExecutable_ForExecutableTypes(ContentType type)
+ {
+ var tempDir = CreateTempDirectory();
+ var txtPath = Path.Combine(tempDir, "config.ini");
+ File.WriteAllText(txtPath, "config");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = type;
+ vm.ContentName = "Test Tool";
+
+ await vm.ImportContentAsync(tempDir);
+
+ // No executable found, so CanAdd should be false
+ Assert.Null(vm.SelectedExecutableItem);
+ Assert.False(vm.CanAdd);
+ }
+
+ ///
+ /// Verifies that CanAdd is true for non-executable types without an executable.
+ ///
+ /// The non-executable content type under test.
+ /// A task representing the asynchronous test.
+ [Theory]
+ [InlineData(ContentType.Mod)]
+ [InlineData(ContentType.Patch)]
+ [InlineData(ContentType.Addon)]
+ [InlineData(ContentType.Map)]
+ [InlineData(ContentType.MapPack)]
+ [InlineData(ContentType.Mission)]
+ public async Task Validation_CanAdd_DoesNotRequireExecutable_ForNonExecutableTypes(ContentType type)
+ {
+ var tempDir = CreateTempDirectory();
+ var txtPath = Path.Combine(tempDir, "mod_data.big");
+ File.WriteAllText(txtPath, "big archive data");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = type;
+ vm.ContentName = "Test Mod";
+
+ await vm.ImportContentAsync(tempDir);
+
+ Assert.True(vm.CanAdd);
+ }
+
+ ///
+ /// Verifies that CanAdd is true when an executable is present for GameClient, ModdingTool, and Executable.
+ ///
+ /// The executable content type under test.
+ /// A task representing the asynchronous test.
+ [Theory]
+ [InlineData(ContentType.GameClient)]
+ [InlineData(ContentType.ModdingTool)]
+ [InlineData(ContentType.Executable)]
+ public async Task Validation_CanAdd_IsTrue_WhenExecutableIsPresent(ContentType type)
+ {
+ var tempDir = CreateTempDirectory();
+ var exePath = Path.Combine(tempDir, "Main.exe");
+ File.WriteAllText(exePath, "exe content");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = type;
+ vm.ContentName = "Test Item";
+
+ await vm.ImportContentAsync(tempDir);
+
+ Assert.NotNull(vm.SelectedExecutableItem);
+ Assert.True(vm.CanAdd);
+ }
+
+ ///
+ /// Verifies that AddContentCommand forwards the relative entry point to ILocalContentService.CreateLocalContentManifestAsync.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task AddContentCommand_PassesEntryPoint_ToCreateLocalContentManifestAsync()
+ {
+ var tempDir = CreateTempDirectory();
+ var exePath = Path.Combine(tempDir, "Game.exe");
+ File.WriteAllText(exePath, "exe");
+
+ string? capturedEntryPoint = null;
+ _localContentServiceMock
+ .Setup(x => x.CreateLocalContentManifestAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny?>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback?, CancellationToken, string?>(
+ (_, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint)
+ .ReturnsAsync(OperationResult.CreateSuccess(new ContentManifest
+ {
+ Id = ManifestId.Create("1.0.local.gameclient.test"),
+ Name = "Test Game Client",
+ ContentType = ContentType.GameClient,
+ TargetGame = GameType.ZeroHour,
+ EntryPoint = "Game.exe",
+ }));
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.GameClient;
+ vm.ContentName = "Test Game Client";
+
+ // Import individual file so it lands at the root of staging
+ await vm.ImportContentAsync(exePath);
+
+ Assert.True(vm.CanAdd);
+
+ await vm.AddContentCommand.ExecuteAsync(null);
+
+ Assert.Equal("Game.exe", capturedEntryPoint);
+ Assert.NotNull(vm.CreatedContentItem);
+ }
+
+ ///
+ /// Verifies that AddContentCommand with nested executable passes correct relative path as entryPoint.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task AddContentCommand_WithNestedExecutable_PassesRelativePathEntryPoint()
+ {
+ var tempDir = CreateTempDirectory();
+ var subDir = Path.Combine(tempDir, "bin");
+ Directory.CreateDirectory(subDir);
+ var exePath = Path.Combine(subDir, "tool.exe");
+ File.WriteAllText(exePath, "tool exe");
+
+ string? capturedEntryPoint = null;
+ _localContentServiceMock
+ .Setup(x => x.CreateLocalContentManifestAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny?>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback?, CancellationToken, string?>(
+ (_, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint)
+ .ReturnsAsync(OperationResult.CreateSuccess(new ContentManifest
+ {
+ Id = ManifestId.Create("1.0.local.moddingtool.tool"),
+ Name = "My Tool",
+ ContentType = ContentType.ModdingTool,
+ TargetGame = GameType.ZeroHour,
+ }));
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.ModdingTool;
+ vm.ContentName = "My Tool";
+
+ await vm.ImportContentAsync(tempDir);
+
+ Assert.NotNull(vm.SelectedExecutableItem);
+
+ await vm.AddContentCommand.ExecuteAsync(null);
+
+ var dirName = Path.GetFileName(tempDir);
+ Assert.Equal($"{dirName}/bin/tool.exe", capturedEntryPoint);
+ }
+
+ ///
+ /// Verifies that LoadFromManifestAsync preserves the manifest EntryPoint when reloading for edit.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task LoadFromManifestAsync_PreservesManifestEntryPoint()
+ {
+ var manifestId = ManifestId.Create("1.0.local.gameclient.zh");
+
+ var manifest = new ContentManifest
+ {
+ Id = manifestId,
+ Name = "ZH Client",
+ ContentType = ContentType.GameClient,
+ TargetGame = GameType.ZeroHour,
+ EntryPoint = "special.exe",
+ Files =
+ [
+ new ManifestFile { RelativePath = "special.exe", IsExecutable = true },
+ new ManifestFile { RelativePath = "bin/decoy.exe", IsExecutable = true },
+ ],
+ };
+
+ _contentStorageServiceMock
+ .Setup(x => x.RetrieveContentAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback((_, targetPath, _) =>
+ {
+ Directory.CreateDirectory(targetPath);
+ File.WriteAllText(Path.Combine(targetPath, "special.exe"), "exe");
+ var targetSub = Path.Combine(targetPath, "bin");
+ Directory.CreateDirectory(targetSub);
+ File.WriteAllText(Path.Combine(targetSub, "decoy.exe"), "decoy");
+ })
+ .ReturnsAsync((ManifestId _, string targetPath, CancellationToken _) => OperationResult.CreateSuccess(targetPath));
+
+ string? capturedEntryPoint = null;
+ _localContentServiceMock
+ .Setup(x => x.UpdateLocalContentManifestAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny?>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback?, CancellationToken, string?>(
+ (_, _, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint)
+ .ReturnsAsync(OperationResult.CreateSuccess(manifest));
+
+ var item = new GenHub.Features.GameProfiles.ViewModels.ContentDisplayItem
+ {
+ Id = manifestId.Value,
+ ManifestId = manifestId,
+ DisplayName = "ZH Client",
+ ContentType = ContentType.GameClient,
+ GameType = GameType.ZeroHour,
+ InstallationType = GameInstallationType.Unknown,
+ Manifest = manifest,
+ };
+
+ var vm = CreateViewModel();
+ await vm.LoadFromManifestAsync(item);
+
+ Assert.NotNull(vm.SelectedExecutableItem);
+ Assert.Equal("special.exe", vm.SelectedExecutableItem.Name);
+
+ await vm.AddContentCommand.ExecuteAsync(null);
+ Assert.Equal("special.exe", capturedEntryPoint);
+ }
+
+ ///
+ /// Verifies that deleting an unrelated item preserves the previously selected executable.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task DeleteItemAsync_PreservesSelectedExecutable()
+ {
+ var tempDir = CreateTempDirectory();
+ File.WriteAllText(Path.Combine(tempDir, "first.exe"), "first");
+ File.WriteAllText(Path.Combine(tempDir, "second.exe"), "second");
+ File.WriteAllText(Path.Combine(tempDir, "readme.txt"), "readme");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.GameClient;
+ vm.ContentName = "Test Client";
+ await vm.ImportContentAsync(tempDir);
+
+ var secondExe = FindInTree(vm.FileTree, f => f.Name == "second.exe");
+ Assert.NotNull(secondExe);
+ vm.SelectExecutableCommand.Execute(secondExe);
+ Assert.Equal("second.exe", vm.SelectedExecutableItem?.Name);
+
+ var readme = FindInTree(vm.FileTree, f => f.Name == "readme.txt");
+ Assert.NotNull(readme);
+ await vm.DeleteItemCommand.ExecuteAsync(readme);
+
+ Assert.NotNull(vm.SelectedExecutableItem);
+ Assert.Equal("second.exe", vm.SelectedExecutableItem.Name);
+ }
+
+ ///
+ /// Verifies that deleting the currently selected executable falls back to auto-selecting the remaining executable.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task DeleteItemAsync_WhenSelectedExecutableDeleted_FallsBackToRemainingExecutable()
+ {
+ var tempDir = CreateTempDirectory();
+ File.WriteAllText(Path.Combine(tempDir, "first.exe"), "first");
+ File.WriteAllText(Path.Combine(tempDir, "second.exe"), "second");
+ File.WriteAllText(Path.Combine(tempDir, "readme.txt"), "readme");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.GameClient;
+ vm.ContentName = "Test Client";
+ await vm.ImportContentAsync(tempDir);
+
+ var secondExe = FindInTree(vm.FileTree, f => f.Name == "second.exe");
+ Assert.NotNull(secondExe);
+ vm.SelectExecutableCommand.Execute(secondExe);
+ Assert.Equal("second.exe", vm.SelectedExecutableItem?.Name);
+
+ await vm.DeleteItemCommand.ExecuteAsync(secondExe);
+
+ Assert.NotNull(vm.SelectedExecutableItem);
+ Assert.Equal("first.exe", vm.SelectedExecutableItem.Name);
+ }
+
+ ///
+ /// Verifies that switching content type away from executable and back preserves the selected entry point.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ContentTypeChanged_SwitchAwayAndBack_PreservesSelectedExecutable()
+ {
+ var tempDir = CreateTempDirectory();
+ File.WriteAllText(Path.Combine(tempDir, "first.exe"), "first");
+ File.WriteAllText(Path.Combine(tempDir, "second.exe"), "second");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.GameClient;
+ vm.ContentName = "Test Client";
+ await vm.ImportContentAsync(tempDir);
+
+ var secondExe = FindInTree(vm.FileTree, f => f.Name == "second.exe");
+ Assert.NotNull(secondExe);
+ vm.SelectExecutableCommand.Execute(secondExe);
+ Assert.Equal("second.exe", vm.SelectedExecutableItem?.Name);
+
+ // Switch to Mod (non-executable type)
+ vm.SelectedContentType = ContentType.Mod;
+ Assert.Null(vm.SelectedExecutableItem);
+
+ // Switch back to GameClient (executable type)
+ vm.SelectedContentType = ContentType.GameClient;
+ Assert.NotNull(vm.SelectedExecutableItem);
+ Assert.Equal("second.exe", vm.SelectedExecutableItem.Name);
+ }
+
+ ///
+ /// Verifies that BuildDirectoryTree prioritizes directories containing executables over non-executable directories.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task BuildDirectoryTree_PrioritizesDirectoriesWithExecutables()
+ {
+ var tempDir = CreateTempDirectory();
+
+ // Create 25 directories named folder01 to folder25
+ for (var i = 1; i <= 25; i++)
+ {
+ var folder = Path.Combine(tempDir, $"folder{i:D2}");
+ Directory.CreateDirectory(folder);
+ File.WriteAllText(Path.Combine(folder, "data.txt"), "content");
+ }
+
+ // Put an executable only in the 25th folder
+ var targetFolder = Path.Combine(tempDir, "folder25");
+ File.WriteAllText(Path.Combine(targetFolder, "game.exe"), "executable");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.GameClient;
+ vm.ContentName = "Test Client";
+ await vm.ImportContentAsync(tempDir);
+
+ var folder25 = FindInTree(vm.FileTree, f => f.Name == "folder25");
+ Assert.NotNull(folder25);
+
+ var exe = FindInTree(folder25.Children, f => f.Name == "game.exe");
+ Assert.NotNull(exe);
+ Assert.True(exe.IsExecutable);
+ }
+
+ ///
+ /// Verifies that switching from an executable type to a non-executable type clears the selected executable.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task ContentTypeChanged_FromExecutableToNonExecutable_ClearsSelectedExecutable()
+ {
+ var tempDir = CreateTempDirectory();
+ File.WriteAllText(Path.Combine(tempDir, "game.exe"), "game");
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.GameClient;
+ vm.ContentName = "Test Client";
+ await vm.ImportContentAsync(tempDir);
+
+ Assert.NotNull(vm.SelectedExecutableItem);
+
+ vm.SelectedContentType = ContentType.Mod;
+
+ Assert.Null(vm.SelectedExecutableItem);
+ }
+
+ ///
+ /// Verifies that AddContentCommand with non-executable content type passes null as entryPoint.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task AddContentCommand_WhenNonExecutableType_PassesNullEntryPoint()
+ {
+ var tempDir = CreateTempDirectory();
+ File.WriteAllText(Path.Combine(tempDir, "somefile.txt"), "text");
+
+ string? capturedEntryPoint = "INITIAL";
+ _localContentServiceMock
+ .Setup(x => x.CreateLocalContentManifestAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny?>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback?, CancellationToken, string?>(
+ (_, _, _, _, _, _, _, entryPoint) => capturedEntryPoint = entryPoint)
+ .ReturnsAsync(OperationResult.CreateSuccess(new ContentManifest
+ {
+ Id = ManifestId.Create("1.0.local.mod.test"),
+ Name = "My Mod",
+ ContentType = ContentType.Mod,
+ TargetGame = GameType.ZeroHour,
+ }));
+
+ var vm = CreateViewModel();
+ vm.SelectedContentType = ContentType.Mod;
+ vm.ContentName = "My Mod";
+ await vm.ImportContentAsync(tempDir);
+
+ await vm.AddContentCommand.ExecuteAsync(null);
+
+ Assert.Null(capturedEntryPoint);
+ }
+
+ private static FileTreeItem? FindInTree(IEnumerable items, Func predicate)
+ {
+ foreach (var item in items)
+ {
+ if (predicate(item)) return item;
+ var child = FindInTree(item.Children, predicate);
+ if (child != null) return child;
+ }
+
+ return null;
+ }
+
+ private string CreateTempDirectory()
+ {
+ var path = Path.Combine(Path.GetTempPath(), "AddLocalContentVmTests_" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(path);
+ _tempDirectories.Add(path);
+ return path;
+ }
+
+ private AddLocalContentViewModel CreateViewModel()
+ {
+ var vm = new AddLocalContentViewModel(
+ _localContentServiceMock.Object,
+ _contentStorageServiceMock.Object,
+ _normalizationServiceMock.Object,
+ _dialogServiceMock.Object,
+ NullLogger.Instance);
+ _viewModels.Add(vm);
+ return vm;
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs
index ec8f7c927..ac6b1e057 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/MainViewModelTests.cs
@@ -1,5 +1,6 @@
using System.Reactive.Linq;
using GenHub.Common.ViewModels;
+using GenHub.Core.Constants;
using GenHub.Core.Interfaces.Common;
using GenHub.Core.Interfaces.GameInstallations;
using GenHub.Core.Interfaces.GameProfiles;
@@ -14,6 +15,8 @@
using GenHub.Core.Interfaces.Tools;
using GenHub.Core.Interfaces.UserData;
using GenHub.Core.Interfaces.Workspace;
+using GenHub.Core.Messages;
+using GenHub.Core.Models.AppUpdate;
using GenHub.Core.Models.Common;
using GenHub.Core.Models.Enums;
using GenHub.Core.Models.Notifications;
@@ -160,6 +163,368 @@ public async Task InitializeAsync_MultipleCallsAreSafeAsync()
Assert.True(true);
}
+ ///
+ /// Tests that receiving updates periodic update timer settings without throwing.
+ ///
+ [Fact]
+ public void Receive_UpdateSettingsChangedMessage_UpdatesPeriodicTimer()
+ {
+ // Arrange
+ var (settingsVm, userSettingsMock) = CreateSettingsVm();
+ var toolsVm = CreateToolsVm();
+ var configProvider = CreateConfigProviderMock();
+ var mockVelopackUpdateManager = new Mock();
+ var mockLogger = new Mock>();
+ var mockNotificationService = CreateNotificationServiceMock();
+ var mockNotificationManager = new Mock(
+ mockNotificationService.Object,
+ Mock.Of>(),
+ Mock.Of>());
+ var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object);
+
+ var vm = new MainViewModel(
+ gameProfilesViewModel: CreateGameProfileLauncherViewModel(),
+ downloadsViewModel: CreateDownloadsViewModel(configProvider),
+ toolsViewModel: toolsVm,
+ settingsViewModel: settingsVm,
+ notificationManager: mockNotificationManager.Object,
+ configurationProvider: configProvider,
+ userSettingsService: userSettingsMock.Object,
+ velopackUpdateManager: mockVelopackUpdateManager.Object,
+ notificationService: mockNotificationService.Object,
+ dialogService: new Mock().Object,
+ notificationFeedViewModel: notificationFeedVm,
+ infoViewModel: CreateInfoViewModel(),
+ logger: mockLogger.Object);
+
+ // Act & Assert (should not throw when enabling/disabling or changing interval)
+ vm.Receive(new UpdateSettingsChangedMessage(false, true, 30));
+ vm.Receive(new UpdateSettingsChangedMessage(false, false, 30));
+ Assert.True(true);
+ }
+
+ ///
+ /// Tests that when AutoCheckForUpdatesOnStartup is false, background update check is not triggered on initialize.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task InitializeAsync_WhenAutoCheckForUpdatesOnStartupFalse_DoesNotCheckUpdatesOnStartupAsync()
+ {
+ // Arrange
+ var (settingsVm, userSettingsMock) = CreateSettingsVm();
+ userSettingsMock.Setup(x => x.Get()).Returns(new UserSettings
+ {
+ AutoCheckForUpdatesOnStartup = false,
+ AutoCheckForUpdatesPeriodically = false,
+ SubscribedBranch = "main",
+ });
+ var toolsVm = CreateToolsVm();
+ var configProvider = CreateConfigProviderMock();
+ var mockVelopackUpdateManager = new Mock();
+ var mockLogger = new Mock>();
+ var mockNotificationService = CreateNotificationServiceMock();
+ var mockNotificationManager = new Mock(
+ mockNotificationService.Object,
+ Mock.Of>(),
+ Mock.Of>());
+ var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object);
+
+ var vm = new MainViewModel(
+ gameProfilesViewModel: CreateGameProfileLauncherViewModel(),
+ downloadsViewModel: CreateDownloadsViewModel(configProvider),
+ toolsViewModel: toolsVm,
+ settingsViewModel: settingsVm,
+ notificationManager: mockNotificationManager.Object,
+ configurationProvider: configProvider,
+ userSettingsService: userSettingsMock.Object,
+ velopackUpdateManager: mockVelopackUpdateManager.Object,
+ notificationService: mockNotificationService.Object,
+ dialogService: new Mock().Object,
+ notificationFeedViewModel: notificationFeedVm,
+ infoViewModel: CreateInfoViewModel(),
+ logger: mockLogger.Object);
+
+ await vm.InitializeAsync();
+ await Task.Delay(100);
+
+ mockVelopackUpdateManager.Verify(x => x.CheckForArtifactUpdatesAsync(It.IsAny()), Times.Never);
+ mockVelopackUpdateManager.Verify(x => x.CheckForUpdatesAsync(It.IsAny()), Times.Never);
+ }
+
+ ///
+ /// Tests that background update check queries artifact updates when subscribed to a PR.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task InitializeAsync_WhenSubscribedToPr_ChecksArtifactUpdatesAsync()
+ {
+ // Arrange
+ var (settingsVm, userSettingsMock) = CreateSettingsVm();
+ userSettingsMock.Setup(x => x.Get()).Returns(new UserSettings { SubscribedPrNumber = 265 });
+ var toolsVm = CreateToolsVm();
+ var configProvider = CreateConfigProviderMock();
+ var updateCheckedTcs = new TaskCompletionSource();
+ var mockVelopackUpdateManager = new Mock();
+ mockVelopackUpdateManager.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny()))
+ .Returns(() =>
+ {
+ updateCheckedTcs.TrySetResult(true);
+ return Task.FromResult(null);
+ });
+ var mockLogger = new Mock>();
+ var mockNotificationService = CreateNotificationServiceMock();
+ var mockNotificationManager = new Mock(
+ mockNotificationService.Object,
+ Mock.Of>(),
+ Mock.Of>());
+ var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object);
+
+ var vm = new MainViewModel(
+ gameProfilesViewModel: CreateGameProfileLauncherViewModel(),
+ downloadsViewModel: CreateDownloadsViewModel(configProvider),
+ toolsViewModel: toolsVm,
+ settingsViewModel: settingsVm,
+ notificationManager: mockNotificationManager.Object,
+ configurationProvider: configProvider,
+ userSettingsService: userSettingsMock.Object,
+ velopackUpdateManager: mockVelopackUpdateManager.Object,
+ notificationService: mockNotificationService.Object,
+ dialogService: new Mock().Object,
+ notificationFeedViewModel: notificationFeedVm,
+ infoViewModel: CreateInfoViewModel(),
+ logger: mockLogger.Object);
+
+ await vm.InitializeAsync();
+
+ // Await deterministic completion of background check
+ await updateCheckedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ mockVelopackUpdateManager.Verify(x => x.CheckForArtifactUpdatesAsync(It.IsAny()), Times.AtLeastOnce);
+ }
+
+ ///
+ /// Tests that background update check queries artifact updates when subscribed to a branch.
+ ///
+ /// A representing the asynchronous operation.
+ [Fact]
+ public async Task InitializeAsync_WhenSubscribedToBranch_ChecksArtifactUpdatesAsync()
+ {
+ // Arrange
+ var (settingsVm, userSettingsMock) = CreateSettingsVm();
+ userSettingsMock.Setup(x => x.Get()).Returns(new UserSettings { SubscribedBranch = "main" });
+ var toolsVm = CreateToolsVm();
+ var configProvider = CreateConfigProviderMock();
+ var updateCheckedTcs = new TaskCompletionSource();
+ var mockVelopackUpdateManager = new Mock();
+ mockVelopackUpdateManager.Setup(x => x.CheckForArtifactUpdatesAsync(It.IsAny()))
+ .Returns(() =>
+ {
+ updateCheckedTcs.TrySetResult(true);
+ return Task.FromResult(null);
+ });
+ var mockLogger = new Mock>();
+ var mockNotificationService = CreateNotificationServiceMock();
+ var mockNotificationManager = new Mock(
+ mockNotificationService.Object,
+ Mock.Of>(),
+ Mock.Of>());
+ var notificationFeedVm = CreateNotificationFeedViewModel(mockNotificationService.Object);
+
+ var vm = new MainViewModel(
+ gameProfilesViewModel: CreateGameProfileLauncherViewModel(),
+ downloadsViewModel: CreateDownloadsViewModel(configProvider),
+ toolsViewModel: toolsVm,
+ settingsViewModel: settingsVm,
+ notificationManager: mockNotificationManager.Object,
+ configurationProvider: configProvider,
+ userSettingsService: userSettingsMock.Object,
+ velopackUpdateManager: mockVelopackUpdateManager.Object,
+ notificationService: mockNotificationService.Object,
+ dialogService: new Mock().Object,
+ notificationFeedViewModel: notificationFeedVm,
+ infoViewModel: CreateInfoViewModel(),
+ logger: mockLogger.Object);
+
+ await vm.InitializeAsync();
+
+ // Await deterministic completion of background check
+ await updateCheckedTcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ mockVelopackUpdateManager.Verify(x => x.CheckForArtifactUpdatesAsync(It.IsAny()), Times.AtLeastOnce);
+ }
+
+ ///
+ /// Tests that background update check shows an update notification with update action and triggers progress notification on click.
+ ///
+ /// A