diff --git a/StabilityMatrix.Avalonia/App.axaml.cs b/StabilityMatrix.Avalonia/App.axaml.cs index 40ae18ba..7be30120 100644 --- a/StabilityMatrix.Avalonia/App.axaml.cs +++ b/StabilityMatrix.Avalonia/App.axaml.cs @@ -70,13 +70,15 @@ public sealed class App : Application public static IServiceProvider? Services { get; private set; } [NotNull] - public static Visual? VisualRoot { get; private set; } + public static Visual? VisualRoot { get; internal set; } + + public static TopLevel TopLevel => TopLevel.GetTopLevel(VisualRoot)!; [NotNull] - public static IStorageProvider? StorageProvider { get; private set; } + public static IStorageProvider? StorageProvider { get; internal set; } [NotNull] - public static IClipboard? Clipboard { get; private set; } + public static IClipboard? Clipboard { get; internal set; } // ReSharper disable once MemberCanBePrivate.Global [NotNull] @@ -86,6 +88,12 @@ public sealed class App : Application public IClassicDesktopStyleApplicationLifetime? DesktopLifetime => ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; + /// + /// Called before is built. + /// Can be used by UI tests to override services. + /// + internal static event EventHandler? BeforeBuildServiceProvider; + public override void Initialize() { AvaloniaXamlLoader.Load(this); @@ -214,6 +222,9 @@ public sealed class App : Application private static void ConfigureServiceProvider() { var services = ConfigureServices(); + + BeforeBuildServiceProvider?.Invoke(null, services); + Services = services.BuildServiceProvider(); var settingsManager = Services.GetRequiredService(); diff --git a/StabilityMatrix.Avalonia/Program.cs b/StabilityMatrix.Avalonia/Program.cs index 899f9fc7..8f1b4e9e 100644 --- a/StabilityMatrix.Avalonia/Program.cs +++ b/StabilityMatrix.Avalonia/Program.cs @@ -83,13 +83,21 @@ public class Program BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); } - // Avalonia configuration, don't remove; also used by visual designer. - public static AppBuilder BuildAvaloniaApp() + /// + /// Called in and UI tests to setup static configurations + /// + internal static void SetupAvaloniaApp() { IconProvider.Current.Register(); // Use our custom image loader for custom local load error handling ImageLoader.AsyncImageLoader.Dispose(); ImageLoader.AsyncImageLoader = new FallbackRamCachedWebImageLoader(); + } + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + { + SetupAvaloniaApp(); var app = AppBuilder.Configure().UsePlatformDetect().WithInterFont().LogToTrace(); diff --git a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj index 4ceea5b9..a0e6c8cc 100644 --- a/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj +++ b/StabilityMatrix.Avalonia/StabilityMatrix.Avalonia.csproj @@ -16,6 +16,7 @@ + diff --git a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs index 8ee671a1..95fe270b 100644 --- a/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/Dialogs/UpdateViewModel.cs @@ -124,24 +124,30 @@ public partial class UpdateViewModel : ContentDialogViewModelBase if (UpdateInfo is null) return; + ReleaseNotes = await GetReleaseNotes(UpdateInfo.ChangelogUrl); + } + + internal async Task GetReleaseNotes(string changelogUrl) + { using var client = httpClientFactory.CreateClient(); - var response = await client.GetAsync(UpdateInfo.ChangelogUrl); + var response = await client.GetAsync(changelogUrl); if (response.IsSuccessStatusCode) { var changelog = await response.Content.ReadAsStringAsync(); // Formatting for new changelog format // https://keepachangelog.com/en/1.1.0/ - if (UpdateInfo.ChangelogUrl.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + if (changelogUrl.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) { - ReleaseNotes = - FormatChangelog(changelog, Compat.AppVersion) + return FormatChangelog(changelog, Compat.AppVersion) ?? "## Unable to format release notes"; } + + return changelog; } else { - ReleaseNotes = "## Unable to load release notes"; + return "## Unable to load release notes"; } } diff --git a/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs b/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs index 1abe5f17..2d479416 100644 --- a/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs +++ b/StabilityMatrix.Avalonia/ViewModels/MainWindowViewModel.cs @@ -52,6 +52,8 @@ public partial class MainWindowViewModel : ViewModelBase public ProgressManagerViewModel ProgressManagerViewModel { get; init; } public UpdateViewModel UpdateViewModel { get; init; } + internal BetterContentDialog? CurrentDialog { get; private set; } + public MainWindowViewModel( ISettingsManager settingsManager, IDiscordRichPresenceService discordRichPresenceService, @@ -128,7 +130,7 @@ public partial class MainWindowViewModel : ViewModelBase EventManager.Instance.OnTeachingTooltipNeeded(); }; - await dialog.ShowAsync(); + await dialog.ShowAsync(App.TopLevel); } } @@ -239,7 +241,10 @@ public partial class MainWindowViewModel : ViewModelBase Content = new SelectDataDirectoryDialog { DataContext = viewModel } }; - var result = await dialog.ShowAsync(); + CurrentDialog = dialog; + dialog.Closed += (_, _) => CurrentDialog = null; + + var result = await dialog.ShowAsync(App.TopLevel); if (result == ContentDialogResult.Primary) { // 1. For portable mode, call settings.SetPortableMode() diff --git a/StabilityMatrix.Core/Helper/Compat.cs b/StabilityMatrix.Core/Helper/Compat.cs index 957955a1..67bcd002 100644 --- a/StabilityMatrix.Core/Helper/Compat.cs +++ b/StabilityMatrix.Core/Helper/Compat.cs @@ -15,34 +15,34 @@ namespace StabilityMatrix.Core.Helper; public static class Compat { private const string AppName = "StabilityMatrix"; - + public static SemVersion AppVersion { get; set; } - + // OS Platform public static PlatformKind Platform { get; } - + [SupportedOSPlatformGuard("windows")] public static bool IsWindows => Platform.HasFlag(PlatformKind.Windows); - + [SupportedOSPlatformGuard("linux")] public static bool IsLinux => Platform.HasFlag(PlatformKind.Linux); - + [SupportedOSPlatformGuard("macos")] public static bool IsMacOS => Platform.HasFlag(PlatformKind.MacOS); - + [UnsupportedOSPlatformGuard("windows")] public static bool IsUnix => Platform.HasFlag(PlatformKind.Unix); public static bool IsArm => Platform.HasFlag(PlatformKind.Arm); public static bool IsX64 => Platform.HasFlag(PlatformKind.X64); - + // Paths - + /// /// AppData directory path. On Windows this is %AppData%, on Linux and MacOS this is ~/.config /// public static DirectoryPath AppData { get; } - + /// /// AppData + AppName (e.g. %AppData%\StabilityMatrix) /// @@ -57,7 +57,7 @@ public static class Compat /// Current path to the app. /// public static FilePath AppCurrentPath => AppCurrentDir.JoinFile(GetExecutableName()); - + // File extensions /// /// Platform-specific executable extension. @@ -70,19 +70,21 @@ public static class Compat /// ".dll" on Windows, ".dylib" on MacOS, ".so" on Linux. /// public static string DllExtension { get; } - + /// /// Delimiter for $PATH environment variable. /// public static char PathDelimiter => IsWindows ? ';' : ':'; - + static Compat() { - var infoVersion = Assembly.GetCallingAssembly() - .GetCustomAttribute()?.InformationalVersion; - + var infoVersion = Assembly + .GetCallingAssembly() + .GetCustomAttribute() + ?.InformationalVersion; + AppVersion = SemVersion.Parse(infoVersion ?? "0.0.0", SemVersionStyles.Strict); - + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { Platform = PlatformKind.Windows; @@ -100,12 +102,13 @@ public static class Compat else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { Platform = PlatformKind.Linux | PlatformKind.Unix; - + // For AppImage builds, the path is in `$APPIMAGE` - var appPath = Environment.GetEnvironmentVariable("APPIMAGE") ?? - AppContext.BaseDirectory; - AppCurrentDir = Path.GetDirectoryName(appPath) ?? - throw new Exception("Could not find application directory"); + var appPath = + Environment.GetEnvironmentVariable("APPIMAGE") ?? AppContext.BaseDirectory; + AppCurrentDir = + Path.GetDirectoryName(appPath) + ?? throw new Exception("Could not find application directory"); ExeExtension = ""; DllExtension = ".so"; } @@ -113,7 +116,7 @@ public static class Compat { throw new PlatformNotSupportedException(); } - + if (RuntimeInformation.ProcessArchitecture == Architecture.Arm64) { Platform |= PlatformKind.Arm; @@ -122,7 +125,7 @@ public static class Compat { Platform |= PlatformKind.X64; } - + AppData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); AppDataHome = AppData + AppName; } @@ -142,12 +145,13 @@ public static class Compat return target; } } - + throw new PlatformNotSupportedException( - $"Platform {Platform.ToString()} is not in supported targets: " + - $"{string.Join(", ", targets.Select(t => t.platform.ToString()))}"); + $"Platform {Platform.ToString()} is not in supported targets: " + + $"{string.Join(", ", targets.Select(t => t.platform.ToString()))}" + ); } - + /// /// Get the current application executable name. /// @@ -172,12 +176,15 @@ public static class Compat } return Path.GetFileName(fullPath); } - + public static string GetEnvPathWithExtensions(params string[] paths) { - var currentPath = Environment.GetEnvironmentVariable("PATH", EnvironmentVariableTarget.Process); + var currentPath = Environment.GetEnvironmentVariable( + "PATH", + EnvironmentVariableTarget.Process + ); var newPath = string.Join(PathDelimiter, paths); - + if (string.IsNullOrEmpty(currentPath)) { return string.Join(PathDelimiter, paths); diff --git a/StabilityMatrix.Core/Helper/ContextManager.cs b/StabilityMatrix.Core/Helper/ContextManager.cs new file mode 100644 index 00000000..0d4395da --- /dev/null +++ b/StabilityMatrix.Core/Helper/ContextManager.cs @@ -0,0 +1,21 @@ +using System.Linq.Expressions; + +namespace StabilityMatrix.Core.Helper; + + +/*/// +/// Context helper for setting properties to one value on entry and another on dispose. +/// +public class ContextManager : IDisposable +{ + private Accessor accessor; + + public ContextManager(Expression> expression, T context, TProperty value) + { + var accessorInfo = ((MemberExpression) expression.Body).Member; + accessor = Accessors.Find(accessorInfo) ?? throw new ArgumentException("Accessor not found", nameof(expression)); + + originalValue = (TProperty)propertyInfo.GetValue(context); + propertyInfo.SetValue(context, value); + } +}*/ diff --git a/StabilityMatrix.Core/StabilityMatrix.Core.csproj b/StabilityMatrix.Core/StabilityMatrix.Core.csproj index 70900a96..03fef193 100644 --- a/StabilityMatrix.Core/StabilityMatrix.Core.csproj +++ b/StabilityMatrix.Core/StabilityMatrix.Core.csproj @@ -10,7 +10,8 @@ - + + diff --git a/StabilityMatrix.UITests/MainWindowTests.cs b/StabilityMatrix.UITests/MainWindowTests.cs new file mode 100644 index 00000000..694297ba --- /dev/null +++ b/StabilityMatrix.UITests/MainWindowTests.cs @@ -0,0 +1,97 @@ +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.VisualTree; +using FluentAvalonia.UI.Controls; +using FluentAvalonia.UI.Windowing; +using Microsoft.Extensions.DependencyInjection; +using StabilityMatrix.Avalonia; +using StabilityMatrix.Avalonia.Controls; +using StabilityMatrix.Avalonia.ViewModels; +using StabilityMatrix.Avalonia.Views; +using StabilityMatrix.Avalonia.Views.Dialogs; + +namespace StabilityMatrix.UITests; + +[UsesVerify] +public class MainWindowTests +{ + private static IServiceProvider Services => App.Services; + + private static VerifySettings Settings + { + get + { + var settings = new VerifySettings(); + settings.IgnoreMembers( + vm => vm.Pages, + vm => vm.FooterPages, + vm => vm.CurrentPage + ); + return settings; + } + } + + private static (AppWindow, MainWindowViewModel) GetMainWindow() + { + var window = Services.GetRequiredService(); + var viewModel = Services.GetRequiredService(); + window.DataContext = viewModel; + + window.SetDefaultFonts(); + + App.VisualRoot = window; + App.StorageProvider = window.StorageProvider; + App.Clipboard = window.Clipboard ?? throw new NullReferenceException("Clipboard is null"); + + return (window, viewModel); + } + + private static BetterContentDialog? GetWindowDialog(Visual window) + { + return window + .FindDescendantOfType() + ?.FindDescendantOfType() + ?.FindDescendantOfType() + ?.FindDescendantOfType() + ?.FindDescendantOfType() + ?.FindDescendantOfType(); + } + + [AvaloniaFact] + public Task MainWindowViewModel_ShouldOk() + { + var viewModel = Services.GetRequiredService(); + + return Verify(viewModel, Settings); + } + + [AvaloniaFact] + public async Task MainWindow_ShouldOpen() + { + var (window, vm) = GetMainWindow(); + + window.Show(); + + // Find the select data directory dialog + var selectDataDirectoryDialog = await WaitHelper.WaitForNotNullAsync( + () => GetWindowDialog(window) + ); + Assert.NotNull(selectDataDirectoryDialog); + + // Click continue button + var continueButton = selectDataDirectoryDialog + .GetVisualDescendants() + .OfType