using System.Windows; using System.Windows.Controls; using System.Windows.Interop; using System.Windows.Media; using System.Windows.Shapes; using CursorLang.Core.Models; using CursorLang.Settings.Services; using CursorLang.Settings.Tests.Infrastructure; using Microsoft.Win32; namespace CursorLang.Settings.Tests.Services; /// /// The look of the windows. The palette lives in the application resources, /// so everything happens on the interface thread. /// public sealed class ThemeServiceTests { [Theory] [InlineData(AppTheme.Light)] [InlineData(AppTheme.Dark)] public void A_chosen_theme_is_applied_as_is(AppTheme theme) { var settings = new AppSettings { Theme = theme }; Sta.Run(() => { using var service = new ThemeService(settings, static () => AppTheme.Dark); Assert.Equal(theme, service.CurrentTheme); }); } [Theory] [InlineData(AppTheme.Light)] [InlineData(AppTheme.Dark)] public void The_system_theme_is_taken_from_Windows(AppTheme system) { var settings = new AppSettings { Theme = AppTheme.System }; Sta.Run(() => { using var service = new ThemeService(settings, () => system); Assert.Equal(system, service.CurrentTheme); }); } [Fact] public void Changing_the_theme_in_the_settings_repaints_the_windows() { var settings = new AppSettings { Theme = AppTheme.Light }; Sta.Run(() => { using var service = new ThemeService(settings, static () => AppTheme.Light); Color light = WindowBackground(); settings.Theme = AppTheme.Dark; Assert.Equal(AppTheme.Dark, service.CurrentTheme); Assert.NotEqual(light, WindowBackground()); }); } // The palette is replaced rather than piled up: otherwise the light one // would still sit under the dark one [Fact] public void The_palette_does_not_pile_up_in_the_resources() { var settings = new AppSettings { Theme = AppTheme.Light }; Sta.Run(() => { int before = Application.Current.Resources.MergedDictionaries.Count; using var service = new ThemeService(settings, static () => AppTheme.Light); settings.Theme = AppTheme.Dark; settings.Theme = AppTheme.Light; settings.Theme = AppTheme.Dark; Assert.Equal(before + 1, Application.Current.Resources.MergedDictionaries.Count); }); } [Fact] public void Choosing_the_same_theme_again_leaves_the_resources_alone() { var settings = new AppSettings { Theme = AppTheme.Dark }; Sta.Run(() => { using var service = new ThemeService(settings, static () => AppTheme.Light); int count = Application.Current.Resources.MergedDictionaries.Count; ResourceDictionary palette = Application.Current.Resources.MergedDictionaries[^1]; settings.Theme = AppTheme.Dark; Assert.Equal(count, Application.Current.Resources.MergedDictionaries.Count); Assert.Same(palette, Application.Current.Resources.MergedDictionaries[^1]); }); } // The other settings have nothing to do with the look [Fact] public void Other_settings_do_not_change_the_theme() { var settings = new AppSettings { Theme = AppTheme.Light }; Sta.Run(() => { using var service = new ThemeService(settings, static () => AppTheme.Dark); settings.FontSize = 40; settings.Language = "ru"; Assert.Equal(AppTheme.Light, service.CurrentTheme); }); } [Fact] public void A_window_that_already_exists_is_attached_at_once() { var settings = new AppSettings { Theme = AppTheme.Dark }; Sta.Run(() => { using var service = new ThemeService(settings, static () => AppTheme.Light); var window = new Window(); try { _ = new WindowInteropHelper(window).EnsureHandle(); // The title bar is painted by Windows, and the only way to check // this is that the call goes through without an error service.Register(window); } finally { window.Close(); } }); } [Fact] public void A_window_without_a_handle_is_attached_once_it_appears() { var settings = new AppSettings { Theme = AppTheme.Dark }; Sta.Run(() => { using var service = new ThemeService(settings, static () => AppTheme.Light); var window = new Window { Opacity = 0, ShowInTaskbar = false, ShowActivated = false }; try { service.Register(window); // The window is created on show — and the look comes with it window.Show(); } finally { window.Close(); } }); } // The ordinary service takes the theme from the Windows settings [Fact] public void The_service_can_work_with_the_real_Windows_theme() { Sta.Run(() => { using var service = new ThemeService(new AppSettings { Theme = AppTheme.System }); Assert.True(service.CurrentTheme is AppTheme.Light or AppTheme.Dark); Assert.Equal(ThemeService.DetectSystemTheme(), service.CurrentTheme); }); } // Windows reports a look change from a thread other than the interface one [Fact] public void A_look_change_in_Windows_repaints_the_windows() { var settings = new AppSettings { Theme = AppTheme.System }; AppTheme system = AppTheme.Light; Sta.Run(() => { using var service = new ThemeService(settings, () => system); Assert.Equal(AppTheme.Light, service.CurrentTheme); system = AppTheme.Dark; RaiseUserPreferenceChanged(service); Sta.WaitFor(() => service.CurrentTheme == AppTheme.Dark, "the theme was recomputed at the request of Windows"); }); } // Windows reports a look change even when nothing changed for the app: // in that case there is nothing to repaint [Fact] public void The_same_theme_does_not_replace_the_resources() { var settings = new AppSettings { Theme = AppTheme.System }; Sta.Run(() => { using var service = new ThemeService(settings, static () => AppTheme.Dark); ResourceDictionary palette = Application.Current.Resources.MergedDictionaries[^1]; int count = Application.Current.Resources.MergedDictionaries.Count; RaiseUserPreferenceChanged(service); Sta.Pause(TimeSpan.FromMilliseconds(30)); Assert.Same(palette, Application.Current.Resources.MergedDictionaries[^1]); Assert.Equal(count, Application.Current.Resources.MergedDictionaries.Count); }); } [Fact] public void A_closed_window_is_forgotten() { var settings = new AppSettings { Theme = AppTheme.Light }; Sta.Run(() => { using var service = new ThemeService(settings, static () => AppTheme.Light); var window = new Window(); _ = new WindowInteropHelper(window).EnsureHandle(); service.Register(window); window.Close(); // A theme change must no longer concern the closed window settings.Theme = AppTheme.Dark; Assert.Equal(AppTheme.Dark, service.CurrentTheme); }); } [Fact] public void Closing_the_service_lets_go_of_the_attached_windows() { var settings = new AppSettings { Theme = AppTheme.Light }; Sta.Run(() => { var service = new ThemeService(settings, static () => AppTheme.Light); var window = new Window { Opacity = 0, ShowInTaskbar = false, ShowActivated = false }; try { window.Show(); service.Register(window); service.Dispose(); // The window now closes on its own, with no regard for the theme window.Close(); } finally { window.Close(); } }); } [Fact] public void After_the_service_is_closed_the_setting_no_longer_changes_the_theme() { var settings = new AppSettings { Theme = AppTheme.Light }; Sta.Run(() => { var service = new ThemeService(settings, static () => AppTheme.Light); service.Dispose(); settings.Theme = AppTheme.Dark; Assert.Equal(AppTheme.Light, service.CurrentTheme); }); } [Fact] public void After_the_service_is_closed_requests_from_Windows_go_unanswered() { var settings = new AppSettings { Theme = AppTheme.System }; AppTheme system = AppTheme.Light; Sta.Run(() => { var service = new ThemeService(settings, () => system); service.Dispose(); system = AppTheme.Dark; Sta.Pause(TimeSpan.FromMilliseconds(50)); Assert.Equal(AppTheme.Light, service.CurrentTheme); }); } [Theory] [InlineData(AppTheme.Light)] [InlineData(AppTheme.Dark)] public void The_palette_of_each_theme_lives_in_the_application_assembly(AppTheme theme) { Sta.Run(() => { var palette = new ResourceDictionary { Source = ThemeService.PaletteUri(theme) }; Assert.NotEmpty(palette.Keys); Assert.True(palette.Contains("Theme.WindowBackground")); }); } // The palette address names the application assembly rather than the one // the process started from [Fact] public void The_palette_address_names_the_application_assembly() { // The palettes live with the settings window, not with the agent next to it Assert.Contains( "CursorLang.Settings;component", ThemeService.PaletteUri(AppTheme.Dark).ToString(), StringComparison.Ordinal); } [Fact] public void The_light_and_dark_palettes_share_one_set_of_keys() { Sta.Run(() => { var light = new ResourceDictionary { Source = ThemeService.PaletteUri(AppTheme.Light) }; var dark = new ResourceDictionary { Source = ThemeService.PaletteUri(AppTheme.Dark) }; Assert.Equal(light.Keys.Cast().OrderBy(key => key.ToString()), dark.Keys.Cast().OrderBy(key => key.ToString())); }); } [Fact] public void The_Windows_theme_is_read_without_errors() { AppTheme theme = ThemeService.DetectSystemTheme(); // The "follow the system" setting has to yield something definite Assert.True(theme is AppTheme.Light or AppTheme.Dark); } // A control the shared dictionary says nothing about keeps the look Windows // gives it and stays light in the dark theme [Theory] [InlineData(typeof(Button))] [InlineData(typeof(ComboBox))] [InlineData(typeof(CheckBox))] [InlineData(typeof(GroupBox))] [InlineData(typeof(ProgressBar))] [InlineData(typeof(Slider))] public void A_control_of_the_window_is_repainted_together_with_the_theme(Type control) { var settings = new AppSettings { Theme = AppTheme.Light }; Sta.Run(() => { using var service = new ThemeService(settings, static () => AppTheme.Light); var window = new Window { Opacity = 0, ShowInTaskbar = false, ShowActivated = false, Width = 200, Height = 100, Content = (Control)Activator.CreateInstance(control)!, }; try { window.Show(); window.UpdateLayout(); IReadOnlyList light = PaintOf(window); Assert.NotEmpty(light); settings.Theme = AppTheme.Dark; window.UpdateLayout(); Assert.NotEqual(light, PaintOf(window)); } finally { window.Close(); } }); } // Windows reports a look change through an event that cannot be synthesised: // the test goes straight to the handler that event arrives at private static void RaiseUserPreferenceChanged(ThemeService service) { System.Reflection.MethodInfo handler = typeof(ThemeService) .GetMethod("OnUserPreferenceChanged", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; handler.Invoke(service, [null, new UserPreferenceChangedEventArgs(UserPreferenceCategory.General)]); } private static Color WindowBackground() => ((SolidColorBrush)Application.Current.Resources["Theme.WindowBackground"]).Color; /// /// Every colour the element tree is painted with. What is compared is the /// whole set: which part of a control the palette reaches is its own business. /// private static IReadOnlyList PaintOf(DependencyObject root) { var colours = new List(); Collect(root, colours); return colours; } private static void Collect(DependencyObject node, List colours) { switch (node) { case Control control: Add(colours, control.Background, control.BorderBrush, control.Foreground); break; case Border border: Add(colours, border.Background, border.BorderBrush); break; case Shape shape: Add(colours, shape.Fill, shape.Stroke); break; case TextBlock text: Add(colours, text.Background, text.Foreground); break; } int count = VisualTreeHelper.GetChildrenCount(node); for (int i = 0; i < count; i++) { Collect(VisualTreeHelper.GetChild(node, i), colours); } } private static void Add(List colours, params Brush?[] brushes) => colours.AddRange(brushes.OfType().Select(brush => brush.Color)); }