feat.完善了时钟轻应用,为启动器提供了多语言支持

This commit is contained in:
lincube
2026-05-18 12:26:23 +08:00
parent 93758fc083
commit b6d820a320
63 changed files with 4581 additions and 342 deletions

View File

@@ -79,6 +79,22 @@ public sealed class AirAppLauncherServiceTests
key);
}
[Fact]
public void BuildSingleInstanceKey_UsesGlobalClockSuiteForWorldClock()
{
var analogKey = AirAppLauncherService.BuildSingleInstanceKey(
AirAppLauncherService.WorldClockAppId,
BuiltInComponentIds.DesktopClock,
"analog-placement");
var worldKey = AirAppLauncherService.BuildSingleInstanceKey(
AirAppLauncherService.WorldClockAppId,
BuiltInComponentIds.DesktopWorldClock,
"world-placement");
Assert.Equal("world-clock:clock-suite:global", analogKey);
Assert.Equal(analogKey, worldKey);
}
[Fact]
public void CreateBrokerStartInfo_UsesAirAppBrokerCommandAndRequesterPid()
{

View File

@@ -0,0 +1,176 @@
using System.Globalization;
using System.Text.Json;
using LanMountainDesktop.Services.ClockAirApp;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class ClockAirAppMvpTests
{
[Fact]
public void SettingsSnapshot_DefaultsMatchClockSuiteMvp()
{
var snapshot = ClockAirAppSettingsSnapshot.Normalize(null);
Assert.Equal(ClockAirAppTimeFormatMode.System, snapshot.TimeFormatMode);
Assert.True(snapshot.ShowSeconds);
Assert.Equal(ClockAirAppTabIds.Last, snapshot.StartupTab);
Assert.Equal(ClockAirAppTabIds.WorldClock, snapshot.LastSelectedTab);
Assert.True(snapshot.ActivateOnTimerFinished);
Assert.Equal(4, snapshot.WorldClockTimeZoneIds.Count);
}
[Fact]
public void SettingsStore_LoadsDefaultsWhenJsonIsBroken()
{
var directory = CreateTempDirectory();
var path = Path.Combine(directory, "settings.json");
File.WriteAllText(path, "{ broken json");
var store = new ClockAirAppSettingsStore(path);
var snapshot = store.Load();
Assert.Equal(ClockAirAppTimeFormatMode.System, snapshot.TimeFormatMode);
Assert.Equal(4, snapshot.WorldClockTimeZoneIds.Count);
}
[Fact]
public void SettingsStore_SavesAndLoadsIndependentClockSettings()
{
var directory = CreateTempDirectory();
var path = Path.Combine(directory, "settings.json");
var store = new ClockAirAppSettingsStore(path);
store.Save(new ClockAirAppSettingsSnapshot
{
TimeFormatMode = ClockAirAppTimeFormatMode.TwelveHour,
ShowSeconds = false,
StartupTab = ClockAirAppTabIds.Timer,
LastSelectedTab = ClockAirAppTabIds.Stopwatch,
ActivateOnTimerFinished = false,
WorldClockTimeZoneIds = ["UTC"]
});
var loaded = store.Load();
Assert.Equal(ClockAirAppTimeFormatMode.TwelveHour, loaded.TimeFormatMode);
Assert.False(loaded.ShowSeconds);
Assert.Equal(ClockAirAppTabIds.Timer, loaded.StartupTab);
Assert.Equal(ClockAirAppTabIds.Stopwatch, loaded.LastSelectedTab);
Assert.False(loaded.ActivateOnTimerFinished);
Assert.Single(loaded.WorldClockTimeZoneIds);
}
[Fact]
public void TimeFormatter_FormatsTimeAndOffsets()
{
var time = new DateTime(2026, 5, 18, 21, 7, 9);
var settings = new ClockAirAppSettingsSnapshot
{
TimeFormatMode = ClockAirAppTimeFormatMode.TwentyFourHour,
ShowSeconds = true
};
Assert.Equal("21:07:09", ClockAirAppTimeFormatter.FormatTime(time, settings, CultureInfo.GetCultureInfo("en-US")));
Assert.Equal("UTC+08:30", ClockAirAppTimeFormatter.FormatUtcOffset(TimeSpan.FromMinutes(510)));
Assert.Equal("UTC-05:00", ClockAirAppTimeFormatter.FormatUtcOffset(TimeSpan.FromHours(-5)));
}
[Fact]
public void StopwatchState_StartPauseLapAndReset()
{
var state = new ClockAirAppStopwatchState();
var start = DateTimeOffset.Parse("2026-05-18T12:00:00Z", CultureInfo.InvariantCulture);
state.StartOrResume(start);
Assert.True(state.IsRunning);
Assert.Equal(TimeSpan.FromSeconds(5), state.GetElapsed(start.AddSeconds(5)));
var lap = state.AddLap(start.AddSeconds(6));
Assert.Equal(TimeSpan.FromSeconds(6), lap);
Assert.Single(state.Laps);
state.Pause(start.AddSeconds(8));
Assert.False(state.IsRunning);
Assert.Equal(TimeSpan.FromSeconds(8), state.GetElapsed(start.AddSeconds(20)));
state.Reset();
Assert.Equal(TimeSpan.Zero, state.GetElapsed(start.AddSeconds(30)));
Assert.Empty(state.Laps);
}
[Fact]
public void TimerState_StartPauseAndComplete()
{
var state = new ClockAirAppTimerState();
var start = DateTimeOffset.Parse("2026-05-18T12:00:00Z", CultureInfo.InvariantCulture);
state.SetDuration(TimeSpan.FromSeconds(10));
state.StartOrResume(start);
Assert.True(state.IsRunning);
Assert.Equal(TimeSpan.FromSeconds(6), state.GetRemaining(start.AddSeconds(4)));
state.Pause(start.AddSeconds(4));
Assert.False(state.IsRunning);
Assert.Equal(TimeSpan.FromSeconds(6), state.GetRemaining(start.AddSeconds(20)));
state.StartOrResume(start.AddSeconds(20));
Assert.False(state.Update(start.AddSeconds(25)));
Assert.True(state.Update(start.AddSeconds(26)));
Assert.True(state.IsCompleted);
Assert.Equal(TimeSpan.Zero, state.GetRemaining(start.AddSeconds(26)));
}
[Fact]
public void LocalizationFiles_ContainClockAirAppKeys()
{
var requiredKeys = new[]
{
"clockairapp.title",
"clockairapp.tab.world",
"clockairapp.tab.stopwatch",
"clockairapp.tab.timer",
"clockairapp.tab.settings",
"clockairapp.settings.time_format.24h"
};
foreach (var language in new[] { "zh-CN", "en-US", "ja-JP", "ko-KR" })
{
var json = ReadRepositoryFile("LanMountainDesktop", "Localization", $"{language}.json");
var table = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
Assert.NotNull(table);
foreach (var key in requiredKeys)
{
Assert.True(table!.ContainsKey(key), $"{language} is missing {key}.");
}
}
}
private static string CreateTempDirectory()
{
var directory = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.Tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
return directory;
}
private static string ReadRepositoryFile(params string[] segments)
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null)
{
var candidate = Path.Combine(new[] { directory.FullName }.Concat(segments).ToArray());
if (File.Exists(candidate))
{
return File.ReadAllText(candidate);
}
if (File.Exists(Path.Combine(directory.FullName, "LanMountainDesktop.slnx")))
{
break;
}
directory = directory.Parent;
}
throw new FileNotFoundException($"Could not locate repository file '{Path.Combine(segments)}'.");
}
}

