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> _loggerMock = new(); + private readonly ContentReconciliationService _reconciliationService; + + /// + /// Initializes a new instance of the class. + /// + public ContentReconciliationServiceHotswapTests() + { + _profileManagerMock.Setup(p => p.GetProfileAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string id, CancellationToken _) => ProfileOperationResult.CreateSuccess(new GameProfile { Id = id })); + + _reconciliationService = new ContentReconciliationService( + _profileManagerMock.Object, + _workspaceManagerMock.Object, + _manifestPoolMock.Object, + _casReferenceTrackerMock.Object, + _casLifecycleManagerMock.Object, + _loggerMock.Object, + _launchRegistryMock.Object); + } + + /// + /// Verifies that ReconcileBulkManifestReplacementAsync skips replacing manifests in running profiles and preserves workspace. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ReconcileBulkManifestReplacementAsync_WhenProfileRunning_SkipsReplacementAndPreservesWorkspaceAsync() + { + // Arrange + const string runningProfileId = "running-profile-1"; + const string oldManifestId = "1.0.0.mod.oldmod"; + const string newManifestId = "1.0.0.mod.newmod"; + + var runningProfile = new GameProfile + { + Id = runningProfileId, + Name = "Running Profile", + ActiveWorkspaceId = "workspace-live-1", + EnabledContentIds = [oldManifestId], + }; + + var newManifest = new ContentManifest + { + Id = ManifestId.Create(newManifestId), + Name = "Updated Content", + }; + + _profileManagerMock.Setup(p => p.GetAllProfilesAsync(It.IsAny())) + .ReturnsAsync(ProfileOperationResult>.CreateSuccess([runningProfile])); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([CreateActiveLaunch(runningProfileId)]); + + _casReferenceTrackerMock.Setup(c => c.TrackManifestReferencesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess()); + + var replacements = new Dictionary + { + { oldManifestId, newManifest }, + }; + + // Act + var result = await _reconciliationService.ReconcileBulkManifestReplacementAsync(replacements); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal(0, result.Data.ProfilesUpdated); + Assert.Equal(1, result.Data.FailedProfilesCount); + _workspaceManagerMock.Verify(w => w.CleanupWorkspaceAsync(It.IsAny(), It.IsAny()), Times.Never); + _profileManagerMock.Verify( + p => p.UpdateProfileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + /// + /// Verifies that OrchestrateBulkRemovalAsync protects manifests from removal when active profiles are running. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task OrchestrateBulkRemovalAsync_WhenProfileRunning_ProtectsManifestFromRemovalAsync() + { + // Arrange + const string runningProfileId = "running-profile-2"; + const string manifestId = "1.0.0.mod.deletedmod"; + + var runningProfile = new GameProfile + { + Id = runningProfileId, + Name = "Running Profile", + ActiveWorkspaceId = "workspace-live-2", + EnabledContentIds = [manifestId], + }; + + _profileManagerMock.Setup(p => p.GetAllProfilesAsync(It.IsAny())) + .ReturnsAsync(ProfileOperationResult>.CreateSuccess([runningProfile])); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([CreateActiveLaunch(runningProfileId)]); + + _casReferenceTrackerMock.Setup(c => c.UntrackManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess()); + _manifestPoolMock.Setup(m => m.RemoveManifestAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var result = await _reconciliationService.OrchestrateBulkRemovalAsync([ManifestId.Create(manifestId)]); + + // Assert + Assert.False(result.Success); + Assert.Contains("active or unreconciled profiles", result.FirstError, StringComparison.OrdinalIgnoreCase); + _workspaceManagerMock.Verify(w => w.CleanupWorkspaceAsync(It.IsAny(), It.IsAny()), Times.Never); + _manifestPoolMock.Verify(m => m.RemoveManifestAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _casReferenceTrackerMock.Verify(c => c.UntrackManifestAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies that ReconcileManifestRemovalAsync returns failure and does not untrack CAS references when an active profile references the manifest. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ReconcileManifestRemovalAsync_WhenProfileRunning_ReturnsFailureAndProtectsManifestAsync() + { + // Arrange + const string runningProfileId = "running-profile-3"; + const string manifestId = "1.0.0.mod.runningmod"; + + var runningProfile = new GameProfile + { + Id = runningProfileId, + Name = "Running Profile 3", + ActiveWorkspaceId = "workspace-live-3", + EnabledContentIds = [manifestId], + }; + + _profileManagerMock.Setup(p => p.GetAllProfilesAsync(It.IsAny())) + .ReturnsAsync(ProfileOperationResult>.CreateSuccess([runningProfile])); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([CreateActiveLaunch(runningProfileId)]); + + // Act + var result = await _reconciliationService.ReconcileManifestRemovalAsync(ManifestId.Create(manifestId)); + + // Assert + Assert.False(result.Success); + Assert.Contains("active or failed reconciliation", result.FirstError, StringComparison.OrdinalIgnoreCase); + _workspaceManagerMock.Verify(w => w.CleanupWorkspaceAsync(It.IsAny(), It.IsAny()), Times.Never); + _casReferenceTrackerMock.Verify(c => c.UntrackManifestAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies that OrchestrateBulkRemovalAsync with mixed running and idle profiles updates the idle profile, + /// skips workspace cleanup for the running profile, and protects the manifest from removal. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task OrchestrateBulkRemovalAsync_WithMixedRunningAndIdleProfiles_UpdatesIdleProfileAndProtectsManifestFromRemovalAsync() + { + // Arrange + const string runningProfileId = "running-profile-mixed"; + const string idleProfileId = "idle-profile-mixed"; + const string manifestId = "1.0.0.mod.mixedmod"; + + var runningProfile = new GameProfile + { + Id = runningProfileId, + Name = "Running Profile", + ActiveWorkspaceId = "workspace-running", + EnabledContentIds = [manifestId], + }; + + var idleProfile = new GameProfile + { + Id = idleProfileId, + Name = "Idle Profile", + ActiveWorkspaceId = "workspace-idle", + EnabledContentIds = [manifestId], + }; + + _profileManagerMock.Setup(p => p.GetAllProfilesAsync(It.IsAny())) + .ReturnsAsync(ProfileOperationResult>.CreateSuccess([runningProfile, idleProfile])); + _profileManagerMock.Setup(p => p.UpdateProfileAsync(idleProfileId, It.IsAny(), It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(idleProfile)); + + _workspaceManagerMock.Setup(w => w.CleanupWorkspaceAsync("workspace-idle", It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([CreateActiveLaunch(runningProfileId)]); + + _casReferenceTrackerMock.Setup(c => c.UntrackManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess()); + _manifestPoolMock.Setup(m => m.RemoveManifestAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var result = await _reconciliationService.OrchestrateBulkRemovalAsync([ManifestId.Create(manifestId)]); + + // Assert + Assert.False(result.Success); + Assert.Contains("active or unreconciled profiles", result.FirstError, StringComparison.OrdinalIgnoreCase); + + // Idle profile's workspace cleaned up and profile updated + _workspaceManagerMock.Verify(w => w.CleanupWorkspaceAsync("workspace-idle", It.IsAny()), Times.Once); + _workspaceManagerMock.Verify(w => w.CleanupWorkspaceAsync("workspace-running", It.IsAny()), Times.Never); + + // Manifest must NOT be untracked or removed because the running profile still references it + _manifestPoolMock.Verify(m => m.RemoveManifestAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _casReferenceTrackerMock.Verify(c => c.UntrackManifestAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies that terminated launches are ignored and their profiles are treated as idle. + /// + /// A task representing the test operation. + [Fact] + public async Task OrchestrateBulkRemovalAsync_WhenLaunchIsTerminated_TreatsProfileAsIdleAndCleansUpWorkspaceAsync() + { + // Arrange + const string manifestId = "1.0.0.mod.old"; + var terminatedProfile = new GameProfile + { + Id = "profile-terminated", + Name = "Terminated Profile", + ActiveWorkspaceId = "workspace-term", + EnabledContentIds = [manifestId], + }; + + _profileManagerMock.Setup(p => p.GetAllProfilesAsync(It.IsAny())) + .ReturnsAsync(ProfileOperationResult>.CreateSuccess([terminatedProfile])); + + var terminatedLaunch = CreateActiveLaunch(terminatedProfile.Id, "launch-term", "workspace-term"); + terminatedLaunch.TerminatedAt = DateTime.UtcNow.AddMinutes(-5); + + _launchRegistryMock.Setup(l => l.GetAllActiveLaunchesAsync()) + .ReturnsAsync([terminatedLaunch]); + + _workspaceManagerMock.Setup(w => w.CleanupWorkspaceAsync("workspace-term", It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + _profileManagerMock.Setup(p => p.UpdateProfileAsync(terminatedProfile.Id, It.IsAny(), It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(terminatedProfile)); + + _casReferenceTrackerMock.Setup(c => c.UntrackManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess()); + _manifestPoolMock.Setup(m => m.RemoveManifestAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var result = await _reconciliationService.OrchestrateBulkRemovalAsync([ManifestId.Create(manifestId)]); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal(1, result.Data.ProfilesUpdated); + Assert.Equal(1, result.Data.WorkspacesInvalidated); + Assert.Equal(0, result.Data.FailedProfilesCount); + + _workspaceManagerMock.Verify(w => w.CleanupWorkspaceAsync("workspace-term", It.IsAny()), Times.Once); + _manifestPoolMock.Verify(m => m.RemoveManifestAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + /// + /// Verifies that reconciliation succeeds when launch registry is null. + /// + /// A task representing the test operation. + [Fact] + public async Task OrchestrateBulkRemovalAsync_WhenLaunchRegistryIsNull_ReconcilesSuccessfullyAsync() + { + // Arrange + const string manifestId = "1.0.0.mod.old"; + var idleProfile = new GameProfile + { + Id = "profile-null-reg", + Name = "Null Registry Profile", + ActiveWorkspaceId = "workspace-null-reg", + EnabledContentIds = [manifestId], + }; + + var serviceWithoutRegistry = new ContentReconciliationService( + _profileManagerMock.Object, + _workspaceManagerMock.Object, + _manifestPoolMock.Object, + _casReferenceTrackerMock.Object, + _casLifecycleManagerMock.Object, + _loggerMock.Object, + launchRegistry: null); + + _profileManagerMock.Setup(p => p.GetAllProfilesAsync(It.IsAny())) + .ReturnsAsync(ProfileOperationResult>.CreateSuccess([idleProfile])); + + _workspaceManagerMock.Setup(w => w.CleanupWorkspaceAsync("workspace-null-reg", It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + _profileManagerMock.Setup(p => p.UpdateProfileAsync(idleProfile.Id, It.IsAny(), It.IsAny())) + .ReturnsAsync(ProfileOperationResult.CreateSuccess(idleProfile)); + + _casReferenceTrackerMock.Setup(c => c.UntrackManifestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess()); + _manifestPoolMock.Setup(m => m.RemoveManifestAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var result = await serviceWithoutRegistry.OrchestrateBulkRemovalAsync([ManifestId.Create(manifestId)]); + + // Assert + Assert.True(result.Success); + Assert.NotNull(result.Data); + Assert.Equal(1, result.Data.ProfilesUpdated); + Assert.Equal(1, result.Data.WorkspacesInvalidated); + Assert.Equal(0, result.Data.FailedProfilesCount); + + _workspaceManagerMock.Verify(w => w.CleanupWorkspaceAsync("workspace-null-reg", It.IsAny()), Times.Once); + } + + 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/UserData/ProfileContentLinkerServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/ProfileContentLinkerServiceTests.cs new file mode 100644 index 000000000..21c7dbc1a --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/ProfileContentLinkerServiceTests.cs @@ -0,0 +1,629 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenHub.Core.Interfaces.UserData; +using GenHub.Core.Models.Enums; +using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; +using GenHub.Core.Models.UserData; +using GenHub.Features.UserData.Services; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.UserData; + +/// +/// Contains unit tests for . +/// +public sealed class ProfileContentLinkerServiceTests : IDisposable +{ + private readonly Mock _userDataTrackerMock = new(); + private readonly Mock> _loggerMock = new(); + private readonly ProfileContentLinkerService _linkerService; + + /// + /// Initializes a new instance of the class. + /// + public ProfileContentLinkerServiceTests() + { + ProfileContentLinkerService.ResetActiveProfilesForTesting(); + _linkerService = new ProfileContentLinkerService( + _userDataTrackerMock.Object, + _loggerMock.Object); + } + + /// + public void Dispose() + { + ProfileContentLinkerService.ResetActiveProfilesForTesting(); + } + + /// + /// Verifies that SwitchProfileUserDataAsync deactivates lingering active user data from other profiles even when oldProfileId is null (e.g. after GenHub/game crash). + /// + /// A task representing the asynchronous test. + [Fact] + public async Task SwitchProfileUserDataAsync_WhenOldProfileIsNull_DeactivatesLingeringActiveUserDataFromOtherProfilesAsync() + { + // Arrange + const string newProfileId = "profile-new"; + const string lingeringProfileId = "profile-crashed"; + const GameType gameType = GameType.ZeroHour; + + var lingeringManifest = new UserDataManifest + { + ManifestId = "1.0.0.patch.crashed", + ProfileId = lingeringProfileId, + TargetGame = gameType, + IsActive = true, + InstalledFiles = [new UserDataFileEntry { AbsolutePath = "C:\\path\\GameData.ini", RelativePath = "Data\\INI\\GameData.ini", InstallTarget = ContentInstallTarget.UserDataDirectory }], + }; + + _userDataTrackerMock.Setup(t => t.GetGameUserDataAsync(gameType, It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([lingeringManifest])); + + _userDataTrackerMock.Setup(t => t.DeactivateProfileUserDataAsync(lingeringProfileId, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + _userDataTrackerMock.Setup(t => t.ActivateProfileUserDataAsync(newProfileId, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var newManifest = new ContentManifest + { + Id = ManifestId.Create("1.0.0.map.desert"), + Name = "Desert Map", + ContentType = ContentType.Map, + Files = [new ManifestFile { RelativePath = "Maps\\Desert.map", InstallTarget = ContentInstallTarget.UserMapsDirectory, Hash = "hash-1" }], + }; + + _userDataTrackerMock.Setup(t => t.GetUserDataManifestAsync(newManifest.Id.Value, newProfileId, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(null)); + + _userDataTrackerMock.Setup(t => t.InstallUserDataAsync( + newManifest.Id.Value, + newProfileId, + gameType, + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(new UserDataManifest { ManifestId = newManifest.Id.Value, ProfileId = newProfileId })); + + // Act + var result = await _linkerService.SwitchProfileUserDataAsync( + oldProfileId: null, + newProfileId: newProfileId, + newManifests: [newManifest], + targetGame: gameType); + + // Assert + Assert.True(result.Success); + _userDataTrackerMock.Verify(t => t.DeactivateProfileUserDataAsync(lingeringProfileId, It.IsAny()), Times.Once); + _userDataTrackerMock.Verify(t => t.ActivateProfileUserDataAsync(newProfileId, It.IsAny()), Times.Once); + } + + /// + /// Verifies that SwitchProfileUserDataAsync returns failure when adopting a manifest from old profile fails during skipCleanup. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task SwitchProfileUserDataAsync_WhenAdoptionInstallFails_ReturnsFailureAsync() + { + // Arrange + const string oldProfileId = "profile-old"; + const string newProfileId = "profile-new"; + const GameType gameType = GameType.ZeroHour; + + var oldManifest = new UserDataManifest + { + ManifestId = "1.0.0.map.desert", + ProfileId = oldProfileId, + TargetGame = gameType, + InstalledFiles = [new UserDataFileEntry { AbsolutePath = "C:\\path\\Desert.map", RelativePath = "Maps\\Desert.map", InstallTarget = ContentInstallTarget.UserMapsDirectory }], + }; + + _userDataTrackerMock.Setup(t => t.GetProfileUserDataAsync(oldProfileId, It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([oldManifest])); + + _userDataTrackerMock.Setup(t => t.InstallUserDataAsync( + oldManifest.ManifestId, + newProfileId, + gameType, + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("Adoption disk error")); + + // Act + var result = await _linkerService.SwitchProfileUserDataAsync( + oldProfileId: oldProfileId, + newProfileId: newProfileId, + newManifests: [], + targetGame: gameType, + skipCleanup: true); + + // Assert + Assert.False(result.Success); + Assert.Contains("Adoption disk error", result.FirstError, StringComparison.OrdinalIgnoreCase); + _userDataTrackerMock.Verify(t => t.ActivateProfileUserDataAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + /// + /// Verifies that SwitchProfileUserDataAsync adopts only manifests matching targetGame or GameType.Unknown during skipCleanup. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task SwitchProfileUserDataAsync_WhenSkipCleanupTrue_AdoptsOnlyManifestsMatchingTargetGameOrUnknownAsync() + { + // Arrange + const string oldProfileId = "profile-old"; + const string newProfileId = "profile-new"; + const GameType gameType = GameType.ZeroHour; + + var matchingManifest = new UserDataManifest + { + ManifestId = "1.0.0.map.zh-map", + ProfileId = oldProfileId, + TargetGame = GameType.ZeroHour, + InstalledFiles = [new UserDataFileEntry { AbsolutePath = "C:\\path\\ZH.map", RelativePath = "Maps\\ZH.map", InstallTarget = ContentInstallTarget.UserMapsDirectory }], + }; + + var otherGameManifest = new UserDataManifest + { + ManifestId = "1.0.0.map.gen-map", + ProfileId = oldProfileId, + TargetGame = GameType.Generals, + InstalledFiles = [new UserDataFileEntry { AbsolutePath = "C:\\path\\Gen.map", RelativePath = "Maps\\Gen.map", InstallTarget = ContentInstallTarget.UserMapsDirectory }], + }; + + var unknownGameManifest = new UserDataManifest + { + ManifestId = "1.0.0.map.unknown-map", + ProfileId = oldProfileId, + TargetGame = GameType.Unknown, + InstalledFiles = [new UserDataFileEntry { AbsolutePath = "C:\\path\\Unknown.map", RelativePath = "Maps\\Unknown.map", InstallTarget = ContentInstallTarget.UserMapsDirectory }], + }; + + _userDataTrackerMock.Setup(t => t.GetProfileUserDataAsync(oldProfileId, It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([matchingManifest, otherGameManifest, unknownGameManifest])); + + _userDataTrackerMock.Setup(t => t.InstallUserDataAsync( + It.IsAny(), + newProfileId, + gameType, + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((string manifestId, string profileId, GameType game, IEnumerable _, string _, string _, CancellationToken _) => + OperationResult.CreateSuccess(new UserDataManifest { ManifestId = manifestId, ProfileId = profileId, TargetGame = game })); + + _userDataTrackerMock.Setup(t => t.GetGameUserDataAsync(gameType, It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([])); + + // Act + var result = await _linkerService.SwitchProfileUserDataAsync( + oldProfileId: oldProfileId, + newProfileId: newProfileId, + newManifests: [], + targetGame: gameType, + skipCleanup: true); + + // Assert + Assert.True(result.Success); + _userDataTrackerMock.Verify( + t => t.InstallUserDataAsync( + "1.0.0.map.zh-map", + newProfileId, + gameType, + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); + + _userDataTrackerMock.Verify( + t => t.InstallUserDataAsync( + "1.0.0.map.unknown-map", + newProfileId, + gameType, + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); + + _userDataTrackerMock.Verify( + t => t.InstallUserDataAsync( + "1.0.0.map.gen-map", + It.IsAny(), + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + /// + /// Verifies that PrepareProfileUserDataAsync cleans up any lingering active user data from other profiles before activating the target profile. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task PrepareProfileUserDataAsync_DeactivatesLingeringActiveUserDataFromOtherProfilesAsync() + { + // Arrange + const string targetProfileId = "profile-target"; + const string lingeringProfileId = "profile-lingering"; + const GameType gameType = GameType.ZeroHour; + + var lingeringManifest = new UserDataManifest + { + ManifestId = "1.0.0.patch.old", + ProfileId = lingeringProfileId, + TargetGame = gameType, + IsActive = true, + InstalledFiles = [new UserDataFileEntry { AbsolutePath = "C:\\path\\GameData.ini", RelativePath = "Data\\INI\\GameData.ini", InstallTarget = ContentInstallTarget.UserDataDirectory }], + }; + + _userDataTrackerMock.Setup(t => t.GetGameUserDataAsync(gameType, It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([lingeringManifest])); + + _userDataTrackerMock.Setup(t => t.DeactivateProfileUserDataAsync(lingeringProfileId, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + _userDataTrackerMock.Setup(t => t.ActivateProfileUserDataAsync(targetProfileId, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.0.0.map.desert"), + Name = "Desert Map", + ContentType = ContentType.Map, + Files = [new ManifestFile { RelativePath = "Maps\\Desert.map", InstallTarget = ContentInstallTarget.UserMapsDirectory, Hash = "hash-1" }], + }; + + _userDataTrackerMock.Setup(t => t.GetUserDataManifestAsync(manifest.Id.Value, targetProfileId, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(null)); + + _userDataTrackerMock.Setup(t => t.InstallUserDataAsync( + manifest.Id.Value, + targetProfileId, + gameType, + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(new UserDataManifest { ManifestId = manifest.Id.Value, ProfileId = targetProfileId })); + + // Act + var result = await _linkerService.PrepareProfileUserDataAsync(targetProfileId, [manifest], gameType); + + // Assert + Assert.True(result.Success); + _userDataTrackerMock.Verify(t => t.DeactivateProfileUserDataAsync(lingeringProfileId, It.IsAny()), Times.Once); + _userDataTrackerMock.Verify(t => t.ActivateProfileUserDataAsync(targetProfileId, It.IsAny()), Times.Once); + } + + /// + /// Verifies that UpdateProfileUserDataAsync returns failure when activation fails. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task UpdateProfileUserDataAsync_WhenActivationFails_ReturnsFailureAsync() + { + // Arrange + const string profileId = "profile-live"; + const GameType gameType = GameType.ZeroHour; + + _userDataTrackerMock.Setup(t => t.GetGameUserDataAsync(gameType, It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([])); + + _userDataTrackerMock.Setup(t => t.GetProfileUserDataAsync(profileId, It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([])); + + _userDataTrackerMock.Setup(t => t.ActivateProfileUserDataAsync(profileId, It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("Activation locked by file")); + + // Simulate that this profile is the active profile (empty manifest list sets active profile without activating user data) + await _linkerService.PrepareProfileUserDataAsync(profileId, [], gameType); + + var manifest = new ContentManifest + { + Id = ManifestId.Create("1.0.0.map.desert"), + Name = "Desert Map", + ContentType = ContentType.Map, + Files = [new ManifestFile { RelativePath = "Maps\\Desert.map", InstallTarget = ContentInstallTarget.UserMapsDirectory, Hash = "hash-1" }], + }; + + _userDataTrackerMock.Setup(t => t.GetUserDataManifestAsync(manifest.Id.Value, profileId, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(null)); + + _userDataTrackerMock.Setup(t => t.InstallUserDataAsync( + manifest.Id.Value, + profileId, + gameType, + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(new UserDataManifest { ManifestId = manifest.Id.Value, ProfileId = profileId })); + + _userDataTrackerMock.Setup(t => t.UninstallUserDataAsync(manifest.Id.Value, profileId, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var result = await _linkerService.UpdateProfileUserDataAsync(profileId, [manifest], gameType); + + // Assert + Assert.False(result.Success); + Assert.Contains("Failed to activate user data", result.FirstError, StringComparison.OrdinalIgnoreCase); + _userDataTrackerMock.Verify(t => t.UninstallUserDataAsync(manifest.Id.Value, profileId, It.IsAny()), Times.Once); + } + + /// + /// Verifies that UpdateProfileUserDataAsync rolls back previously uninstalled manifests when a subsequent uninstall fails. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task UpdateProfileUserDataAsync_WhenSecondUninstallFails_RollsBackFirstUninstallAsync() + { + // Arrange + const string profileId = "profile-uninstall-fail"; + const GameType gameType = GameType.ZeroHour; + const string manifest1Id = "1.0.0.map.desert1"; + const string manifest2Id = "1.0.0.map.desert2"; + + var existing1 = new UserDataManifest + { + ManifestId = manifest1Id, + ProfileId = profileId, + TargetGame = gameType, + IsActive = true, + InstalledFiles = [new UserDataFileEntry { AbsolutePath = "C:\\path\\1.map", RelativePath = "1.map", InstallTarget = ContentInstallTarget.UserMapsDirectory }], + }; + var existing2 = new UserDataManifest + { + ManifestId = manifest2Id, + ProfileId = profileId, + TargetGame = gameType, + IsActive = true, + InstalledFiles = [new UserDataFileEntry { AbsolutePath = "C:\\path\\2.map", RelativePath = "2.map", InstallTarget = ContentInstallTarget.UserMapsDirectory }], + }; + + _userDataTrackerMock.Setup(t => t.GetProfileUserDataAsync(profileId, It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([existing1, existing2])); + + _userDataTrackerMock.Setup(t => t.UninstallUserDataAsync(manifest1Id, profileId, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + _userDataTrackerMock.Setup(t => t.UninstallUserDataAsync(manifest2Id, profileId, It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("File locked")); + + _userDataTrackerMock.Setup(t => t.InstallUserDataAsync( + manifest1Id, + profileId, + gameType, + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(existing1)); + + // Act - remove both manifests + var result = await _linkerService.UpdateProfileUserDataAsync(profileId, [], gameType); + + // Assert + Assert.False(result.Success); + Assert.Contains("File locked", result.FirstError, StringComparison.OrdinalIgnoreCase); + + // Verify manifest-1 was reinstalled as part of rollback + _userDataTrackerMock.Verify( + t => t.InstallUserDataAsync( + manifest1Id, + profileId, + gameType, + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + /// + /// Verifies that UpdateProfileUserDataAsync rolls back previously installed manifests when a subsequent install fails. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task UpdateProfileUserDataAsync_WhenSecondInstallFails_RollsBackFirstInstallAsync() + { + // Arrange + const string profileId = "profile-install-fail"; + const GameType gameType = GameType.ZeroHour; + const string manifest1Id = "1.0.0.map.desert1"; + const string manifest2Id = "1.0.0.map.desert2"; + + _userDataTrackerMock.Setup(t => t.GetProfileUserDataAsync(profileId, It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([])); + + var manifest1 = new ContentManifest + { + Id = ManifestId.Create(manifest1Id), + Name = "Map 1", + ContentType = ContentType.Map, + Files = [new ManifestFile { RelativePath = "Maps\\1.map", InstallTarget = ContentInstallTarget.UserMapsDirectory, Hash = "hash-1" }], + }; + var manifest2 = new ContentManifest + { + Id = ManifestId.Create(manifest2Id), + Name = "Map 2", + ContentType = ContentType.Map, + Files = [new ManifestFile { RelativePath = "Maps\\2.map", InstallTarget = ContentInstallTarget.UserMapsDirectory, Hash = "hash-2" }], + }; + + _userDataTrackerMock.Setup(t => t.InstallUserDataAsync( + manifest1Id, + profileId, + gameType, + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(new UserDataManifest { ManifestId = manifest1Id, ProfileId = profileId })); + + _userDataTrackerMock.Setup(t => t.InstallUserDataAsync( + manifest2Id, + profileId, + gameType, + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("Disk full")); + + _userDataTrackerMock.Setup(t => t.UninstallUserDataAsync(manifest1Id, profileId, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act - install both manifests + var result = await _linkerService.UpdateProfileUserDataAsync(profileId, [manifest1, manifest2], gameType); + + // Assert + Assert.False(result.Success); + Assert.Contains("Disk full", result.FirstError, StringComparison.OrdinalIgnoreCase); + + // Verify manifest-1 was uninstalled as part of rollback + _userDataTrackerMock.Verify(t => t.UninstallUserDataAsync(manifest1Id, profileId, It.IsAny()), Times.Once); + } + + /// + /// Verifies that UpdateProfileUserDataAsync reports that live rollback was incomplete when rollback compensation itself fails. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task UpdateProfileUserDataAsync_WhenRollbackFails_ReturnsFailureWithIncompleteRollbackNoticeAsync() + { + // Arrange + const string profileId = "profile-rollback-fail"; + const GameType gameType = GameType.ZeroHour; + const string manifest1Id = "1.0.0.map.desert1"; + const string manifest2Id = "1.0.0.map.desert2"; + + var existing1 = new UserDataManifest + { + ManifestId = manifest1Id, + ProfileId = profileId, + TargetGame = gameType, + InstalledFiles = [new UserDataFileEntry { AbsolutePath = "C:\\path\\1.map", RelativePath = "1.map", InstallTarget = ContentInstallTarget.UserMapsDirectory }], + }; + var existing2 = new UserDataManifest + { + ManifestId = manifest2Id, + ProfileId = profileId, + TargetGame = gameType, + InstalledFiles = [new UserDataFileEntry { AbsolutePath = "C:\\path\\2.map", RelativePath = "2.map", InstallTarget = ContentInstallTarget.UserMapsDirectory }], + }; + + _userDataTrackerMock.Setup(t => t.GetProfileUserDataAsync(profileId, It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([existing1, existing2])); + + _userDataTrackerMock.Setup(t => t.UninstallUserDataAsync(manifest1Id, profileId, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + _userDataTrackerMock.Setup(t => t.UninstallUserDataAsync(manifest2Id, profileId, It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("File locked")); + + // Compensating reinstall fails during rollback + _userDataTrackerMock.Setup(t => t.InstallUserDataAsync( + manifest1Id, + profileId, + gameType, + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateFailure("Disk unreadable during rollback")); + + // Act - remove both manifests + var result = await _linkerService.UpdateProfileUserDataAsync(profileId, [], gameType); + + // Assert + Assert.False(result.Success); + Assert.Contains("live rollback was incomplete", result.FirstError, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that a workspace-targeted map is recognized as profile user data and is not uninstalled when another map is added during live sync. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task UpdateProfileUserDataAsync_WhenWorkspaceTargetedMapExistsAndNewUserMapAdded_PreservesWorkspaceTargetedMapAsync() + { + // Arrange + const string profileId = "profile-workspace-map"; + const GameType gameType = GameType.ZeroHour; + const string legacyMapId = "1.0.0.map.legacymap"; + const string newMapId = "1.0.0.map.newmap"; + + var existingLegacyMap = new UserDataManifest + { + ManifestId = legacyMapId, + ProfileId = profileId, + TargetGame = gameType, + IsActive = true, + InstalledFiles = [new UserDataFileEntry { AbsolutePath = "C:\\path\\legacy.map", RelativePath = "legacy.map", InstallTarget = ContentInstallTarget.UserMapsDirectory }], + }; + + _userDataTrackerMock.Setup(t => t.GetProfileUserDataAsync(profileId, It.IsAny())) + .ReturnsAsync(OperationResult>.CreateSuccess([existingLegacyMap])); + + var legacyMapManifest = new ContentManifest + { + Id = ManifestId.Create(legacyMapId), + Name = "Legacy Map", + ContentType = ContentType.Map, + Files = [new ManifestFile { RelativePath = "Maps\\legacy.map", InstallTarget = ContentInstallTarget.Workspace, Hash = "hash-legacy" }], + }; + + var newMapManifest = new ContentManifest + { + Id = ManifestId.Create(newMapId), + Name = "New Map", + ContentType = ContentType.Map, + Files = [new ManifestFile { RelativePath = "Maps\\new.map", InstallTarget = ContentInstallTarget.UserMapsDirectory, Hash = "hash-new" }], + }; + + _userDataTrackerMock.Setup(t => t.InstallUserDataAsync( + newMapId, + profileId, + gameType, + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(new UserDataManifest { ManifestId = newMapId, ProfileId = profileId })); + + _userDataTrackerMock.Setup(t => t.ActivateProfileUserDataAsync(profileId, It.IsAny())) + .ReturnsAsync(OperationResult.CreateSuccess(true)); + + // Act + var result = await _linkerService.UpdateProfileUserDataAsync(profileId, [legacyMapManifest, newMapManifest], gameType); + + // Assert + Assert.True(result.Success); + _userDataTrackerMock.Verify( + t => t.UninstallUserDataAsync(legacyMapId, profileId, It.IsAny()), + Times.Never); + _userDataTrackerMock.Verify( + t => t.InstallUserDataAsync( + newMapId, + profileId, + gameType, + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); + } +} diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceSafetyTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceSafetyTests.cs index 65682c712..4deaf3488 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceSafetyTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceSafetyTests.cs @@ -577,6 +577,79 @@ public async Task UninstallUserDataAsync_WhenConsumedBackupCannotBeDeleted_Still } } + /// + /// Verifies that if a deployed file without a backup fails to delete (e.g. because it is locked or held + /// by another process), UninstallUserDataAsync reports failure and retains its tracking metadata so the + /// uninstall can be safely retried once the process closes. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task UninstallUserDataAsync_WhenFileDeleteFails_ReportsFailureAndKeepsTrackingDataAsync() + { + // Arrange + var deployedPath = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData", "splash.bmp"); + var deployedDir = Path.GetDirectoryName(deployedPath)!; + + var installResult = await _trackerService.InstallUserDataAsync( + TestManifestId, + TestProfileId, + GameType.ZeroHour, + BuildFiles(), + TestVersion, + TestManifestName, + CancellationToken.None); + Assert.True(installResult.Success); + Assert.True(File.Exists(deployedPath)); + + FileStream? openFileHandle = null; + UnixFileMode? originalDirectoryMode = null; + string? probePath = null; + if (OperatingSystem.IsWindows()) + { + openFileHandle = new FileStream(deployedPath, System.IO.FileMode.Open, FileAccess.ReadWrite, FileShare.None); + } + else + { + probePath = Path.Combine(deployedDir, "delete-permission-probe"); + File.WriteAllText(probePath, string.Empty); + + originalDirectoryMode = File.GetUnixFileMode(deployedDir); + File.SetUnixFileMode(deployedDir, UnixFileMode.UserRead | UnixFileMode.UserExecute); + + if (DeleteSucceeds(probePath)) + { + File.SetUnixFileMode(deployedDir, originalDirectoryMode.Value); + return; + } + } + + try + { + // Act + var uninstallResult = await _trackerService.UninstallUserDataAsync(TestManifestId, TestProfileId, CancellationToken.None); + + // Assert + Assert.False(uninstallResult.Success); + + var manifestResult = await _trackerService.GetUserDataManifestAsync(TestManifestId, TestProfileId, CancellationToken.None); + Assert.True(manifestResult.Success); + Assert.NotNull(manifestResult.Data); + } + finally + { + openFileHandle?.Dispose(); + if (!OperatingSystem.IsWindows() && originalDirectoryMode.HasValue) + { + File.SetUnixFileMode(deployedDir, originalDirectoryMode.Value); + } + + if (probePath is not null && File.Exists(probePath)) + { + File.Delete(probePath); + } + } + } + /// /// A cancelled delete-all must abort before any tracking metadata is destroyed. Swallowing the /// cancellation and carrying on wipes the manifests and the index while the backups they describe diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs index c452f620d..a4ea3887a 100644 --- a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/UserData/UserDataTrackerServiceTests.cs @@ -1052,4 +1052,110 @@ public async Task CheckFileConflictAsync_WhenManifestDeactivated_PrunesStaleMapp Assert.NotNull(index); Assert.False(index.FileToInstallationMap.ContainsKey(Path.GetFullPath(targetPath))); } + + /// + /// Verifies that when CAS materialization throws an exception during install, changes are rolled back and original files are restored. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task InstallUserDataAsync_WhenMaterializationThrowsException_RestoresOriginalFile() + { + // Arrange + var gameDataDir = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData"); + Directory.CreateDirectory(gameDataDir); + var splashPath = Path.Combine(gameDataDir, "splash.bmp"); + var originalUserContent = "original-user-splash-for-throw"; + File.WriteAllText(splashPath, originalUserContent); + + var files = new List + { + new() + { + RelativePath = "GeneralsOnlineGameData/splash.bmp", + Hash = "throwhash123", + Size = 100, + InstallTarget = ContentInstallTarget.UserDataDirectory, + }, + }; + + _fileOperationsMock.Setup(f => f.LinkFromCasAsync("throwhash123", It.IsAny(), true, null, It.IsAny())) + .ThrowsAsync(new IOException("Simulated disk error")); + _fileOperationsMock.Setup(f => f.CopyFromCasAsync("throwhash123", It.IsAny(), null, It.IsAny())) + .ThrowsAsync(new IOException("Simulated disk error")); + + // Act + var result = await _trackerService.InstallUserDataAsync( + "1.1015255.generalsonline.patch.gamedata", + "profile-fail-install-throw", + GameType.ZeroHour, + files, + "101525_QFE5", + "GameData Patch", + CancellationToken.None); + + // Assert + Assert.False(result.Success); + Assert.True(File.Exists(splashPath)); + Assert.Equal(originalUserContent, File.ReadAllText(splashPath)); + } + + /// + /// Verifies that when CAS materialization throws an exception during activation, activated files are rolled back and original backups are restored. + /// + /// A task representing the asynchronous test. + [Fact] + public async Task ActivateUserDataManifestsAsync_WhenMaterializationThrowsException_RollsBackActivatedFilesAndRestoresBackupsAsync() + { + // Arrange + var gameDataDir = Path.Combine(_zeroHourDataDir, "GeneralsOnlineGameData"); + Directory.CreateDirectory(gameDataDir); + var splashPath = Path.Combine(gameDataDir, "splash.bmp"); + var originalUserContent = "original-user-splash-for-activation-throw"; + File.WriteAllText(splashPath, originalUserContent); + + var files = new List + { + new() + { + RelativePath = "GeneralsOnlineGameData/splash.bmp", + Hash = "actthrowhash123", + Size = 100, + InstallTarget = ContentInstallTarget.UserDataDirectory, + }, + }; + + _fileOperationsMock.Setup(f => f.VerifyFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var installResult = await _trackerService.InstallUserDataAsync( + "1.1015255.generalsonline.patch.gamedata", + "profile-act-throw", + GameType.ZeroHour, + files, + "101525_QFE5", + "GameData Patch", + CancellationToken.None); + + Assert.True(installResult.Success); + + // Now de-activate + var deactivateResult = await _trackerService.DeactivateProfileUserDataAsync("profile-act-throw", CancellationToken.None); + Assert.True(deactivateResult.Success); + + // On re-activation, the file is restored to original user content (doesn't match CAS hash), and materialization throws + _fileOperationsMock.Setup(f => f.VerifyFileHashAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + _fileOperationsMock.Setup(f => f.LinkFromCasAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new IOException("Simulated activation disk error")); + _fileOperationsMock.Setup(f => f.CopyFromCasAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new IOException("Simulated activation disk error")); + + // Act + var activateResult = await _trackerService.ActivateProfileUserDataAsync("profile-act-throw", CancellationToken.None); + + // Assert + Assert.False(activateResult.Success); + Assert.True(File.Exists(splashPath)); + Assert.Equal(originalUserContent, File.ReadAllText(splashPath)); + } } diff --git a/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/ContentHotswapClassificationTests.cs b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/ContentHotswapClassificationTests.cs new file mode 100644 index 000000000..1e56ad808 --- /dev/null +++ b/GenHub/GenHub.Tests/GenHub.Tests.Core/Features/Workspace/ContentHotswapClassificationTests.cs @@ -0,0 +1,225 @@ +using System; +using GenHub.Core.Models.Workspace; +using Xunit; +using ContentType = GenHub.Core.Models.Enums.ContentType; + +namespace GenHub.Tests.Core.Features.Workspace; + +/// +/// Contains unit tests for . +/// +public class ContentHotswapClassificationTests +{ + /// + /// Verifies that IsHotswappable returns expected truth values for all content types. + /// + /// The content type under test. + /// The expected boolean result. + [Theory] + [InlineData(ContentType.Map, true)] + [InlineData(ContentType.MapPack, true)] + [InlineData(ContentType.Patch, false)] + [InlineData(ContentType.Replay, true)] + [InlineData(ContentType.Mod, false)] + [InlineData(ContentType.GameClient, false)] + [InlineData(ContentType.GameInstallation, false)] + [InlineData(ContentType.Addon, false)] + [InlineData(ContentType.Executable, false)] + [InlineData(ContentType.ModdingTool, false)] + [InlineData(ContentType.Mission, false)] + [InlineData(ContentType.Skin, false)] + [InlineData(ContentType.LanguagePack, false)] + [InlineData(ContentType.ContentBundle, false)] + [InlineData(ContentType.PublisherReferral, false)] + [InlineData(ContentType.ContentReferral, false)] + [InlineData(ContentType.Video, false)] + [InlineData(ContentType.Screensaver, false)] + [InlineData(ContentType.UnknownContentType, false)] + public void IsHotswappable_ReturnsExpectedResult(ContentType contentType, bool expected) + { + // Act + var result = ContentHotswapClassification.IsHotswappable(contentType); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Verifies that IsLocked returns the exact opposite of IsHotswappable. + /// + /// The content type under test. + /// The expected boolean result. + [Theory] + [InlineData(ContentType.Map, false)] + [InlineData(ContentType.MapPack, false)] + [InlineData(ContentType.Patch, true)] + [InlineData(ContentType.Replay, false)] + [InlineData(ContentType.Mod, true)] + [InlineData(ContentType.GameClient, true)] + [InlineData(ContentType.GameInstallation, true)] + [InlineData(ContentType.Addon, true)] + [InlineData(ContentType.Executable, true)] + [InlineData(ContentType.ModdingTool, true)] + [InlineData(ContentType.Mission, true)] + [InlineData(ContentType.Skin, true)] + [InlineData(ContentType.LanguagePack, true)] + [InlineData(ContentType.ContentBundle, true)] + [InlineData(ContentType.PublisherReferral, true)] + [InlineData(ContentType.ContentReferral, true)] + [InlineData(ContentType.Video, true)] + [InlineData(ContentType.Screensaver, true)] + [InlineData(ContentType.UnknownContentType, true)] + public void IsLocked_ReturnsOppositeOfIsHotswappable(ContentType contentType, bool expected) + { + // Act + var result = ContentHotswapClassification.IsLocked(contentType); + + // Assert + Assert.Equal(expected, result); + } + + /// + /// Verifies that IsHotswappable and IsLocked throw ArgumentNullException when manifest is null. + /// + [Fact] + public void IsHotswappable_NullManifest_ThrowsArgumentNullException() + { + Assert.Throws(() => ContentHotswapClassification.IsHotswappable(null!)); + Assert.Throws(() => ContentHotswapClassification.IsLocked(null!)); + } + + /// + /// Verifies that IsHotswappable with ContentManifest returns false if any file targets Workspace or System. + /// + [Fact] + public void IsHotswappable_ManifestWithWorkspaceFiles_ReturnsFalse() + { + // Arrange + var manifest = new GenHub.Core.Models.Manifest.ContentManifest + { + Id = GenHub.Core.Models.Manifest.ManifestId.Create("1.0.0.mappack.mixed"), + Name = "Mixed MapPack", + ContentType = ContentType.MapPack, + Files = + [ + new GenHub.Core.Models.Manifest.ManifestFile { RelativePath = "Maps/desert.map", InstallTarget = GenHub.Core.Models.Enums.ContentInstallTarget.UserMapsDirectory }, + new GenHub.Core.Models.Manifest.ManifestFile { RelativePath = "INIData.big", InstallTarget = GenHub.Core.Models.Enums.ContentInstallTarget.Workspace }, + ], + }; + + // Act & Assert + Assert.False(ContentHotswapClassification.IsHotswappable(manifest)); + Assert.True(ContentHotswapClassification.IsLocked(manifest)); + } + + /// + /// Verifies that IsHotswappable with ContentManifest returns true when all files target user data. + /// + [Fact] + public void IsHotswappable_ManifestWithOnlyUserDataFiles_ReturnsTrue() + { + // Arrange + var manifest = new GenHub.Core.Models.Manifest.ContentManifest + { + Id = GenHub.Core.Models.Manifest.ManifestId.Create("1.0.0.map.desert"), + Name = "Desert Map", + ContentType = ContentType.Map, + Files = + [ + new GenHub.Core.Models.Manifest.ManifestFile { RelativePath = "Maps/desert.map", InstallTarget = GenHub.Core.Models.Enums.ContentInstallTarget.UserMapsDirectory }, + ], + }; + + // Act & Assert + Assert.True(ContentHotswapClassification.IsHotswappable(manifest)); + Assert.False(ContentHotswapClassification.IsLocked(manifest)); + } + + /// + /// Verifies that IsHotswappable with ContentManifest accounts for variants correctly. + /// + [Fact] + public void IsHotswappable_ManifestWithVariantTargetingWorkspace_ReturnsFalse() + { + // Arrange + var manifest = new GenHub.Core.Models.Manifest.ContentManifest + { + Id = GenHub.Core.Models.Manifest.ManifestId.Create("1.0.0.mappack.variant"), + Name = "Variant MapPack", + ContentType = ContentType.MapPack, + Variants = + [ + new GenHub.Core.Models.Manifest.ArtifactVariant + { + Files = + [ + new GenHub.Core.Models.Manifest.ManifestFile { RelativePath = "INIData.big", InstallTarget = GenHub.Core.Models.Enums.ContentInstallTarget.Workspace }, + ], + }, + ], + }; + + // Act & Assert + Assert.False(ContentHotswapClassification.IsHotswappable(manifest)); + Assert.True(ContentHotswapClassification.IsLocked(manifest)); + } + + /// + /// Verifies that IsHotswappable returns true when the manifest has no files that target workspace. + /// + [Fact] + public void IsHotswappable_ManifestWithDefaultUserDataTarget_ReturnsTrue() + { + // Arrange + var manifest = new GenHub.Core.Models.Manifest.ContentManifest + { + Id = GenHub.Core.Models.Manifest.ManifestId.Create("1.0.0.map.custom"), + Name = "Custom Map", + ContentType = ContentType.Map, + Variants = + [ + new GenHub.Core.Models.Manifest.ArtifactVariant + { + Files = + [ + new GenHub.Core.Models.Manifest.ManifestFile { RelativePath = "Custom.map", InstallTarget = GenHub.Core.Models.Enums.ContentInstallTarget.UserMapsDirectory }, + ], + }, + ], + }; + + // Act & Assert + Assert.True(ContentHotswapClassification.IsHotswappable(manifest)); + Assert.False(ContentHotswapClassification.IsLocked(manifest)); + } + + /// + /// Verifies that IsHotswappable returns false when the manifest declares variants but none resolve. + /// + [Fact] + public void IsHotswappable_ManifestWithUnresolvableVariants_ReturnsFalse() + { + // Arrange + var manifest = new GenHub.Core.Models.Manifest.ContentManifest + { + Id = GenHub.Core.Models.Manifest.ManifestId.Create("1.0.0.map.unmatched"), + Name = "Unmatched Map", + ContentType = ContentType.Map, + Variants = + [ + new GenHub.Core.Models.Manifest.ArtifactVariant + { + RuntimeIdentifiers = ["unsupported-platform-rid-123"], + Files = + [ + new GenHub.Core.Models.Manifest.ManifestFile { RelativePath = "Custom.map", InstallTarget = GenHub.Core.Models.Enums.ContentInstallTarget.UserMapsDirectory }, + ], + }, + ], + }; + + // Act & Assert + Assert.False(ContentHotswapClassification.IsHotswappable(manifest)); + Assert.True(ContentHotswapClassification.IsLocked(manifest)); + } +} diff --git a/GenHub/GenHub/App.axaml b/GenHub/GenHub/App.axaml index b0176d602..64c0d9359 100644 --- a/GenHub/GenHub/App.axaml +++ b/GenHub/GenHub/App.axaml @@ -1,6 +1,7 @@ @@ -20,11 +21,12 @@ + - + - - - - - - - - - - - - @@ -161,15 +67,15 @@ - + @@ -183,18 +89,23 @@ VerticalAlignment="Center" /> - + @@ -211,7 +122,7 @@ - + @@ -238,7 +149,7 @@ @@ -248,10 +159,8 @@ - @@ -259,79 +168,76 @@ + Foreground="{DynamicResource TextSecondary}" /> - + - - - - - - - - - - - - - @@ -422,7 +330,7 @@ + Padding="8,4"> @@ -441,7 +349,7 @@ - + @@ -451,6 +359,7 @@ @@ -520,14 +430,14 @@ - diff --git a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs index d7eba6242..d878867d5 100644 --- a/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Infrastructure/GameProcessManager.cs @@ -81,13 +81,14 @@ public async Task> StartProcessAsync(GameLaunch return OperationResult.CreateFailure(startResult.FirstError ?? "Failed to start process"); } + var launchTimeFallback = DateTime.UtcNow; process = startResult.Data; logger.LogDebug("[Process] Process {ProcessId} started successfully", process.Id); // Read while the launcher is still alive: a Unix process that has exited can no longer // report its start time, and that time is the only thing separating the child this // launch spawned from an instance of the same game the user already had running. - var launcherStartTime = ReadStartTime(process); + var launcherStartTime = ReadStartTime(process) ?? launchTimeFallback; var capturedErrors = SetupErrorRedirection(process); diff --git a/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs b/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs index 329907b24..1f965efab 100644 --- a/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs +++ b/GenHub/GenHub/Features/GameProfiles/Services/GameProfileManager.cs @@ -8,11 +8,13 @@ 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.Manifest; using GenHub.Core.Models.Results; +using GenHub.Core.Models.Workspace; using Microsoft.Extensions.Logging; namespace GenHub.Features.GameProfiles.Services; @@ -25,7 +27,8 @@ public class GameProfileManager( IGameInstallationService installationService, IContentManifestPool manifestPool, IGameSettingsService gameSettingsService, - ILogger logger) : IGameProfileManager + ILogger logger, + ILaunchRegistry? launchRegistry = null) : IGameProfileManager { /// public async Task> CreateProfileAsync(CreateProfileRequest request, CancellationToken cancellationToken = default) @@ -190,6 +193,23 @@ public async Task> UpdateProfileAsync(string var previousEnabledContentIds = profile.EnabledContentIds?.ToList() ?? []; var previousGameClientId = profile.GameClient?.Id; + // Check if profile is currently running + var isRunning = false; + if (launchRegistry != null) + { + var activeLaunches = await launchRegistry.GetAllActiveLaunchesAsync(); + isRunning = activeLaunches.Any(l => string.Equals(l.ProfileId, profileId, StringComparison.OrdinalIgnoreCase) && !l.TerminatedAt.HasValue); + } + + if (isRunning) + { + var validationResult = await ValidateRunningProfileUpdateRequestAsync(profile, request, previousEnabledContentIds, cancellationToken); + if (validationResult != null) + { + return validationResult; + } + } + if (request.Name != null) { if (!TryValidateProfileName(request.Name, out var nameValidationError)) @@ -200,7 +220,7 @@ public async Task> UpdateProfileAsync(string profile.Name = request.Name; } - CheckAndHandleContentChanges(profile, request, previousEnabledContentIds, previousGameClientId); + CheckAndHandleContentChanges(profile, request, previousEnabledContentIds, previousGameClientId, isRunning); ApplyUpdateRequestToProfile(profile, request); GameSettingsMapper.UpdateFromRequest(profile, request); @@ -316,6 +336,62 @@ public async Task>> GetAva } } + private static ProfileOperationResult? ValidateRunningProfileImmutableSettings(GameProfile profile, UpdateProfileRequest request) + { + if (request.WorkspaceStrategy.HasValue && request.WorkspaceStrategy.Value != profile.WorkspaceStrategy) + { + return ProfileOperationResult.CreateFailure("Cannot change workspace strategy while profile is running."); + } + + if (request.GameInstallationId != null && !string.Equals(request.GameInstallationId, profile.GameInstallationId, StringComparison.OrdinalIgnoreCase)) + { + return ProfileOperationResult.CreateFailure("Cannot change game installation while profile is running."); + } + + if (request.ActiveWorkspaceId != null && !string.Equals(request.ActiveWorkspaceId, profile.ActiveWorkspaceId, StringComparison.OrdinalIgnoreCase)) + { + return ProfileOperationResult.CreateFailure("Cannot change active workspace while profile is running."); + } + + if (request.CustomExecutablePath != null && !string.Equals(request.CustomExecutablePath, profile.CustomExecutablePath, StringComparison.OrdinalIgnoreCase)) + { + return ProfileOperationResult.CreateFailure("Cannot change custom executable path while profile is running."); + } + + if (request.WorkingDirectory != null && !string.Equals(request.WorkingDirectory, profile.WorkingDirectory, StringComparison.OrdinalIgnoreCase)) + { + return ProfileOperationResult.CreateFailure("Cannot change working directory while profile is running."); + } + + if (request.CommandLineArguments != null && !string.Equals(request.CommandLineArguments, profile.CommandLineArguments, StringComparison.Ordinal)) + { + return ProfileOperationResult.CreateFailure("Cannot change command line arguments while profile is running."); + } + + return null; + } + + private static ProfileOperationResult? ValidateRunningProfileGameClient(GameProfile profile, GameClient? requestedClient) + { + if (requestedClient == null) + { + return null; + } + + if (profile.GameClient == null || + !string.Equals(requestedClient.Id, profile.GameClient.Id, StringComparison.OrdinalIgnoreCase) || + !string.Equals(requestedClient.ExecutablePath, profile.GameClient.ExecutablePath, StringComparison.OrdinalIgnoreCase) || + !string.Equals(requestedClient.Version, profile.GameClient.Version, StringComparison.OrdinalIgnoreCase) || + !string.Equals(requestedClient.WorkingDirectory, profile.GameClient.WorkingDirectory, StringComparison.OrdinalIgnoreCase) || + !string.Equals(requestedClient.InstallationId, profile.GameClient.InstallationId, StringComparison.OrdinalIgnoreCase) || + requestedClient.GameType != profile.GameClient.GameType) + { + return ProfileOperationResult.CreateFailure("Cannot change game client while profile is running."); + } + + return null; + } + /// /// Validates the profile name. /// @@ -373,6 +449,65 @@ private async Task LoadExistingSettingsIntoProfileAsync(GameProfile profile, Cor } } + private async Task?> ValidateRunningProfileUpdateRequestAsync( + GameProfile profile, + UpdateProfileRequest request, + List previousEnabledContentIds, + CancellationToken cancellationToken) + { + var settingsError = ValidateRunningProfileImmutableSettings(profile, request); + if (settingsError != null) + { + return settingsError; + } + + var clientError = ValidateRunningProfileGameClient(profile, request.GameClient); + if (clientError != null) + { + return clientError; + } + + return await ValidateRunningProfileContentChangesAsync(previousEnabledContentIds, request.EnabledContentIds, cancellationToken); + } + + private async Task?> ValidateRunningProfileContentChangesAsync( + List previousEnabledContentIds, + List? requestedContentIds, + CancellationToken cancellationToken) + { + if (requestedContentIds == null) + { + return null; + } + + var newContentIds = requestedContentIds.ToList(); + var addedIds = newContentIds.Except(previousEnabledContentIds, StringComparer.OrdinalIgnoreCase).ToList(); + var removedIds = previousEnabledContentIds.Except(newContentIds, StringComparer.OrdinalIgnoreCase).ToList(); + var changedIds = addedIds.Concat(removedIds).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + + foreach (var id in changedIds) + { + if (!ManifestId.TryCreate(id, out var manifestId)) + { + return ProfileOperationResult.CreateFailure($"Cannot modify content '{id}' while profile is running: invalid manifest ID format."); + } + + var manifestResult = await manifestPool.GetManifestAsync(manifestId, cancellationToken); + if (!manifestResult.Success || manifestResult.Data == null) + { + return ProfileOperationResult.CreateFailure($"Cannot modify content '{id}' while profile is running: manifest not found."); + } + + var manifest = manifestResult.Data; + if (!ContentHotswapClassification.IsHotswappable(manifest)) + { + return ProfileOperationResult.CreateFailure($"Cannot modify content '{manifest.Name}' while profile is running. Only content targeting user documents (such as maps and replays) can be hot swapped during an active game session."); + } + } + + return null; + } + private void ApplyUpdateRequestToProfile(GameProfile profile, UpdateProfileRequest request) { profile.Description = request.Description ?? profile.Description; @@ -399,7 +534,8 @@ private void CheckAndHandleContentChanges( GameProfile profile, UpdateProfileRequest request, List previousEnabledContentIds, - string? previousGameClientId) + string? previousGameClientId, + bool isRunning) { bool contentChanged = false; if (request.EnabledContentIds != null) @@ -414,7 +550,7 @@ private void CheckAndHandleContentChanges( contentChanged = contentChanged || !string.Equals(previousGameClientId, newGameClientId, StringComparison.OrdinalIgnoreCase); } - if (contentChanged && !string.IsNullOrEmpty(profile.ActiveWorkspaceId)) + if (contentChanged && !isRunning && !string.IsNullOrEmpty(profile.ActiveWorkspaceId)) { logger.LogDebug( "Profile '{ProfileName}' content changed - clearing ActiveWorkspaceId '{WorkspaceId}' to force workspace rebuild on next launch", diff --git a/GenHub/GenHub/Features/GameProfiles/Services/ProfileContentLoader.cs b/GenHub/GenHub/Features/GameProfiles/Services/ProfileContentLoader.cs index 99f98d90c..3dcbe64cc 100644 --- a/GenHub/GenHub/Features/GameProfiles/Services/ProfileContentLoader.cs +++ b/GenHub/GenHub/Features/GameProfiles/Services/ProfileContentLoader.cs @@ -416,7 +416,8 @@ private static ObservableCollection CloneWithEnabledState( private ContentDisplayItem CreateInstallationDisplayItem( GameInstallation installation, GameClient baseClient, - GameType gameType) + GameType gameType, + bool isEnabled = false) { var (versionForManifestId, versionForDisplay) = GetVersionStrings(baseClient.Version); var manifestId = ManifestIdGenerator.GenerateGameInstallationId( @@ -438,6 +439,7 @@ private ContentDisplayItem CreateInstallationDisplayItem( GameType = gameType, InstallationType = installation.InstallationType, Publisher = publisher, + IsEnabled = isEnabled, IsEditable = false, }; } @@ -701,7 +703,7 @@ private ContentDisplayItem CreateEnabledInstallationItem( var baseClient = GetBaseGameClient(gameInstallation, manifest.TargetGame); if (baseClient is not null) { - return CreateInstallationDisplayItem(gameInstallation, baseClient, manifest.TargetGame); + return CreateInstallationDisplayItem(gameInstallation, baseClient, manifest.TargetGame, isEnabled: true); } } diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileItemViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileItemViewModel.cs index 96cb9f194..a1493b31f 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileItemViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileItemViewModel.cs @@ -525,9 +525,9 @@ public GameProfileItemViewModel(string profileId, IGameProfile profile, string i public bool IsWorkspacePrepared => !string.IsNullOrEmpty(ActiveWorkspaceId); /// - /// Gets a value indicating whether the profile can be edited (not running and not being prepared). + /// Gets a value indicating whether the profile can be edited (not being prepared). /// - public bool CanEdit => !IsProcessRunning && !IsPreparingWorkspace; + public bool CanEdit => !IsPreparingWorkspace; /// /// Gets a value indicating whether the profile can be launched (not running). @@ -625,6 +625,22 @@ public void UpdateFromProfile(IGameProfile updatedProfile) OnPropertyChanged(nameof(CommandLineArguments)); } + private static string MapPublisherName(string publisherSegment, string fallback) => + publisherSegment switch + { + PublisherTypeConstants.Steam => "Steam", + PublisherTypeConstants.EaApp => "EA App", + "thefirstdecade" => "The First Decade", + PublisherTypeConstants.Retail => "Retail", + "cdiso" => "CD/ISO", + "wine" => "Wine", + PublisherTypeConstants.GeneralsOnline => "Generals Online", + PublisherTypeConstants.TheSuperHackers => "The Super Hackers", + CommunityOutpostConstants.PublisherType => "Community Outpost", + "local" => "Local", + _ => fallback, + }; + private static string GetPublisherNameFromId(string manifestId) { if (string.IsNullOrEmpty(manifestId)) @@ -639,19 +655,7 @@ private static string GetPublisherNameFromId(string manifestId) } var publisher = segments[2].ToLowerInvariant(); - return publisher switch - { - PublisherTypeConstants.Steam => "Steam", - PublisherTypeConstants.EaApp => "EA App", - "thefirstdecade" => "The First Decade", - PublisherTypeConstants.Retail => "Retail", - "cdiso" => "CD/ISO", - "wine" => "Wine", - PublisherTypeConstants.GeneralsOnline => "Generals Online", - PublisherTypeConstants.TheSuperHackers => "The Super Hackers", - CommunityOutpostConstants.PublisherType => "Community Outpost", - _ => System.Globalization.CultureInfo.CurrentCulture.TextInfo.ToTitleCase(publisher), - }; + return MapPublisherName(publisher, System.Globalization.CultureInfo.CurrentCulture.TextInfo.ToTitleCase(publisher)); } /// @@ -737,6 +741,72 @@ var p when p.Contains("/Assets/Images/", StringComparison.OrdinalIgnoreCase) && }; } + /// + /// Checks if the version is zero or a placeholder. + /// + /// The version string to check. + private static bool IsZeroOrPlaceholderVersion(string version) + { + return version.Equals(GameClientConstants.AutoDetectedVersion, StringComparison.OrdinalIgnoreCase) || + version.Equals(GameClientConstants.UnknownVersion, StringComparison.OrdinalIgnoreCase) || + version.Equals("Auto-Updated", StringComparison.OrdinalIgnoreCase) || + version.Contains("Automatically", StringComparison.OrdinalIgnoreCase) || + version == "0" || + version == "0.0" || + version == "0.0.0" || + version == "0.0.0.0" || + version.Equals("v0", StringComparison.OrdinalIgnoreCase); + } + + private static string ParsePublisherName(string publisherSegment, string originalSegment) => + MapPublisherName(publisherSegment, originalSegment.ToUpperInvariant()); + + private static string ParseManifestVersion(string publisherSegment, string versionSegment) + { + if (publisherSegment == "local") + { + return string.Empty; + } + + if (int.TryParse(versionSegment, out var versionNumber) && versionNumber > 0) + { + if (publisherSegment == PublisherTypeConstants.GeneralsOnline) + { + return versionNumber.ToString("D6"); + } + + return versionNumber >= 100 + ? $"v{versionNumber / 100}.{versionNumber % 100:D2}" + : $"v{versionNumber}"; + } + + return string.Empty; + } + + private static string ParseContentType(string gameTypeSegment) + { + if (!gameTypeSegment.Contains('-')) + { + return string.Empty; + } + + var parts = gameTypeSegment.Split('-'); + return parts[1] switch + { + "gameinstallation" => "Game Installation", + "gameclient" => "Game Client", + "mod" => "Mod", + "patch" => "Patch", + "addon" => "Add-on", + "map" => "Map", + "mappack" => "Map Pack", + "executable" => "Executable", + "moddingtool" => "Modding Tool", + "mission" => "Mission", + _ => parts[1].ToUpperInvariant(), + }; + } + private void UpdateDescription(GameProfile gameProfile) { // Use actual profile description if available @@ -800,23 +870,6 @@ private void UpdateDescription(GameProfile gameProfile) Description = string.Join(" • ", parts); } - /// - /// Checks if the version is zero or a placeholder. - /// - /// The version string to check. - private bool IsZeroOrPlaceholderVersion(string version) - { - return version.Equals(GameClientConstants.AutoDetectedVersion, StringComparison.OrdinalIgnoreCase) || - version.Equals(GameClientConstants.UnknownVersion, StringComparison.OrdinalIgnoreCase) || - version.Equals("Auto-Updated", StringComparison.OrdinalIgnoreCase) || - version.Contains("Automatically", StringComparison.OrdinalIgnoreCase) || - version == "0" || - version == "0.0" || - version == "0.0.0" || - version == "0.0.0.0" || - version.Equals("v0", StringComparison.OrdinalIgnoreCase); - } - /// /// Extracts version, publisher, and content type information from a manifest ID. /// Expected format: schemaVersion.userVersion.publisher.contentType.contentName. @@ -850,22 +903,6 @@ private void ExtractManifestInfo(string manifestId) } } - private string ParsePublisherName(string publisherSegment, string originalSegment) => - publisherSegment switch - { - PublisherTypeConstants.Steam => "Steam", - PublisherTypeConstants.EaApp => "EA App", - "thefirstdecade" => "The First Decade", - PublisherTypeConstants.Retail => "Retail", - "cdiso" => "CD/ISO", - "wine" => "Wine", - PublisherTypeConstants.GeneralsOnline => "Generals Online", - PublisherTypeConstants.TheSuperHackers => "The Super Hackers", - CommunityOutpostConstants.PublisherType => "Community Outpost", - "local" => "Local", - _ => originalSegment.ToUpperInvariant(), - }; - private void ApplyPublisherBranding(string publisherSegment) { if (publisherSegment == PublisherTypeConstants.TheSuperHackers) @@ -884,50 +921,4 @@ private void ApplyPublisherBranding(string publisherSegment) CoverImagePath = CommunityOutpostConstants.CoverSource; } } - - private string ParseManifestVersion(string publisherSegment, string versionSegment) - { - if (publisherSegment == "local") - { - return string.Empty; - } - - if (int.TryParse(versionSegment, out var versionNumber) && versionNumber > 0) - { - if (publisherSegment == PublisherTypeConstants.GeneralsOnline) - { - return versionNumber.ToString("D6"); - } - - return versionNumber >= 100 - ? $"v{versionNumber / 100}.{versionNumber % 100:D2}" - : $"v{versionNumber}"; - } - - return string.Empty; - } - - private string ParseContentType(string gameTypeSegment) - { - if (!gameTypeSegment.Contains('-')) - { - return string.Empty; - } - - var parts = gameTypeSegment.Split('-'); - return parts[1] switch - { - "gameinstallation" => "Game Installation", - "gameclient" => "Game Client", - "mod" => "Mod", - "patch" => "Patch", - "addon" => "Add-on", - "map" => "Map", - "mappack" => "Map Pack", - "executable" => "Executable", - "moddingtool" => "Modding Tool", - "mission" => "Mission", - _ => parts[1].ToUpperInvariant(), - }; - } } diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs index 493d49e7a..5245597b8 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Commands.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Messaging; @@ -10,6 +11,7 @@ using GenHub.Core.Models.GameProfile; using GenHub.Core.Models.GameProfiles; using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Results; using Microsoft.Extensions.Logging; namespace GenHub.Features.GameProfiles.ViewModels; @@ -57,10 +59,8 @@ protected virtual async Task LoadAvailableContentAsync() { IsLoadingContent = true; StatusMessage = "Loading content..."; - var existingLocks = AvailableContent - .Concat(EnabledContent) - .GroupBy(x => x.ManifestId.Value, StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => (g.First().IsLocked, g.First().CanToggle), StringComparer.OrdinalIgnoreCase); + + await RefreshHotswapStateAsync(); AvailableContent.Clear(); @@ -111,12 +111,6 @@ protected virtual async Task LoadAvailableContentAsync() } var viewModelItem = ConvertToViewModelContentDisplayItem(coreItem); - if (existingLocks.TryGetValue(coreItem.ManifestId, out var lockState)) - { - viewModelItem.IsLocked = lockState.IsLocked; - viewModelItem.CanToggle = lockState.CanToggle; - } - AvailableContent.Add(viewModelItem); } catch (ArgumentException argEx) @@ -199,6 +193,7 @@ private async Task DisableContentAsync(ContentDisplayItem? contentItem) { StatusMessage = "This content item is locked and cannot be modified"; _logger?.LogWarning("DisableContent: Cannot disable locked item {DisplayName}", contentItem.DisplayName); + _localNotificationService.ShowWarning("Content Locked", $"'{contentItem.DisplayName}' is locked and cannot be modified while the game is running."); return; } @@ -397,94 +392,321 @@ private async Task SaveAsync() if (string.IsNullOrEmpty(CurrentProfileId)) { - var createRequest = new CreateProfileRequest - { - Name = Name, - Description = Description, - GameInstallationId = SelectedGameInstallation.SourceId, - GameClientId = SelectedGameInstallation.GameClientId, - WorkspaceStrategy = SelectedWorkspaceStrategy, - EnabledContentIds = enabledContentIds, - CommandLineArguments = CommandLineArguments, - IconPath = IconPath, - CoverPath = CoverPath, - ThemeColor = ColorValue, - }; - - var gameSettings = GameSettingsViewModel.GetProfileSettings(); - PopulateGameSettings(createRequest, gameSettings); - - var result = await _gameProfileManager.CreateProfileAsync(createRequest); - if (result.Success && result.Data != null) - { - if (GameSettingsViewModel.SaveSettingsCommand.CanExecute(null)) - { - await GameSettingsViewModel.SaveSettingsCommand.ExecuteAsync(null); - } + await CreateProfileAsync(enabledContentIds, cancellationToken: default); + } + else + { + await UpdateProfileAsync(enabledContentIds, cancellationToken: default); + } + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error saving profile"); + StatusMessage = "Error saving profile"; + } + finally + { + IsSaving = false; + } + } - StatusMessage = "Profile created successfully"; - _logger?.LogInformation("Created new profile {ProfileName} with {ContentCount} enabled content items", Name, enabledContentIds.Count); + private async Task CreateProfileAsync(List enabledContentIds, CancellationToken cancellationToken = default) + { + if (_gameProfileManager == null) + { + return; + } - ExecuteCancel(); - } - else - { - StatusMessage = $"Failed to create profile: {string.Join(", ", result.Errors)}"; - _logger?.LogWarning("Failed to create profile: {Errors}", string.Join(", ", result.Errors)); - } + var createRequest = new CreateProfileRequest + { + Name = Name, + Description = Description, + GameInstallationId = SelectedGameInstallation?.SourceId, + GameClientId = SelectedGameInstallation?.GameClientId, + WorkspaceStrategy = SelectedWorkspaceStrategy, + EnabledContentIds = enabledContentIds, + CommandLineArguments = CommandLineArguments, + IconPath = IconPath, + CoverPath = CoverPath, + ThemeColor = ColorValue, + }; + + var gameSettings = GameSettingsViewModel.GetProfileSettings(); + PopulateGameSettings(createRequest, gameSettings); + + var result = await _gameProfileManager.CreateProfileAsync(createRequest, cancellationToken); + if (result.Success && result.Data != null) + { + CurrentProfileId = result.Data.Id; + + if (GameSettingsViewModel.SaveSettingsCommand.CanExecute(null)) + { + await GameSettingsViewModel.SaveSettingsCommand.ExecuteAsync(null); + } + + StatusMessage = "Profile created successfully"; + _logger?.LogInformation("Created new profile {ProfileName} with {ContentCount} enabled content items", Name, enabledContentIds.Count); + + WeakReferenceMessenger.Default.Send(new ProfileCreatedMessage(result.Data)); + ExecuteCancel(); + } + else + { + StatusMessage = $"Failed to create profile: {string.Join(", ", result.Errors)}"; + _logger?.LogWarning("Failed to create profile: {Errors}", string.Join(", ", result.Errors)); + } + } + + private async Task UpdateProfileAsync(List enabledContentIds, CancellationToken cancellationToken = default) + { + if (_gameProfileManager == null || string.IsNullOrEmpty(CurrentProfileId)) + { + return; + } + + var gameSettings = GameSettingsViewModel.GetProfileSettings(); + + var wasHotswap = IsHotswapMode; + bool isProfileRunning = await CheckIsProfileRunningAsync(); + if (!wasHotswap && isProfileRunning) + { + StatusMessage = "Game session started; non-hotswappable settings are now locked"; + _localNotificationService.ShowWarning( + "Hotswap Mode Enabled", + "The game was started while editing this profile. Non-hotswappable settings have been locked. Please review your changes and save again."); + return; + } + + var liveGameType = SelectedGameInstallation?.GameType ?? GameTypeFilter; + var updateRequest = BuildUpdateRequest(enabledContentIds, gameSettings); + + if (isProfileRunning && _profileContentLinker != null && _manifestPool != null) + { + var liveSyncSuccess = await PerformLiveSyncAsync(enabledContentIds, liveGameType, cancellationToken); + if (!liveSyncSuccess) + { + return; + } + } + + var result = await _gameProfileManager.UpdateProfileAsync(CurrentProfileId, updateRequest, cancellationToken); + if (result.Success && result.Data != null) + { + await HandleProfileUpdateSuccessAsync(result, enabledContentIds, isProfileRunning); + } + else + { + await HandleProfileUpdateFailureAsync(isProfileRunning, liveGameType, result, cancellationToken); + } + } + + private UpdateProfileRequest BuildUpdateRequest(List enabledContentIds, UpdateProfileRequest? gameSettings) + { + var updateRequest = new UpdateProfileRequest + { + Name = Name, + Description = Description, + ThemeColor = ColorValue, + GameInstallationId = SelectedGameInstallation?.SourceId, + WorkspaceStrategy = OriginalWorkspaceStrategy.HasValue && SelectedWorkspaceStrategy != OriginalWorkspaceStrategy.Value + ? SelectedWorkspaceStrategy + : null, + EnabledContentIds = enabledContentIds, + CommandLineArguments = CommandLineArguments, + IconPath = IconPath, + CoverPath = CoverPath, + }; + + PopulateGameSettings(updateRequest, gameSettings); + return updateRequest; + } + + private async Task HandleProfileUpdateSuccessAsync(ProfileOperationResult result, List enabledContentIds, bool isProfileRunning) + { + if (!isProfileRunning && GameSettingsViewModel.SaveSettingsCommand.CanExecute(null)) + { + await GameSettingsViewModel.SaveSettingsCommand.ExecuteAsync(null); + } + + if (isProfileRunning) + { + _localNotificationService.ShowSuccess( + "Live Update Complete", + "Content changes have been applied to the active game session."); + } + + StatusMessage = "Profile updated successfully"; + _logger?.LogInformation("Updated profile {ProfileId} with {ContentCount} enabled content items", CurrentProfileId, enabledContentIds.Count); + + WeakReferenceMessenger.Default.Send(new ProfileUpdatedMessage(result.Data)); + ExecuteCancel(); + } + + private async Task CheckIsProfileRunningAsync() + { + if (string.IsNullOrEmpty(CurrentProfileId)) + { + return false; + } + + var isRunning = await DetermineHotswapModeAsync(CurrentProfileId); + if (isRunning != IsHotswapMode) + { + IsHotswapMode = isRunning; + UpdateAllItemsHotswapState(); + } + + return isRunning; + } + + private async Task PerformLiveSyncAsync( + List enabledContentIds, + GameType liveGameType, + CancellationToken cancellationToken = default) + { + if (_manifestPool == null || _profileContentLinker == null || string.IsNullOrEmpty(CurrentProfileId)) + { + return false; + } + + var manifests = new List(); + var missingManifestIds = new List(); + foreach (var id in enabledContentIds) + { + if (!ManifestId.TryCreate(id, out var manifestId)) + { + missingManifestIds.Add(id); + continue; + } + + var manifestRes = await _manifestPool.GetManifestAsync(manifestId, cancellationToken); + if (manifestRes.Success && manifestRes.Data != null) + { + manifests.Add(manifestRes.Data); } else { - var gameSettings = GameSettingsViewModel.GetProfileSettings(); + missingManifestIds.Add(id); + } + } - var updateRequest = new UpdateProfileRequest - { - Name = Name, - Description = Description, - ThemeColor = ColorValue, - GameInstallationId = SelectedGameInstallation?.SourceId, - - WorkspaceStrategy = OriginalWorkspaceStrategy.HasValue && SelectedWorkspaceStrategy != OriginalWorkspaceStrategy.Value - ? SelectedWorkspaceStrategy - : null, - EnabledContentIds = enabledContentIds, - CommandLineArguments = CommandLineArguments, - IconPath = IconPath, - CoverPath = CoverPath, - }; - - PopulateGameSettings(updateRequest, gameSettings); - - var result = await _gameProfileManager.UpdateProfileAsync(CurrentProfileId, updateRequest); - if (result.Success && result.Data != null) - { - if (GameSettingsViewModel.SaveSettingsCommand.CanExecute(null)) - { - await GameSettingsViewModel.SaveSettingsCommand.ExecuteAsync(null); - } + if (missingManifestIds.Count > 0) + { + var error = $"Cannot live-sync active session: failed to resolve manifests for {string.Join(", ", missingManifestIds)}"; + StatusMessage = error; + _localNotificationService.ShowWarning("Live Update Warning", error); + _logger?.LogWarning("Profile {ProfileId} live sync aborted due to missing manifests: {Ids}", CurrentProfileId, string.Join(", ", missingManifestIds)); + return false; + } - StatusMessage = "Profile updated successfully"; - _logger?.LogInformation("Updated profile {ProfileId} with {ContentCount} enabled content items", CurrentProfileId, enabledContentIds.Count); + var liveUpdateResult = await _profileContentLinker.UpdateProfileUserDataAsync( + CurrentProfileId, + manifests, + liveGameType, + cancellationToken); - WeakReferenceMessenger.Default.Send(new ProfileUpdatedMessage(result.Data)); + if (!liveUpdateResult.Success) + { + StatusMessage = $"Live sync failed: {liveUpdateResult.FirstError}"; + _localNotificationService.ShowWarning( + "Live Update Failed", + $"Live content synchronization failed: {liveUpdateResult.FirstError}. Profile changes were not saved."); + _logger?.LogWarning("Profile {ProfileId} live sync failed: {Error}", CurrentProfileId, liveUpdateResult.FirstError); + return false; + } - ExecuteCancel(); - } - else - { - StatusMessage = $"Failed to update profile: {string.Join(", ", result.Errors)}"; - _logger?.LogWarning("Failed to update profile {ProfileId}: {Errors}", CurrentProfileId, string.Join(", ", result.Errors)); - } + return true; + } + + private async Task HandleProfileUpdateFailureAsync( + bool isProfileRunning, + GameType liveGameType, + ProfileOperationResult result, + CancellationToken cancellationToken = default) + { + if (!isProfileRunning || _profileContentLinker == null || _manifestPool == null || string.IsNullOrEmpty(CurrentProfileId)) + { + StatusMessage = $"Failed to update profile: {string.Join(", ", result.Errors)}"; + _logger?.LogWarning("Failed to update profile {ProfileId}: {Errors}", CurrentProfileId, string.Join(", ", result.Errors)); + return; + } + + var (originalManifests, missingOriginalIds) = await ResolveOriginalManifestsForRollbackAsync(cancellationToken); + if (missingOriginalIds.Count > 0) + { + _logger?.LogError("Live sync rollback for profile {ProfileId} had missing original manifests: {Ids}", CurrentProfileId, string.Join(", ", missingOriginalIds)); + _localNotificationService.ShowError( + "Live Rollback Warning", + $"Profile save failed ({string.Join(", ", result.Errors)}), and original content could not be fully resolved for rollback: {string.Join(", ", missingOriginalIds)}. Live content was left as synchronized and may not match the saved profile."); + StatusMessage = $"Failed to update profile: {string.Join(", ", result.Errors)}. Live rollback skipped: unresolved original manifests."; + _logger?.LogWarning("Failed to update profile {ProfileId}: {Errors}", CurrentProfileId, string.Join(", ", result.Errors)); + return; + } + + await ExecuteLiveSyncRollbackAsync(originalManifests, liveGameType, result, cancellationToken); + } + + private async Task<(List Manifests, List MissingIds)> ResolveOriginalManifestsForRollbackAsync(CancellationToken cancellationToken) + { + var originalManifests = new List(); + var missingOriginalIds = new List(); + + if (_manifestPool == null) + { + return (originalManifests, _originalEnabledContentIds.ToList()); + } + + foreach (var id in _originalEnabledContentIds) + { + if (!ManifestId.TryCreate(id, out var manifestId)) + { + missingOriginalIds.Add(id); + continue; + } + + var manifestRes = await _manifestPool.GetManifestAsync(manifestId, cancellationToken); + if (manifestRes.Success && manifestRes.Data != null) + { + originalManifests.Add(manifestRes.Data); + } + else + { + missingOriginalIds.Add(id); } } - catch (Exception ex) + + return (originalManifests, missingOriginalIds); + } + + private async Task ExecuteLiveSyncRollbackAsync( + List originalManifests, + GameType liveGameType, + ProfileOperationResult result, + CancellationToken cancellationToken) + { + if (_profileContentLinker == null || string.IsNullOrEmpty(CurrentProfileId)) { - _logger?.LogError(ex, "Error saving profile"); - StatusMessage = "Error saving profile"; + return; } - finally + + var rollbackResult = await _profileContentLinker.UpdateProfileUserDataAsync( + CurrentProfileId, + originalManifests, + liveGameType, + cancellationToken); + + if (!rollbackResult.Success) { - IsSaving = false; + _logger?.LogError("Failed to roll back live user data sync for profile {ProfileId}: {Error}", CurrentProfileId, rollbackResult.FirstError); + _localNotificationService.ShowError( + "Live Rollback Failed", + $"Profile save failed ({string.Join(", ", result.Errors)}), and live content rollback reported: {rollbackResult.FirstError}"); + StatusMessage = $"Failed to update profile: {string.Join(", ", result.Errors)}. Live rollback failed: {rollbackResult.FirstError}"; + } + else + { + _logger?.LogInformation("Successfully rolled back live user data sync for profile {ProfileId}", CurrentProfileId); + StatusMessage = $"Failed to update profile: {string.Join(", ", result.Errors)}. Live content was rolled back."; } } diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs index 4cda6cfe5..9d9cafdf9 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Initialization.cs @@ -36,6 +36,7 @@ public virtual async Task InitializeForNewProfileAsync() } CurrentProfileId = null; + IsHotswapMode = false; Name = ProfileConstants.DefaultProfileName; Description = "A new game profile"; ColorValue = "#1976D2"; @@ -147,49 +148,25 @@ public virtual async Task InitializeForProfileAsync(string profileId) } var profile = profileResult.Data; - Name = profile.Name; - Description = profile.Description ?? string.Empty; - ColorValue = profile.ThemeColor ?? "#1976D2"; - var defaultIconPath = _profileResourceService?.GetDefaultIconPath(profile.GameClient?.GameType.ToString() ?? "ZeroHour") - ?? Core.Constants.UriConstants.DefaultIconUri; - IconPath = NormalizeResourcePath(profile.IconPath, defaultIconPath); - var defaultCoverPath = _profileResourceService?.GetDefaultCoverPath(profile.GameClient?.GameType.ToString() ?? "ZeroHour") ?? string.Empty; - CoverPath = NormalizeResourcePath(profile.CoverPath, defaultCoverPath); - SelectedWorkspaceStrategy = profile.WorkspaceStrategy ?? GetDefaultWorkspaceStrategy(); - OriginalWorkspaceStrategy = profile.WorkspaceStrategy ?? GetDefaultWorkspaceStrategy(); - CommandLineArguments = profile.CommandLineArguments ?? string.Empty; - - LoadAvailableIconsAndCovers(profile.GameClient?.GameType.ToString() ?? "ZeroHour"); - GameTypeFilter = profile.GameClient?.GameType ?? Core.Models.Enums.GameType.ZeroHour; + ApplyLoadedProfileProperties(profile); GameSettingsViewModel.ColorValue = ColorValue; await GameSettingsViewModel.InitializeForProfileAsync(profileId, profile); - if (!profile.HasCustomSettings()) - { - var gameSettings = GameSettingsViewModel.GetProfileSettings(); - var updateRequest = new UpdateProfileRequest(); - PopulateGameSettings(updateRequest, gameSettings); + IsHotswapMode = await DetermineHotswapModeAsync(profileId); - var updateResult = await _gameProfileManager.UpdateProfileAsync(profileId, updateRequest); - if (updateResult.Success) - { - _logger?.LogInformation("Saved default game settings for profile {ProfileId}", profileId); - } + if (!IsHotswapMode && !profile.HasCustomSettings()) + { + await SaveDefaultGameSettingsAsync(profileId); } await LoadEnabledContentForProfileAsync(profile); await LoadAvailableGameInstallationsAsync(); await LoadAvailableContentAsync(); + UpdateAllItemsHotswapState(); await RefreshVisibleFiltersAsync(); - var enabledInstallation = EnabledContent.FirstOrDefault(c => c.ContentType == Core.Models.Enums.ContentType.GameInstallation); - if (enabledInstallation != null) - { - SelectedGameInstallation = AvailableGameInstallations - .FirstOrDefault(a => a.ManifestId.Value == enabledInstallation.ManifestId.Value) - ?? enabledInstallation; - } + SelectInitialGameInstallation(profile); StatusMessage = $"Profile loaded with {EnabledContent.Count} enabled content items"; } @@ -257,4 +234,75 @@ void AddFilterIfAvailable(ContentType type, string iconData) _logger?.LogError(ex, "Error refreshing visible filters"); } } + + private void ApplyLoadedProfileProperties(GameProfile profile) + { + Name = profile.Name; + Description = profile.Description ?? string.Empty; + ColorValue = profile.ThemeColor ?? "#1976D2"; + var defaultIconPath = _profileResourceService?.GetDefaultIconPath(profile.GameClient?.GameType.ToString() ?? "ZeroHour") + ?? Core.Constants.UriConstants.DefaultIconUri; + IconPath = NormalizeResourcePath(profile.IconPath, defaultIconPath); + var defaultCoverPath = _profileResourceService?.GetDefaultCoverPath(profile.GameClient?.GameType.ToString() ?? "ZeroHour") ?? string.Empty; + CoverPath = NormalizeResourcePath(profile.CoverPath, defaultCoverPath); + SelectedWorkspaceStrategy = profile.WorkspaceStrategy ?? GetDefaultWorkspaceStrategy(); + OriginalWorkspaceStrategy = profile.WorkspaceStrategy ?? GetDefaultWorkspaceStrategy(); + _originalEnabledContentIds.Clear(); + if (profile.EnabledContentIds != null) + { + _originalEnabledContentIds.AddRange(profile.EnabledContentIds); + } + + CommandLineArguments = profile.CommandLineArguments ?? string.Empty; + LoadAvailableIconsAndCovers(profile.GameClient?.GameType.ToString() ?? "ZeroHour"); + GameTypeFilter = profile.GameClient?.GameType ?? Core.Models.Enums.GameType.ZeroHour; + } + + private async Task SaveDefaultGameSettingsAsync(string profileId) + { + var gameSettings = GameSettingsViewModel.GetProfileSettings(); + var updateRequest = new UpdateProfileRequest(); + PopulateGameSettings(updateRequest, gameSettings); + + var updateResult = await _gameProfileManager.UpdateProfileAsync(profileId, updateRequest); + if (updateResult.Success) + { + _logger?.LogInformation("Saved default game settings for profile {ProfileId}", profileId); + } + } + + private async Task DetermineHotswapModeAsync(string profileId) + { + if (_launchRegistry == null) + { + return false; + } + + var activeLaunches = await _launchRegistry.GetAllActiveLaunchesAsync(); + return activeLaunches.Any(l => string.Equals(l.ProfileId, profileId, StringComparison.OrdinalIgnoreCase) && !l.TerminatedAt.HasValue); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", Justification = "Mutates SelectedGameInstallation and instance collections in partial view model")] + private void SelectInitialGameInstallation(GameProfile profile) + { + var enabledInstallation = EnabledContent.FirstOrDefault(c => c.ContentType == Core.Models.Enums.ContentType.GameInstallation); + if (enabledInstallation != null) + { + enabledInstallation.IsEnabled = true; + SelectedGameInstallation = AvailableGameInstallations + .FirstOrDefault(a => a.ManifestId.Value == enabledInstallation.ManifestId.Value) + ?? enabledInstallation; + SelectedGameInstallation.IsEnabled = true; + } + else if (!string.IsNullOrEmpty(profile.GameInstallationId)) + { + var matchingInstallation = AvailableGameInstallations + .FirstOrDefault(a => a.SourceId == profile.GameInstallationId && (profile.GameClient == null || a.GameType == profile.GameClient.GameType)); + if (matchingInstallation != null) + { + SelectedGameInstallation = matchingInstallation; + SelectedGameInstallation.IsEnabled = true; + } + } + } } diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Properties.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Properties.cs index aba8bbfe4..985e19828 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Properties.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.Properties.cs @@ -197,4 +197,14 @@ public ContentType SelectedContentType [ObservableProperty] private GameType _selectedLocalGameType = Core.Models.Enums.GameType.ZeroHour; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CanEditImmutableMetadata))] + private bool _isHotswapMode; + + /// + /// Gets a value indicating whether immutable profile metadata can be edited (i.e. not in hotswap mode). + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage("Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", Justification = "Observable property accessed via property binding on ViewModel instance")] + public bool CanEditImmutableMetadata => !IsHotswapMode; } diff --git a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.cs b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.cs index e7aaec40f..2accc5144 100644 --- a/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.cs +++ b/GenHub/GenHub/Features/GameProfiles/ViewModels/GameProfileSettingsViewModel.cs @@ -13,11 +13,14 @@ using GenHub.Core.Interfaces.Content; using GenHub.Core.Interfaces.GameProfiles; using GenHub.Core.Interfaces.GameSettings; +using GenHub.Core.Interfaces.Launching; using GenHub.Core.Interfaces.Manifest; using GenHub.Core.Interfaces.Notifications; +using GenHub.Core.Interfaces.UserData; using GenHub.Core.Models.Enums; using GenHub.Core.Models.GameProfile; using GenHub.Core.Models.Manifest; +using GenHub.Core.Models.Workspace; using GenHub.Features.GameProfiles.Services; using GenHub.Features.Notifications.Services; using GenHub.Features.Notifications.ViewModels; @@ -121,27 +124,6 @@ private static void PopulateGameSettings(UpdateProfileRequest request, UpdatePro if (gameSettings != null) GameSettingsMapper.PopulateRequest(request, gameSettings); } - private static ContentDisplayItem ConvertToViewModelContentDisplayItem(Core.Models.Content.ContentDisplayItem coreItem) - { - return new ContentDisplayItem - { - ManifestId = ManifestId.Create(coreItem.ManifestId), - DisplayName = coreItem.DisplayName, - ContentType = coreItem.ContentType, - GameType = coreItem.GameType, - InstallationType = coreItem.InstallationType, - Publisher = coreItem.Publisher, - Version = coreItem.Version, - SourceId = coreItem.SourceId, - GameClientId = coreItem.GameClientId, - IsEnabled = coreItem.IsEnabled, - IsEditable = coreItem.IsEditable, - SourcePath = coreItem.SourcePath, - IsLocked = false, - CanToggle = true, - }; - } - private static void ValidateSingleDependencyWarning( ContentManifest manifest, ContentDependency dependency, @@ -198,6 +180,75 @@ private static void ValidateSingleDependencyWarning( private static bool HasCompatibleCatalogMatch(string declaredId, string availableId) => DependencyResolver.HasCompatibleCatalogIdentity(declaredId, availableId); + private static bool IsDependencyAlreadyEnabled(ContentDependency dependency, IEnumerable enabledContent) + { + var declaredId = dependency.Id.ToString(); + return declaredId != ManifestConstants.DefaultContentDependencyId + ? enabledContent.Any(x => x.ManifestId.Value == declaredId || + (x.ContentType == dependency.DependencyType && + HasCompatibleCatalogMatch(declaredId, x.ManifestId.Value))) + : enabledContent.Any(x => x.ContentType == dependency.DependencyType); + } + + private static (bool IsLocked, bool CanToggle) GetItemHotswapState(bool isHotswapMode, ContentType contentType, ContentManifest? manifest = null) + { + var isHotswappable = manifest != null + ? ContentHotswapClassification.IsHotswappable(manifest) + : ContentHotswapClassification.IsHotswappable(contentType); + var isLocked = isHotswapMode && !isHotswappable; + var canToggle = !isHotswapMode || isHotswappable; + return (isLocked, canToggle); + } + + private ContentDisplayItem ConvertToViewModelContentDisplayItem(Core.Models.Content.ContentDisplayItem coreItem) + { + var (isLocked, canToggle) = GetItemHotswapState(IsHotswapMode, coreItem.ContentType, coreItem.Manifest); + + return new ContentDisplayItem + { + ManifestId = ManifestId.Create(coreItem.ManifestId), + DisplayName = coreItem.DisplayName, + ContentType = coreItem.ContentType, + GameType = coreItem.GameType, + InstallationType = coreItem.InstallationType, + Publisher = coreItem.Publisher, + Version = coreItem.Version, + SourceId = coreItem.SourceId, + GameClientId = coreItem.GameClientId, + IsEnabled = coreItem.IsEnabled, + IsEditable = coreItem.IsEditable, + SourcePath = coreItem.SourcePath, + Manifest = coreItem.Manifest, + IsLocked = isLocked, + CanToggle = canToggle, + }; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Minor Code Smell", "S2325:Methods and properties that don't access instance data should be static", Justification = "Operates on observable collection properties defined across partial view model classes")] + private void UpdateAllItemsHotswapState() + { + var hotswapMode = IsHotswapMode; + foreach (var item in EnabledContent) + { + var (isLocked, canToggle) = GetItemHotswapState(hotswapMode, item.ContentType, item.Manifest); + item.IsLocked = isLocked; + item.CanToggle = canToggle; + } + + foreach (var item in AvailableContent) + { + var (isLocked, canToggle) = GetItemHotswapState(hotswapMode, item.ContentType, item.Manifest); + item.IsLocked = isLocked; + item.CanToggle = canToggle; + } + + foreach (var item in AvailableGameInstallations) + { + item.IsLocked = hotswapMode; + item.CanToggle = !hotswapMode; + } + } + private readonly IGameProfileManager? _gameProfileManager; private readonly IGameSettingsService? _gameSettingsService; private readonly IConfigurationProviderService? _configurationProvider; @@ -211,8 +262,11 @@ private static bool HasCompatibleCatalogMatch(string declaredId, string availabl private readonly IDialogService? _dialogService; private readonly ILogger? _logger; private readonly ILogger? _gameSettingsLogger; + private readonly IProfileContentLinker? _profileContentLinker; + private readonly ILaunchRegistry? _launchRegistry; private readonly NotificationService _localNotificationService = new(NullLogger.Instance); + private readonly List _originalEnabledContentIds = []; private WorkspaceStrategy? OriginalWorkspaceStrategy { get; set; } @@ -249,6 +303,8 @@ private static bool HasCompatibleCatalogMatch(string declaredId, string availabl /// The dialog service. /// The logger for this view model. /// The logger for the game settings view model. + /// The profile content linker service. + /// The launch registry service. public GameProfileSettingsViewModel( IGameProfileManager? gameProfileManager, IGameSettingsService? gameSettingsService, @@ -262,7 +318,9 @@ public GameProfileSettingsViewModel( IGenLauncherNormalizationService? genLauncherNormalizationService, IDialogService? dialogService, ILogger? logger, - ILogger? gameSettingsLogger) + ILogger? gameSettingsLogger, + IProfileContentLinker? profileContentLinker = null, + ILaunchRegistry? launchRegistry = null) { _gameProfileManager = gameProfileManager; _gameSettingsService = gameSettingsService; @@ -277,6 +335,8 @@ public GameProfileSettingsViewModel( _dialogService = dialogService; _logger = logger; _gameSettingsLogger = gameSettingsLogger; + _profileContentLinker = profileContentLinker; + _launchRegistry = launchRegistry; NotificationManager = new NotificationManagerViewModel( _localNotificationService, @@ -300,6 +360,32 @@ public void Receive(ManifestReplacedMessage message) Dispatcher.UIThread.Post(() => _ = HandleManifestReplacementAsync(message.OldId, message.NewId)); } + /// + /// Refreshes the hotswap mode and updates item lock states if the profile running state has changed. + /// + /// A task representing the asynchronous operation. + public virtual async Task RefreshHotswapStateAsync() + { + try + { + if (string.IsNullOrEmpty(CurrentProfileId)) + { + return; + } + + var isRunning = await DetermineHotswapModeAsync(CurrentProfileId); + if (isRunning != IsHotswapMode) + { + IsHotswapMode = isRunning; + UpdateAllItemsHotswapState(); + } + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Error refreshing hotswap mode for profile {ProfileId}", CurrentProfileId); + } + } + /// /// Handles the replacement of a manifest ID with a new one globally. /// Updates enabled and available content collections to use the new manifest ID. @@ -395,10 +481,26 @@ partial void OnGameTypeFilterChanged(GameType value) /// partial void OnSelectedGameInstallationChanged(ContentDisplayItem? value) { - if (value is { GameType: var gameType } && gameType != GameTypeFilter) + if (value != null) + { + value.IsEnabled = true; + foreach (var item in AvailableGameInstallations) + { + item.IsEnabled = item.ManifestId.Value == value.ManifestId.Value; + } + + if (value.GameType != GameTypeFilter) + { + GameTypeFilter = value.GameType; + _logger?.LogInformation("Auto-synced GameTypeFilter to {GameType} based on SelectedGameInstallation", value.GameType); + } + } + else { - GameTypeFilter = gameType; - _logger?.LogInformation("Auto-synced GameTypeFilter to {GameType} based on SelectedGameInstallation", gameType); + foreach (var item in AvailableGameInstallations) + { + item.IsEnabled = false; + } } } @@ -409,6 +511,7 @@ private async Task EnableContentInternal( bool bypassLoadingGuard = false, bool isRootOperation = true, List? autoEnabledNames = null, + HashSet? warnedLockedNames = null, CancellationToken cancellationToken = default) { if (contentItem is null || !CanEnableContent(contentItem, bypassLoadingGuard)) @@ -420,7 +523,8 @@ private async Task EnableContentInternal( ActivateContentItem(contentItem); var autoResolved = autoEnabledNames ?? []; - await ResolveDependenciesAsync(contentItem, autoResolved, cancellationToken); + var warnedLocked = warnedLockedNames ?? new HashSet(StringComparer.OrdinalIgnoreCase); + await ResolveDependenciesAsync(contentItem, autoResolved, warnedLocked, cancellationToken); if (isRootOperation) { @@ -443,6 +547,8 @@ private bool CanEnableContent(ContentDisplayItem? contentItem, bool bypassLoadin if (contentItem.IsLocked) { StatusMessage = "This content item is locked and cannot be modified"; + _logger?.LogWarning("EnableContent: Cannot enable locked item {DisplayName}", contentItem.DisplayName); + _localNotificationService.ShowWarning("Content Locked", $"'{contentItem.DisplayName}' is locked and cannot be modified while the game is running."); return false; } @@ -548,7 +654,11 @@ private async Task HandleRootOperationCompletionAsync(ContentDisplayItem content await ValidateEnabledContentDependenciesAsync(contentItem.DisplayName, cancellationToken); } - private async Task ResolveDependenciesAsync(ContentDisplayItem contentItem, List autoEnabledNames, CancellationToken cancellationToken = default) + private async Task ResolveDependenciesAsync( + ContentDisplayItem contentItem, + List autoEnabledNames, + HashSet warnedLockedNames, + CancellationToken cancellationToken = default) { try { @@ -564,11 +674,11 @@ private async Task ResolveDependenciesAsync(ContentDisplayItem contentItem, List { if (dependency.DependencyType == ContentType.GameInstallation) { - await ResolveGameInstallationDependencyAsync(contentItem, dependency, autoEnabledNames, cancellationToken); + await ResolveGameInstallationDependencyAsync(contentItem, dependency, autoEnabledNames, warnedLockedNames, cancellationToken); } else { - await ResolveContentDependencyAsync(dependency, autoEnabledNames, cancellationToken); + await ResolveContentDependencyAsync(dependency, autoEnabledNames, warnedLockedNames, cancellationToken); } } } @@ -620,6 +730,7 @@ private async Task ResolveGameInstallationDependencyAsync( ContentDisplayItem contentItem, ContentDependency dependency, List autoEnabledNames, + HashSet warnedLockedNames, CancellationToken cancellationToken = default) { bool isSatisfied = false; @@ -666,29 +777,52 @@ private async Task ResolveGameInstallationDependencyAsync( if (compatibleInstallation != null) { - if (!autoEnabledNames.Contains(compatibleInstallation.DisplayName)) + if (!compatibleInstallation.IsLocked && compatibleInstallation.CanToggle) { - autoEnabledNames.Add(compatibleInstallation.DisplayName); - } + if (!autoEnabledNames.Contains(compatibleInstallation.DisplayName)) + { + autoEnabledNames.Add(compatibleInstallation.DisplayName); + } - await EnableContentInternal(compatibleInstallation, bypassLoadingGuard: true, isRootOperation: false, autoEnabledNames, cancellationToken); + await EnableContentInternal(compatibleInstallation, bypassLoadingGuard: true, isRootOperation: false, autoEnabledNames, warnedLockedNames, cancellationToken); + } + else + { + _logger?.LogWarning("Auto-resolve skipped: Installation {DisplayName} is locked or cannot toggle", compatibleInstallation.DisplayName); + if (compatibleInstallation.IsLocked && warnedLockedNames.Add(compatibleInstallation.DisplayName)) + { + _localNotificationService.ShowWarning("Content Locked", $"Required dependency '{compatibleInstallation.DisplayName}' is locked and cannot be automatically enabled while the game is running."); + } + } } } private async Task ResolveContentDependencyAsync( ContentDependency dependency, List autoEnabledNames, + HashSet warnedLockedNames, CancellationToken cancellationToken = default) { - var declaredId = dependency.Id.ToString(); - bool alreadyEnabled = declaredId != ManifestConstants.DefaultContentDependencyId - ? EnabledContent.Any(x => x.ManifestId.Value == declaredId || - (x.ContentType == dependency.DependencyType && - HasCompatibleCatalogMatch(declaredId, x.ManifestId.Value))) - : EnabledContent.Any(x => x.ContentType == dependency.DependencyType); + if (IsDependencyAlreadyEnabled(dependency, EnabledContent) || dependency.IsOptional || _profileContentLoader == null) + { + return; + } + + var match = await FindMatchingContentDependencyAsync(dependency); + if (match != null) + { + await ProcessMatchedDependencyItemAsync(match, autoEnabledNames, warnedLockedNames, cancellationToken); + } + } - if (alreadyEnabled || dependency.IsOptional || _profileContentLoader == null) return; + private async Task FindMatchingContentDependencyAsync(ContentDependency dependency) + { + if (_profileContentLoader == null) + { + return null; + } + var declaredId = dependency.Id.ToString(); var availableOfTargetType = await _profileContentLoader.LoadAvailableContentAsync( dependency.DependencyType, new ObservableCollection(AvailableGameInstallations.Select(x => new Core.Models.Content.ContentDisplayItem @@ -701,22 +835,34 @@ private async Task ResolveContentDependencyAsync( })), EnabledContent.Select(x => x.ManifestId.Value)); - var match = declaredId != ManifestConstants.DefaultContentDependencyId + return declaredId != ManifestConstants.DefaultContentDependencyId ? (availableOfTargetType.FirstOrDefault(x => x.ManifestId == declaredId) ?? availableOfTargetType.FirstOrDefault(x => HasCompatibleCatalogMatch(declaredId, x.ManifestId))) : availableOfTargetType.FirstOrDefault(x => x.ContentType == dependency.DependencyType); + } - if (match != null) + private async Task ProcessMatchedDependencyItemAsync( + Core.Models.Content.ContentDisplayItem match, + List autoEnabledNames, + HashSet warnedLockedNames, + CancellationToken cancellationToken) + { + var viewModelItem = ConvertToViewModelContentDisplayItem(match); + if (!viewModelItem.IsEnabled && !viewModelItem.IsLocked && viewModelItem.CanToggle) { - var viewModelItem = ConvertToViewModelContentDisplayItem(match); - if (!viewModelItem.IsEnabled) + if (!autoEnabledNames.Contains(viewModelItem.DisplayName)) { - if (!autoEnabledNames.Contains(viewModelItem.DisplayName)) - { - autoEnabledNames.Add(viewModelItem.DisplayName); - } + autoEnabledNames.Add(viewModelItem.DisplayName); + } - await EnableContentInternal(viewModelItem, bypassLoadingGuard: true, isRootOperation: false, autoEnabledNames, cancellationToken); + await EnableContentInternal(viewModelItem, bypassLoadingGuard: true, isRootOperation: false, autoEnabledNames, warnedLockedNames, cancellationToken); + } + else if (viewModelItem.IsLocked || !viewModelItem.CanToggle) + { + _logger?.LogWarning("Auto-resolve skipped: Content {DisplayName} is locked or cannot toggle", viewModelItem.DisplayName); + if (viewModelItem.IsLocked && warnedLockedNames.Add(viewModelItem.DisplayName)) + { + _localNotificationService.ShowWarning("Content Locked", $"Required dependency '{viewModelItem.DisplayName}' is locked and cannot be automatically enabled while the game is running."); } } } @@ -906,6 +1052,15 @@ private async Task LoadAvailableGameInstallationsAsync() SelectedGameInstallation = AvailableGameInstallations .OrderByDescending(i => i.GameType == Core.Models.Enums.GameType.ZeroHour) .First(); + SelectedGameInstallation.IsEnabled = true; + } + else if (SelectedGameInstallation != null) + { + var match = AvailableGameInstallations.FirstOrDefault(a => a.ManifestId.Value == SelectedGameInstallation.ManifestId.Value); + if (match != null) + { + match.IsEnabled = true; + } } } catch (Exception ex) diff --git a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml index 12ae3350a..d7a02cafc 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentView.axaml @@ -26,28 +26,28 @@ @@ -83,7 +83,7 @@ @@ -179,9 +179,9 @@ - + - + diff --git a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml.cs b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml.cs index 923853957..ae7c25760 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml.cs +++ b/GenHub/GenHub/Features/GameProfiles/Views/AddLocalContentWindow.axaml.cs @@ -31,13 +31,6 @@ protected override void OnOpened(EventArgs e) GenHub.Infrastructure.Interop.AdminDragDropFix.Apply(this, OnAdminDrop); } - /// - protected override void OnClosed(EventArgs e) - { - base.OnClosed(e); - (DataContext as IDisposable)?.Dispose(); - } - /// protected override void OnDataContextChanged(EventArgs e) { @@ -54,13 +47,12 @@ protected override void OnDataContextChanged(EventArgs e) return null; } - var folders = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions + var result = await StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions { Title = "Select Content Folder", AllowMultiple = false, }); - - return folders.Count > 0 ? folders[0].Path.LocalPath : null; + return result.Count > 0 ? result[0].Path.LocalPath : null; }; vm.BrowseFileAction = async () => @@ -70,48 +62,26 @@ protected override void OnDataContextChanged(EventArgs e) return null; } - var files = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + var result = await StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions { - Title = "Select Archive File", + Title = "Select Files", AllowMultiple = true, FileTypeFilter = [ - new FilePickerFileType("Archive Files") + new("Supported Content Files (*.zip, *.7z, *.rar, *.tar, *.gz, *.big)") { Patterns = ["*.zip", "*.7z", "*.rar", "*.tar", "*.gz", "*.big"], }, - new FilePickerFileType("All Files") - { - Patterns = ["*.*"], - }, + new("Zip Archives (*.zip)") { Patterns = ["*.zip"] }, + new("BIG Files (*.big)") { Patterns = ["*.big"] }, + FilePickerFileTypes.All, ], }); - - return files.Count > 0 ? files.Select(f => f.Path.LocalPath).ToList() : null; + return result.Count > 0 ? result.Select(f => f.Path.LocalPath).ToList() : null; }; } } - /// - /// Handles pointer pressed on the title bar for dragging and maximizing. - /// - /// The sender. - /// The event arguments. - private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e) - { - if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) - { - if (e.ClickCount == 2 && CanResize) - { - WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; - } - else - { - BeginMoveDrag(e); - } - } - } - private void OnAdminDrop(string[] files) { _ = ProcessAdminDropAsync(files); @@ -164,4 +134,18 @@ private async void OnDrop(object? sender, DragEventArgs e) } } } + + private void OnTitleBarPointerPressed(object? sender, PointerPressedEventArgs e) + { + if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + { + if (e.ClickCount == 2 && CanResize) + { + WindowState = WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized; + return; + } + + BeginMoveDrag(e); + } + } } diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileCardView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileCardView.axaml index f58b0bbe9..1bd7638c2 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileCardView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileCardView.axaml @@ -581,6 +581,33 @@ + + + + + + + + + + + + diff --git a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml index c64a7e1c9..9829b9d8b 100644 --- a/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml +++ b/GenHub/GenHub/Features/GameProfiles/Views/GameProfileContentEditorView.axaml @@ -31,22 +31,23 @@ @@ -80,46 +81,14 @@ - - - - - @@ -170,10 +139,10 @@ + Padding="12,24"> @@ -131,7 +131,7 @@ Spacing="8" Margin="0,0,0,8" IsVisible="{Binding IsValidating}"> - + diff --git a/GenHub/GenHub/Features/Info/ViewModels/ChangelogsViewModel.cs b/GenHub/GenHub/Features/Info/ViewModels/ChangelogsViewModel.cs index f797b54c0..48b9fa7cb 100644 --- a/GenHub/GenHub/Features/Info/ViewModels/ChangelogsViewModel.cs +++ b/GenHub/GenHub/Features/Info/ViewModels/ChangelogsViewModel.cs @@ -66,6 +66,10 @@ public async Task LoadChangelogsAsync() if (Releases.Count == 0) { logger.LogWarning("No releases found."); + HasError = true; + ErrorMessage = gitHubApiClient.IsRateLimited + ? "GitHub API rate limit exceeded. Please configure a GitHub Personal Access Token in Settings or try again later." + : "No release changelogs found."; } } catch (Exception ex) diff --git a/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml b/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml index 543bdf0ea..c6b9a15af 100644 --- a/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml +++ b/GenHub/GenHub/Features/Info/Views/GenHubInfoSectionView.axaml @@ -68,7 +68,7 @@ @@ -92,7 +92,7 @@