feat.airapp与融合桌面

This commit is contained in:
lincube
2026-05-14 19:44:01 +08:00
parent ada0cd4a3a
commit a5abda62dc
64 changed files with 3617 additions and 362 deletions

View File

@@ -0,0 +1,81 @@
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class AirAppLauncherServiceTests
{
[Fact]
public void BuildOpenRequest_IncludesWorldClockSourceContext()
{
var request = AirAppLauncherService.BuildOpenRequest(
AirAppLauncherService.WorldClockAppId,
BuiltInComponentIds.DesktopWorldClock,
"placement-7",
42);
Assert.Equal("world-clock", request.AppId);
Assert.Equal(BuiltInComponentIds.DesktopWorldClock, request.SourceComponentId);
Assert.Equal("placement-7", request.SourcePlacementId);
Assert.Equal(42, request.RequesterProcessId);
}
[Fact]
public void BuildOpenRequest_NormalizesEmptyOptionalContext()
{
var request = AirAppLauncherService.BuildOpenRequest(
AirAppLauncherService.WorldClockAppId,
null,
" ",
42);
Assert.Equal("world-clock", request.AppId);
Assert.Null(request.SourceComponentId);
Assert.Null(request.SourcePlacementId);
Assert.Equal(42, request.RequesterProcessId);
}
[Fact]
public void BuildOpenRequest_IncludesWhiteboardSourceContext()
{
var request = AirAppLauncherService.BuildOpenRequest(
AirAppLauncherService.WhiteboardAppId,
BuiltInComponentIds.DesktopWhiteboard,
"whiteboard-placement",
99);
Assert.Equal("whiteboard", request.AppId);
Assert.Equal(BuiltInComponentIds.DesktopWhiteboard, request.SourceComponentId);
Assert.Equal("whiteboard-placement", request.SourcePlacementId);
Assert.Equal(99, request.RequesterProcessId);
}
[Fact]
public void BuildSingleInstanceKey_UsesWhiteboardComponentAndPlacement()
{
var key = AirAppLauncherService.BuildSingleInstanceKey(
AirAppLauncherService.WhiteboardAppId,
BuiltInComponentIds.DesktopBlackboardLandscape,
"placement-3");
Assert.Equal(
$"whiteboard:{BuiltInComponentIds.DesktopBlackboardLandscape}:placement-3",
key);
}
[Fact]
public void CreateBrokerStartInfo_UsesAirAppBrokerCommandAndRequesterPid()
{
var startInfo = AirAppLauncherService.CreateBrokerStartInfo(
@"C:\Apps\LanMountainDesktop.Launcher.exe",
12345);
Assert.Equal(@"C:\Apps\LanMountainDesktop.Launcher.exe", startInfo.FileName);
Assert.Equal(@"C:\Apps", startInfo.WorkingDirectory);
Assert.False(startInfo.UseShellExecute);
Assert.Equal(
["air-app-broker", "--requester-pid", "12345"],
startInfo.ArgumentList);
}
}

View File