View File

@@ -31,6 +31,32 @@ public sealed class LauncherAirAppLifecycleServiceTests
Assert.Equal(first.Instance!.InstanceKey, second.Instance!.InstanceKey);
}
[Fact]
public async Task OpenAsync_ReusesGlobalClockSuiteAcrossClockComponents()
{
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
var service = new LauncherAirAppLifecycleService(starter);
var first = await service.OpenAsync(new AirAppOpenRequest(
"world-clock",
BuiltInComponentIds.DesktopClock,
"analog-placement",
Environment.ProcessId));
var second = await service.OpenAsync(new AirAppOpenRequest(
"world-clock",
BuiltInComponentIds.DesktopWorldClock,
"world-placement",
Environment.ProcessId));
Assert.True(first.Accepted);
Assert.True(second.Accepted);
Assert.Equal("started", first.Code);
Assert.Equal("activated_existing", second.Code);
Assert.Equal("world-clock:clock-suite:global", first.Instance!.InstanceKey);
Assert.Equal(first.Instance.InstanceKey, second.Instance!.InstanceKey);
Assert.Equal(1, starter.StartCount);
}
[Fact]
public async Task OpenAsync_PrunesExitedRegisteredInstanceBeforeRestart()
{

View File

@@ -16,6 +16,22 @@ public sealed class WindowLayerIsolationTests
Assert.DoesNotContain("Topmost=true", source);
}
[Fact]
public void AirAppWindow_UsesFluentAvaloniaChromeInsteadOfHandRolledTitleBar()
{
var xaml = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppWindow.axaml");
var source = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppWindow.axaml.cs");
Assert.Contains("<faWindowing:FAAppWindow", xaml);
Assert.Contains(": FAAppWindow", source);
Assert.Contains("ShowAsDialog", source);
Assert.Contains("TitleBar.ExtendsContentIntoTitleBar", source);
Assert.DoesNotContain("OnTitleBarPointerPressed", source);
Assert.DoesNotContain("BeginMoveDrag", source);
Assert.DoesNotContain("OnCloseClick", source);
Assert.DoesNotContain("PointerPressed=\"OnTitleBarPointerPressed\"", xaml);
}
[Fact]
public void AirAppWindowDescriptor_DefinesSupportedChromeModes()
{
@@ -36,12 +52,29 @@ public sealed class WindowLayerIsolationTests
Assert.Contains("AirAppLaunchOptions.WorldClockAppId", source);
Assert.Contains("AirAppWindowChromeMode.Standard", source);
Assert.Contains("width: 360", source);
Assert.Contains("height: 220", source);
Assert.Contains("width: 780", source);
Assert.Contains("height: 560", source);
Assert.Contains("minWidth: 680", source);
Assert.Contains("minHeight: 480", source);
Assert.Contains("canResize: true", source);
Assert.Contains("showAsDialog: false", source);
Assert.Contains("AirAppLaunchOptions.WhiteboardAppId", source);
Assert.Contains("AirAppWindowChromeMode.FullScreen", source);
}
[Fact]
public void AirAppWindow_LoadsClockSuiteForWorldClockApp()
{
var source = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppWindow.axaml.cs");
var viewXaml = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "ClockAirAppView.axaml");
var projectFile = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "LanMountainDesktop.AirAppHost.csproj");
Assert.Contains("new ClockAirAppView(_options)", source);
Assert.Contains("clock-suite:global", source);
Assert.Contains("ClockAirAppView", viewXaml);
Assert.Contains("Localization\\*.json", projectFile);
}
[Fact]
public void DesktopComponentHost_DoesNotInterceptLivePointerInputForAirApps()
{