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