@@ -0,0 +1,110 @@
using FluentIcons.Common;
using LanMountainDesktop.ComponentSystem;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class ComponentCategoryIconResolverTests
{
[Fact]
public void ResolveCategoryIcon_AllCategory_ReturnsApps()
{
var result = ComponentCategoryIconResolver.ResolveCategoryIcon("all", []);
Assert.Equal(Icon.Apps, result);
}
[Fact]
public void ResolveCategoryIcon_ResolvesFromFirstComponentIconKey()
{
var components = new[]
{
new DesktopComponentDefinition("test1", "Test", "Clock", "Clock", 2, 2, false, true)
};
var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Clock", components);
Assert.Equal(Icon.Clock, result);
}
[Fact]
public void ResolveCategoryIcon_WeatherSunny_ResolvesCorrectly()
{
var components = new[]
{
new DesktopComponentDefinition("test1", "Test", "WeatherSunny", "Weather", 2, 2, false, true)
};
var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Weather", components);
Assert.Equal(Icon.WeatherSunny, result);
}
[Fact]
public void ResolveCategoryIcon_News_ResolvesCorrectly()
{
var components = new[]
{
new DesktopComponentDefinition("test1", "Test", "News", "Info", 2, 2, false, true)
};
var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Info", components);
Assert.Equal(Icon.News, result);
}
[Fact]
public void ResolveCategoryIcon_Edit_ResolvesCorrectly()
{
var components = new[]
{
new DesktopComponentDefinition("test1", "Test", "Edit", "Board", 2, 2, false, true)
};
var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Board", components);
Assert.Equal(Icon.Edit, result);
}
[Fact]
public void ResolveCategoryIcon_InvalidIconKey_FallsBackToApps()
{
var components = new[]
{
new DesktopComponentDefinition("test1", "Test", "NonExistentIcon", "Other", 2, 2, false, true)
};
var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Other", components);
Assert.Equal(Icon.Apps, result);
}
[Fact]
public void ResolveCategoryIcon_EmptyComponents_FallsBackToApps()
{
var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Unknown", []);
Assert.Equal(Icon.Apps, result);
}
[Fact]
public void ResolveCategoryIcon_Play_ResolvesCorrectly()
{
var components = new[]
{
new DesktopComponentDefinition("test1", "Test", "Play", "Media", 2, 2, false, true)
};
var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Media", components);
Assert.Equal(Icon.Play, result);
}
[Fact]
public void ResolveCategoryIcon_Calculator_ResolvesCorrectly()
{
var components = new[]
{
new DesktopComponentDefinition("test1", "Test", "Calculator", "Calculator", 2, 2, false, true)
};
var result = ComponentCategoryIconResolver.ResolveCategoryIcon("Calculator", components);
Assert.Equal(Icon.Calculator, result);
}
[Fact]
public void ResolveCategoryIcon_Folder_ResolvesCorrectly()
{
var components = new[]
{
new DesktopComponentDefinition("test1", "Test", "Folder", "File", 2, 2, false, true)
};
var result = ComponentCategoryIconResolver.ResolveCategoryIcon("File", components);
Assert.Equal(Icon.Folder, result);
}
}

View File

