Skip to content

Commit 2c0c5e0

Browse files
authored
Merge pull request #3037 from unoplatform/dev/mara/fix-os-theme
2 parents 17c564f + e9d6815 commit 2c0c5e0

File tree

7 files changed

+213
-0
lines changed

7 files changed

+213
-0
lines changed
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
using FluentAssertions;
2+
using Microsoft.UI.Xaml;
3+
using Microsoft.UI.Xaml.Controls;
4+
using Microsoft.VisualStudio.TestTools.UnitTesting;
5+
using Uno.Extensions.Toolkit;
6+
using Uno.UI.RuntimeTests;
7+
8+
namespace Uno.Extensions.Core.UI.Tests;
9+
10+
[TestClass]
11+
[RunsOnUIThread]
12+
public class Given_ThemeService
13+
{
14+
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5);
15+
16+
[TestMethod]
17+
public async Task When_SavedThemeIsSystem_And_ActualThemeChanges_Then_ThemeChangedReportsSystem()
18+
{
19+
// Arrange
20+
var settings = new InMemorySettings();
21+
settings.Set("CurrentTheme", AppTheme.System.ToString());
22+
var dispatcher = new SynchronousDispatcher();
23+
var element = new Grid();
24+
element.RequestedTheme = ElementTheme.Light;
25+
26+
using var service = new ThemeService(element, dispatcher, settings);
27+
28+
var tcs = new TaskCompletionSource<AppTheme>();
29+
service.ThemeChanged += (_, theme) => tcs.TrySetResult(theme);
30+
31+
// Act - Simulate OS theme change by switching RequestedTheme
32+
element.RequestedTheme = ElementTheme.Dark;
33+
34+
using var cts = new CancellationTokenSource(DefaultTimeout);
35+
cts.Token.Register(() => tcs.TrySetCanceled());
36+
var receivedTheme = await tcs.Task;
37+
38+
// Assert - should report System, not the specific dark/light value
39+
receivedTheme.Should().Be(AppTheme.System,
40+
because: "when following system theme, ThemeChanged should report System, not the actual dark/light value");
41+
}
42+
43+
[TestMethod]
44+
public async Task When_SavedThemeIsSystem_And_ActualThemeChanges_Then_SavedThemeNotOverwritten()
45+
{
46+
// Arrange
47+
var settings = new InMemorySettings();
48+
settings.Set("CurrentTheme", AppTheme.System.ToString());
49+
var dispatcher = new SynchronousDispatcher();
50+
var element = new Grid();
51+
element.RequestedTheme = ElementTheme.Light;
52+
53+
using var service = new ThemeService(element, dispatcher, settings);
54+
55+
var tcs = new TaskCompletionSource<AppTheme>();
56+
service.ThemeChanged += (_, theme) => tcs.TrySetResult(theme);
57+
58+
// Act
59+
element.RequestedTheme = ElementTheme.Dark;
60+
61+
using var cts = new CancellationTokenSource(DefaultTimeout);
62+
cts.Token.Register(() => tcs.TrySetCanceled());
63+
await tcs.Task;
64+
65+
// Assert - saved theme should remain "System", not be overwritten to "Dark"
66+
settings.Get("CurrentTheme").Should().Be(AppTheme.System.ToString(),
67+
because: "the saved theme should not be overwritten when following system theme");
68+
}
69+
70+
[TestMethod]
71+
public async Task When_SavedThemeIsSystem_And_MultipleActualThemeChanges_Then_AllReportSystem()
72+
{
73+
// Arrange
74+
var settings = new InMemorySettings();
75+
settings.Set("CurrentTheme", AppTheme.System.ToString());
76+
var dispatcher = new SynchronousDispatcher();
77+
var element = new Grid();
78+
element.RequestedTheme = ElementTheme.Light;
79+
80+
using var service = new ThemeService(element, dispatcher, settings);
81+
82+
var receivedThemes = new List<AppTheme>();
83+
var tcs1 = new TaskCompletionSource<AppTheme>();
84+
var tcs2 = new TaskCompletionSource<AppTheme>();
85+
service.ThemeChanged += (_, theme) =>
86+
{
87+
receivedThemes.Add(theme);
88+
if (receivedThemes.Count == 1) tcs1.TrySetResult(theme);
89+
if (receivedThemes.Count == 2) tcs2.TrySetResult(theme);
90+
};
91+
92+
using var cts = new CancellationTokenSource(DefaultTimeout);
93+
cts.Token.Register(() => { tcs1.TrySetCanceled(); tcs2.TrySetCanceled(); });
94+
95+
// Act - First theme change
96+
element.RequestedTheme = ElementTheme.Dark;
97+
await tcs1.Task;
98+
99+
// Second theme change
100+
element.RequestedTheme = ElementTheme.Light;
101+
await tcs2.Task;
102+
103+
// Assert - all events should report System, not Dark/Light
104+
receivedThemes.Should().HaveCount(2);
105+
receivedThemes.Should().OnlyContain(t => t == AppTheme.System,
106+
because: "repeated OS theme changes should continue reporting System when following system theme");
107+
}
108+
109+
[TestMethod]
110+
public async Task When_SavedThemeIsExplicit_And_ActualThemeChanges_Then_DoesNotReportSystem()
111+
{
112+
// Arrange - saved theme is explicitly Dark, not System
113+
var settings = new InMemorySettings();
114+
settings.Set("CurrentTheme", AppTheme.Dark.ToString());
115+
var dispatcher = new SynchronousDispatcher();
116+
var element = new Grid();
117+
element.RequestedTheme = ElementTheme.Dark;
118+
119+
using var service = new ThemeService(element, dispatcher, settings);
120+
121+
var tcs = new TaskCompletionSource<AppTheme>();
122+
service.ThemeChanged += (_, theme) => tcs.TrySetResult(theme);
123+
124+
// Act - Change to light; without XamlRoot, InternalSetThemeAsync will fail silently
125+
element.RequestedTheme = ElementTheme.Light;
126+
127+
// Assert - Without XamlRoot, InternalSetThemeAsync fails silently so no event fires.
128+
// The key verification is that the System shortcut path is NOT taken for explicit themes.
129+
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200));
130+
cts.Token.Register(() => tcs.TrySetCanceled());
131+
132+
var eventFired = false;
133+
try
134+
{
135+
var receivedTheme = await tcs.Task;
136+
eventFired = true;
137+
receivedTheme.Should().NotBe(AppTheme.System,
138+
because: "when an explicit theme is saved, the system theme shortcut should not be taken");
139+
}
140+
catch (TaskCanceledException)
141+
{
142+
// Expected: no event fires because InternalSetThemeAsync cannot succeed without XamlRoot
143+
}
144+
145+
eventFired.Should().BeFalse(
146+
because: "without a XamlRoot, InternalSetThemeAsync fails silently and ThemeChanged should not fire");
147+
}
148+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace Uno.Extensions.Core.UI.Tests;
2+
3+
internal class InMemorySettings : ISettings
4+
{
5+
private readonly Dictionary<string, string?> _store = new();
6+
7+
public string? Get(string key) => _store.TryGetValue(key, out var value) ? value : null;
8+
9+
public void Set(string key, string? value) => _store[key] = value;
10+
11+
public void Remove(string key) => _store.Remove(key);
12+
13+
public void Clear() => _store.Clear();
14+
15+
public IReadOnlyCollection<string> Keys => _store.Keys;
16+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace Uno.Extensions.Core.UI.Tests;
2+
3+
internal class SynchronousDispatcher : IDispatcher
4+
{
5+
public bool HasThreadAccess => true;
6+
7+
public bool TryEnqueue(Action action)
8+
{
9+
action();
10+
return true;
11+
}
12+
13+
public ValueTask<TResult> ExecuteAsync<TResult>(AsyncFunc<TResult> action, CancellationToken cancellation)
14+
=> action(cancellation);
15+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<Project Sdk="Uno.Sdk">
2+
<Import Project="..\tfms-ui-winui.props" />
3+
4+
<PropertyGroup>
5+
<Description>Test library for Core UI extensions (WinUI)</Description>
6+
<EnableDefaultPageItems>false</EnableDefaultPageItems>
7+
<DefineConstants>$(DefineConstants);WINUI</DefineConstants>
8+
<UnoSingleProject>true</UnoSingleProject>
9+
<OutputType>Library</OutputType>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<PackageReference Include="MSTest.TestFramework" />
14+
<PackageReference Include="FluentAssertions" />
15+
</ItemGroup>
16+
17+
<ItemGroup>
18+
<ProjectReference Include="..\Uno.Extensions.Core.UI\Uno.Extensions.Core.WinUI.csproj" />
19+
<ProjectReference Include="..\Uno.Extensions.RuntimeTests\Uno.Extensions.RuntimeTests.Core\Uno.Extensions.RuntimeTests.Core.csproj" />
20+
</ItemGroup>
21+
</Project>

src/Uno.Extensions.Core.UI/Toolkit/ThemeService.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,17 @@ private UIElement? RootElement
8282

8383
private void ElementThemeChanged(FrameworkElement sender, object args)
8484
{
85+
var savedTheme = GetSavedTheme();
86+
if (savedTheme == AppTheme.System)
87+
{
88+
// OS theme changed while following system theme.
89+
// Don't set an explicit RequestedTheme, which would override
90+
// ElementTheme.Default and prevent future system theme changes
91+
// from being tracked.
92+
ThemeChanged?.Invoke(this, AppTheme.System);
93+
return;
94+
}
95+
8596
_ = InternalSetThemeAsync(sender.ActualTheme switch
8697
{
8798
ElementTheme.Default => AppTheme.System,

src/Uno.Extensions.Core.UI/Uno.Extensions.Core.WinUI.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
<ItemGroup>
2626
<InternalsVisibleTo Include="Uno.Extensions.Hosting.UWP" />
2727
<InternalsVisibleTo Include="Uno.Extensions.Hosting.WinUI" />
28+
<InternalsVisibleTo Include="Uno.Extensions.Core.WinUI.Tests" />
2829

2930
<PackageReference Include="Uno.WinUI" />
3031
</ItemGroup>

src/Uno.Extensions.RuntimeTests/Uno.Extensions.RuntimeTests/Uno.Extensions.RuntimeTests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
This file is being imported in all runtime tests head.
4444
Add here ref to all UI tests projects.
4545
-->
46+
<ProjectReference Include="..\..\Uno.Extensions.Core.UI.Tests\Uno.Extensions.Core.WinUI.Tests.csproj" />
4647
<ProjectReference Include="..\..\Uno.Extensions.Reactive.UI.Tests\Uno.Extensions.Reactive.WinUI.Tests.csproj" />
4748
<ProjectReference Include="..\..\Uno.Extensions.Navigation.UI.Tests\Uno.Extensions.Navigation.WinUI.Tests.csproj" />
4849
<ProjectReference Include="..\Uno.Extensions.RuntimeTests.Core\Uno.Extensions.RuntimeTests.Core.csproj" />

0 commit comments

Comments
 (0)