added tray menu
This commit is contained in:
@@ -18,6 +18,9 @@ public sealed class AppTests
|
||||
[InlineData(typeof(ThemeService))]
|
||||
[InlineData(typeof(IThemeService))]
|
||||
[InlineData(typeof(MainWindowPlacement))]
|
||||
[InlineData(typeof(MainWindowPresenter))]
|
||||
[InlineData(typeof(TrayIcon))]
|
||||
[InlineData(typeof(ITrayIcon))]
|
||||
[InlineData(typeof(ILocalizationService))]
|
||||
[InlineData(typeof(IStartupService))]
|
||||
[InlineData(typeof(IUpdateService))]
|
||||
@@ -100,6 +103,15 @@ public sealed class AppTests
|
||||
Assert.NotNull(window.ImplementationFactory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_tray_icon_and_its_interface_are_one_icon()
|
||||
{
|
||||
ServiceDescriptor tray = Describe()
|
||||
.Single(descriptor => descriptor.ServiceType == typeof(ITrayIcon));
|
||||
|
||||
Assert.NotNull(tray.ImplementationFactory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_theme_and_its_interface_are_one_service()
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using CursorLang.Services;
|
||||
|
||||
namespace CursorLang.Tests;
|
||||
|
||||
@@ -25,6 +26,9 @@ public sealed class EndToEndTests
|
||||
private static readonly TimeSpan StartTimeout = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan ExitTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>How long "the application went on working" is worth watching for.</summary>
|
||||
private static readonly TimeSpan StayTimeout = TimeSpan.FromSeconds(3);
|
||||
|
||||
[Fact]
|
||||
public void The_application_starts_and_shows_the_settings_window()
|
||||
{
|
||||
@@ -34,6 +38,22 @@ public sealed class EndToEndTests
|
||||
Assert.False(launch.Process.HasExited);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Started by Windows itself, the application goes straight to the tray: the
|
||||
/// user asked for it to be there, not for a window to greet them.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_launch_by_Windows_shows_no_window()
|
||||
{
|
||||
using Launch launch = Launch.Start(StartupLaunch.Argument);
|
||||
|
||||
// Nothing is expected to appear, so the wait is for the whole time
|
||||
Assert.False(launch.Process.WaitForExit(StayTimeout), "the application ended by itself");
|
||||
|
||||
launch.Process.Refresh();
|
||||
Assert.Equal(IntPtr.Zero, launch.Process.MainWindowHandle);
|
||||
}
|
||||
|
||||
// A second run raises no second window but shows the window of the running one
|
||||
[Fact]
|
||||
public void The_second_run_ends_by_itself()
|
||||
@@ -50,15 +70,19 @@ public sealed class EndToEndTests
|
||||
Assert.False(launch.Process.HasExited);
|
||||
}
|
||||
|
||||
// The way out of the application is the tray menu alone: the close button of
|
||||
// the window merely puts the window away
|
||||
[Fact]
|
||||
public void Closing_the_window_ends_the_application()
|
||||
public void Closing_the_window_leaves_the_application_in_the_tray()
|
||||
{
|
||||
using Launch launch = Launch.Start();
|
||||
launch.WaitForWindow();
|
||||
|
||||
Assert.True(launch.Process.CloseMainWindow(), "the window did not accept the request to close");
|
||||
Assert.True(launch.Process.WaitForExit(ExitTimeout), "the application did not end after the window closed");
|
||||
Assert.Equal(0, launch.Process.ExitCode);
|
||||
Assert.False(launch.Process.WaitForExit(StayTimeout), "the application ended together with its window");
|
||||
|
||||
launch.Process.Refresh();
|
||||
Assert.Equal(IntPtr.Zero, launch.Process.MainWindowHandle);
|
||||
}
|
||||
|
||||
/// <summary>A started application that shuts down together with the check.</summary>
|
||||
@@ -79,7 +103,7 @@ public sealed class EndToEndTests
|
||||
internal Process Process { get; }
|
||||
|
||||
/// <summary>Starts the application first — making sure the place is free.</summary>
|
||||
internal static Launch Start()
|
||||
internal static Launch Start(params string[] arguments)
|
||||
{
|
||||
if (!HasInteractiveDesktop())
|
||||
{
|
||||
@@ -91,11 +115,11 @@ public sealed class EndToEndTests
|
||||
Assert.Skip("The application is already running — this check keeps out of someone else's run");
|
||||
}
|
||||
|
||||
return new Launch(StartProcess());
|
||||
return new Launch(StartProcess(arguments));
|
||||
}
|
||||
|
||||
/// <summary>Starts the application the way the user does.</summary>
|
||||
internal static Process StartProcess()
|
||||
/// <summary>Starts the application the way the user — or Windows — does.</summary>
|
||||
internal static Process StartProcess(params string[] arguments)
|
||||
{
|
||||
string path = ExecutablePath();
|
||||
|
||||
@@ -104,7 +128,14 @@ public sealed class EndToEndTests
|
||||
Assert.Skip($"The application is not built: {path}");
|
||||
}
|
||||
|
||||
return Process.Start(new ProcessStartInfo(path) { UseShellExecute = true })!;
|
||||
var start = new ProcessStartInfo(path) { UseShellExecute = true };
|
||||
|
||||
foreach (string argument in arguments)
|
||||
{
|
||||
start.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
return Process.Start(start)!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -171,12 +202,11 @@ public sealed class EndToEndTests
|
||||
{
|
||||
if (!Process.HasExited)
|
||||
{
|
||||
// The polite way first — that way the app gets to save its settings
|
||||
if (!Process.CloseMainWindow() || !Process.WaitForExit(ExitTimeout))
|
||||
{
|
||||
Process.Kill(entireProcessTree: true);
|
||||
Process.WaitForExit(ExitTimeout);
|
||||
}
|
||||
// Asking the window to close would only put it away into the
|
||||
// tray, and the exit lives in a menu no test can reach. The
|
||||
// settings are saved as they change, so nothing is lost here
|
||||
Process.Kill(entireProcessTree: true);
|
||||
Process.WaitForExit(ExitTimeout);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.Windows;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The fate of the settings window: it comes and goes, and the application
|
||||
/// outlives it either way.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The window has to be alive for real — showing and hiding are the point here —
|
||||
/// so it is kept fully transparent and off the taskbar.
|
||||
/// </remarks>
|
||||
public sealed class MainWindowPresenterTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_close_button_hides_the_window_instead_of_closing_it()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var presenter = new MainWindowPresenter(new MainWindowPlacement());
|
||||
|
||||
Open(presenter, window =>
|
||||
{
|
||||
window.Close();
|
||||
|
||||
Assert.False(window.IsVisible);
|
||||
Assert.False(presenter.IsShown);
|
||||
|
||||
// The window is hidden rather than closed: a closed one would
|
||||
// refuse to be shown again
|
||||
presenter.Show();
|
||||
Assert.True(window.IsVisible);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_minimise_button_hides_the_window_as_well()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var presenter = new MainWindowPresenter(new MainWindowPlacement());
|
||||
|
||||
Open(presenter, window =>
|
||||
{
|
||||
window.WindowState = WindowState.Minimized;
|
||||
|
||||
Assert.False(window.IsVisible);
|
||||
|
||||
// And the window is left in the state the next show needs:
|
||||
// a minimised one would come back as an icon on the taskbar
|
||||
Assert.Equal(WindowState.Normal, window.WindowState);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_hidden_window_comes_back_on_request()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var presenter = new MainWindowPresenter(new MainWindowPlacement());
|
||||
|
||||
Open(presenter, window =>
|
||||
{
|
||||
presenter.Hide();
|
||||
Assert.False(presenter.IsShown);
|
||||
|
||||
presenter.Show();
|
||||
|
||||
Assert.True(window.IsVisible);
|
||||
Assert.True(presenter.IsShown);
|
||||
Assert.Equal(WindowState.Normal, window.WindowState);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// On the way out of the application the window closes for real: otherwise
|
||||
// the exit from the tray menu would run into the very same refusal
|
||||
[Fact]
|
||||
public void The_window_closes_once_the_application_is_quitting()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var presenter = new MainWindowPresenter(new MainWindowPlacement());
|
||||
|
||||
Open(presenter, window =>
|
||||
{
|
||||
presenter.AllowClose();
|
||||
window.Close();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(window.Show);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Without a notification area there is no way back to a hidden window,
|
||||
// so the window goes back to closing when told to close
|
||||
[Fact]
|
||||
public void A_window_let_go_of_behaves_the_usual_way()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var presenter = new MainWindowPresenter(new MainWindowPlacement());
|
||||
|
||||
Open(presenter, window =>
|
||||
{
|
||||
presenter.Detach();
|
||||
window.Close();
|
||||
|
||||
Assert.Throws<InvalidOperationException>(window.Show);
|
||||
});
|
||||
|
||||
// Letting go twice is what happens when the application quits
|
||||
// right after: it is no reason to fail
|
||||
presenter.Detach();
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void With_no_window_attached_nothing_happens()
|
||||
{
|
||||
var presenter = new MainWindowPresenter(new MainWindowPlacement());
|
||||
|
||||
presenter.Show();
|
||||
presenter.Hide();
|
||||
|
||||
Assert.False(presenter.IsShown);
|
||||
}
|
||||
|
||||
private static void Open(MainWindowPresenter presenter, Action<Window> check)
|
||||
{
|
||||
var window = new Window
|
||||
{
|
||||
// The window is needed alive, but not in sight
|
||||
Opacity = 0,
|
||||
ShowInTaskbar = false,
|
||||
ShowActivated = false,
|
||||
Width = 100,
|
||||
Height = 100,
|
||||
};
|
||||
|
||||
presenter.Attach(window);
|
||||
|
||||
try
|
||||
{
|
||||
window.Show();
|
||||
check(window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
presenter.AllowClose();
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using CursorLang.Services;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Telling a launch by Windows apart from a launch by the user: the first one goes
|
||||
/// to the tray without a window, the second one is what the window is for.
|
||||
/// </summary>
|
||||
public sealed class StartupLaunchTests
|
||||
{
|
||||
[Fact]
|
||||
public void A_launch_by_the_user_carries_no_argument()
|
||||
{
|
||||
Assert.False(StartupLaunch.HasArgument([]));
|
||||
Assert.False(StartupLaunch.IsAutomatic([]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_startup_entry_says_so_in_the_command_line()
|
||||
{
|
||||
Assert.True(StartupLaunch.HasArgument([StartupLaunch.Argument]));
|
||||
Assert.True(StartupLaunch.IsAutomatic([StartupLaunch.Argument]));
|
||||
}
|
||||
|
||||
// The argument does not have to come first: Windows may put its own
|
||||
// alongside it one day
|
||||
[Fact]
|
||||
public void The_argument_is_looked_for_among_the_others()
|
||||
{
|
||||
Assert.True(StartupLaunch.HasArgument(["--whatever", StartupLaunch.Argument]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_case_of_the_argument_does_not_matter()
|
||||
{
|
||||
Assert.True(StartupLaunch.HasArgument([StartupLaunch.Argument.ToUpperInvariant()]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Anything_else_is_a_launch_by_the_user()
|
||||
{
|
||||
Assert.False(StartupLaunch.HasArgument(["--startupp", "startup", "-startup"]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The command written into the registry carries the argument: that is the whole
|
||||
/// point of the argument.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void The_startup_entry_is_written_with_the_argument()
|
||||
{
|
||||
string? command = RegistryStartup.GetCommand();
|
||||
|
||||
Assert.NotNull(command);
|
||||
Assert.EndsWith(StartupLaunch.Argument, command, StringComparison.Ordinal);
|
||||
|
||||
// And the path itself stays quoted: it has spaces in it more often than not
|
||||
Assert.StartsWith("\"", command, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using CursorLang.Interop;
|
||||
using CursorLang.Services;
|
||||
using CursorLang.Tests.Infrastructure;
|
||||
|
||||
namespace CursorLang.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The icon in the notification area and its menu — the whole interface of an
|
||||
/// application that works in the background.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The icon needs a window of its own, so the checks run on the interface thread.
|
||||
/// Whether the tray accepts the icon is up to Windows: there is no notification area
|
||||
/// in a session without a desktop, and such a check skips itself.
|
||||
/// </remarks>
|
||||
public sealed class TrayIconTests
|
||||
{
|
||||
[Fact]
|
||||
public void The_icon_ends_up_in_the_notification_area()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var tray = new TrayIcon(new FakeLocalizationService());
|
||||
|
||||
if (!tray.Install())
|
||||
{
|
||||
Assert.Skip("Windows did not take the icon — there is no notification area here");
|
||||
}
|
||||
|
||||
// Asking twice changes nothing: a second icon of the same
|
||||
// application in the tray is not what anyone wants
|
||||
Assert.True(tray.Install());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_icon_is_taken_away_on_the_way_out()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var tray = new TrayIcon(new FakeLocalizationService());
|
||||
tray.Install();
|
||||
|
||||
tray.Dispose();
|
||||
|
||||
// A second disposal is what the container does after the application
|
||||
// has already shut the tray down itself
|
||||
tray.Dispose();
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_menu_offers_the_settings_and_the_way_out()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var tray = new TrayIcon(new FakeLocalizationService());
|
||||
|
||||
List<MenuItem> items = [.. tray.Menu.Items.OfType<MenuItem>()];
|
||||
|
||||
Assert.Equal(2, items.Count);
|
||||
Assert.Equal("en:TrayMenuSettings", items[0].Header);
|
||||
Assert.Equal("en:TrayMenuExit", items[1].Header);
|
||||
|
||||
// The way out is set apart from the rest: it is the one point of no return
|
||||
Assert.Single(tray.Menu.Items.OfType<Separator>());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_menu_follows_the_language_chosen_in_the_settings()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var localization = new FakeLocalizationService();
|
||||
using var tray = new TrayIcon(localization);
|
||||
|
||||
MenuItem settings = tray.Menu.Items.OfType<MenuItem>().First();
|
||||
Assert.Equal("en:TrayMenuSettings", settings.Header);
|
||||
|
||||
localization.CurrentLanguage = "ru";
|
||||
|
||||
Assert.Equal("ru:TrayMenuSettings", settings.Header);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_settings_point_of_the_menu_asks_for_the_window()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var tray = new TrayIcon(new FakeLocalizationService());
|
||||
int opened = 0;
|
||||
int exits = 0;
|
||||
|
||||
tray.OpenRequested += (_, _) => opened++;
|
||||
tray.ExitRequested += (_, _) => exits++;
|
||||
|
||||
Click(tray.Menu.Items.OfType<MenuItem>().First());
|
||||
|
||||
Assert.Equal(1, opened);
|
||||
Assert.Equal(0, exits);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_exit_point_of_the_menu_asks_for_the_way_out()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var tray = new TrayIcon(new FakeLocalizationService());
|
||||
int opened = 0;
|
||||
int exits = 0;
|
||||
|
||||
tray.OpenRequested += (_, _) => opened++;
|
||||
tray.ExitRequested += (_, _) => exits++;
|
||||
|
||||
Click(tray.Menu.Items.OfType<MenuItem>().Last());
|
||||
|
||||
Assert.Equal(1, exits);
|
||||
Assert.Equal(0, opened);
|
||||
});
|
||||
}
|
||||
|
||||
// The menu belongs to no window and hangs on no element tree: WPF has to
|
||||
// raise it all the same
|
||||
[Fact]
|
||||
public void The_menu_shows_up_on_request()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using var tray = new TrayIcon(new FakeLocalizationService());
|
||||
tray.Install();
|
||||
|
||||
try
|
||||
{
|
||||
tray.ShowMenu();
|
||||
|
||||
Assert.True(tray.Menu.IsOpen);
|
||||
Assert.Equal(PlacementMode.MousePoint, tray.Menu.Placement);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// An open menu holds the mouse: the checks that follow would
|
||||
// never see a click of their own
|
||||
tray.Menu.IsOpen = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The line between the points of the menu is drawn by the theme of the app.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// WPF gives a separator inside a menu a style of its own, found by a key rather
|
||||
/// than by the type: a style by type never reaches it. Left with the system one,
|
||||
/// the line comes out indented from the left, where the icons of a system menu
|
||||
/// would go, and stretched past the right edge of the menu.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void The_line_between_the_points_of_the_menu_keeps_to_the_theme()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
var themed = Application.Current.TryFindResource(MenuItem.SeparatorStyleKey) as Style;
|
||||
|
||||
Assert.NotNull(themed);
|
||||
Assert.Equal(typeof(Separator), themed.TargetType);
|
||||
|
||||
using var tray = new TrayIcon(new FakeLocalizationService());
|
||||
|
||||
try
|
||||
{
|
||||
tray.ShowMenu();
|
||||
|
||||
Separator line = tray.Menu.Items.OfType<Separator>().Single();
|
||||
|
||||
// The style has reached the line: it is the theme drawing it,
|
||||
// margins and all
|
||||
Assert.Same(themed, line.Style);
|
||||
Assert.True(line.ActualWidth > 0);
|
||||
}
|
||||
finally
|
||||
{
|
||||
tray.Menu.IsOpen = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The image comes from the executable itself; the icon Windows keeps for an
|
||||
// application without one is the last resort. An empty spot in the tray is not
|
||||
// an option either way
|
||||
[Fact]
|
||||
public void There_is_an_icon_to_show()
|
||||
{
|
||||
IntPtr icon = TrayIconNative.LoadApplicationIcon();
|
||||
|
||||
Assert.NotEqual(IntPtr.Zero, icon);
|
||||
|
||||
TrayIconNative.ReleaseIcon(icon);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Explorer_restarting_is_a_message_of_its_own()
|
||||
{
|
||||
Assert.NotEqual(0, TrayIconNative.TaskbarCreatedMessage);
|
||||
}
|
||||
|
||||
private static void Click(MenuItem item) => item.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent));
|
||||
}
|
||||
@@ -265,6 +265,28 @@ public sealed class MainWindowTests
|
||||
});
|
||||
}
|
||||
|
||||
// The application lives in the tray, and the settings window is a guest on the
|
||||
// screen: its close button puts it away rather than ends anything
|
||||
[Fact]
|
||||
public void The_window_hooks_up_to_the_tray()
|
||||
{
|
||||
Sta.Run(() =>
|
||||
{
|
||||
using SettingsViewModel viewModel = CreateViewModel();
|
||||
|
||||
Open(viewModel, window =>
|
||||
{
|
||||
window.Close();
|
||||
|
||||
Assert.False(window.IsVisible);
|
||||
|
||||
// A closed window would refuse this
|
||||
window.Show();
|
||||
Assert.True(window.IsVisible);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_window_hooks_up_to_the_placement()
|
||||
{
|
||||
@@ -305,7 +327,9 @@ public sealed class MainWindowTests
|
||||
MainWindowPlacement placement,
|
||||
Action<MainWindow> check)
|
||||
{
|
||||
var window = new MainWindow(viewModel, theme, placement)
|
||||
var presenter = new MainWindowPresenter(placement);
|
||||
|
||||
var window = new MainWindow(viewModel, theme, placement, presenter)
|
||||
{
|
||||
// The window is needed alive, but not in sight
|
||||
Opacity = 0,
|
||||
@@ -322,6 +346,9 @@ public sealed class MainWindowTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The window belongs to the tray now and refuses to close until
|
||||
// the application is on its way out
|
||||
presenter.AllowClose();
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user