@@ -117,6 +117,40 @@ public sealed class DesktopComponentRenderModeTests
Assert.NotNull(WeatherIconAssetResolver.ResolveAssetUri(styleId, 999, "Unknown", isDaylight: true));
}
[Theory]
[InlineData(WeatherVisualStyleId.GoogleWeatherV4, "google")]
[InlineData(WeatherVisualStyleId.Geometric, "geometric")]
[InlineData(WeatherVisualStyleId.Breezy, "breezy")]
[InlineData(WeatherVisualStyleId.LemonFlutter, "lemon")]
public void WeatherSceneProfileResolver_UsesDistinctRendererPerVisualStyle(string styleId, string expectedRenderer)
{
var profile = WeatherSceneProfileResolver.Resolve(styleId, MaterialWeatherCondition.Rain, isNight: false, isLive: true);
Assert.Equal(expectedRenderer, profile.RendererId);
Assert.Equal("rain", profile.WeatherLayerId);
Assert.True(profile.IsLive);
}
[Theory]
[InlineData(MaterialWeatherCondition.Clear, "clear")]
[InlineData(MaterialWeatherCondition.PartlyCloudy, "partly-cloudy")]
[InlineData(MaterialWeatherCondition.Cloudy, "cloudy")]
[InlineData(MaterialWeatherCondition.Rain, "rain")]
[InlineData(MaterialWeatherCondition.Storm, "storm")]
[InlineData(MaterialWeatherCondition.Snow, "snow")]
[InlineData(MaterialWeatherCondition.Fog, "fog")]
[InlineData(MaterialWeatherCondition.Haze, "haze")]
[InlineData(MaterialWeatherCondition.Unknown, "ambient")]
public void WeatherSceneProfileResolver_UsesDistinctWeatherLayerPerCondition(MaterialWeatherCondition condition, string expectedLayer)
{
var profile = WeatherSceneProfileResolver.Resolve(WeatherVisualStyleId.Breezy, condition, isNight: true, isLive: false);
Assert.Equal("breezy", profile.RendererId);
Assert.Equal(expectedLayer, profile.WeatherLayerId);
Assert.True(profile.IsNight);
Assert.False(profile.IsLive);
}
private static DesktopComponentRuntimeDescriptor CreateDescriptor()
{
Assert.True(CreateRuntimeRegistry().TryGetDescriptor(ComponentId, out var descriptor));

View File

@@ -0,0 +1,164 @@
using System.Diagnostics;
using LanMountainDesktop.ComponentSystem;
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.Services.AirApp;
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class LauncherAirAppLifecycleServiceTests
{
[Fact]
public async Task OpenAsync_ReusesExistingInstanceForSameKey()
{
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
var service = new LauncherAirAppLifecycleService(starter);
var request = new AirAppOpenRequest(
"whiteboard",
BuiltInComponentIds.DesktopWhiteboard,
"placement-1",
Environment.ProcessId);
var first = await service.OpenAsync(request);
var second = await service.OpenAsync(request);
Assert.True(first.Accepted);
Assert.True(second.Accepted);
Assert.Equal("started", first.Code);
Assert.Equal("activated_existing", second.Code);
Assert.Equal(1, starter.StartCount);
Assert.Equal(first.Instance!.InstanceKey, second.Instance!.InstanceKey);
}
[Fact]
public async Task OpenAsync_PrunesExitedRegisteredInstanceBeforeRestart()
{
var starter = new TestAirAppProcessStarter(Process.GetCurrentProcess());
var service = new LauncherAirAppLifecycleService(starter);
var instanceKey = AirAppInstanceKey.Build(
"whiteboard",
BuiltInComponentIds.DesktopWhiteboard,
"placement-2");
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
instanceKey,
"whiteboard",
"dead-session",
int.MaxValue,
"Dead Air APP",
BuiltInComponentIds.DesktopWhiteboard,
"placement-2"));
var result = await service.OpenAsync(new AirAppOpenRequest(
"whiteboard",
BuiltInComponentIds.DesktopWhiteboard,
"placement-2",
Environment.ProcessId));
Assert.True(result.Accepted);
Assert.Equal("started", result.Code);
Assert.Equal(1, starter.StartCount);
Assert.Equal(Environment.ProcessId, result.Instance!.ProcessId);
}
[Fact]
public async Task HasLiveAirApps_ReturnsFalseAfterUnregisteringLastInstance()
{
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(Process.GetCurrentProcess()));
var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-1");
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
instanceKey,
"world-clock",
"session",
Environment.ProcessId,
"World Clock",
BuiltInComponentIds.DesktopWorldClock,
"clock-1"));
Assert.True(service.HasLiveAirApps());
_ = await service.UnregisterAsync(instanceKey, Environment.ProcessId);
Assert.False(service.HasLiveAirApps());
}
[Fact]
public void AirAppBrokerLifetime_KeepsAliveWhileRequesterIsAlive()
{
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
Assert.True(LanMountainDesktop.Launcher.App.ShouldKeepAirAppBrokerAlive(Environment.ProcessId, service));
}
[Fact]
public void AirAppBrokerLifetime_StopsWhenRequesterExitedAndNoAirAppsRemain()
{
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
Assert.False(LanMountainDesktop.Launcher.App.ShouldKeepAirAppBrokerAlive(int.MaxValue, service));
}
[Fact]
public async Task AirAppBrokerLifetime_KeepsAliveWhileAirAppIsAlive()
{
var service = new LauncherAirAppLifecycleService(new TestAirAppProcessStarter(null));
var instanceKey = AirAppInstanceKey.Build("world-clock", BuiltInComponentIds.DesktopWorldClock, "clock-2");
_ = await service.RegisterAsync(new AirAppRegistrationRequest(
instanceKey,
"world-clock",
"session",
Environment.ProcessId,
"World Clock",
BuiltInComponentIds.DesktopWorldClock,
"clock-2"));
Assert.True(LanMountainDesktop.Launcher.App.ShouldKeepAirAppBrokerAlive(int.MaxValue, service));
}
[Fact]
public void CommandContext_RecognizesAirAppBrokerAsGuiCommandInDebugEnvironment()
{
var oldEnvironment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
try
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Development");
var context = CommandContext.FromArgs(["air-app-broker", "--requester-pid", "42"]);
Assert.True(context.IsGuiCommand);
Assert.True(context.IsAirAppBrokerCommand);
Assert.True(context.IsDebugMode);
Assert.Equal(42, context.GetIntOption("requester-pid", 0));
}
finally
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", oldEnvironment);
}
}
private sealed class TestAirAppProcessStarter : IAirAppProcessStarter
{
private readonly Process? _process;
public TestAirAppProcessStarter(Process? process)
{
_process = process;
}
public int StartCount { get; private set; }
public Process? Start(
string appId,
string sessionId,
string instanceKey,
string? sourceComponentId,
string? sourcePlacementId)
{
StartCount++;
return _process;
}
}
}

