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