using CommunityToolkit.Mvvm.Input; using LanMountainDesktop.Models; using LanMountainDesktop.PluginSdk; using LanMountainDesktop.Services; using LanMountainDesktop.Services.Plonds; using LanMountainDesktop.Services.Settings; using LanMountainDesktop.Services.Update; using LanMountainDesktop.Shared.Contracts.Update; using LanMountainDesktop.ViewModels; using Xunit; using UpdateDownloadResult = LanMountainDesktop.Services.Update.DownloadResult; using SettingsUpdateState = LanMountainDesktop.Services.Settings.UpdateSettingsState; namespace LanMountainDesktop.Tests; public sealed class UpdateSettingsInterfaceTests { [Fact] public async Task UpdateSettingsViewModel_RoutesActionsThroughUpdateSettingsService() { var update = new FakeUpdateSettingsService(); var viewModel = new UpdateSettingsViewModel(new FakeSettingsFacade(update)); Assert.Equal(0, update.SaveCalls); update.CheckReport = new UpdateCheckReport( true, "1.2.3", "1.0.0", UpdatePayloadKind.DeltaPlonds, "dist-1", UpdateSettingsValues.ChannelStable, DateTimeOffset.Parse("2026-05-06T00:00:00Z"), 42, null, null); await ((IAsyncRelayCommand)viewModel.CheckCommand).ExecuteAsync(null); Assert.Equal(1, update.CheckCalls); Assert.Equal("1.2.3", viewModel.LatestVersionText); Assert.True(viewModel.IsDeltaUpdate); update.SetPhase(UpdatePhase.Checked); await ((IAsyncRelayCommand)viewModel.DownloadCommand).ExecuteAsync(null); Assert.Equal(1, update.DownloadCalls); update.SetPhase(UpdatePhase.Downloaded); await ((IAsyncRelayCommand)viewModel.InstallCommand).ExecuteAsync(null); Assert.Equal(1, update.InstallCalls); update.SetPhase(UpdatePhase.Downloading); await ((IAsyncRelayCommand)viewModel.PauseCommand).ExecuteAsync(null); Assert.Equal(1, update.PauseCalls); update.SetPhase(UpdatePhase.PausedDownloading); await ((IAsyncRelayCommand)viewModel.ResumeCommand).ExecuteAsync(null); Assert.Equal(1, update.ResumeCalls); update.SetPhase(UpdatePhase.Downloading); await ((IAsyncRelayCommand)viewModel.CancelCommand).ExecuteAsync(null); Assert.Equal(1, update.CancelCalls); } [Fact] public void UpdateSettingsViewModel_SavesPreferencesThroughUpdateSettingsService() { var update = new FakeUpdateSettingsService(); var viewModel = new UpdateSettingsViewModel(new FakeSettingsFacade(update)); viewModel.SelectedUpdateChannelValue = UpdateSettingsValues.ChannelPreview; viewModel.SelectedUpdateSourceValue = UpdateSettingsValues.DownloadSourceGitHub; viewModel.SelectedUpdateModeValue = UpdateSettingsValues.ModeManual; viewModel.DownloadThreadsSliderValue = 12; viewModel.ForceReinstall = true; Assert.True(update.SaveCalls >= 5); Assert.Equal(UpdateSettingsValues.ChannelPreview, update.State.UpdateChannel); Assert.Equal(UpdateSettingsValues.DownloadSourceGitHub, update.State.UpdateDownloadSource); Assert.Equal(UpdateSettingsValues.ModeManual, update.State.UpdateMode); Assert.Equal(12, update.State.UpdateDownloadThreads); Assert.True(update.State.ForceUpdateReinstall); } [Fact] public void UpdateSettingsViewModel_RestoresPersistedPendingAndLastCheckedState() { var update = new FakeUpdateSettingsService { State = DefaultUpdateState() with { PendingUpdateVersion = "2.0.0", PendingUpdatePublishedAtUtcMs = DateTimeOffset.Parse("2026-05-06T00:00:00Z").ToUnixTimeMilliseconds(), LastUpdateCheckUtcMs = DateTimeOffset.Parse("2026-05-07T00:00:00Z").ToUnixTimeMilliseconds() } }; var viewModel = new UpdateSettingsViewModel(new FakeSettingsFacade(update)); Assert.True(viewModel.IsUpdateAvailable); Assert.Equal("2.0.0", viewModel.LatestVersionText); Assert.NotEmpty(viewModel.PublishedAtText); Assert.Contains("Last checked", viewModel.LastCheckedText, StringComparison.OrdinalIgnoreCase); } [Fact] public async Task UpdateSettingsService_WhenPlondsSelected_UsesPlondsServiceWithoutCreatingOrchestrator() { var settings = new FakeSettingsService { Snapshot = { UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds } }; var plonds = new FakePlondsService { LatestResult = PlondsLatestResult.Available( new Version(1, 0, 0), new Version(9, 9, 9), [new PlondsManifestCandidate( new PlondsSourceDescriptor("s3", "s3", "https://s3.test/PLONDS.json", 100), CreatePlondsManifest("9.9.9"))]) }; var orchestratorCreated = false; var service = new UpdateSettingsService( settings, orchestratorFactory: () => { orchestratorCreated = true; throw new InvalidOperationException("UpdateOrchestrator should not be created for PLONDS."); }, plondsService: plonds); var report = await service.CheckAsync(CancellationToken.None); Assert.True(report.IsUpdateAvailable); Assert.Equal("9.9.9", report.LatestVersion); Assert.Equal(1, plonds.FindLatestCalls); Assert.False(orchestratorCreated); } [Fact] public async Task UpdateSettingsService_WhenGitHubSelected_UsesOrchestrator() { var settings = new FakeSettingsService { Snapshot = { UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub } }; var orchestrator = CreateTestOrchestrator(DefaultUpdateState() with { UpdateDownloadSource = UpdateSettingsValues.DownloadSourceGitHub }); var orchestratorCreated = false; var service = new UpdateSettingsService( settings, orchestratorFactory: () => { orchestratorCreated = true; return orchestrator; }, plondsService: new FakePlondsService()); var _ = service.CurrentPhase; Assert.False(orchestratorCreated); var report = await service.CheckAsync(CancellationToken.None); Assert.True(orchestratorCreated); Assert.True(report.IsUpdateAvailable); } [Fact] public void FromFullInstaller_IncludesPreferredInstallerInMirrors() { var release = new GitHubReleaseInfo( "v1.2.3", "Release", false, false, DateTimeOffset.Parse("2026-05-06T00:00:00Z"), [new GitHubReleaseAsset("LanMountainDesktop-setup-x64.exe", "https://example.test/setup.exe", 123, "abc")]); var manifest = UpdateManifestMapper.FromFullInstaller(release, UpdateSettingsValues.ChannelStable, "windows-x64"); Assert.NotNull(manifest.InstallerMirrors); var mirror = Assert.Single(manifest.InstallerMirrors!); Assert.Equal("https://example.test/setup.exe", mirror.Url); } [Fact] public void ApplyDownloadSource_UsesGhProxyForGithubProxySource() { var url = "https://github.com/owner/repo/releases/download/v1/app.exe"; Assert.Equal(url, UpdateDownloadEngine.ApplyDownloadSource(url, UpdateSettingsValues.DownloadSourceGitHub)); Assert.Equal( $"{UpdateSettingsValues.DefaultGhProxyBaseUrl}{url}", UpdateDownloadEngine.ApplyDownloadSource(url, UpdateSettingsValues.DownloadSourceGhProxy)); } private static SettingsUpdateState DefaultUpdateState() => new( IncludePrereleaseUpdates: false, UpdateChannel: UpdateSettingsValues.ChannelStable, UpdateMode: UpdateSettingsValues.ModeSilentDownload, UpdateDownloadSource: UpdateSettingsValues.DownloadSourcePlonds, UpdateDownloadThreads: UpdateSettingsValues.DefaultDownloadThreads, ForceUpdateReinstall: false, UseGhProxyMirror: false, PendingUpdateInstallerPath: null, PendingUpdateVersion: null, PendingUpdatePublishedAtUtcMs: null, LastUpdateCheckUtcMs: null, PendingUpdateSha256: null); private static UpdateOrchestrator CreateTestOrchestrator(SettingsUpdateState state) { return new UpdateOrchestrator( new FakeManifestProvider("github"), new UpdateDownloadEngine(new FakeManifestProvider("github"), new ResumableDownloadService(new HttpClient(new EmptyHandler()))), new UpdateInstallGateway(), new UpdateStateStore(new FakeSettingsFacade(new FakeUpdateSettingsService { State = state }))); } private static PlondsClientManifest CreatePlondsManifest(string version) { return new PlondsClientManifest( FormatVersion: "2.0", CurrentVersion: version, PreviousVersion: "1.0.0", IsFullUpdate: false, RequiresCleanInstall: false, Channel: "stable", Platform: "windows-x64", UpdatedAt: DateTimeOffset.Parse("2026-06-01T00:00:00Z"), FilesMap: new Dictionary(), ChangedFilesMap: new Dictionary(), Checksums: new Dictionary(), Downloads: null, Sources: []); } private sealed class FakeUpdateSettingsService : IUpdateSettingsService { public SettingsUpdateState State { get; set; } = DefaultUpdateState(); public UpdatePhase CurrentPhase { get; private set; } = UpdatePhase.Idle; public UpdateCheckReport CheckReport { get; set; } = new(false, null, null, null, null, null, null, null, null, null); public UpdateDownloadResult DownloadResult { get; set; } = new(true, "downloaded", null, true); public InstallResult InstallResult { get; set; } = new(true, null, false); public int SaveCalls { get; private set; } public int CheckCalls { get; private set; } public int DownloadCalls { get; private set; } public int InstallCalls { get; private set; } public int PauseCalls { get; private set; } public int ResumeCalls { get; private set; } public int CancelCalls { get; private set; } public event Action? PhaseChanged; public event Action? ProgressChanged; public void SetPhase(UpdatePhase phase) { CurrentPhase = phase; PhaseChanged?.Invoke(phase); } public SettingsUpdateState Get() => State; public void Save(SettingsUpdateState state) { SaveCalls++; State = state; } public Task CheckAsync(CancellationToken cancellationToken = default) { CheckCalls++; SetPhase(UpdatePhase.Checked); return Task.FromResult(CheckReport); } public Task DownloadAsync(CancellationToken cancellationToken = default) { DownloadCalls++; SetPhase(UpdatePhase.Downloaded); return Task.FromResult(DownloadResult); } public Task InstallAsync(CancellationToken cancellationToken = default) { InstallCalls++; SetPhase(UpdatePhase.Installed); return Task.FromResult(InstallResult); } public Task RollbackAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public Task PauseAsync() { PauseCalls++; SetPhase(UpdatePhase.PausedDownloading); return Task.CompletedTask; } public Task ResumeAsync(CancellationToken cancellationToken = default) { ResumeCalls++; SetPhase(UpdatePhase.Downloaded); return Task.FromResult(DownloadResult); } public Task CancelAsync() { CancelCalls++; SetPhase(UpdatePhase.Idle); return Task.CompletedTask; } public Task AutoCheckIfEnabledAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; public bool TryApplyOnExit() => false; public Task CheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default) => Task.FromResult(new UpdateCheckResult(true, false, currentVersion.ToString(), string.Empty, null, null, null)); public Task ForceCheckForUpdatesAsync(Version currentVersion, bool includePrerelease, CancellationToken cancellationToken = default) => CheckForUpdatesAsync(currentVersion, includePrerelease, cancellationToken); public Task DownloadAssetAsync( GitHubReleaseAsset asset, string destinationFilePath, string downloadSource, int maxParallelSegments, IProgress? progress = null, CancellationToken cancellationToken = default) => Task.FromResult(new LanMountainDesktop.Services.UpdateDownloadResult(false, null, "not used", false)); public Task RedownloadAssetAsync( GitHubReleaseAsset asset, string destinationFilePath, string downloadSource, int maxParallelSegments, IProgress? progress = null, CancellationToken cancellationToken = default) => Task.FromResult(new LanMountainDesktop.Services.UpdateDownloadResult(false, null, "not used", false)); } private sealed class FakePlondsService : IPlondsService { public PlondsLatestResult LatestResult { get; set; } = PlondsLatestResult.UpToDate(new Version(1, 0, 0), new Version(1, 0, 0)); public PlondsPrepareResult PrepareResult { get; set; } = PlondsPrepareResult.FailedForUi("not prepared"); public int FindLatestCalls { get; private set; } public int PrepareLatestCalls { get; private set; } public Task FindLatestAsync(Version currentVersion, CancellationToken cancellationToken) { FindLatestCalls++; return Task.FromResult(LatestResult); } public Task FindAndPrepareLatestAsync(CancellationToken cancellationToken) { PrepareLatestCalls++; return Task.FromResult(PrepareResult); } public Task FindAndPrepareLatestAsync(Version currentVersion, CancellationToken cancellationToken) { PrepareLatestCalls++; return Task.FromResult(PrepareResult); } } private sealed class FakeSettingsService : ISettingsService { public event EventHandler? Changed; public AppSettingsSnapshot Snapshot { get; init; } = new(); public T LoadSnapshot(SettingsScope scope, string? subjectId = null, string? placementId = null) where T : new() { if (typeof(T) == typeof(AppSettingsSnapshot)) { return (T)(object)Snapshot.Clone(); } return new T(); } public void SaveSnapshot( SettingsScope scope, T snapshot, string? subjectId = null, string? placementId = null, string? sectionId = null, IReadOnlyCollection? changedKeys = null) { if (snapshot is AppSettingsSnapshot appSettings) { CopyUpdateSettings(appSettings, Snapshot); } Changed?.Invoke(this, new SettingsChangedEvent(scope, subjectId, placementId, sectionId, changedKeys)); } public T LoadSection(SettingsScope scope, string subjectId, string sectionId, string? placementId = null) where T : new() => new(); public void SaveSection( SettingsScope scope, string subjectId, string sectionId, T section, string? placementId = null, IReadOnlyCollection? changedKeys = null) { } public void DeleteSection(SettingsScope scope, string subjectId, string sectionId, string? placementId = null) { } public T? GetValue(SettingsScope scope, string key, string? subjectId = null, string? placementId = null, string? sectionId = null) => default; public void SetValue( SettingsScope scope, string key, T value, string? subjectId = null, string? placementId = null, string? sectionId = null, IReadOnlyCollection? changedKeys = null) { } public IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId) => throw new NotSupportedException(); private static void CopyUpdateSettings(AppSettingsSnapshot source, AppSettingsSnapshot target) { target.IncludePrereleaseUpdates = source.IncludePrereleaseUpdates; target.UpdateChannel = source.UpdateChannel; target.UpdateMode = source.UpdateMode; target.UpdateDownloadSource = source.UpdateDownloadSource; target.UpdateDownloadThreads = source.UpdateDownloadThreads; target.ForceUpdateReinstall = source.ForceUpdateReinstall; target.UseGhProxyMirror = source.UseGhProxyMirror; target.PendingUpdateInstallerPath = source.PendingUpdateInstallerPath; target.PendingUpdateVersion = source.PendingUpdateVersion; target.PendingUpdatePublishedAtUtcMs = source.PendingUpdatePublishedAtUtcMs; target.LastUpdateCheckUtcMs = source.LastUpdateCheckUtcMs; target.PendingUpdateSha256 = source.PendingUpdateSha256; } } private sealed class FakeManifestProvider(string providerName) : IUpdateManifestProvider { public string ProviderName { get; } = providerName; public int GetLatestCalls { get; private set; } public Task GetLatestAsync(string channel, string platform, Version currentVersion, CancellationToken ct) { GetLatestCalls++; return Task.FromResult(CreateManifest(ProviderName, channel, platform)); } public Task GetByVersionAsync(string version, string channel, string platform, CancellationToken ct) => Task.FromResult(CreateManifest(ProviderName, channel, platform)); public Task> GetIncrementalChainAsync(string channel, string platform, Version fromVersion, Version toVersion, CancellationToken ct) => Task.FromResult>([CreateManifest(ProviderName, channel, platform)]); private static UpdateManifest CreateManifest(string id, string channel, string platform) => new( id, "1.0.0", "1.1.0", platform, channel, DateTimeOffset.Parse("2026-05-06T00:00:00Z"), UpdatePayloadKind.DeltaPlonds, "https://example.test/filemap.json", "https://example.test/filemap.json.sig", null, [], null, new Dictionary()); } private sealed class EmptyHandler : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { return Task.FromResult(new HttpResponseMessage(System.Net.HttpStatusCode.NotFound)); } } private sealed class FakeSettingsFacade(IUpdateSettingsService update) : ISettingsFacadeService { public ISettingsService Settings => throw new NotSupportedException(); public ISettingsCatalog Catalog => throw new NotSupportedException(); public IGridSettingsService Grid => throw new NotSupportedException(); public IWallpaperSettingsService Wallpaper => throw new NotSupportedException(); public IWallpaperMediaService WallpaperMedia => throw new NotSupportedException(); public IThemeAppearanceService Theme => throw new NotSupportedException(); public IStatusBarSettingsService StatusBar => throw new NotSupportedException(); public ITextCapsuleSettingsService TextCapsule => throw new NotSupportedException(); public IWeatherSettingsService Weather => throw new NotSupportedException(); public IRegionSettingsService Region { get; } = new FakeRegionSettingsService(); public IPrivacySettingsService Privacy => throw new NotSupportedException(); public IUpdateSettingsService Update { get; } = update; public ILauncherCatalogService LauncherCatalog => throw new NotSupportedException(); public ILauncherPolicyService LauncherPolicy => throw new NotSupportedException(); public IPluginManagementSettingsService PluginManagement => throw new NotSupportedException(); public IPluginCatalogSettingsService PluginCatalog => throw new NotSupportedException(); public IApplicationInfoService ApplicationInfo { get; } = new FakeApplicationInfoService(); } private sealed class FakeRegionSettingsService : IRegionSettingsService { public RegionSettingsState Get() => new("en-US", null); public void Save(RegionSettingsState state) { } public TimeZoneService GetTimeZoneService() => throw new NotSupportedException(); } private sealed class FakeApplicationInfoService : IApplicationInfoService { public string GetAppVersionText() => "1.0.0"; public string GetAppCodenameText() => "Test"; public AppRenderBackendInfo GetRenderBackendInfo() => throw new NotSupportedException(); } }