View File

@@ -0,0 +1,42 @@
using LanMountainDesktop.Services;
using LanMountainDesktop.ViewModels;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class MusicControlViewModelTests : IDisposable
{
private readonly MusicControlViewModel _viewModel;
public MusicControlViewModelTests()
{
_viewModel = new MusicControlViewModel();
}
[Fact]
public void Dispose_CanBeCalledMultipleTimes()
{
_viewModel.Dispose();
_viewModel.Dispose();
}
[Fact]
public async Task Dispose_StopsRefreshAfterCancellation()
{
var refreshTask = _viewModel.RefreshAsync();
_viewModel.Dispose();
await Task.Delay(100);
}
[Fact]
public void ViewModel_InitializesWithNoSession()
{
Assert.True(_viewModel.IsNoMedia);
}
public void Dispose()
{
_viewModel.Dispose();
}
}

View File

@@ -0,0 +1,88 @@
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class WindowLayerIsolationTests
{
[Fact]
public void AirAppWindow_DoesNotUseDesktopBottomMostOrTopmostPromotion()
{
var source = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppWindow.axaml.cs");
Assert.DoesNotContain("WindowBottomMostServiceFactory", source);
Assert.DoesNotContain("IWindowBottomMostService", source);
Assert.DoesNotContain("SendToBottom", source);
Assert.DoesNotContain("Topmost = true", source);
Assert.DoesNotContain("Topmost=true", source);
}
[Fact]
public void AirAppWindowDescriptor_DefinesSupportedChromeModes()
{
var source = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppWindowDescriptor.cs");
Assert.Contains("AirAppWindowChromeMode", source);
Assert.Contains("Standard", source);
Assert.Contains("Borderless", source);
Assert.Contains("FullScreen", source);
Assert.Contains("Tool", source);
Assert.Contains("BackgroundOnly", source);
}
[Fact]
public void AirAppWindowDescriptor_MapsBuiltInAppsToExpectedChromeModes()
{
var source = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppWindowDescriptor.cs");
Assert.Contains("AirAppLaunchOptions.WorldClockAppId", source);
Assert.Contains("AirAppWindowChromeMode.Standard", source);
Assert.Contains("AirAppLaunchOptions.WhiteboardAppId", source);
Assert.Contains("AirAppWindowChromeMode.FullScreen", source);
}
[Fact]
public void FusedDesktopWindows_KeepDesktopBottomMostBoundary()
{
var desktopWidgetWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "DesktopWidgetWindow.axaml.cs");
var transparentOverlayWindow = ReadRepositoryFile("LanMountainDesktop", "Views", "TransparentOverlayWindow.axaml.cs");
Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", desktopWidgetWindow);
Assert.Contains("RefreshDesktopLayer", desktopWidgetWindow);
Assert.Contains("SendToBottom", desktopWidgetWindow);
Assert.Contains("WindowBottomMostServiceFactory.GetOrCreate()", transparentOverlayWindow);
Assert.Contains("RefreshDesktopLayer", transparentOverlayWindow);
Assert.Contains("SendToBottom", transparentOverlayWindow);
}
[Fact]
public void FusedDesktopManager_RefreshesDesktopLayerAfterShowingWidgets()
{
var source = ReadRepositoryFile("LanMountainDesktop", "Services", "FusedDesktopManagerService.cs");
Assert.Contains("existingWindow.RefreshDesktopLayer()", source);
Assert.Contains("window.RefreshDesktopLayer()", source);
}
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)}'.");
}
}