diff --git a/GenHub/GenHub.Core/Constants/GitHubConstants.cs b/GenHub/GenHub.Core/Constants/GitHubConstants.cs
index 0a567a174..a5625bbc3 100644
--- a/GenHub/GenHub.Core/Constants/GitHubConstants.cs
+++ b/GenHub/GenHub.Core/Constants/GitHubConstants.cs
@@ -14,6 +14,12 @@ public static class GitHubConstants
/// Default rate limit reset period in hours.
public const int DefaultRateLimitResetHours = 1;
+ /// Environment variable name for standard GitHub token.
+ public const string GitHubTokenEnvVar = "GITHUB_TOKEN";
+
+ /// Environment variable name for GenHub-specific GitHub token.
+ public const string GenHubTokenEnvVar = "GENHUB_GITHUB_TOKEN";
+
// Build parsing constants
/// String identifier for Zero Hour game variant.
diff --git a/GenHub/GenHub.Core/Interfaces/Github/IGitHubApiClient.cs b/GenHub/GenHub.Core/Interfaces/Github/IGitHubApiClient.cs
index 4124aa0d2..ac0a7f8bc 100644
--- a/GenHub/GenHub.Core/Interfaces/Github/IGitHubApiClient.cs
+++ b/GenHub/GenHub.Core/Interfaces/Github/IGitHubApiClient.cs
@@ -13,6 +13,11 @@ public interface IGitHubApiClient
///
bool IsAuthenticated { get; }
+ ///
+ /// Gets a value indicating whether the GitHub API rate limit is reached.
+ ///
+ bool IsRateLimited { get; }
+
///
/// Gets the latest release from the specified repository.
///
@@ -121,6 +126,11 @@ Task DownloadArtifactAsync(
/// The GitHub token.
void SetAuthenticationToken(SecureString token);
+ ///
+ /// Clears any configured authentication token.
+ ///
+ void ClearAuthenticationToken();
+
///
/// Gets the currently authenticated user.
///
diff --git a/GenHub/GenHub.Core/Interfaces/UserData/IProfileContentLinker.cs b/GenHub/GenHub.Core/Interfaces/UserData/IProfileContentLinker.cs
index 988cdfd8d..2aff649d5 100644
--- a/GenHub/GenHub.Core/Interfaces/UserData/IProfileContentLinker.cs
+++ b/GenHub/GenHub.Core/Interfaces/UserData/IProfileContentLinker.cs
@@ -79,6 +79,13 @@ Task> UpdateProfileUserDataAsync(
/// The active profile ID, or null if no profile is active.
string? GetActiveProfileId();
+ ///
+ /// Gets the currently active profile ID for the specified game type (if any).
+ ///
+ /// The target game type.
+ /// The active profile ID for the specified game, or null if none is active.
+ string? GetActiveProfileId(GameType targetGame);
+
///
/// Checks if a profile has its user data currently active.
///
diff --git a/GenHub/GenHub.Core/Models/Workspace/ContentHotswapClassification.cs b/GenHub/GenHub.Core/Models/Workspace/ContentHotswapClassification.cs
new file mode 100644
index 000000000..329f6afd0
--- /dev/null
+++ b/GenHub/GenHub.Core/Models/Workspace/ContentHotswapClassification.cs
@@ -0,0 +1,75 @@
+using System.Linq;
+using GenHub.Core.Models.Enums;
+using GenHub.Core.Models.Manifest;
+
+namespace GenHub.Core.Models.Workspace;
+
+///
+/// Classifies content types based on whether they can be safely hot-swapped during an active game session.
+/// Hotswappable content is deployed to the user Documents directory and read dynamically by the game engine.
+/// Locked content modifies game process executables, BIG archives in the workspace, or memory-sensitive assets.
+///
+public static class ContentHotswapClassification
+{
+ ///
+ /// Determines whether the specified content type can be hot-swapped while the game is running.
+ ///
+ /// The content type to evaluate.
+ /// true if the content type is hotswappable; otherwise, false.
+ public static bool IsHotswappable(ContentType contentType)
+ {
+ return contentType switch
+ {
+ ContentType.Map => true,
+ ContentType.MapPack => true,
+ ContentType.Replay => true,
+ _ => false,
+ };
+ }
+
+ ///
+ /// Determines whether the specified manifest can be hot-swapped while the game is running.
+ ///
+ /// The manifest to evaluate.
+ /// true if the manifest is hotswappable; otherwise, false.
+ public static bool IsHotswappable(ContentManifest manifest)
+ {
+ ArgumentNullException.ThrowIfNull(manifest);
+
+ if (!IsHotswappable(manifest.ContentType))
+ {
+ return false;
+ }
+
+ var files = ManifestVariantResolver.ResolveFiles(manifest);
+ if (files.Count == 0 && (manifest.Variants.Count > 0 || manifest.Files.Count > 0))
+ {
+ return false;
+ }
+
+ return files.All(f =>
+ f.InstallTarget != ContentInstallTarget.Workspace &&
+ f.InstallTarget != ContentInstallTarget.System);
+ }
+
+ ///
+ /// Determines whether the specified content type is locked and cannot be modified during an active game session.
+ ///
+ /// The content type to evaluate.
+ /// true if the content type is locked during active sessions; otherwise, false.
+ public static bool IsLocked(ContentType contentType)
+ {
+ return !IsHotswappable(contentType);
+ }
+
+ ///
+ /// Determines whether the specified manifest is locked and cannot be modified during an active game session.
+ ///
+ /// The manifest to evaluate.
+ /// true if the manifest is locked during active sessions; otherwise, false.
+ public static bool IsLocked(ContentManifest manifest)
+ {
+ ArgumentNullException.ThrowIfNull(manifest);
+ return !IsHotswappable(manifest);
+ }
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/OctokitGitHubApiClientTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/OctokitGitHubApiClientTests.cs
index 0e9158aff..7f6f802e7 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/OctokitGitHubApiClientTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/AppUpdate/Services/OctokitGitHubApiClientTests.cs
@@ -132,4 +132,118 @@ public void SetAuthenticationToken_ThrowsWithMockClient()
// Act & Assert
Assert.Throws(() => api.SetAuthenticationToken(secureToken));
}
+
+ ///
+ /// Verifies that credentials are automatically loaded from IGitHubTokenStorage.
+ ///
+ [Fact]
+ public void EnsureCredentialsLoaded_LoadsFromTokenStorage()
+ {
+ // Arrange
+ var concreteClient = new GitHubClient(new ProductHeaderValue("test"));
+ var secureToken = new SecureString();
+ foreach (char c in "stored-secret-pat")
+ {
+ secureToken.AppendChar(c);
+ }
+
+ var tokenStorageMock = new Mock();
+ tokenStorageMock.Setup(x => x.HasToken()).Returns(true);
+ tokenStorageMock.Setup(x => x.LoadTokenAsync()).ReturnsAsync(secureToken);
+
+ var api = new OctokitGitHubApiClient(
+ concreteClient,
+ Mock.Of(),
+ Mock.Of>(),
+ Mock.Of(),
+ tokenStorageMock.Object);
+
+ // Act & Assert
+ api.IsAuthenticated.Should().BeTrue();
+ concreteClient.Credentials.Should().NotBeNull();
+ concreteClient.Credentials.Password.Should().Be("stored-secret-pat");
+ }
+
+ ///
+ /// Verifies that ClearAuthenticationToken resets credentials to Anonymous.
+ ///
+ [Fact]
+ public void ClearAuthenticationToken_ResetsCredentialsToAnonymous()
+ {
+ // Arrange
+ var concreteClient = new GitHubClient(new ProductHeaderValue("test"));
+ var api = new OctokitGitHubApiClient(
+ concreteClient,
+ Mock.Of(),
+ Mock.Of>(),
+ Mock.Of());
+
+ var secureToken = new SecureString();
+ foreach (char c in "test-token")
+ {
+ secureToken.AppendChar(c);
+ }
+
+ api.SetAuthenticationToken(secureToken);
+ api.IsAuthenticated.Should().BeTrue();
+
+ // Act
+ api.ClearAuthenticationToken();
+
+ // Assert
+ api.IsAuthenticated.Should().BeFalse();
+ concreteClient.Credentials.Should().Be(Credentials.Anonymous);
+ }
+
+ ///
+ /// Verifies that rate limit tracker is updated when RateLimitExceededException occurs.
+ ///
+ /// A task representing the asynchronous test operation.
+ [Fact]
+ public async Task GetLatestReleaseAsync_WhenRateLimitExceeded_UpdatesTrackerAsync()
+ {
+ // Arrange
+ var resetEpoch = ((DateTimeOffset)DateTime.UtcNow.AddMinutes(30)).ToUnixTimeSeconds();
+ var headers = new Dictionary
+ {
+ ["X-RateLimit-Reset"] = resetEpoch.ToString(),
+ };
+ var responseMock = new Mock();
+ responseMock.SetupGet(x => x.Headers).Returns(headers);
+ var rateLimit = new Octokit.RateLimit(60, 0, resetEpoch);
+ var apiInfo = new Octokit.ApiInfo(new Dictionary(), new List(), new List(), "etag", rateLimit);
+ responseMock.SetupGet(x => x.ApiInfo).Returns(apiInfo);
+
+ var rateLimitException = new RateLimitExceededException(responseMock.Object);
+
+ var releasesClientMock = new Mock();
+ releasesClientMock
+ .Setup(x => x.GetLatest(It.IsAny(), It.IsAny()))
+ .ThrowsAsync(rateLimitException);
+
+ var repositoriesClientMock = new Mock();
+ repositoriesClientMock
+ .SetupGet(x => x.Release)
+ .Returns(releasesClientMock.Object);
+
+ var gitHubClientMock = new Mock();
+ gitHubClientMock.SetupGet(x => x.Repository).Returns(repositoriesClientMock.Object);
+
+ var tracker = new GitHubRateLimitTracker(Mock.Of>());
+
+ var api = new OctokitGitHubApiClient(
+ gitHubClientMock.Object,
+ Mock.Of(),
+ Mock.Of>(),
+ Mock.Of(),
+ rateLimitTracker: tracker);
+
+ // Act
+ var result = await api.GetLatestReleaseAsync("owner", "repo");
+
+ // Assert
+ result.Should().BeNull();
+ api.IsRateLimited.Should().BeTrue();
+ tracker.IsAtLimit.Should().BeTrue();
+ }
}
\ No newline at end of file
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 443b0f5cf..a36576dd3 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/GameProcessManagerTests.cs
@@ -538,7 +538,9 @@ public static LauncherHarness Create(
// Batch has no $$. PowerShell's own parent is the batch host, so it can report the
// PID the harness needs. If PowerShell is unavailable the loop simply writes
// nothing and Dispose falls back to leaving the launcher alone.
- var recordPid = $"for /f %%p in ('powershell -NoProfile -Command \"(Get-Process -Id $PID).Parent.Id\"') do @echo %%p> \"{Path.Combine(workingDirectory, LauncherPidFileName)}\"\n";
+ var recordPid = exitImmediately
+ ? string.Empty
+ : $"for /f %%p in ('powershell -NoProfile -Command \"(Get-Process -Id $PID).Parent.Id\"') do @echo %%p> \"{Path.Combine(workingDirectory, LauncherPidFileName)}\"\n";
// Leave the working directory afterwards: a batch host holds its current directory
// open, which would defeat the cleanup delete for the launcher's whole lifetime.
@@ -554,9 +556,10 @@ public static LauncherHarness Create(
var spawn = spawnChild ? $"\"{childPath}\" {LauncherLifetimeSeconds} &\n" : string.Empty;
var linger = exitImmediately ? string.Empty : $"sleep {LauncherLifetimeSeconds}\n";
var complain = stderrMessage is null ? string.Empty : $"echo \"{stderrMessage}\" >&2\n";
+ var recordPid = exitImmediately ? string.Empty : $"echo $$ > \"{Path.Combine(workingDirectory, LauncherPidFileName)}\"\n";
// The harness does not start the launcher, so the launcher reports its own PID.
- script = $"#!/bin/bash\necho $$ > \"{Path.Combine(workingDirectory, LauncherPidFileName)}\"\n{complain}{spawn}{linger}";
+ script = $"#!/bin/bash\n{recordPid}{complain}{spawn}{linger}";
}
File.WriteAllText(launcherPath, script);
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/GameProfileManagerHotswapTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/GameProfileManagerHotswapTests.cs
new file mode 100644
index 000000000..42e313b51
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/Services/GameProfileManagerHotswapTests.cs
@@ -0,0 +1,445 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Interfaces.GameInstallations;
+using GenHub.Core.Interfaces.GameProfiles;
+using GenHub.Core.Interfaces.GameSettings;
+using GenHub.Core.Interfaces.Launching;
+using GenHub.Core.Interfaces.Manifest;
+using GenHub.Core.Models.GameClients;
+using GenHub.Core.Models.GameProfile;
+using GenHub.Core.Models.Launching;
+using GenHub.Core.Models.Manifest;
+using GenHub.Core.Models.Results;
+using GenHub.Features.GameProfiles.Services;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+using ContentType = GenHub.Core.Models.Enums.ContentType;
+using WorkspaceStrategy = GenHub.Core.Models.Enums.WorkspaceStrategy;
+
+namespace GenHub.Tests.Core.Features.GameProfiles.Services;
+
+///
+/// Unit tests for runtime content hot-swapping validation in .
+///
+public class GameProfileManagerHotswapTests
+{
+ private readonly Mock _profileRepositoryMock = new();
+ private readonly Mock _installationServiceMock = new();
+ private readonly Mock _manifestPoolMock = new();
+ private readonly Mock _gameSettingsServiceMock = new();
+ private readonly Mock _launchRegistryMock = new();
+ private readonly Mock> _loggerMock = new();
+ private readonly GameProfileManager _profileManager;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public GameProfileManagerHotswapTests()
+ {
+ _profileManager = new GameProfileManager(
+ _profileRepositoryMock.Object,
+ _installationServiceMock.Object,
+ _manifestPoolMock.Object,
+ _gameSettingsServiceMock.Object,
+ _loggerMock.Object,
+ _launchRegistryMock.Object);
+ }
+
+ ///
+ /// Verifies that updating a non-running profile clears the ActiveWorkspaceId when content changes.
+ ///
+ /// A task representing the test operation.
+ [Fact]
+ public async Task UpdateProfileAsync_WhenProfileNotRunning_ClearsActiveWorkspaceIdOnContentChangeAsync()
+ {
+ // Arrange
+ const string profileId = "profile-1";
+ var existingProfile = new GameProfile
+ {
+ Id = profileId,
+ Name = "Existing Profile",
+ ActiveWorkspaceId = "workspace-abc",
+ EnabledContentIds = ["1.0.0.mod.first"],
+ };
+
+ _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile));
+ _profileRepositoryMock.Setup(r => r.SaveProfileAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile));
+ _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync())
+ .ReturnsAsync(new List());
+
+ var request = new UpdateProfileRequest
+ {
+ EnabledContentIds = ["1.0.0.mod.second"],
+ };
+
+ // Act
+ var result = await _profileManager.UpdateProfileAsync(profileId, request);
+
+ // Assert
+ Assert.True(result.Success);
+ Assert.Empty(existingProfile.ActiveWorkspaceId);
+ }
+
+ ///
+ /// Verifies that updating a running profile with map changes succeeds and preserves ActiveWorkspaceId.
+ ///
+ /// A task representing the test operation.
+ [Fact]
+ public async Task UpdateProfileAsync_WhenProfileRunning_WithMapChanges_SucceedsAndPreservesActiveWorkspaceIdAsync()
+ {
+ // Arrange
+ const string profileId = "profile-running-1";
+ const string oldMapId = "1.0.0.map.oldmap";
+ const string newMapId = "1.0.0.mappack.newpack";
+
+ var existingProfile = new GameProfile
+ {
+ Id = profileId,
+ Name = "Running Profile",
+ ActiveWorkspaceId = "workspace-live-123",
+ EnabledContentIds = [oldMapId],
+ };
+
+ var oldMapManifest = new ContentManifest
+ {
+ Id = ManifestId.Create(oldMapId),
+ Name = "Old Map",
+ ContentType = ContentType.Map,
+ };
+
+ var newMapManifest = new ContentManifest
+ {
+ Id = ManifestId.Create(newMapId),
+ Name = "New Map Pack",
+ ContentType = ContentType.MapPack,
+ };
+
+ _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile));
+ _profileRepositoryMock.Setup(r => r.SaveProfileAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile));
+
+ _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync())
+ .ReturnsAsync([CreateActiveLaunch(profileId)]);
+
+ _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(oldMapId), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(oldMapManifest));
+ _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(newMapId), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(newMapManifest));
+
+ var request = new UpdateProfileRequest
+ {
+ EnabledContentIds = [newMapId],
+ };
+
+ // Act
+ var result = await _profileManager.UpdateProfileAsync(profileId, request);
+
+ // Assert
+ Assert.True(result.Success);
+ Assert.Equal("workspace-live-123", existingProfile.ActiveWorkspaceId);
+ Assert.Contains(newMapId, existingProfile.EnabledContentIds);
+ }
+
+ ///
+ /// Verifies that updating a running profile with locked mod changes fails with a descriptive error.
+ ///
+ /// A task representing the test operation.
+ [Fact]
+ public async Task UpdateProfileAsync_WhenProfileRunning_WithModChanges_FailsWithDescriptiveErrorAsync()
+ {
+ // Arrange
+ const string profileId = "profile-running-2";
+ const string baseModId = "1.0.0.mod.base";
+ const string addedModId = "1.0.0.mod.shockwave";
+
+ var existingProfile = new GameProfile
+ {
+ Id = profileId,
+ Name = "Running Profile",
+ ActiveWorkspaceId = "workspace-live-123",
+ EnabledContentIds = [baseModId],
+ };
+
+ var modManifest = new ContentManifest
+ {
+ Id = ManifestId.Create(addedModId),
+ Name = "ShockWave Mod",
+ ContentType = ContentType.Mod,
+ };
+
+ _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile));
+
+ _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync())
+ .ReturnsAsync([CreateActiveLaunch(profileId)]);
+
+ _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(addedModId), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(modManifest));
+
+ var request = new UpdateProfileRequest
+ {
+ EnabledContentIds = [baseModId, addedModId],
+ };
+
+ // Act
+ var result = await _profileManager.UpdateProfileAsync(profileId, request);
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.Contains("ShockWave Mod", result.FirstError);
+ Assert.Contains("while profile is running", result.FirstError, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("hot swapped", result.FirstError, StringComparison.OrdinalIgnoreCase);
+ _profileRepositoryMock.Verify(r => r.SaveProfileAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ ///
+ /// Verifies that updating a running profile with game client changes fails with a descriptive error.
+ ///
+ /// A task representing the test operation.
+ [Fact]
+ public async Task UpdateProfileAsync_WhenProfileRunning_WithGameClientChanges_FailsWithDescriptiveErrorAsync()
+ {
+ // Arrange
+ const string profileId = "profile-running-3";
+ var existingProfile = new GameProfile
+ {
+ Id = profileId,
+ Name = "Running Profile",
+ ActiveWorkspaceId = "workspace-live-123",
+ GameClient = new GameClient { Id = "client-original", Name = "Client 1.04" },
+ };
+
+ _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile));
+
+ _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync())
+ .ReturnsAsync([CreateActiveLaunch(profileId)]);
+
+ var request = new UpdateProfileRequest
+ {
+ GameClient = new GameClient { Id = "client-new", Name = "Client 1.06" },
+ };
+
+ // Act
+ var result = await _profileManager.UpdateProfileAsync(profileId, request);
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.Contains("game client", result.FirstError, StringComparison.OrdinalIgnoreCase);
+ _profileRepositoryMock.Verify(r => r.SaveProfileAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ ///
+ /// Verifies that updating immutable metadata on a running profile is rejected.
+ ///
+ /// A task representing the test operation.
+ [Fact]
+ public async Task UpdateProfileAsync_WhenProfileRunning_WithImmutableMetadataChanges_FailsAsync()
+ {
+ // Arrange
+ const string profileId = "profile-running-4";
+ var existingProfile = new GameProfile
+ {
+ Id = profileId,
+ Name = "Running Profile",
+ ActiveWorkspaceId = "workspace-live-123",
+ WorkspaceStrategy = WorkspaceStrategy.SymlinkOnly,
+ GameInstallationId = "install-1",
+ CustomExecutablePath = "C:\\game\\generals.exe",
+ WorkingDirectory = "C:\\game",
+ };
+
+ _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile));
+
+ _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync())
+ .ReturnsAsync([CreateActiveLaunch(profileId)]);
+
+ // 1. Workspace Strategy change
+ var req1 = new UpdateProfileRequest { WorkspaceStrategy = WorkspaceStrategy.HardLink };
+ var res1 = await _profileManager.UpdateProfileAsync(profileId, req1);
+ Assert.False(res1.Success);
+ Assert.Contains("workspace strategy", res1.FirstError, StringComparison.OrdinalIgnoreCase);
+
+ // 2. Installation change
+ var req2 = new UpdateProfileRequest { GameInstallationId = "install-2" };
+ var res2 = await _profileManager.UpdateProfileAsync(profileId, req2);
+ Assert.False(res2.Success);
+ Assert.Contains("game installation", res2.FirstError, StringComparison.OrdinalIgnoreCase);
+
+ // 2b. Empty installation change
+ var req2b = new UpdateProfileRequest { GameInstallationId = string.Empty };
+ var res2b = await _profileManager.UpdateProfileAsync(profileId, req2b);
+ Assert.False(res2b.Success);
+ Assert.Contains("game installation", res2b.FirstError, StringComparison.OrdinalIgnoreCase);
+
+ // 3. Custom executable path change
+ var req3 = new UpdateProfileRequest { CustomExecutablePath = "C:\\game\\new_generals.exe" };
+ var res3 = await _profileManager.UpdateProfileAsync(profileId, req3);
+ Assert.False(res3.Success);
+ Assert.Contains("custom executable path", res3.FirstError, StringComparison.OrdinalIgnoreCase);
+
+ // 4. Working directory change
+ var req4 = new UpdateProfileRequest { WorkingDirectory = "C:\\other_dir" };
+ var res4 = await _profileManager.UpdateProfileAsync(profileId, req4);
+ Assert.False(res4.Success);
+ Assert.Contains("working directory", res4.FirstError, StringComparison.OrdinalIgnoreCase);
+
+ // 5. Command line arguments change
+ var req5 = new UpdateProfileRequest { CommandLineArguments = "-win -quickstart" };
+ var res5 = await _profileManager.UpdateProfileAsync(profileId, req5);
+ Assert.False(res5.Success);
+ Assert.Contains("command line arguments", res5.FirstError, StringComparison.OrdinalIgnoreCase);
+
+ // 6. Active workspace ID change
+ var req6 = new UpdateProfileRequest { ActiveWorkspaceId = "workspace-new-999" };
+ var res6 = await _profileManager.UpdateProfileAsync(profileId, req6);
+ Assert.False(res6.Success);
+ Assert.Contains("active workspace", res6.FirstError, StringComparison.OrdinalIgnoreCase);
+
+ // 7. Game client change
+ var req7 = new UpdateProfileRequest { GameClient = new GameClient { Id = "different-client-id" } };
+ var res7 = await _profileManager.UpdateProfileAsync(profileId, req7);
+ Assert.False(res7.Success);
+ Assert.Contains("game client", res7.FirstError, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Verifies that updating a running profile with content whose manifest cannot be found fails gracefully.
+ ///
+ /// A task representing the test operation.
+ [Fact]
+ public async Task UpdateProfileAsync_WhenProfileRunning_WithManifestNotFound_ReturnsFailureAsync()
+ {
+ // Arrange
+ const string profileId = "profile-running-missing-manifest";
+ const string missingManifestId = "1.0.0.map.missing";
+
+ var existingProfile = new GameProfile
+ {
+ Id = profileId,
+ Name = "Running Profile",
+ ActiveWorkspaceId = "workspace-live-123",
+ EnabledContentIds = [],
+ };
+
+ _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile));
+
+ _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync())
+ .ReturnsAsync([CreateActiveLaunch(profileId)]);
+
+ _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(missingManifestId), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateFailure("Manifest not found"));
+
+ var request = new UpdateProfileRequest
+ {
+ EnabledContentIds = [missingManifestId],
+ };
+
+ // Act
+ var result = await _profileManager.UpdateProfileAsync(profileId, request);
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.Contains("manifest not found", result.FirstError, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Verifies that updating a running profile with an invalid manifest ID format fails gracefully.
+ ///
+ /// A task representing the test operation.
+ [Fact]
+ public async Task UpdateProfileAsync_WhenProfileRunning_WithInvalidManifestId_ReturnsFailureAsync()
+ {
+ // Arrange
+ const string profileId = "profile-running-invalid-manifest";
+ const string invalidManifestId = "invalid manifest id!";
+
+ var existingProfile = new GameProfile
+ {
+ Id = profileId,
+ Name = "Running Profile",
+ ActiveWorkspaceId = "workspace-live-123",
+ EnabledContentIds = [],
+ };
+
+ _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile));
+
+ _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync())
+ .ReturnsAsync([CreateActiveLaunch(profileId)]);
+
+ var request = new UpdateProfileRequest
+ {
+ EnabledContentIds = [invalidManifestId],
+ };
+
+ // Act
+ var result = await _profileManager.UpdateProfileAsync(profileId, request);
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.Contains("invalid manifest ID format", result.FirstError, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Verifies that stale launch records with TerminatedAt set are ignored when checking running status.
+ ///
+ /// A task representing the test operation.
+ [Fact]
+ public async Task UpdateProfileAsync_WhenLaunchRecordIsTerminated_TreatsProfileAsNotRunningAsync()
+ {
+ // Arrange
+ const string profileId = "profile-terminated-1";
+ var existingProfile = new GameProfile
+ {
+ Id = profileId,
+ Name = "Terminated Profile",
+ ActiveWorkspaceId = "workspace-stale",
+ EnabledContentIds = ["1.0.0.mod.first"],
+ };
+
+ var terminatedLaunch = CreateActiveLaunch(profileId);
+ terminatedLaunch.TerminatedAt = DateTime.UtcNow.AddMinutes(-5);
+
+ _profileRepositoryMock.Setup(r => r.LoadProfileAsync(profileId, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile));
+ _profileRepositoryMock.Setup(r => r.SaveProfileAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(existingProfile));
+ _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync())
+ .ReturnsAsync([terminatedLaunch]);
+
+ var request = new UpdateProfileRequest
+ {
+ EnabledContentIds = ["1.0.0.mod.second"],
+ };
+
+ // Act
+ var result = await _profileManager.UpdateProfileAsync(profileId, request);
+
+ // Assert
+ Assert.True(result.Success);
+ Assert.Empty(existingProfile.ActiveWorkspaceId);
+ }
+
+ private static GameLaunchInfo CreateActiveLaunch(string profileId, string launchId = "launch-1", string workspaceId = "ws-1") => new()
+ {
+ LaunchId = launchId,
+ ProfileId = profileId,
+ WorkspaceId = workspaceId,
+ ProcessInfo = new GameProcessInfo
+ {
+ ProcessId = 1234,
+ ProcessName = "generals.exe",
+ StartTime = DateTime.UtcNow,
+ },
+ };
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileSettingsViewModelDependencyTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileSettingsViewModelDependencyTests.cs
index 6d6f92768..e21f3d7c0 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileSettingsViewModelDependencyTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileSettingsViewModelDependencyTests.cs
@@ -540,4 +540,68 @@ public async Task EnableContent_AutoEnables_DependentContentAsync()
Assert.Contains(_viewModel.EnabledContent, c => c.ManifestId.Value == mapPackId.Value);
Assert.True(_viewModel.EnabledContent.First(c => c.ManifestId.Value == mapPackId.Value).IsEnabled);
}
+
+ ///
+ /// Verifies that enabling content requiring a GameInstallation does not auto switch when a compatible installation is already selected.
+ ///
+ /// A task representing the asynchronous operation.
+ [Fact]
+ public async Task EnableContent_DoesNotAutoSwitch_WhenMatchingGameInstallationAlreadySelectedAsync()
+ {
+ // Arrange
+ var mapPackManifestId = new ManifestId("1.813262.generalsonline.mappack.quickmatchmaps");
+ var zeroHourInstallId = new ManifestId("1.104.steam.gameinstallation.zerohour");
+
+ var mapPackManifest = new ContentManifest
+ {
+ Id = mapPackManifestId,
+ Name = "GeneralsOnline QuickMatch Maps",
+ ContentType = ContentType.MapPack,
+ TargetGame = GameType.ZeroHour,
+ Dependencies =
+ [
+ new()
+ {
+ DependencyType = ContentType.GameInstallation,
+ CompatibleGameTypes = [GameType.ZeroHour],
+ },
+ ],
+ };
+
+ _mockManifestPool.Setup(x => x.GetManifestAsync(It.Is(id => id.Value == mapPackManifestId.Value), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(mapPackManifest));
+
+ var zeroHourInstall = new ViewModelContentDisplayItem
+ {
+ ManifestId = zeroHourInstallId,
+ DisplayName = "Zero Hour v1.04",
+ ContentType = ContentType.GameInstallation,
+ GameType = GameType.ZeroHour,
+ InstallationType = GameInstallationType.Steam,
+ IsEnabled = true,
+ };
+
+ _viewModel.AvailableGameInstallations = [zeroHourInstall];
+ _viewModel.SelectedGameInstallation = zeroHourInstall;
+ _viewModel.EnabledContent.Add(zeroHourInstall);
+
+ var mapPackItem = new ViewModelContentDisplayItem
+ {
+ ManifestId = mapPackManifestId,
+ DisplayName = "GeneralsOnline QuickMatch Maps",
+ ContentType = ContentType.MapPack,
+ GameType = GameType.ZeroHour,
+ InstallationType = GameInstallationType.Steam,
+ IsEnabled = false,
+ };
+ _viewModel.AvailableContent.Add(mapPackItem);
+
+ // Act
+ await _viewModel.EnableContentCommand.ExecuteAsync(mapPackItem);
+
+ // Assert
+ Assert.Equal(zeroHourInstall, _viewModel.SelectedGameInstallation);
+ Assert.Single(_viewModel.EnabledContent, c => c.ContentType == ContentType.GameInstallation);
+ Assert.Contains(_viewModel.EnabledContent, c => c.ManifestId.Value == mapPackManifestId.Value);
+ }
}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileSettingsViewModelHotswapTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileSettingsViewModelHotswapTests.cs
new file mode 100644
index 000000000..f8c3c97a2
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/GameProfiles/ViewModels/GameProfileSettingsViewModelHotswapTests.cs
@@ -0,0 +1,866 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Interfaces.Common;
+using GenHub.Core.Interfaces.GameProfiles;
+using GenHub.Core.Interfaces.GameSettings;
+using GenHub.Core.Interfaces.Launching;
+using GenHub.Core.Interfaces.Manifest;
+using GenHub.Core.Interfaces.UserData;
+using GenHub.Core.Models.GameClients;
+using GenHub.Core.Models.GameProfile;
+using GenHub.Core.Models.Launching;
+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;
+using CoreContentDisplayItem = GenHub.Core.Models.Content.ContentDisplayItem;
+using GameInstallationType = GenHub.Core.Models.Enums.GameInstallationType;
+using GameType = GenHub.Core.Models.Enums.GameType;
+
+namespace GenHub.Tests.Core.Features.GameProfiles.ViewModels;
+
+///
+/// Unit tests for runtime hotswap mode behavior.
+///
+public class GameProfileSettingsViewModelHotswapTests
+{
+ private readonly Mock _gameProfileManagerMock = new();
+ private readonly Mock _gameSettingsServiceMock = new();
+ private readonly Mock _configProviderMock = new();
+ private readonly Mock _contentLoaderMock = new();
+ private readonly Mock _manifestPoolMock = new();
+ private readonly Mock _profileContentLinkerMock = new();
+ private readonly Mock _launchRegistryMock = new();
+ private readonly GameProfileSettingsViewModel _viewModel;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public GameProfileSettingsViewModelHotswapTests()
+ {
+ _viewModel = new GameProfileSettingsViewModel(
+ _gameProfileManagerMock.Object,
+ _gameSettingsServiceMock.Object,
+ _configProviderMock.Object,
+ _contentLoaderMock.Object,
+ null,
+ null,
+ _manifestPoolMock.Object,
+ null,
+ null,
+ null,
+ null,
+ NullLogger.Instance,
+ NullLogger.Instance,
+ _profileContentLinkerMock.Object,
+ _launchRegistryMock.Object);
+ }
+
+ ///
+ /// Verifies that initializing for a running profile activates Hotswap Mode and locks non-hotswappable content.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task InitializeForProfileAsync_WhenProfileIsRunning_SetsIsHotswapModeTrueAndLocksNonHotswappableContentAsync()
+ {
+ // Arrange
+ const string profileId = "profile-live-1";
+ const string installId = "1.108.steam.gameinstallation.zh";
+ const string mapId = "1.0.0.map.desert";
+ const string modId = "1.0.0.mod.shockwave";
+
+ var profile = new GameProfile
+ {
+ Id = profileId,
+ Name = "Live Game Profile",
+ EnabledContentIds = [installId, mapId, modId],
+ GameClient = new GameClient
+ {
+ Id = "client-zh",
+ Name = "Zero Hour",
+ GameType = GameType.ZeroHour,
+ },
+ };
+
+ _gameProfileManagerMock.Setup(m => m.GetProfileAsync(profileId, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+ _gameProfileManagerMock.Setup(m => m.UpdateProfileAsync(profileId, It.IsAny(), It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+
+ _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync())
+ .ReturnsAsync([CreateActiveLaunch(profileId)]);
+
+ var enabledItems = new ObservableCollection
+ {
+ new()
+ {
+ Id = installId,
+ ManifestId = installId,
+ DisplayName = "Command & Conquer: Zero Hour",
+ ContentType = ContentType.GameInstallation,
+ GameType = GameType.ZeroHour,
+ },
+ new()
+ {
+ Id = mapId,
+ ManifestId = mapId,
+ DisplayName = "Tournament Desert",
+ ContentType = ContentType.Map,
+ GameType = GameType.ZeroHour,
+ },
+ new()
+ {
+ Id = modId,
+ ManifestId = modId,
+ DisplayName = "ShockWave Mod",
+ ContentType = ContentType.Mod,
+ GameType = GameType.ZeroHour,
+ },
+ };
+
+ _contentLoaderMock.Setup(c => c.LoadEnabledContentForProfileAsync(profile))
+ .ReturnsAsync(enabledItems);
+ _contentLoaderMock.Setup(c => c.LoadAvailableGameInstallationsAsync())
+ .ReturnsAsync([]);
+ _contentLoaderMock.Setup(c => c.LoadAvailableContentAsync(It.IsAny(), It.IsAny>(), It.IsAny>()))
+ .ReturnsAsync([]);
+
+ _manifestPoolMock.Setup(m => m.GetAllManifestsAsync(It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([]));
+
+ // Act
+ await _viewModel.InitializeForProfileAsync(profileId);
+
+ // Assert
+ Assert.True(_viewModel.IsHotswapMode);
+ Assert.False(_viewModel.CanEditImmutableMetadata);
+
+ var mapItem = _viewModel.EnabledContent.FirstOrDefault(c => c.ManifestId.Value == mapId);
+ Assert.NotNull(mapItem);
+ Assert.False(mapItem.IsLocked);
+ Assert.True(mapItem.CanToggle);
+
+ var modItem = _viewModel.EnabledContent.FirstOrDefault(c => c.ManifestId.Value == modId);
+ Assert.NotNull(modItem);
+ Assert.True(modItem.IsLocked);
+ Assert.False(modItem.CanToggle);
+ }
+
+ ///
+ /// Verifies that initializing for an idle profile sets Hotswap Mode to false.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task InitializeForProfileAsync_WhenProfileIsNotRunning_SetsIsHotswapModeFalseAsync()
+ {
+ // Arrange
+ const string profileId = "profile-idle-1";
+ const string installId = "1.108.steam.gameinstallation.zh";
+ const string modId = "1.0.0.mod.shockwave";
+
+ var profile = new GameProfile
+ {
+ Id = profileId,
+ Name = "Idle Profile",
+ EnabledContentIds = [installId, modId],
+ GameClient = new GameClient
+ {
+ Id = "client-zh",
+ Name = "Zero Hour",
+ GameType = GameType.ZeroHour,
+ },
+ };
+
+ _gameProfileManagerMock.Setup(m => m.GetProfileAsync(profileId, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+ _gameProfileManagerMock.Setup(m => m.UpdateProfileAsync(profileId, It.IsAny(), It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+
+ _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync())
+ .ReturnsAsync(new List());
+
+ var enabledItems = new ObservableCollection
+ {
+ new()
+ {
+ Id = installId,
+ ManifestId = installId,
+ DisplayName = "Command & Conquer: Zero Hour",
+ ContentType = ContentType.GameInstallation,
+ GameType = GameType.ZeroHour,
+ },
+ new()
+ {
+ Id = modId,
+ ManifestId = modId,
+ DisplayName = "ShockWave Mod",
+ ContentType = ContentType.Mod,
+ GameType = GameType.ZeroHour,
+ },
+ };
+
+ _contentLoaderMock.Setup(c => c.LoadEnabledContentForProfileAsync(profile))
+ .ReturnsAsync(enabledItems);
+ _contentLoaderMock.Setup(c => c.LoadAvailableGameInstallationsAsync())
+ .ReturnsAsync([]);
+ _contentLoaderMock.Setup(c => c.LoadAvailableContentAsync(It.IsAny(), It.IsAny>(), It.IsAny>()))
+ .ReturnsAsync([]);
+
+ _manifestPoolMock.Setup(m => m.GetAllManifestsAsync(It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([]));
+
+ // Act
+ await _viewModel.InitializeForProfileAsync(profileId);
+
+ // Assert
+ Assert.False(_viewModel.IsHotswapMode);
+ Assert.True(_viewModel.CanEditImmutableMetadata);
+
+ var modItem = _viewModel.EnabledContent.FirstOrDefault(c => c.ManifestId.Value == modId);
+ Assert.NotNull(modItem);
+ Assert.False(modItem.IsLocked);
+ Assert.True(modItem.CanToggle);
+ }
+
+ ///
+ /// Verifies that SaveAsync in Hotswap Mode invokes UpdateProfileUserDataAsync on the profile content linker.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task SaveAsync_WhenInHotswapMode_CallsUpdateProfileUserDataAsync()
+ {
+ // Arrange
+ const string profileId = "profile-live-2";
+ const string installId = "1.108.steam.gameinstallation.zh";
+ const string mapId = "1.0.0.map.desert";
+
+ var profile = new GameProfile
+ {
+ Id = profileId,
+ Name = "Live Profile",
+ EnabledContentIds = [installId, mapId],
+ GameClient = new GameClient
+ {
+ Id = "client-zh",
+ Name = "Zero Hour",
+ GameType = GameType.ZeroHour,
+ },
+ };
+
+ _gameProfileManagerMock.Setup(m => m.GetProfileAsync(profileId, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+ _gameProfileManagerMock.Setup(m => m.UpdateProfileAsync(profileId, It.IsAny(), It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+
+ _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync())
+ .ReturnsAsync([CreateActiveLaunch(profileId)]);
+
+ var enabledItems = new ObservableCollection
+ {
+ new()
+ {
+ Id = installId,
+ ManifestId = installId,
+ DisplayName = "Command & Conquer: Zero Hour",
+ ContentType = ContentType.GameInstallation,
+ GameType = GameType.ZeroHour,
+ },
+ new()
+ {
+ Id = mapId,
+ ManifestId = mapId,
+ DisplayName = "Tournament Desert",
+ ContentType = ContentType.Map,
+ GameType = GameType.ZeroHour,
+ },
+ };
+
+ _contentLoaderMock.Setup(c => c.LoadEnabledContentForProfileAsync(profile))
+ .ReturnsAsync(enabledItems);
+ _contentLoaderMock.Setup(c => c.LoadAvailableGameInstallationsAsync())
+ .ReturnsAsync([]);
+ _contentLoaderMock.Setup(c => c.LoadAvailableContentAsync(It.IsAny(), It.IsAny>(), It.IsAny>()))
+ .ReturnsAsync([]);
+ _manifestPoolMock.Setup(m => m.GetAllManifestsAsync(It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([]));
+
+ var mapManifest = new ContentManifest
+ {
+ Id = ManifestId.Create(mapId),
+ Name = "Tournament Desert",
+ ContentType = ContentType.Map,
+ };
+ var installManifest = new ContentManifest
+ {
+ Id = ManifestId.Create(installId),
+ Name = "Zero Hour",
+ ContentType = ContentType.GameInstallation,
+ };
+
+ _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(mapId), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(mapManifest));
+ _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(installId), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(installManifest));
+
+ _profileContentLinkerMock.Setup(p => p.UpdateProfileUserDataAsync(
+ profileId,
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(true));
+
+ await _viewModel.InitializeForProfileAsync(profileId);
+
+ // Act
+ await _viewModel.SaveCommand.ExecuteAsync(null);
+
+ // Assert
+ _profileContentLinkerMock.Verify(
+ p => p.UpdateProfileUserDataAsync(
+ profileId,
+ It.Is>(m => m.Count() == 2 && m.Any(x => x.Id.Value == mapId) && m.Any(x => x.Id.Value == installId)),
+ GameType.ZeroHour,
+ It.IsAny()),
+ Times.Once);
+
+ _gameProfileManagerMock.Verify(
+ m => m.UpdateProfileAsync(profileId, It.IsAny(), It.IsAny()),
+ Times.AtLeastOnce());
+ }
+
+ ///
+ /// Verifies that SaveAsync reports a failure status message when live content sync fails.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task SaveAsync_WhenLiveSyncFails_SetsStatusMessageWarningAsync()
+ {
+ // Arrange
+ const string profileId = "profile-live-3";
+ const string installId = "1.108.steam.gameinstallation.zh";
+ const string mapId = "1.0.0.map.desert";
+
+ var profile = new GameProfile
+ {
+ Id = profileId,
+ Name = "Live Profile",
+ EnabledContentIds = [installId, mapId],
+ GameClient = new GameClient
+ {
+ Id = "client-zh",
+ Name = "Zero Hour",
+ GameType = GameType.ZeroHour,
+ },
+ };
+
+ _gameProfileManagerMock.Setup(m => m.GetProfileAsync(profileId, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+ _gameProfileManagerMock.Setup(m => m.UpdateProfileAsync(profileId, It.IsAny(), It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+
+ _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync())
+ .ReturnsAsync([CreateActiveLaunch(profileId)]);
+
+ var enabledItems = new ObservableCollection
+ {
+ new()
+ {
+ Id = installId,
+ ManifestId = installId,
+ DisplayName = "Command & Conquer: Zero Hour",
+ ContentType = ContentType.GameInstallation,
+ GameType = GameType.ZeroHour,
+ },
+ new()
+ {
+ Id = mapId,
+ ManifestId = mapId,
+ DisplayName = "Tournament Desert",
+ ContentType = ContentType.Map,
+ GameType = GameType.ZeroHour,
+ },
+ };
+
+ _contentLoaderMock.Setup(c => c.LoadEnabledContentForProfileAsync(profile))
+ .ReturnsAsync(enabledItems);
+ _contentLoaderMock.Setup(c => c.LoadAvailableGameInstallationsAsync())
+ .ReturnsAsync([]);
+ _contentLoaderMock.Setup(c => c.LoadAvailableContentAsync(It.IsAny(), It.IsAny>(), It.IsAny>()))
+ .ReturnsAsync([]);
+ _manifestPoolMock.Setup(m => m.GetAllManifestsAsync(It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([]));
+
+ var mapManifest = new ContentManifest
+ {
+ Id = ManifestId.Create(mapId),
+ Name = "Tournament Desert",
+ ContentType = ContentType.Map,
+ };
+ var installManifest = new ContentManifest
+ {
+ Id = ManifestId.Create(installId),
+ Name = "Zero Hour",
+ ContentType = ContentType.GameInstallation,
+ };
+
+ _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(mapId), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(mapManifest));
+ _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(installId), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(installManifest));
+
+ _profileContentLinkerMock.Setup(p => p.UpdateProfileUserDataAsync(
+ profileId,
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateFailure("Live file locked by process"));
+
+ await _viewModel.InitializeForProfileAsync(profileId);
+ _gameProfileManagerMock.Invocations.Clear();
+
+ // Act
+ await _viewModel.SaveCommand.ExecuteAsync(null);
+
+ // Assert
+ Assert.Contains("live sync failed", _viewModel.StatusMessage, StringComparison.OrdinalIgnoreCase);
+ _gameProfileManagerMock.Verify(
+ m => m.UpdateProfileAsync(It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ ///
+ /// Verifies that SaveAsync aborts live sync without calling UpdateProfileUserDataAsync if any enabled manifest cannot be resolved.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task SaveAsync_WhenManifestResolutionFails_AbortsLiveSyncAndDoesNotInvokeLinkerAsync()
+ {
+ // Arrange
+ const string profileId = "profile-live-4";
+ const string installId = "1.108.steam.gameinstallation.zh";
+ const string mapId = "1.0.0.map.desert";
+
+ var profile = new GameProfile
+ {
+ Id = profileId,
+ Name = "Live Profile",
+ EnabledContentIds = [installId, mapId],
+ GameClient = new GameClient
+ {
+ Id = "client-zh",
+ Name = "Zero Hour",
+ GameType = GameType.ZeroHour,
+ },
+ };
+
+ _gameProfileManagerMock.Setup(m => m.GetProfileAsync(profileId, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+ _gameProfileManagerMock.Setup(m => m.UpdateProfileAsync(profileId, It.IsAny(), It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+
+ _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync())
+ .ReturnsAsync([CreateActiveLaunch(profileId)]);
+
+ var enabledItems = new ObservableCollection
+ {
+ new()
+ {
+ Id = installId,
+ ManifestId = installId,
+ DisplayName = "Command & Conquer: Zero Hour",
+ ContentType = ContentType.GameInstallation,
+ GameType = GameType.ZeroHour,
+ },
+ new()
+ {
+ Id = mapId,
+ ManifestId = mapId,
+ DisplayName = "Tournament Desert",
+ ContentType = ContentType.Map,
+ GameType = GameType.ZeroHour,
+ },
+ };
+
+ _contentLoaderMock.Setup(c => c.LoadEnabledContentForProfileAsync(profile))
+ .ReturnsAsync(enabledItems);
+ _contentLoaderMock.Setup(c => c.LoadAvailableGameInstallationsAsync())
+ .ReturnsAsync([]);
+ _contentLoaderMock.Setup(c => c.LoadAvailableContentAsync(It.IsAny(), It.IsAny>(), It.IsAny>()))
+ .ReturnsAsync([]);
+ _manifestPoolMock.Setup(m => m.GetAllManifestsAsync(It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([]));
+
+ // Setup install manifest resolution success, but map manifest resolution failure
+ var installManifest = new ContentManifest
+ {
+ Id = ManifestId.Create(installId),
+ Name = "Zero Hour",
+ ContentType = ContentType.GameInstallation,
+ };
+
+ _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(installId), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(installManifest));
+ _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(mapId), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateFailure("Manifest not found in pool"));
+
+ await _viewModel.InitializeForProfileAsync(profileId);
+ _gameProfileManagerMock.Invocations.Clear();
+
+ // Act
+ await _viewModel.SaveCommand.ExecuteAsync(null);
+
+ // Assert
+ Assert.Contains("failed to resolve manifests", _viewModel.StatusMessage, StringComparison.OrdinalIgnoreCase);
+ _profileContentLinkerMock.Verify(
+ p => p.UpdateProfileUserDataAsync(
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Never);
+ _gameProfileManagerMock.Verify(
+ m => m.UpdateProfileAsync(It.IsAny(), It.IsAny(), It.IsAny()),
+ Times.Never);
+ }
+
+ ///
+ /// Verifies that SaveAsync rolls back live content sync to original manifests if profile persistence fails.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task SaveAsync_WhenProfileUpdateFailsAfterLiveSync_RollsBackLiveSyncToOriginalManifestsAsync()
+ {
+ // Arrange
+ const string profileId = "profile-live-5";
+ const string installId = "1.108.steam.gameinstallation.zh";
+ const string originalMapId = "1.0.0.map.desert";
+
+ var profile = new GameProfile
+ {
+ Id = profileId,
+ Name = "Live Profile",
+ EnabledContentIds = [installId, originalMapId],
+ GameClient = new GameClient
+ {
+ Id = "client-zh",
+ Name = "Zero Hour",
+ GameType = GameType.ZeroHour,
+ },
+ };
+
+ _gameProfileManagerMock.Setup(m => m.GetProfileAsync(profileId, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+ _gameProfileManagerMock.Setup(m => m.UpdateProfileAsync(profileId, It.IsAny(), It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateFailure("Database lock failure"));
+
+ _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync())
+ .ReturnsAsync([CreateActiveLaunch(profileId)]);
+
+ var enabledItems = new ObservableCollection
+ {
+ new()
+ {
+ Id = installId,
+ ManifestId = installId,
+ DisplayName = "Command & Conquer: Zero Hour",
+ ContentType = ContentType.GameInstallation,
+ GameType = GameType.ZeroHour,
+ },
+ new()
+ {
+ Id = originalMapId,
+ ManifestId = originalMapId,
+ DisplayName = "Tournament Desert",
+ ContentType = ContentType.Map,
+ GameType = GameType.ZeroHour,
+ },
+ };
+
+ _contentLoaderMock.Setup(c => c.LoadEnabledContentForProfileAsync(profile))
+ .ReturnsAsync(enabledItems);
+ _contentLoaderMock.Setup(c => c.LoadAvailableGameInstallationsAsync())
+ .ReturnsAsync([]);
+ _contentLoaderMock.Setup(c => c.LoadAvailableContentAsync(It.IsAny(), It.IsAny>(), It.IsAny>()))
+ .ReturnsAsync([]);
+ _manifestPoolMock.Setup(m => m.GetAllManifestsAsync(It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([]));
+
+ var mapManifest = new ContentManifest
+ {
+ Id = ManifestId.Create(originalMapId),
+ Name = "Tournament Desert",
+ ContentType = ContentType.Map,
+ };
+ var installManifest = new ContentManifest
+ {
+ Id = ManifestId.Create(installId),
+ Name = "Zero Hour",
+ ContentType = ContentType.GameInstallation,
+ };
+
+ _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(originalMapId), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(mapManifest));
+ _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(installId), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(installManifest));
+
+ _profileContentLinkerMock.Setup(p => p.UpdateProfileUserDataAsync(
+ profileId,
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(true));
+
+ await _viewModel.InitializeForProfileAsync(profileId);
+ _gameProfileManagerMock.Invocations.Clear();
+
+ // Simulate user disabling the map during active session
+ var mapItem = _viewModel.EnabledContent.First(i => i.ManifestId.Value == originalMapId);
+ _viewModel.EnabledContent.Remove(mapItem);
+
+ // Act
+ await _viewModel.SaveCommand.ExecuteAsync(null);
+
+ // Assert
+ Assert.Contains("Failed to update profile", _viewModel.StatusMessage, StringComparison.OrdinalIgnoreCase);
+
+ // First call: forward live update with new enabled content (map removed)
+ _profileContentLinkerMock.Verify(
+ p => p.UpdateProfileUserDataAsync(
+ profileId,
+ It.Is>(m => m.Count() == 1 && m.Any(x => x.Id.Value == installId)),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Once);
+
+ // Second call: rollback live update with original enabled content (map restored)
+ _profileContentLinkerMock.Verify(
+ p => p.UpdateProfileUserDataAsync(
+ profileId,
+ It.Is>(m => m.Count() == 2 && m.Any(x => x.Id.Value == originalMapId)),
+ It.IsAny(),
+ It.IsAny()),
+ Times.Once);
+ }
+
+ ///
+ /// Verifies that SaveAsync notifies user with an error when rollback live synchronization fails.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task SaveAsync_WhenLiveSyncRollbackFails_ShowsErrorNotificationAsync()
+ {
+ // Arrange
+ const string profileId = "profile-live-6";
+ const string installId = "1.108.steam.gameinstallation.zh";
+ const string originalMapId = "1.0.0.map.desert";
+
+ var profile = new GameProfile
+ {
+ Id = profileId,
+ Name = "Live Profile",
+ EnabledContentIds = [installId, originalMapId],
+ GameClient = new GameClient
+ {
+ Id = "client-zh",
+ Name = "Zero Hour",
+ GameType = GameType.ZeroHour,
+ },
+ };
+
+ _gameProfileManagerMock.Setup(m => m.GetProfileAsync(profileId, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+ _gameProfileManagerMock.Setup(m => m.UpdateProfileAsync(profileId, It.IsAny(), It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateFailure("Database lock failure"));
+
+ _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync())
+ .ReturnsAsync([CreateActiveLaunch(profileId)]);
+
+ var enabledItems = new ObservableCollection
+ {
+ new()
+ {
+ Id = installId,
+ ManifestId = installId,
+ DisplayName = "Command & Conquer: Zero Hour",
+ ContentType = ContentType.GameInstallation,
+ GameType = GameType.ZeroHour,
+ },
+ new()
+ {
+ Id = originalMapId,
+ ManifestId = originalMapId,
+ DisplayName = "Tournament Desert",
+ ContentType = ContentType.Map,
+ GameType = GameType.ZeroHour,
+ },
+ };
+
+ _contentLoaderMock.Setup(c => c.LoadEnabledContentForProfileAsync(profile))
+ .ReturnsAsync(enabledItems);
+ _contentLoaderMock.Setup(c => c.LoadAvailableGameInstallationsAsync())
+ .ReturnsAsync([]);
+ _contentLoaderMock.Setup(c => c.LoadAvailableContentAsync(It.IsAny(), It.IsAny>(), It.IsAny>()))
+ .ReturnsAsync([]);
+ _manifestPoolMock.Setup(m => m.GetAllManifestsAsync(It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([]));
+
+ var mapManifest = new ContentManifest
+ {
+ Id = ManifestId.Create(originalMapId),
+ Name = "Tournament Desert",
+ ContentType = ContentType.Map,
+ };
+ var installManifest = new ContentManifest
+ {
+ Id = ManifestId.Create(installId),
+ Name = "Zero Hour",
+ ContentType = ContentType.GameInstallation,
+ };
+
+ _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(originalMapId), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(mapManifest));
+ _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(installId), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(installManifest));
+
+ int callCount = 0;
+ _profileContentLinkerMock.Setup(p => p.UpdateProfileUserDataAsync(
+ profileId,
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(() =>
+ {
+ callCount++;
+ return callCount == 1
+ ? OperationResult.CreateSuccess(true)
+ : OperationResult.CreateFailure("Rollback disk IO error");
+ });
+
+ await _viewModel.InitializeForProfileAsync(profileId);
+ _gameProfileManagerMock.Invocations.Clear();
+
+ // Act
+ await _viewModel.SaveCommand.ExecuteAsync(null);
+
+ // Assert
+ Assert.Contains("Failed to update profile", _viewModel.StatusMessage, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("Live rollback failed: Rollback disk IO error", _viewModel.StatusMessage, StringComparison.OrdinalIgnoreCase);
+ }
+
+ ///
+ /// Verifies that enabling a hotswappable map pack during hotswap mode succeeds without triggering locked installation errors.
+ ///
+ /// A task representing the asynchronous test.
+ [Fact]
+ public async Task EnableContent_DuringHotswap_EnablesHotswappableMapPackWithoutAttemptingToModifyLockedInstallationAsync()
+ {
+ // Arrange
+ const string profileId = "profile-live-hotswap";
+ const string installId = "1.104.steam.gameinstallation.zerohour";
+ const string mapPackId = "1.813262.generalsonline.mappack.quickmatchmaps";
+
+ var profile = new GameProfile
+ {
+ Id = profileId,
+ Name = "GeneralsOnline 60Hz",
+ EnabledContentIds = [installId],
+ GameInstallationId = "steam_zh",
+ GameClient = new GameClient
+ {
+ Id = "client-60hz",
+ Name = "GeneralsOnline 60Hz",
+ GameType = GameType.ZeroHour,
+ },
+ };
+
+ _gameProfileManagerMock.Setup(m => m.GetProfileAsync(profileId, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+ _gameProfileManagerMock.Setup(m => m.UpdateProfileAsync(profileId, It.IsAny(), It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+ _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync())
+ .ReturnsAsync([CreateActiveLaunch(profileId)]);
+
+ var installItem = new CoreContentDisplayItem
+ {
+ Id = installId,
+ ManifestId = installId,
+ DisplayName = "Zero Hour v1.04",
+ ContentType = ContentType.GameInstallation,
+ GameType = GameType.ZeroHour,
+ InstallationType = GameInstallationType.Steam,
+ IsEnabled = true,
+ };
+
+ _contentLoaderMock.Setup(c => c.LoadEnabledContentForProfileAsync(profile))
+ .ReturnsAsync([installItem]);
+ _contentLoaderMock.Setup(c => c.LoadAvailableGameInstallationsAsync())
+ .ReturnsAsync([installItem]);
+ _contentLoaderMock.Setup(c => c.LoadAvailableContentAsync(It.IsAny(), It.IsAny>(), It.IsAny>()))
+ .ReturnsAsync([]);
+ _manifestPoolMock.Setup(m => m.GetAllManifestsAsync(It.IsAny()))
+ .ReturnsAsync(OperationResult>.CreateSuccess([]));
+
+ var mapPackManifest = new ContentManifest
+ {
+ Id = ManifestId.Create(mapPackId),
+ Name = "GeneralsOnline QuickMatch Maps",
+ ContentType = ContentType.MapPack,
+ TargetGame = GameType.ZeroHour,
+ Dependencies =
+ [
+ new()
+ {
+ DependencyType = ContentType.GameInstallation,
+ CompatibleGameTypes = [GameType.ZeroHour],
+ },
+ ],
+ };
+
+ _manifestPoolMock.Setup(m => m.GetManifestAsync(ManifestId.Create(mapPackId), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(mapPackManifest));
+
+ await _viewModel.InitializeForProfileAsync(profileId);
+
+ var mapPackVmItem = new ContentDisplayItem
+ {
+ ManifestId = ManifestId.Create(mapPackId),
+ DisplayName = "GeneralsOnline QuickMatch Maps",
+ ContentType = ContentType.MapPack,
+ GameType = GameType.ZeroHour,
+ InstallationType = GameInstallationType.Steam,
+ IsEnabled = false,
+ CanToggle = true,
+ IsLocked = false,
+ };
+ _viewModel.AvailableContent.Add(mapPackVmItem);
+
+ // Act
+ await _viewModel.EnableContentCommand.ExecuteAsync(mapPackVmItem);
+
+ // Assert
+ Assert.True(_viewModel.IsHotswapMode);
+ Assert.Contains(_viewModel.EnabledContent, c => c.ManifestId.Value == mapPackId);
+ Assert.True(_viewModel.EnabledContent.First(c => c.ManifestId.Value == mapPackId).IsEnabled);
+ Assert.Equal(installId, _viewModel.SelectedGameInstallation?.ManifestId.Value);
+ }
+
+ private static GameLaunchInfo CreateActiveLaunch(string profileId, string launchId = "launch-1", string workspaceId = "ws-1") => new()
+ {
+ LaunchId = launchId,
+ ProfileId = profileId,
+ WorkspaceId = workspaceId,
+ ProcessInfo = new GameProcessInfo
+ {
+ ProcessId = 1234,
+ ProcessName = "generals.exe",
+ StartTime = DateTime.UtcNow,
+ },
+ };
+}
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs
index d178079dd..65eb892a0 100644
--- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Launching/GameLauncherTests.cs
@@ -123,6 +123,8 @@ public GameLauncherTests()
_profileContentLinkerMock.Setup(x => x.GetActiveProfileId())
.Returns((string?)null);
+ _profileContentLinkerMock.Setup(x => x.GetActiveProfileId(It.IsAny()))
+ .Returns((string?)null);
// Setup dependency resolver mock - returns resolved manifests including dependencies
_dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync(
@@ -333,6 +335,59 @@ public async Task LaunchProfileAsync_WithProcessStartFailure_ShouldFailAsync()
Assert.Contains("Process start failed", result.FirstError);
}
+ ///
+ /// Launches a profile asynchronously and asserts failure when user data preparation fails.
+ ///
+ /// The async task.
+ [Fact]
+ public async Task LaunchProfileAsync_WhenUserDataPreparationFails_ShouldFailAndUnregisterLaunchAsync()
+ {
+ // Arrange
+ var profile = CreateTestProfile();
+ var workspaceInfo = new WorkspaceInfo
+ {
+ Id = profile.Id,
+ WorkspacePath = @"C:\workspace",
+ ExecutablePath = @"C:\workspace\generals.exe",
+ };
+ var manifest = new ContentManifest { Id = "1.0.genhub.mod.test", Name = "Test Content" };
+
+ _profileManagerMock.Setup(x => x.GetProfileAsync(profile.Id, It.IsAny()))
+ .ReturnsAsync(ProfileOperationResult.CreateSuccess(profile));
+
+ _manifestPoolMock.Setup(x => x.GetManifestAsync("1.0.genhub.mod.test", It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(manifest));
+
+ _dependencyResolverMock.Setup(x => x.ResolveDependenciesWithManifestsAsync(
+ It.Is>(ids => ids.SequenceEqual(TestContentIds)),
+ It.IsAny()))
+ .ReturnsAsync(DependencyResolutionResult.CreateSuccess(
+ TestContentIds,
+ [manifest],
+ []));
+
+ _workspaceManagerMock.Setup(x => x.PrepareWorkspaceAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateSuccess(workspaceInfo));
+
+ _profileContentLinkerMock.Setup(x => x.SwitchProfileUserDataAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(OperationResult.CreateFailure("User data preparation failed due to locked files"));
+
+ // Act
+ var result = await _gameLauncher.LaunchProfileAsync(profile.Id);
+
+ // Assert
+ Assert.False(result.Success);
+ Assert.Contains("User data preparation failed due to locked files", result.FirstError);
+ _launchRegistryMock.Verify(x => x.UnregisterLaunchAsync(It.IsAny()), Times.Once);
+ _processManagerMock.Verify(x => x.StartProcessAsync(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
///
/// Terminates a game asynchronously with a valid launch ID and asserts success.
///
diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationServiceHotswapTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationServiceHotswapTests.cs
new file mode 100644
index 000000000..de0f80efe
--- /dev/null
+++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Reconciliation/ContentReconciliationServiceHotswapTests.cs
@@ -0,0 +1,363 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using GenHub.Core.Interfaces.GameProfiles;
+using GenHub.Core.Interfaces.Launching;
+using GenHub.Core.Interfaces.Manifest;
+using GenHub.Core.Interfaces.Storage;
+using GenHub.Core.Interfaces.Workspace;
+using GenHub.Core.Models.GameProfile;
+using GenHub.Core.Models.Launching;
+using GenHub.Core.Models.Manifest;
+using GenHub.Core.Models.Results;
+using GenHub.Features.Content.Services;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace GenHub.Tests.Core.Features.Reconciliation;
+
+///
+/// Unit tests verifying that guards running profiles from workspace cleanup.
+///
+public class ContentReconciliationServiceHotswapTests
+{
+ private readonly Mock _profileManagerMock = new();
+ private readonly Mock _workspaceManagerMock = new();
+ private readonly Mock _manifestPoolMock = new();
+ private readonly Mock _casReferenceTrackerMock = new();
+ private readonly Mock _casLifecycleManagerMock = new();
+ private readonly Mock _launchRegistryMock = new();
+ private readonly Mock