mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-22 09:14:25 +08:00
合并对设置系统的更新 (#11)
* Add Windows system chrome patchers (Harmony) Introduce support for toggling the system chrome on Windows using Harmony patchers. Adds Lib.Harmony.Thin to package props and project, new patcher infrastructure (ChromePatchState, PatcherEntrance) and two Harmony patches that disable FluentAvalonia's Windows chrome when configured. Program.cs now loads the chrome setting and installs patchers conditionally on Windows/x86-x64. Settings viewmodel and view updated: expose IsWindowsOs, require restart on appearance changes, migrate SettingsWindow to FAAppWindow and adapt titlebar/layout (include Windows caption placeholder and footer menu items). Also add a .gitkeep and a build log file. * Refactor settings window UI and theming Improve theming and layout for the Settings window and related services. - MaterialSurfaceService: add special material parameters for SettingsWindowBackground (lower alpha, no blur) and avoid hot-switching real backdrops for non-settings windows. - GlassEffectService: add AdaptiveSettingsWindowTintBrush + ResolveSettingsWindowTintAlpha to provide optional content tinting tied to system material mode. - SettingsWindowService: refactor theme application into ApplyThemeVariantAndResources, ensure settings window material is applied at show/activate times, and tidy theme/resource application flow. - SettingsWindow.axaml / .axaml.cs: restructure title bar (separate Grid.Row=0 border) and FANavigationView host, add pane-footer toggle button for :minimal layout, use dynamic corner radius resource, and update toggle/visibility/icon logic and responsive layout code. - SettingsPages: remove some IconText usages and adjust margins; use DesignCornerRadiusLg for update card corner radius. - Add NuGet.Config to set local globalPackagesFolder and ignore .nuget/packages in .gitignore. These changes aim to improve visuals, avoid backdrop overdraw, and make the settings window behavior consistent across themes and layouts. * Add localization and localize settings pages Add many new localization keys (en-US and zh-CN) for notifications, developer tools, about page, status bar, and video wallpaper. Update Notification, Dev, About and StatusBar view models to use LocalizationService, expose localized ObservableProperties, and refresh localized text at construction. Localize selection options and test notification texts, and fix notification severity handling. Wire up XAML to the new localized properties (About/Dev/StatusBar pages) and update the settings page title for notifications. Also adjust copyright line generation and replace hardcoded placeholders with bound Watermark properties. * Redesign settings window with fluent shell & search Rebuild the settings window as a Fluent shell: adds a custom 48-DIP titlebar with Back, pane toggle, icon/title, search box, restart/more menu, and caption-button spacer; moves compact pane toggle into the titlebar and preserves FANavigationView as the primary navigation surface. Introduces a SettingsSearchService (with UI AutoComplete integration, search indexing, navigation-by-result, and search result highlighting) plus focused tests for search filtering and theme material normalization. Adds navigation history/back stack, updates SettingsViewModels for new bindings and localization keys, and updates General/Apearance pages to expose new strings and options. Implements an "auto" system material mode: default in AppSettingsSnapshot, new MaterialAuto constants and normalization/resolution logic in ThemeAppearanceValues, WindowMaterialService and MaterialSurfaceService adjustments to prefer Mica on Win11 and Acrylic on Win10 using TransparencyLevelHint. GlassEffectService and AppearanceThemeService updated to use effective material mode and to track live theme state changes. Adds localization entries (en-US, zh-CN), spec/tasks docs, and other UI/style tweaks to support the redesign. * fix.修折叠与展开按钮 * Add OOBE startup presentation and settings merge Introduce a new OOBE step for "Startup & Presentation" that exposes startup and UI preferences in OobeWindow (toggles for taskbar, slide/fade transitions, fused popup, and autostart). Add HostAppSettingsOobeMerger to read/write Host settings.json (PascalCase fields) and MergeStartupPresentation behavior, plus LauncherWindowsStartupService to sync the current Launcher into the Windows Run key on Windows. Wire UI handlers, persist choices on Next, and load defaults when entering the step. Include unit tests for the merger, adjust SettingsWindow navigation pane/toggle handling, and update docs/LAUNCHER.md to describe the new OOBE step and implementation files. * Move whiteboard persistence to file storage Switch whiteboard note storage from legacy DB rows to per-note JSON files and add migration support. Update WhiteboardNoteSnapshot schema (version bump, viewport, canvas, expires, PathSvgData) and change IWhiteboardNotePersistenceService.SaveNote to return bool to surface write failures (e.g. read-only files). Implement file-based WhiteboardNotePersistenceService with legacy DB migration/cleanup, retention handling, and logging. Add comprehensive unit tests for persistence, stroke path builder, SVG import and viewport helper. Also add ThirdParty/DotNetCampus.InkCanvas project and reference it in the main csproj, and bump PostHog package to 2.6.0. * Introduce render gate and chart caching Replace UI DispatcherTimer polling with a StudySnapshotRenderGate across multiple widgets to queue and apply only the latest analytics snapshot; components updated include StudyDeductionReasonsWidget, StudyEnvironmentWidget, StudyInterruptDensityWidget, StudyNoiseCurveWidget. Add StudySnapshotRenderGate implementation to coordinate rendering and monitoring leases and update subscription/lease lifecycle handling (subscribe/unsubscribe, Acquire/Dispose leases, Clear/Dispose gate). Rewrite chart controls (StudyNoiseCurveChartControl and StudyNoiseDistributionScatterChartControl) to use stable logical-time origins, split series into static vs dynamic tails, add geometry/sample caching, stable jitter/coordinate mapping helpers, and expose internal helpers & counts for testing. Add unit tests (StudyComponentRenderingTests) covering the render gate and chart behaviors (layer counts, logical X mapping, stable jitter, cache rebuild). These changes improve rendering correctness and performance by avoiding redundant renders and enabling deterministic chart layout. * Use MaterialColorSnapshot in appearance flow Introduce unified material/color spec and tests, and refactor appearance plumbing to use MaterialColorSnapshot as the single source of truth. Add .trae material-color-service spec/checklist/tasks and integration/unit tests for plugin mapping and appearance VM behavior. AppearanceChangedEvent extended with new appearance change flags and HasChanged logic. ComponentEditorMaterialThemeAdapter rewritten to accept MaterialColorSnapshot and derive palette from snapshot data. Simplify AppearanceSettingsPageViewModel and related view code: remove legacy preview/custom-seed UI logic, preserve material/color fields when updating theme or corner radius, and update save calls to use with-expressions. Update ComponentEditorWindow to use adapter-provided OnPrimary brush and minor docs updates. * Add material color services, plugin DTOs, and tests Introduce IPC wire-format appearance DTOs (PluginIsolation.Contracts) and clarify they are distinct from the runtime PluginSdk snapshot. Update PluginSdk comments to document the runtime-facing snapshot shape. Change ComponentColorSchemeHelper to use the HostMaterialColorProvider and add an overload that accepts a MaterialColorSnapshot. Add new services and pipelines (MaterialColorService, MaterialSurfaceService, WindowMaterialService, WallpaperColorPipeline) and refactor AppearanceThemeService to depend on MaterialColorService while removing legacy internal implementations. Add multiple unit tests (ComponentColorSchemeHelper, PluginAppearanceBoundary, SettingsCatalogService, WallpaperSettingsPageViewModel) and update localization resources with new material_color and wallpaper keys. * Add CODE_WIKI and update localization Add a comprehensive CODE_WIKI.md documenting project architecture, modules, startup flow, plugin system, testing and developer workflows. Update localization resources (en-US.json, zh-CN.json) with new/translated keys for wallpaper controls (custom color UI), material & color settings (semantic roles, surfaces, refresh/polling state), appearance (corner radius), status bar font size options, privacy policy text, component library labels, clock settings, and new language entry (Korean). Also modify settings-related ViewModels and Settings page views to surface these new features and texts (MaterialColorSettingsPageViewModel.cs, SettingsViewModels.cs, WallpaperSettingsPageViewModel.cs, MainWindow.SettingsHardCut.Stubs.cs, ComponentsSettingsPage.axaml, WallpaperSettingsPage.axaml). * Add Data settings page and storage scanner Introduce a new "Data" settings page to visualize and manage local app storage. Adds DataStorageService (scanning, disk info, clean operations), DataSettingsPageViewModel, XAML view and code-behind, and HexToColor/HexToBrush converters; registers converters in App.axaml. Also update localization strings for the new page and add icon mapping so the settings entry uses the Database icon. Enables per-category and global cleaning workflows and formatted size display. * Add IPC backoff/retries and safer disposal Introduce exponential backoff, jitter and retry logic across IPC components to improve robustness and avoid tight retry loops; make disposal idempotent and add connection guards. Key changes: - LauncherCoordinatorIpcServer / LauncherIpcServer: add backoff constants, ComputeBackoff(), consecutive error tracking and delayed retries with jitter. - LanMountainDesktopIpcClient / LauncherIpcClient: add connect retry loops, timeouts, delayed retries, improved error logging, and use ArrayPool for buffered async writes; ensure proper cleanup on failures. - PublicIpcHostService: add disposed flag, guard OnPeerConnected and Dispose, and clear connected peers on dispose. - Add many auto-generated commit analysis docs under docs/auto_commit_md and new scripts for analyzing/generating commit docs. These changes aim to make IPC connection handling more resilient and resource-safe. * Add preview controls and settings UI tweaks Introduce GridPreviewControl and CornerRadiusPreviewControl for visual previews and wire them into the Components settings (add ScreenAspectRatio, CornerRadiusPreviewValue, and screen aspect init). Refactor ComponentsSettingsPage UI to show live previews. Improve DataSettingsPage layout and storage bar logic (use item percentages directly, include remaining segment, adjust visuals and visibility triggers). Simplify LauncherSettingsPage header/appearance layout. Add SECURITY_AUDIT_REPORT.md, analysis summary, mockup HTML, and a local .claude settings file. * Add install checkpoint/resume and DDSS workflows Introduce install checkpoint support and resume logic for updates, plus related locking and validation. Adds InstallCheckpoint model, AppJsonContext serialization, and UpdatePaths helpers for deployment lock, apply-in-progress lock and install-checkpoint path. UpdateEngineService gains checkpoint load/save/delete, incoming-state validation, resume logic for PLONDS and legacy updates, apply lock handling, and safer cleanup; ApplyPendingPlondsUpdateAsync and ApplyPendingUpdate flow updated accordingly. Add DeploymentLock contract and extend UpdateState with pause/resume/cancel helpers. Tests updated to cover stale/valid checkpoint resume and legacy/PLONDS flows. CI: enhance ddss-publish to detect release channel, validate S3 assets, prepare and atomically publish channel pointer; add ddss-rollback workflow to publish rollbacks; adjust plonds-build concurrency and release events. * changed.更了好多 * fix.消息盒子媒体播放器组件服务修复 * change.重做天气,为回到系统提供自定义功能。 * feat.airapp与融合桌面 * feat.动画优化与更新界面 * feat.数字时钟,白板功能修复 * feat.完善了时钟轻应用,为启动器提供了多语言支持 * feat.发布与打包优化 * changed.天气选项卡更新
This commit is contained in:
112
LanMountainDesktop.Tests/AirAppLauncherServiceTests.cs
Normal file
112
LanMountainDesktop.Tests/AirAppLauncherServiceTests.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
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_IncludesAnalogClockSourceContext()
|
||||
{
|
||||
var request = AirAppLauncherService.BuildOpenRequest(
|
||||
AirAppLauncherService.WorldClockAppId,
|
||||
BuiltInComponentIds.DesktopClock,
|
||||
"analog-placement",
|
||||
43);
|
||||
|
||||
Assert.Equal("world-clock", request.AppId);
|
||||
Assert.Equal(BuiltInComponentIds.DesktopClock, request.SourceComponentId);
|
||||
Assert.Equal("analog-placement", request.SourcePlacementId);
|
||||
Assert.Equal(43, 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 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()
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
177
LanMountainDesktop.Tests/AppearanceSettingsPageViewModelTests.cs
Normal file
177
LanMountainDesktop.Tests/AppearanceSettingsPageViewModelTests.cs
Normal file
@@ -0,0 +1,177 @@
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class AppearanceSettingsPageViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void ChangingThemeMode_PreservesMaterialColorSettings()
|
||||
{
|
||||
var initialState = new ThemeAppearanceSettingsState(
|
||||
IsNightMode: false,
|
||||
ThemeColor: "#ff123456",
|
||||
UseSystemChrome: false,
|
||||
CornerRadiusStyle: GlobalAppearanceSettings.CornerRadiusStyleRounded,
|
||||
ThemeColorMode: ThemeAppearanceValues.ColorModeWallpaperMonet,
|
||||
SystemMaterialMode: ThemeAppearanceValues.MaterialMica,
|
||||
SelectedWallpaperSeed: "#ff654321",
|
||||
ThemeMode: ThemeAppearanceValues.ThemeModeLight,
|
||||
ThemeWallpaperColorSource: ThemeAppearanceValues.WallpaperColorSourceSystem,
|
||||
UseNativeWallpaperChangeEvents: false);
|
||||
var facade = new FakeSettingsFacade(initialState);
|
||||
var viewModel = new AppearanceSettingsPageViewModel(facade);
|
||||
|
||||
viewModel.SelectedThemeMode = viewModel.ThemeModeOptions.Single(option =>
|
||||
option.Value == ThemeAppearanceValues.ThemeModeDark);
|
||||
|
||||
var saved = facade.ThemeState;
|
||||
Assert.True(saved.IsNightMode);
|
||||
Assert.Equal(ThemeAppearanceValues.ThemeModeDark, saved.ThemeMode);
|
||||
Assert.Equal("#ff123456", saved.ThemeColor);
|
||||
Assert.Equal(ThemeAppearanceValues.ColorModeWallpaperMonet, saved.ThemeColorMode);
|
||||
Assert.Equal(ThemeAppearanceValues.MaterialMica, saved.SystemMaterialMode);
|
||||
Assert.Equal("#ff654321", saved.SelectedWallpaperSeed);
|
||||
Assert.Equal(ThemeAppearanceValues.WallpaperColorSourceSystem, saved.ThemeWallpaperColorSource);
|
||||
Assert.False(saved.UseNativeWallpaperChangeEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ChangingComponentCornerRadius_PreservesMaterialColorSettings()
|
||||
{
|
||||
var initialState = new ThemeAppearanceSettingsState(
|
||||
IsNightMode: true,
|
||||
ThemeColor: "#ffabcdef",
|
||||
UseSystemChrome: true,
|
||||
CornerRadiusStyle: GlobalAppearanceSettings.CornerRadiusStyleBalanced,
|
||||
ThemeColorMode: ThemeAppearanceValues.ColorModeWallpaperMonet,
|
||||
SystemMaterialMode: ThemeAppearanceValues.MaterialAcrylic,
|
||||
SelectedWallpaperSeed: "#ff111111",
|
||||
ThemeMode: ThemeAppearanceValues.ThemeModeDark,
|
||||
ThemeWallpaperColorSource: ThemeAppearanceValues.WallpaperColorSourceApp,
|
||||
UseNativeWallpaperChangeEvents: false);
|
||||
var facade = new FakeSettingsFacade(initialState);
|
||||
var viewModel = new ComponentsSettingsPageViewModel(facade);
|
||||
|
||||
viewModel.SelectedCornerRadiusStyle = viewModel.CornerRadiusStyleOptions.Single(option =>
|
||||
option.Value == GlobalAppearanceSettings.CornerRadiusStyleOpen);
|
||||
|
||||
var saved = facade.ThemeState;
|
||||
Assert.Equal(GlobalAppearanceSettings.CornerRadiusStyleOpen, saved.CornerRadiusStyle);
|
||||
Assert.True(saved.IsNightMode);
|
||||
Assert.Equal("#ffabcdef", saved.ThemeColor);
|
||||
Assert.True(saved.UseSystemChrome);
|
||||
Assert.Equal(ThemeAppearanceValues.ColorModeWallpaperMonet, saved.ThemeColorMode);
|
||||
Assert.Equal(ThemeAppearanceValues.MaterialAcrylic, saved.SystemMaterialMode);
|
||||
Assert.Equal("#ff111111", saved.SelectedWallpaperSeed);
|
||||
Assert.Equal(ThemeAppearanceValues.ThemeModeDark, saved.ThemeMode);
|
||||
Assert.Equal(ThemeAppearanceValues.WallpaperColorSourceApp, saved.ThemeWallpaperColorSource);
|
||||
Assert.False(saved.UseNativeWallpaperChangeEvents);
|
||||
}
|
||||
|
||||
private sealed class FakeSettingsFacade(ThemeAppearanceSettingsState themeState) : ISettingsFacadeService
|
||||
{
|
||||
private readonly FakeThemeAppearanceService _theme = new(themeState);
|
||||
private readonly FakeRegionSettingsService _region = new();
|
||||
private readonly FakeGridSettingsService _grid = new();
|
||||
|
||||
public ThemeAppearanceSettingsState ThemeState => _theme.State;
|
||||
|
||||
public IThemeAppearanceService Theme => _theme;
|
||||
|
||||
public IRegionSettingsService Region => _region;
|
||||
|
||||
public ISettingsService Settings => throw new NotSupportedException();
|
||||
public ISettingsCatalog Catalog => throw new NotSupportedException();
|
||||
public IGridSettingsService Grid => _grid;
|
||||
public IWallpaperSettingsService Wallpaper => throw new NotSupportedException();
|
||||
public IWallpaperMediaService WallpaperMedia => throw new NotSupportedException();
|
||||
public IStatusBarSettingsService StatusBar => throw new NotSupportedException();
|
||||
public ITextCapsuleSettingsService TextCapsule => throw new NotSupportedException();
|
||||
public IWeatherSettingsService Weather => throw new NotSupportedException();
|
||||
public IPrivacySettingsService Privacy => throw new NotSupportedException();
|
||||
public IUpdateSettingsService Update => throw new NotSupportedException();
|
||||
public ILauncherCatalogService LauncherCatalog => throw new NotSupportedException();
|
||||
public ILauncherPolicyService LauncherPolicy => throw new NotSupportedException();
|
||||
public IPluginManagementSettingsService PluginManagement => throw new NotSupportedException();
|
||||
public IPluginCatalogSettingsService PluginCatalog => throw new NotSupportedException();
|
||||
public IApplicationInfoService ApplicationInfo => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed class FakeThemeAppearanceService(ThemeAppearanceSettingsState state) : IThemeAppearanceService
|
||||
{
|
||||
public ThemeAppearanceSettingsState State { get; private set; } = state;
|
||||
|
||||
public ThemeAppearanceSettingsState Get() => State;
|
||||
|
||||
public void Save(ThemeAppearanceSettingsState state)
|
||||
{
|
||||
State = state;
|
||||
}
|
||||
|
||||
public MonetPalette BuildPalette(bool nightMode, string? wallpaperPath, string? preferredSeedColor = null)
|
||||
{
|
||||
var seed = Color.Parse(preferredSeedColor ?? "#ff3b82f6");
|
||||
return new MonetPalette([seed], seed, seed, seed, seed, seed, seed);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeRegionSettingsService : IRegionSettingsService
|
||||
{
|
||||
public RegionSettingsState Get() => new("en-US", null);
|
||||
|
||||
public void Save(RegionSettingsState state)
|
||||
{
|
||||
_ = state;
|
||||
}
|
||||
|
||||
public TimeZoneService GetTimeZoneService() => new();
|
||||
}
|
||||
|
||||
private sealed class FakeGridSettingsService : IGridSettingsService
|
||||
{
|
||||
public GridSettingsState State { get; private set; } = new(12, "Relaxed", 18);
|
||||
|
||||
public GridSettingsState Get() => State;
|
||||
|
||||
public void Save(GridSettingsState state)
|
||||
{
|
||||
State = state;
|
||||
}
|
||||
|
||||
public string NormalizeSpacingPreset(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? "Relaxed" : value;
|
||||
}
|
||||
|
||||
public double ResolveGapRatio(string? preset)
|
||||
{
|
||||
_ = preset;
|
||||
return 0.08;
|
||||
}
|
||||
|
||||
public double CalculateEdgeInset(double hostWidth, double hostHeight, int shortSideCells, int insetPercent)
|
||||
{
|
||||
_ = hostWidth;
|
||||
_ = hostHeight;
|
||||
_ = shortSideCells;
|
||||
return insetPercent;
|
||||
}
|
||||
|
||||
public DesktopGridMetrics CalculateGridMetrics(
|
||||
double hostWidth,
|
||||
double hostHeight,
|
||||
int shortSideCells,
|
||||
double gapRatio,
|
||||
double edgeInsetPx)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
176
LanMountainDesktop.Tests/ClockAirAppMvpTests.cs
Normal file
176
LanMountainDesktop.Tests/ClockAirAppMvpTests.cs
Normal 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)}'.");
|
||||
}
|
||||
}
|
||||
110
LanMountainDesktop.Tests/ComponentCategoryIconResolverTests.cs
Normal file
110
LanMountainDesktop.Tests/ComponentCategoryIconResolverTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
125
LanMountainDesktop.Tests/ComponentColorSchemeHelperTests.cs
Normal file
125
LanMountainDesktop.Tests/ComponentColorSchemeHelperTests.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class ComponentColorSchemeHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void GetCurrentGlobalThemeColorMode_UsesMaterialColorSnapshot()
|
||||
{
|
||||
var snapshot = CreateSnapshot(ThemeAppearanceValues.ColorModeWallpaperMonet);
|
||||
|
||||
var mode = ComponentColorSchemeHelper.GetCurrentGlobalThemeColorMode(snapshot);
|
||||
|
||||
Assert.Equal(ThemeAppearanceValues.ColorModeWallpaperMonet, mode);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ThemeAppearanceValues.ColorSchemeNative, ThemeAppearanceValues.ColorModeWallpaperMonet, false)]
|
||||
[InlineData(ThemeAppearanceValues.ColorSchemeFollowSystem, ThemeAppearanceValues.ColorModeDefaultNeutral, true)]
|
||||
[InlineData(null, ThemeAppearanceValues.ColorModeDefaultNeutral, false)]
|
||||
public void ShouldUseMonetColor_FollowsExpectedRules(
|
||||
string? componentColorScheme,
|
||||
string globalThemeColorMode,
|
||||
bool expected)
|
||||
{
|
||||
var shouldUseMonetColor = ComponentColorSchemeHelper.ShouldUseMonetColor(componentColorScheme, globalThemeColorMode);
|
||||
|
||||
Assert.Equal(expected, shouldUseMonetColor);
|
||||
}
|
||||
|
||||
private static MaterialColorSnapshot CreateSnapshot(string themeColorMode)
|
||||
{
|
||||
var seed = Color.Parse("#FF123456");
|
||||
var accent = Color.Parse("#FF214365");
|
||||
var palette = new MaterialColorPalette(
|
||||
Color.Parse("#FF315577"),
|
||||
Color.Parse("#FF557799"),
|
||||
accent,
|
||||
Color.Parse("#FFFFFFFF"),
|
||||
Color.Parse("#FF5F7F9F"),
|
||||
Color.Parse("#FF7F9FBF"),
|
||||
Color.Parse("#FF9FBFDF"),
|
||||
Color.Parse("#FF17314B"),
|
||||
Color.Parse("#FF102840"),
|
||||
Color.Parse("#FF082038"),
|
||||
Color.Parse("#FF0B1118"),
|
||||
Color.Parse("#FF141C24"),
|
||||
Color.Parse("#FF1C2630"),
|
||||
Color.Parse("#FFF5F7FA"),
|
||||
Color.Parse("#FFC8D0DA"),
|
||||
Color.Parse("#FF9EA8B4"),
|
||||
Color.Parse("#FF91B8E8"),
|
||||
Color.Parse("#FFF5F7FA"),
|
||||
Color.Parse("#FFFFFFFF"),
|
||||
Color.Parse("#FF9FBFDF"),
|
||||
Color.Parse("#33141C24"),
|
||||
Color.Parse("#441C2630"),
|
||||
Color.Parse("#55315577"),
|
||||
Color.Parse("#FF315577"),
|
||||
Color.Parse("#88557799"),
|
||||
Color.Parse("#667F9FBF"));
|
||||
var monetPalette = new MonetPalette(
|
||||
[seed],
|
||||
seed,
|
||||
palette.Primary,
|
||||
palette.Secondary,
|
||||
Color.Parse("#FF775577"),
|
||||
Color.Parse("#FF202830"),
|
||||
Color.Parse("#FF26313B"));
|
||||
var surfaces = new Dictionary<MaterialSurfaceRole, MaterialSurfaceSnapshot>
|
||||
{
|
||||
[MaterialSurfaceRole.WindowBackground] = new(
|
||||
MaterialSurfaceRole.WindowBackground,
|
||||
Color.Parse("#FF101820"),
|
||||
Color.Parse("#33557799"),
|
||||
18,
|
||||
0.92),
|
||||
[MaterialSurfaceRole.OverlayPanel] = new(
|
||||
MaterialSurfaceRole.OverlayPanel,
|
||||
Color.Parse("#FF202A34"),
|
||||
Color.Parse("#556688AA"),
|
||||
24,
|
||||
0.88)
|
||||
};
|
||||
|
||||
return new MaterialColorSnapshot(
|
||||
IsNightMode: true,
|
||||
ThemeColorMode: themeColorMode,
|
||||
ThemeWallpaperColorSource: ThemeAppearanceValues.WallpaperColorSourceAuto,
|
||||
ColorSourceKind: MaterialColorSourceKind.CustomSeed,
|
||||
ResolvedSeedSource: "user_color",
|
||||
CornerRadiusTokens: new AppearanceCornerRadiusTokens(
|
||||
new CornerRadius(2),
|
||||
new CornerRadius(4),
|
||||
new CornerRadius(6),
|
||||
new CornerRadius(8),
|
||||
new CornerRadius(10),
|
||||
new CornerRadius(12),
|
||||
new CornerRadius(14),
|
||||
new CornerRadius(8)),
|
||||
UserThemeColor: seed.ToString(),
|
||||
SelectedWallpaperSeed: seed.ToString(),
|
||||
EffectiveSeedColor: seed,
|
||||
AccentColor: accent,
|
||||
MonetPalette: monetPalette,
|
||||
Palette: palette,
|
||||
WallpaperSeedCandidates: [seed],
|
||||
SystemMaterialMode: ThemeAppearanceValues.MaterialMica,
|
||||
AvailableSystemMaterialModes: [ThemeAppearanceValues.MaterialAuto, ThemeAppearanceValues.MaterialMica],
|
||||
CanChangeSystemMaterial: true,
|
||||
UseSystemChrome: false,
|
||||
ResolvedWallpaperPath: @"C:\wallpaper.png",
|
||||
UseNativeWallpaperChangeEvents: true,
|
||||
NativeWallpaperChangeEventsActive: true,
|
||||
WallpaperPollingActive: true,
|
||||
Surfaces: surfaces);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.ComponentSystem;
|
||||
using LanMountainDesktop.Services;
|
||||
@@ -73,6 +75,82 @@ public sealed class DesktopComponentRenderModeTests
|
||||
Assert.Null(probe.RuntimeContext?.PlacementId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultRuntimeRegistrations_IncludeMaterialWeatherComponents()
|
||||
{
|
||||
var componentIds = DesktopComponentRuntimeRegistry.GetDefaultRegistrations()
|
||||
.Select(registration => registration.ComponentId)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
Assert.Contains(BuiltInComponentIds.DesktopWeatherClock, componentIds);
|
||||
Assert.Contains(BuiltInComponentIds.DesktopWeather, componentIds);
|
||||
Assert.Contains(BuiltInComponentIds.DesktopHourlyWeather, componentIds);
|
||||
Assert.Contains(BuiltInComponentIds.DesktopMultiDayWeather, componentIds);
|
||||
Assert.Contains(BuiltInComponentIds.DesktopExtendedWeather, componentIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeatherVisualStyleCatalog_NormalizesLegacyAndSupportedIds()
|
||||
{
|
||||
Assert.Equal(WeatherVisualStyleId.GoogleWeatherV4, WeatherVisualStyleCatalog.Normalize(null));
|
||||
Assert.Equal(WeatherVisualStyleId.GoogleWeatherV4, WeatherVisualStyleCatalog.Normalize("DefaultWeather"));
|
||||
Assert.Equal(WeatherVisualStyleId.GoogleWeatherV4, WeatherVisualStyleCatalog.Normalize("HyperOS3"));
|
||||
Assert.Equal(WeatherVisualStyleId.Geometric, WeatherVisualStyleCatalog.Normalize("Geometric"));
|
||||
Assert.Equal(WeatherVisualStyleId.Breezy, WeatherVisualStyleCatalog.Normalize("Breezy"));
|
||||
Assert.Equal(WeatherVisualStyleId.LemonFlutter, WeatherVisualStyleCatalog.Normalize("LemonFlutter"));
|
||||
Assert.Equal(WeatherVisualStyleId.GoogleWeatherV4, WeatherVisualStyleCatalog.Normalize("MissingPack"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(WeatherVisualStyleId.GoogleWeatherV4)]
|
||||
[InlineData(WeatherVisualStyleId.Geometric)]
|
||||
[InlineData(WeatherVisualStyleId.Breezy)]
|
||||
[InlineData(WeatherVisualStyleId.LemonFlutter)]
|
||||
public void WeatherIconAssetResolver_ResolvesCoreWeatherStates(string styleId)
|
||||
{
|
||||
Assert.NotNull(WeatherIconAssetResolver.ResolveAssetUri(styleId, 0, "Clear", isDaylight: true));
|
||||
Assert.NotNull(WeatherIconAssetResolver.ResolveAssetUri(styleId, 1, "Partly cloudy", isDaylight: false));
|
||||
Assert.NotNull(WeatherIconAssetResolver.ResolveAssetUri(styleId, 7, "Rain", isDaylight: true));
|
||||
Assert.NotNull(WeatherIconAssetResolver.ResolveAssetUri(styleId, 4, "Thunderstorm", isDaylight: true));
|
||||
Assert.NotNull(WeatherIconAssetResolver.ResolveAssetUri(styleId, 13, "Snow", isDaylight: true));
|
||||
Assert.NotNull(WeatherIconAssetResolver.ResolveAssetUri(styleId, 18, "Fog", isDaylight: true));
|
||||
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));
|
||||
|
||||
38
LanMountainDesktop.Tests/DesktopEditOverlayPresenterTests.cs
Normal file
38
LanMountainDesktop.Tests/DesktopEditOverlayPresenterTests.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using LanMountainDesktop.DesktopEditing;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class DesktopEditOverlayPresenterTests
|
||||
{
|
||||
[Fact]
|
||||
public void CompositionOffsetHelperFallsBackWhenVisualIsUnavailable()
|
||||
{
|
||||
var service = new CompositionVisualAnimationService(_ => null);
|
||||
var target = new Border();
|
||||
|
||||
var result = service.TrySetOffset(target, new Point(12, 34));
|
||||
|
||||
Assert.False(result);
|
||||
Assert.False(service.TrySetOpacity(target, 0.5));
|
||||
Assert.False(service.TrySetUniformScale(target, 1.05));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreviewRectUsesCanvasPlacementWhenCompositionIsUnavailable()
|
||||
{
|
||||
var presenter = new DesktopEditOverlayPresenter(new CompositionVisualAnimationService(_ => null));
|
||||
var root = Assert.IsType<Canvas>(presenter.Root);
|
||||
|
||||
presenter.SetPreviewRect(new Rect(12, 34, 180, 120));
|
||||
|
||||
var ghost = root.Children.OfType<DesktopEditGhostView>().Single();
|
||||
Assert.Equal(12, Canvas.GetLeft(ghost));
|
||||
Assert.Equal(34, Canvas.GetTop(ghost));
|
||||
Assert.Equal(180, ghost.Width);
|
||||
Assert.Equal(120, ghost.Height);
|
||||
}
|
||||
}
|
||||
142
LanMountainDesktop.Tests/HostAppSettingsOobeMergerTests.cs
Normal file
142
LanMountainDesktop.Tests/HostAppSettingsOobeMergerTests.cs
Normal file
@@ -0,0 +1,142 @@
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class HostAppSettingsOobeMergerTests
|
||||
{
|
||||
[Fact]
|
||||
public void MergeStartupPresentation_PreservesUnrelatedJsonKeys()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "LMD.OobeMerge", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "settings.json");
|
||||
File.WriteAllText(path, """
|
||||
{
|
||||
"LanguageCode": "ja-JP",
|
||||
"ShowInTaskbar": false,
|
||||
"EnableFadeTransition": true,
|
||||
"EnableSlideTransition": false
|
||||
}
|
||||
""");
|
||||
|
||||
try
|
||||
{
|
||||
HostAppSettingsOobeMerger.MergeStartupPresentation(
|
||||
path,
|
||||
new HostAppSettingsStartupChoices(
|
||||
ShowInTaskbar: true,
|
||||
EnableFadeTransition: false,
|
||||
EnableSlideTransition: true,
|
||||
FusedPopupExperience: true,
|
||||
AutoStartWithWindows: true));
|
||||
|
||||
var json = File.ReadAllText(path);
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
Assert.Equal("ja-JP", root.GetProperty("LanguageCode").GetString());
|
||||
Assert.True(root.GetProperty("ShowInTaskbar").GetBoolean());
|
||||
Assert.False(root.GetProperty("EnableFadeTransition").GetBoolean());
|
||||
Assert.True(root.GetProperty("EnableSlideTransition").GetBoolean());
|
||||
Assert.True(root.GetProperty("EnableFusedDesktop").GetBoolean());
|
||||
Assert.True(root.GetProperty("EnableThreeFingerSwipe").GetBoolean());
|
||||
Assert.True(root.GetProperty("AutoStartWithWindows").GetBoolean());
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSettingsFilePath_NormalizesDataRoot()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "LMD.OobePath", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(root);
|
||||
try
|
||||
{
|
||||
var path = HostAppSettingsOobeMerger.GetSettingsFilePath(root + Path.DirectorySeparatorChar);
|
||||
Assert.Equal(Path.Combine(Path.GetFullPath(root), "settings.json"), path);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(root);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadStartupDefaults_WhenFusedAndSwipeDiffer_TreatsPopupExperienceAsBothTrue()
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "LMD.OobeDefaults", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "settings.json");
|
||||
File.WriteAllText(path, """
|
||||
{
|
||||
"EnableFusedDesktop": true,
|
||||
"EnableThreeFingerSwipe": false
|
||||
}
|
||||
""");
|
||||
|
||||
try
|
||||
{
|
||||
var d = HostAppSettingsOobeMerger.LoadStartupDefaults(path);
|
||||
Assert.False(d.FusedPopupExperience);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("RestartApp", MultiInstanceLaunchBehavior.RestartApp)]
|
||||
[InlineData("OpenDesktopSilently", MultiInstanceLaunchBehavior.OpenDesktopSilently)]
|
||||
[InlineData("PromptOnly", MultiInstanceLaunchBehavior.PromptOnly)]
|
||||
[InlineData("NotifyAndOpenDesktop", MultiInstanceLaunchBehavior.NotifyAndOpenDesktop)]
|
||||
public void LoadMultiInstanceLaunchBehavior_ReadsStringValues(
|
||||
string value,
|
||||
MultiInstanceLaunchBehavior expected)
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "LMD.MultiInstanceSettings", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "settings.json");
|
||||
File.WriteAllText(path, $$"""
|
||||
{
|
||||
"MultiInstanceLaunchBehavior": "{{value}}"
|
||||
}
|
||||
""");
|
||||
|
||||
try
|
||||
{
|
||||
Assert.Equal(expected, HostAppSettingsOobeMerger.LoadMultiInstanceLaunchBehavior(path));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{}")]
|
||||
[InlineData("{ \"MultiInstanceLaunchBehavior\": \"Unknown\" }")]
|
||||
public void LoadMultiInstanceLaunchBehavior_FallsBackToNotifyAndOpenDesktop(string json)
|
||||
{
|
||||
var dir = Path.Combine(Path.GetTempPath(), "LMD.MultiInstanceSettings", Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var path = Path.Combine(dir, "settings.json");
|
||||
File.WriteAllText(path, json);
|
||||
|
||||
try
|
||||
{
|
||||
Assert.Equal(
|
||||
MultiInstanceLaunchBehavior.NotifyAndOpenDesktop,
|
||||
HostAppSettingsOobeMerger.LoadMultiInstanceLaunchBehavior(path));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Directory.Delete(dir, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
190
LanMountainDesktop.Tests/LauncherAirAppLifecycleServiceTests.cs
Normal file
190
LanMountainDesktop.Tests/LauncherAirAppLifecycleServiceTests.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
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_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()
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
123
LanMountainDesktop.Tests/LauncherMultiInstancePolicyTests.cs
Normal file
123
LanMountainDesktop.Tests/LauncherMultiInstancePolicyTests.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using LanMountainDesktop.Launcher;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Shared.Contracts.Launcher;
|
||||
using LanMountainDesktop.Shared.IPC.Abstractions.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class LauncherMultiInstancePolicyTests
|
||||
{
|
||||
[Fact]
|
||||
public void AppSettingsSnapshot_DefaultsToNotifyAndOpenDesktop()
|
||||
{
|
||||
Assert.Equal(
|
||||
MultiInstanceLaunchBehavior.NotifyAndOpenDesktop,
|
||||
new AppSettingsSnapshot().MultiInstanceLaunchBehavior);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldProbeExistingHostBeforeLaunch_ReturnsTrue_ForNormalLaunch()
|
||||
{
|
||||
var context = CommandContext.FromArgs(["launch"]);
|
||||
|
||||
Assert.True(LauncherFlowCoordinator.ShouldProbeExistingHostBeforeLaunch(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldProbeExistingHostBeforeLaunch_ReturnsFalse_ForRestartLaunch()
|
||||
{
|
||||
var context = CommandContext.FromArgs([
|
||||
"launch",
|
||||
$"--{LauncherIpcConstants.LaunchSourceOptionName}=restart"
|
||||
]);
|
||||
|
||||
Assert.False(LauncherFlowCoordinator.ShouldProbeExistingHostBeforeLaunch(context));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ActivationExitCodes_AreClassifiedSeparatelyFromEarlyHostExit()
|
||||
{
|
||||
Assert.True(LauncherFlowCoordinator.IsSuccessfulActivationExitCode(HostExitCodes.SecondaryActivationSucceeded));
|
||||
Assert.True(LauncherFlowCoordinator.IsFailedActivationExitCode(HostExitCodes.SecondaryActivationFailed));
|
||||
Assert.True(LauncherFlowCoordinator.IsFailedActivationExitCode(HostExitCodes.RestartLockNotAcquired));
|
||||
Assert.False(LauncherFlowCoordinator.IsFailedActivationExitCode(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecoverableActivationFailure_ReturnsTrue_WhenPublicIpcIsReadyButShellIsPending()
|
||||
{
|
||||
var activation = new PublicShellActivationResult(
|
||||
false,
|
||||
"shell_not_ready",
|
||||
"Desktop shell is still initializing.",
|
||||
CreateShellStatus(
|
||||
publicIpcReady: true,
|
||||
mainWindowOpened: false,
|
||||
desktopVisible: false));
|
||||
|
||||
Assert.True(LauncherFlowCoordinator.IsRecoverableActivationFailure(activation));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsRecoverableActivationFailure_ReturnsFalse_WhenShutdownIsInProgress()
|
||||
{
|
||||
var activation = new PublicShellActivationResult(
|
||||
false,
|
||||
"shutdown_in_progress",
|
||||
"Desktop is shutting down.",
|
||||
CreateShellStatus(
|
||||
publicIpcReady: true,
|
||||
mainWindowOpened: false,
|
||||
desktopVisible: false));
|
||||
|
||||
Assert.False(LauncherFlowCoordinator.IsRecoverableActivationFailure(activation));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExistingHostReadyForLauncherDecision_RequiresPublicIpcReady()
|
||||
{
|
||||
Assert.False(LauncherFlowCoordinator.IsExistingHostReadyForLauncherDecision(null));
|
||||
Assert.False(LauncherFlowCoordinator.IsExistingHostReadyForLauncherDecision(CreateShellStatus(
|
||||
publicIpcReady: false,
|
||||
mainWindowOpened: true,
|
||||
desktopVisible: true)));
|
||||
Assert.True(LauncherFlowCoordinator.IsExistingHostReadyForLauncherDecision(CreateShellStatus(
|
||||
publicIpcReady: true,
|
||||
mainWindowOpened: true,
|
||||
desktopVisible: true)));
|
||||
}
|
||||
|
||||
|
||||
private static PublicShellStatus CreateShellStatus(
|
||||
bool publicIpcReady,
|
||||
bool mainWindowOpened,
|
||||
bool desktopVisible)
|
||||
{
|
||||
return new PublicShellStatus(
|
||||
ProcessId: Environment.ProcessId,
|
||||
StartedAtUtc: DateTimeOffset.UtcNow,
|
||||
LaunchSource: "normal",
|
||||
ShellState: mainWindowOpened ? "opened" : "initializing",
|
||||
MainWindowCreated: mainWindowOpened,
|
||||
MainWindowVisible: desktopVisible,
|
||||
MainWindowOpened: mainWindowOpened,
|
||||
DesktopVisible: desktopVisible,
|
||||
PublicIpcReady: publicIpcReady,
|
||||
Tray: new PublicTrayStatus(
|
||||
State: "Unavailable",
|
||||
IsReady: false,
|
||||
HasIcon: false,
|
||||
HasMenu: false,
|
||||
IsVisible: false,
|
||||
ConsecutiveRecoveryFailures: 0),
|
||||
Taskbar: new PublicTaskbarStatus(
|
||||
RequestedBySettings: false,
|
||||
MainWindowExists: mainWindowOpened,
|
||||
MainWindowShowInTaskbar: mainWindowOpened,
|
||||
MainWindowVisible: desktopVisible,
|
||||
MainWindowMinimized: false,
|
||||
IsUsable: mainWindowOpened));
|
||||
}
|
||||
}
|
||||
136
LanMountainDesktop.Tests/MaterialColorIntegrationTests.cs
Normal file
136
LanMountainDesktop.Tests/MaterialColorIntegrationTests.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class MaterialColorIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void PluginMapper_ExposesUnifiedMaterialColorSnapshot()
|
||||
{
|
||||
var snapshot = CreateSnapshot();
|
||||
|
||||
var pluginSnapshot = PluginAppearanceSnapshotMapper.FromMaterialColorSnapshot(snapshot);
|
||||
|
||||
Assert.Equal("Dark", pluginSnapshot.ThemeVariant);
|
||||
Assert.Equal(Color.Parse("#FF214365").ToString(), pluginSnapshot.AccentColor);
|
||||
Assert.Equal(Color.Parse("#FF123456").ToString(), pluginSnapshot.SeedColor);
|
||||
Assert.Equal(MaterialColorSourceKind.CustomSeed.ToString(), pluginSnapshot.ColorSource);
|
||||
Assert.Equal(ThemeAppearanceValues.MaterialMica, pluginSnapshot.SystemMaterialMode);
|
||||
Assert.Equal(Color.Parse("#FF214365").ToString(), pluginSnapshot.ColorRoles?["accent"]);
|
||||
Assert.Equal(Color.Parse("#FF101820").ToString(), pluginSnapshot.MaterialSurfaces?["WindowBackground"].BackgroundColor);
|
||||
Assert.Equal(Color.Parse("#FF123456").ToString(), Assert.Single(pluginSnapshot.WallpaperSeedCandidates ?? []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComponentEditorAdapter_UsesMaterialColorSnapshotAsSource()
|
||||
{
|
||||
var snapshot = CreateSnapshot();
|
||||
|
||||
var palette = ComponentEditorMaterialThemeAdapter.Build(snapshot);
|
||||
|
||||
Assert.Equal(snapshot.Palette.Primary, palette.PrimaryColor);
|
||||
Assert.Equal(snapshot.Palette.Secondary, palette.SecondaryColor);
|
||||
Assert.Equal(snapshot.Palette.OnAccent, palette.OnPrimaryColor);
|
||||
Assert.Equal(snapshot.Surfaces[MaterialSurfaceRole.WindowBackground].BackgroundColor, palette.WindowBackgroundColor);
|
||||
Assert.Equal(snapshot.Surfaces[MaterialSurfaceRole.OverlayPanel].BackgroundColor, palette.SurfaceContainerHighColor);
|
||||
}
|
||||
|
||||
private static MaterialColorSnapshot CreateSnapshot()
|
||||
{
|
||||
var seed = Color.Parse("#FF123456");
|
||||
var accent = Color.Parse("#FF214365");
|
||||
var palette = new MaterialColorPalette(
|
||||
Color.Parse("#FF315577"),
|
||||
Color.Parse("#FF557799"),
|
||||
accent,
|
||||
Color.Parse("#FFFFFFFF"),
|
||||
Color.Parse("#FF5F7F9F"),
|
||||
Color.Parse("#FF7F9FBF"),
|
||||
Color.Parse("#FF9FBFDF"),
|
||||
Color.Parse("#FF17314B"),
|
||||
Color.Parse("#FF102840"),
|
||||
Color.Parse("#FF082038"),
|
||||
Color.Parse("#FF0B1118"),
|
||||
Color.Parse("#FF141C24"),
|
||||
Color.Parse("#FF1C2630"),
|
||||
Color.Parse("#FFF5F7FA"),
|
||||
Color.Parse("#FFC8D0DA"),
|
||||
Color.Parse("#FF9EA8B4"),
|
||||
Color.Parse("#FF91B8E8"),
|
||||
Color.Parse("#FFF5F7FA"),
|
||||
Color.Parse("#FFFFFFFF"),
|
||||
Color.Parse("#FF9FBFDF"),
|
||||
Color.Parse("#33141C24"),
|
||||
Color.Parse("#441C2630"),
|
||||
Color.Parse("#55315577"),
|
||||
Color.Parse("#FF315577"),
|
||||
Color.Parse("#88557799"),
|
||||
Color.Parse("#667F9FBF"));
|
||||
var monetPalette = new MonetPalette(
|
||||
[seed],
|
||||
seed,
|
||||
palette.Primary,
|
||||
palette.Secondary,
|
||||
Color.Parse("#FF775577"),
|
||||
Color.Parse("#FF202830"),
|
||||
Color.Parse("#FF26313B"));
|
||||
var surfaces = new Dictionary<MaterialSurfaceRole, MaterialSurfaceSnapshot>
|
||||
{
|
||||
[MaterialSurfaceRole.WindowBackground] = new(
|
||||
MaterialSurfaceRole.WindowBackground,
|
||||
Color.Parse("#FF101820"),
|
||||
Color.Parse("#33557799"),
|
||||
18,
|
||||
0.92),
|
||||
[MaterialSurfaceRole.DesktopComponentHost] = new(
|
||||
MaterialSurfaceRole.DesktopComponentHost,
|
||||
Color.Parse("#FF141C24"),
|
||||
Color.Parse("#44557799"),
|
||||
20,
|
||||
0.90),
|
||||
[MaterialSurfaceRole.OverlayPanel] = new(
|
||||
MaterialSurfaceRole.OverlayPanel,
|
||||
Color.Parse("#FF202A34"),
|
||||
Color.Parse("#556688AA"),
|
||||
24,
|
||||
0.88)
|
||||
};
|
||||
|
||||
return new MaterialColorSnapshot(
|
||||
IsNightMode: true,
|
||||
ThemeColorMode: ThemeAppearanceValues.ColorModeSeedMonet,
|
||||
ThemeWallpaperColorSource: ThemeAppearanceValues.WallpaperColorSourceAuto,
|
||||
ColorSourceKind: MaterialColorSourceKind.CustomSeed,
|
||||
ResolvedSeedSource: "user_color",
|
||||
CornerRadiusTokens: new AppearanceCornerRadiusTokens(
|
||||
new CornerRadius(2),
|
||||
new CornerRadius(4),
|
||||
new CornerRadius(6),
|
||||
new CornerRadius(8),
|
||||
new CornerRadius(10),
|
||||
new CornerRadius(12),
|
||||
new CornerRadius(14),
|
||||
new CornerRadius(8)),
|
||||
UserThemeColor: seed.ToString(),
|
||||
SelectedWallpaperSeed: seed.ToString(),
|
||||
EffectiveSeedColor: seed,
|
||||
AccentColor: accent,
|
||||
MonetPalette: monetPalette,
|
||||
Palette: palette,
|
||||
WallpaperSeedCandidates: [seed],
|
||||
SystemMaterialMode: ThemeAppearanceValues.MaterialMica,
|
||||
AvailableSystemMaterialModes: [ThemeAppearanceValues.MaterialAuto, ThemeAppearanceValues.MaterialMica],
|
||||
CanChangeSystemMaterial: true,
|
||||
UseSystemChrome: false,
|
||||
ResolvedWallpaperPath: @"C:\wallpaper.png",
|
||||
UseNativeWallpaperChangeEvents: true,
|
||||
NativeWallpaperChangeEventsActive: true,
|
||||
WallpaperPollingActive: true,
|
||||
Surfaces: surfaces);
|
||||
}
|
||||
}
|
||||
125
LanMountainDesktop.Tests/MusicControlServiceTests.cs
Normal file
125
LanMountainDesktop.Tests/MusicControlServiceTests.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class MusicControlServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void SelectCurrentSession_PrefersPlayingSession()
|
||||
{
|
||||
var olderPlaying = CreateState("playing", MusicPlaybackStatus.Playing, DateTimeOffset.UtcNow.AddMinutes(-10));
|
||||
var newerPaused = CreateState("paused", MusicPlaybackStatus.Paused, DateTimeOffset.UtcNow);
|
||||
|
||||
var selected = MusicControlService.SelectCurrentSession([newerPaused, olderPlaying], MusicPlatform.Windows);
|
||||
|
||||
Assert.Equal("playing", selected.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectCurrentSession_UsesMostRecentWhenNothingPlaying()
|
||||
{
|
||||
var older = CreateState("older", MusicPlaybackStatus.Paused, DateTimeOffset.UtcNow.AddMinutes(-10));
|
||||
var newer = CreateState("newer", MusicPlaybackStatus.Stopped, DateTimeOffset.UtcNow);
|
||||
|
||||
var selected = MusicControlService.SelectCurrentSession([older, newer], MusicPlatform.Linux);
|
||||
|
||||
Assert.Equal("newer", selected.SessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMetadata_MapsCommonMprisFields()
|
||||
{
|
||||
const string metadata = """
|
||||
array [
|
||||
dict entry(
|
||||
string "xesam:title"
|
||||
variant string "Song Title"
|
||||
)
|
||||
dict entry(
|
||||
string "xesam:artist"
|
||||
variant array [
|
||||
string "Artist A"
|
||||
string "Artist B"
|
||||
]
|
||||
)
|
||||
dict entry(
|
||||
string "xesam:album"
|
||||
variant string "Album"
|
||||
)
|
||||
dict entry(
|
||||
string "mpris:length"
|
||||
variant int64 185000000
|
||||
)
|
||||
]
|
||||
""";
|
||||
|
||||
var parsed = LinuxMprisMusicSessionProvider.ParseMetadata(metadata);
|
||||
|
||||
Assert.Equal("Song Title", parsed["xesam:title"]);
|
||||
Assert.Equal("Artist A, Artist B", parsed["xesam:artist"]);
|
||||
Assert.Equal("Album", parsed["xesam:album"]);
|
||||
Assert.Equal("185000000", parsed["mpris:length"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapMprisSession_ConvertsStatusCapabilitiesAndDuration()
|
||||
{
|
||||
const string metadata = """
|
||||
dict entry(
|
||||
string "xesam:title"
|
||||
variant string "Track"
|
||||
)
|
||||
dict entry(
|
||||
string "mpris:length"
|
||||
variant int64 120000000
|
||||
)
|
||||
""";
|
||||
|
||||
var state = LinuxMprisMusicSessionProvider.MapMprisSession(
|
||||
"org.mpris.MediaPlayer2.spotify",
|
||||
"Spotify",
|
||||
"Playing",
|
||||
metadata,
|
||||
positionMicroseconds: 30_000_000,
|
||||
canPlay: true,
|
||||
canPause: true,
|
||||
canGoNext: true,
|
||||
canGoPrevious: false,
|
||||
canControl: true,
|
||||
DateTimeOffset.UtcNow);
|
||||
|
||||
Assert.True(state.HasSession);
|
||||
Assert.Equal(MusicPlatform.Linux, state.Platform);
|
||||
Assert.Equal(MusicPlaybackStatus.Playing, state.PlaybackStatus);
|
||||
Assert.Equal(TimeSpan.FromSeconds(30), state.Position);
|
||||
Assert.Equal(TimeSpan.FromSeconds(120), state.Duration);
|
||||
Assert.True(state.CanPlayPause);
|
||||
Assert.True(state.CanSkipNext);
|
||||
Assert.False(state.CanSkipPrevious);
|
||||
}
|
||||
|
||||
private static MusicPlaybackState CreateState(string sessionId, MusicPlaybackStatus status, DateTimeOffset updatedAt)
|
||||
=> new(
|
||||
IsSupported: true,
|
||||
HasSession: true,
|
||||
Platform: MusicPlatform.Windows,
|
||||
SessionId: sessionId,
|
||||
SourceAppId: sessionId,
|
||||
SourceAppName: sessionId,
|
||||
SourceExecutableOrBusName: sessionId,
|
||||
Title: sessionId,
|
||||
Artist: string.Empty,
|
||||
AlbumTitle: string.Empty,
|
||||
ThumbnailBytes: null,
|
||||
Position: TimeSpan.Zero,
|
||||
Duration: TimeSpan.Zero,
|
||||
PlaybackStatus: status,
|
||||
CanPlayPause: true,
|
||||
CanSkipPrevious: true,
|
||||
CanSkipNext: true,
|
||||
CanLaunch: true,
|
||||
IsStale: false,
|
||||
StatusMessage: string.Empty,
|
||||
UpdatedAtUtc: updatedAt);
|
||||
}
|
||||
42
LanMountainDesktop.Tests/MusicControlViewModelTests.cs
Normal file
42
LanMountainDesktop.Tests/MusicControlViewModelTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
106
LanMountainDesktop.Tests/NotificationListenerServiceTests.cs
Normal file
106
LanMountainDesktop.Tests/NotificationListenerServiceTests.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class NotificationListenerServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void AddNotification_DeduplicatesByPlatformAndSourceId()
|
||||
{
|
||||
var settings = new FakeSettingsService();
|
||||
var service = new NotificationListenerService(settings);
|
||||
|
||||
service.AddNotification(new NotificationItem
|
||||
{
|
||||
Platform = "Windows",
|
||||
SourceNotificationId = "42",
|
||||
AppId = "mail",
|
||||
AppName = "Mail",
|
||||
Title = "First"
|
||||
});
|
||||
service.AddNotification(new NotificationItem
|
||||
{
|
||||
Platform = "Windows",
|
||||
SourceNotificationId = "42",
|
||||
AppId = "mail",
|
||||
AppName = "Mail",
|
||||
Title = "Updated"
|
||||
});
|
||||
|
||||
var notification = Assert.Single(service.GetNotifications());
|
||||
Assert.Equal("Updated", notification.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddNotification_RespectsBlockedApps()
|
||||
{
|
||||
var settings = new FakeSettingsService();
|
||||
settings.Snapshot.NotificationBoxBlockedApps.Add("blocked-app");
|
||||
var service = new NotificationListenerService(settings);
|
||||
|
||||
service.AddNotification(new NotificationItem
|
||||
{
|
||||
AppId = "blocked-app",
|
||||
AppName = "Blocked",
|
||||
Title = "Hidden"
|
||||
});
|
||||
|
||||
Assert.Empty(service.GetNotifications());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddNotification_TrimsToMaxStoredCount()
|
||||
{
|
||||
var settings = new FakeSettingsService();
|
||||
settings.Snapshot.NotificationBoxMaxStoredCount = 2;
|
||||
var service = new NotificationListenerService(settings);
|
||||
|
||||
service.AddNotification(new NotificationItem { AppId = "a", AppName = "A", Title = "1" });
|
||||
service.AddNotification(new NotificationItem { AppId = "b", AppName = "B", Title = "2" });
|
||||
service.AddNotification(new NotificationItem { AppId = "c", AppName = "C", Title = "3" });
|
||||
|
||||
var notifications = service.GetNotifications();
|
||||
Assert.Equal(2, notifications.Count);
|
||||
Assert.DoesNotContain(notifications, n => n.Title == "1");
|
||||
}
|
||||
|
||||
private sealed class FakeSettingsService : ISettingsService
|
||||
{
|
||||
public AppSettingsSnapshot Snapshot { get; } = new();
|
||||
|
||||
public event EventHandler<SettingsChangedEvent>? Changed;
|
||||
|
||||
public T LoadSnapshot<T>(SettingsScope scope, string? subjectId = null, string? placementId = null) where T : new()
|
||||
=> typeof(T) == typeof(AppSettingsSnapshot)
|
||||
? (T)(object)Snapshot
|
||||
: new T();
|
||||
|
||||
public void SaveSnapshot<T>(SettingsScope scope, T snapshot, string? subjectId = null, string? placementId = null, string? sectionId = null, IReadOnlyCollection<string>? changedKeys = null)
|
||||
{
|
||||
}
|
||||
|
||||
public T LoadSection<T>(SettingsScope scope, string subjectId, string sectionId, string? placementId = null) where T : new()
|
||||
=> new();
|
||||
|
||||
public void SaveSection<T>(SettingsScope scope, string subjectId, string sectionId, T section, string? placementId = null, IReadOnlyCollection<string>? changedKeys = null)
|
||||
{
|
||||
}
|
||||
|
||||
public void DeleteSection(SettingsScope scope, string subjectId, string sectionId, string? placementId = null)
|
||||
{
|
||||
}
|
||||
|
||||
public T? GetValue<T>(SettingsScope scope, string key, string? subjectId = null, string? placementId = null, string? sectionId = null)
|
||||
=> default;
|
||||
|
||||
public void SetValue<T>(SettingsScope scope, string key, T value, string? subjectId = null, string? placementId = null, string? sectionId = null, IReadOnlyCollection<string>? changedKeys = null)
|
||||
{
|
||||
}
|
||||
|
||||
public IComponentSettingsAccessor GetComponentAccessor(string componentId, string? placementId)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
379
LanMountainDesktop.Tests/PluginAppearanceBoundaryTests.cs
Normal file
379
LanMountainDesktop.Tests/PluginAppearanceBoundaryTests.cs
Normal file
@@ -0,0 +1,379 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Appearance;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Plugins;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.Shared.Contracts;
|
||||
using Xunit;
|
||||
using PluginIsolationAppearanceChangedNotification = LanMountainDesktop.PluginIsolation.Contracts.PluginAppearanceChangedNotification;
|
||||
using PluginIsolationAppearanceSnapshot = LanMountainDesktop.PluginIsolation.Contracts.PluginAppearanceSnapshot;
|
||||
using PluginIsolationAppearanceSnapshotRequest = LanMountainDesktop.PluginIsolation.Contracts.PluginAppearanceSnapshotRequest;
|
||||
using PluginIsolationMaterialSurfaceSnapshot = LanMountainDesktop.PluginIsolation.Contracts.PluginMaterialSurfaceSnapshot;
|
||||
using PluginIsolationJsonContext = LanMountainDesktop.PluginIsolation.Contracts.PluginIsolationJsonContext;
|
||||
using MaterialColorPalette = LanMountainDesktop.Models.MaterialColorPalette;
|
||||
using PluginSdkAppearanceSnapshot = LanMountainDesktop.PluginSdk.PluginAppearanceSnapshot;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class PluginAppearanceBoundaryTests
|
||||
{
|
||||
[Fact]
|
||||
public void PluginLoader_PrefersMaterialColorService_WhenBothAppearanceSourcesAreAvailable()
|
||||
{
|
||||
var materialService = new TrackingMaterialColorService(CreateMaterialSnapshot());
|
||||
var themeService = new TrackingAppearanceThemeService(CreateThemeSnapshot());
|
||||
var provider = new TrackingServiceProvider(materialService, themeService);
|
||||
|
||||
var snapshot = InvokeLoaderSnapshotBuilder(provider);
|
||||
|
||||
Assert.Equal(1, materialService.GetMaterialColorSnapshotCalls);
|
||||
Assert.Equal(0, themeService.GetCurrentCalls);
|
||||
Assert.Equal("Dark", snapshot.ThemeVariant);
|
||||
Assert.Equal(Color.Parse("#FF214365").ToString(), snapshot.AccentColor);
|
||||
Assert.Equal(Color.Parse("#FF123456").ToString(), snapshot.SeedColor);
|
||||
Assert.Equal(MaterialColorSourceKind.CustomSeed.ToString(), snapshot.ColorSource);
|
||||
Assert.Equal(ThemeAppearanceValues.MaterialMica, snapshot.SystemMaterialMode);
|
||||
Assert.Equal(Color.Parse("#FF214365").ToString(), snapshot.ColorRoles?["accent"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginIsolationJsonContext_RoundTripsAppearancePayloads()
|
||||
{
|
||||
var request = new PluginIsolationAppearanceSnapshotRequest("session-42");
|
||||
var snapshot = new PluginIsolationAppearanceSnapshot(
|
||||
ThemeVariant: "Dark",
|
||||
AccentColor: "#FF214365",
|
||||
CornerRadiusScale: 1.25,
|
||||
CornerRadiusTokens: new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["component"] = 24,
|
||||
["sm"] = 8
|
||||
},
|
||||
ResourceAliases: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["surface-base"] = "DesignSurfaceBase"
|
||||
},
|
||||
SeedColor: "#FF123456",
|
||||
ColorSource: "custom_seed",
|
||||
SystemMaterialMode: ThemeAppearanceValues.MaterialMica,
|
||||
ColorRoles: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["accent"] = "#FF214365",
|
||||
["primary"] = "#FF315577"
|
||||
},
|
||||
MaterialSurfaces: new Dictionary<string, PluginIsolationMaterialSurfaceSnapshot>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["WindowBackground"] = new PluginIsolationMaterialSurfaceSnapshot("#FF101820", "#33557799", 18, 0.92)
|
||||
},
|
||||
WallpaperSeedCandidates: ["#FF123456", "#FF214365"]);
|
||||
var notification = new PluginIsolationAppearanceChangedNotification(snapshot);
|
||||
|
||||
var requestJson = JsonSerializer.Serialize(request, PluginIsolationJsonContext.Default.PluginAppearanceSnapshotRequest);
|
||||
var requestRoundTrip = JsonSerializer.Deserialize(requestJson, PluginIsolationJsonContext.Default.PluginAppearanceSnapshotRequest);
|
||||
|
||||
var snapshotJson = JsonSerializer.Serialize(snapshot, PluginIsolationJsonContext.Default.PluginAppearanceSnapshot);
|
||||
var snapshotRoundTrip = JsonSerializer.Deserialize(snapshotJson, PluginIsolationJsonContext.Default.PluginAppearanceSnapshot);
|
||||
|
||||
var notificationJson = JsonSerializer.Serialize(notification, PluginIsolationJsonContext.Default.PluginAppearanceChangedNotification);
|
||||
var notificationRoundTrip = JsonSerializer.Deserialize(notificationJson, PluginIsolationJsonContext.Default.PluginAppearanceChangedNotification);
|
||||
|
||||
Assert.Equal(request, requestRoundTrip);
|
||||
AssertAppearanceSnapshotEqual(snapshot, snapshotRoundTrip!);
|
||||
AssertAppearanceSnapshotEqual(snapshot, notificationRoundTrip!.Snapshot);
|
||||
}
|
||||
|
||||
private static PluginSdkAppearanceSnapshot InvokeLoaderSnapshotBuilder(IServiceProvider provider)
|
||||
{
|
||||
var method = typeof(PluginLoader).GetMethod(
|
||||
"BuildAppearanceSnapshot",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
Assert.NotNull(method);
|
||||
|
||||
var result = method!.Invoke(null, [provider]);
|
||||
return Assert.IsType<PluginSdkAppearanceSnapshot>(result);
|
||||
}
|
||||
|
||||
private static void AssertAppearanceSnapshotEqual(PluginIsolationAppearanceSnapshot expected, PluginIsolationAppearanceSnapshot actual)
|
||||
{
|
||||
Assert.Equal(expected.ThemeVariant, actual.ThemeVariant);
|
||||
Assert.Equal(expected.AccentColor, actual.AccentColor);
|
||||
Assert.Equal(expected.CornerRadiusScale, actual.CornerRadiusScale);
|
||||
Assert.Equal(expected.SeedColor, actual.SeedColor);
|
||||
Assert.Equal(expected.ColorSource, actual.ColorSource);
|
||||
Assert.Equal(expected.SystemMaterialMode, actual.SystemMaterialMode);
|
||||
Assert.Equal(expected.WallpaperSeedCandidates, actual.WallpaperSeedCandidates);
|
||||
AssertDictionaryEqual(expected.CornerRadiusTokens, actual.CornerRadiusTokens);
|
||||
AssertDictionaryEqual(expected.ResourceAliases, actual.ResourceAliases);
|
||||
AssertDictionaryEqual(expected.ColorRoles, actual.ColorRoles);
|
||||
|
||||
Assert.NotNull(actual.MaterialSurfaces);
|
||||
Assert.Equal(expected.MaterialSurfaces!.Count, actual.MaterialSurfaces!.Count);
|
||||
foreach (var pair in expected.MaterialSurfaces)
|
||||
{
|
||||
Assert.True(actual.MaterialSurfaces.TryGetValue(pair.Key, out var actualSurface));
|
||||
Assert.Equal(pair.Value.BackgroundColor, actualSurface.BackgroundColor);
|
||||
Assert.Equal(pair.Value.BorderColor, actualSurface.BorderColor);
|
||||
Assert.Equal(pair.Value.BlurRadius, actualSurface.BlurRadius);
|
||||
Assert.Equal(pair.Value.Opacity, actualSurface.Opacity);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AssertDictionaryEqual<TKey, TValue>(
|
||||
IReadOnlyDictionary<TKey, TValue>? expected,
|
||||
IReadOnlyDictionary<TKey, TValue>? actual)
|
||||
where TKey : notnull
|
||||
{
|
||||
if (expected is null)
|
||||
{
|
||||
Assert.Null(actual);
|
||||
return;
|
||||
}
|
||||
|
||||
Assert.NotNull(actual);
|
||||
Assert.Equal(expected.Count, actual!.Count);
|
||||
foreach (var pair in expected)
|
||||
{
|
||||
Assert.True(actual.TryGetValue(pair.Key, out var actualValue));
|
||||
Assert.Equal(pair.Value, actualValue);
|
||||
}
|
||||
}
|
||||
|
||||
private static MaterialColorSnapshot CreateMaterialSnapshot()
|
||||
{
|
||||
var seed = Color.Parse("#FF123456");
|
||||
var accent = Color.Parse("#FF214365");
|
||||
var palette = new MaterialColorPalette(
|
||||
Color.Parse("#FF315577"),
|
||||
Color.Parse("#FF557799"),
|
||||
accent,
|
||||
Color.Parse("#FFFFFFFF"),
|
||||
Color.Parse("#FF5F7F9F"),
|
||||
Color.Parse("#FF7F9FBF"),
|
||||
Color.Parse("#FF9FBFDF"),
|
||||
Color.Parse("#FF17314B"),
|
||||
Color.Parse("#FF102840"),
|
||||
Color.Parse("#FF082038"),
|
||||
Color.Parse("#FF0B1118"),
|
||||
Color.Parse("#FF141C24"),
|
||||
Color.Parse("#FF1C2630"),
|
||||
Color.Parse("#FFF5F7FA"),
|
||||
Color.Parse("#FFC8D0DA"),
|
||||
Color.Parse("#FF9EA8B4"),
|
||||
Color.Parse("#FF91B8E8"),
|
||||
Color.Parse("#FFF5F7FA"),
|
||||
Color.Parse("#FFFFFFFF"),
|
||||
Color.Parse("#FF9FBFDF"),
|
||||
Color.Parse("#33141C24"),
|
||||
Color.Parse("#441C2630"),
|
||||
Color.Parse("#55315577"),
|
||||
Color.Parse("#FF315577"),
|
||||
Color.Parse("#88557799"),
|
||||
Color.Parse("#667F9FBF"));
|
||||
var monetPalette = new MonetPalette(
|
||||
[seed],
|
||||
seed,
|
||||
palette.Primary,
|
||||
palette.Secondary,
|
||||
Color.Parse("#FF775577"),
|
||||
Color.Parse("#FF202830"),
|
||||
Color.Parse("#FF26313B"));
|
||||
var surfaces = new Dictionary<MaterialSurfaceRole, MaterialSurfaceSnapshot>
|
||||
{
|
||||
[MaterialSurfaceRole.WindowBackground] = new(
|
||||
MaterialSurfaceRole.WindowBackground,
|
||||
Color.Parse("#FF101820"),
|
||||
Color.Parse("#33557799"),
|
||||
18,
|
||||
0.92),
|
||||
[MaterialSurfaceRole.DesktopComponentHost] = new(
|
||||
MaterialSurfaceRole.DesktopComponentHost,
|
||||
Color.Parse("#FF141C24"),
|
||||
Color.Parse("#44557799"),
|
||||
20,
|
||||
0.90),
|
||||
[MaterialSurfaceRole.OverlayPanel] = new(
|
||||
MaterialSurfaceRole.OverlayPanel,
|
||||
Color.Parse("#FF202A34"),
|
||||
Color.Parse("#556688AA"),
|
||||
24,
|
||||
0.88)
|
||||
};
|
||||
|
||||
return new MaterialColorSnapshot(
|
||||
IsNightMode: true,
|
||||
ThemeColorMode: ThemeAppearanceValues.ColorModeSeedMonet,
|
||||
ThemeWallpaperColorSource: ThemeAppearanceValues.WallpaperColorSourceAuto,
|
||||
ColorSourceKind: MaterialColorSourceKind.CustomSeed,
|
||||
ResolvedSeedSource: "user_color",
|
||||
CornerRadiusTokens: new AppearanceCornerRadiusTokens(
|
||||
new CornerRadius(2),
|
||||
new CornerRadius(4),
|
||||
new CornerRadius(6),
|
||||
new CornerRadius(8),
|
||||
new CornerRadius(10),
|
||||
new CornerRadius(12),
|
||||
new CornerRadius(14),
|
||||
new CornerRadius(8)),
|
||||
UserThemeColor: seed.ToString(),
|
||||
SelectedWallpaperSeed: seed.ToString(),
|
||||
EffectiveSeedColor: seed,
|
||||
AccentColor: accent,
|
||||
MonetPalette: monetPalette,
|
||||
Palette: palette,
|
||||
WallpaperSeedCandidates: [seed],
|
||||
SystemMaterialMode: ThemeAppearanceValues.MaterialMica,
|
||||
AvailableSystemMaterialModes: [ThemeAppearanceValues.MaterialAuto, ThemeAppearanceValues.MaterialMica],
|
||||
CanChangeSystemMaterial: true,
|
||||
UseSystemChrome: false,
|
||||
ResolvedWallpaperPath: @"C:\wallpaper.png",
|
||||
UseNativeWallpaperChangeEvents: true,
|
||||
NativeWallpaperChangeEventsActive: true,
|
||||
WallpaperPollingActive: true,
|
||||
Surfaces: surfaces);
|
||||
}
|
||||
|
||||
private static AppearanceThemeSnapshot CreateThemeSnapshot()
|
||||
{
|
||||
var seed = Color.Parse("#FF456789");
|
||||
var monetPalette = new MonetPalette(
|
||||
[seed],
|
||||
seed,
|
||||
Color.Parse("#FF667788"),
|
||||
Color.Parse("#FF8899AA"),
|
||||
Color.Parse("#FF112233"),
|
||||
Color.Parse("#FF334455"),
|
||||
Color.Parse("#FF556677"));
|
||||
|
||||
return new AppearanceThemeSnapshot(
|
||||
IsNightMode: false,
|
||||
ThemeColorMode: ThemeAppearanceValues.ColorModeWallpaperMonet,
|
||||
UserThemeColor: "#FF456789",
|
||||
SelectedWallpaperSeed: "#FF456789",
|
||||
CornerRadiusStyle: GlobalAppearanceSettings.CornerRadiusStyleRounded,
|
||||
CornerRadiusTokens: new AppearanceCornerRadiusTokens(
|
||||
new CornerRadius(1),
|
||||
new CornerRadius(2),
|
||||
new CornerRadius(3),
|
||||
new CornerRadius(4),
|
||||
new CornerRadius(5),
|
||||
new CornerRadius(6),
|
||||
new CornerRadius(7),
|
||||
new CornerRadius(8)),
|
||||
ResolvedSeedSource: "theme-source",
|
||||
MonetPalette: monetPalette,
|
||||
AccentColor: Color.Parse("#FF556677"),
|
||||
EffectiveSeedColor: seed,
|
||||
WallpaperSeedCandidates: [seed],
|
||||
SystemMaterialMode: ThemeAppearanceValues.MaterialAcrylic,
|
||||
AvailableSystemMaterialModes: [ThemeAppearanceValues.MaterialAuto, ThemeAppearanceValues.MaterialAcrylic],
|
||||
CanChangeSystemMaterial: true,
|
||||
UseSystemChrome: true,
|
||||
ResolvedWallpaperPath: @"C:\theme-wallpaper.png",
|
||||
ThemeWallpaperColorSource: ThemeAppearanceValues.WallpaperColorSourceSystem,
|
||||
UseNativeWallpaperChangeEvents: false);
|
||||
}
|
||||
|
||||
private sealed class TrackingServiceProvider : IServiceProvider
|
||||
{
|
||||
private readonly Dictionary<Type, object> _services = new();
|
||||
|
||||
public TrackingServiceProvider(IMaterialColorService materialColorService, IAppearanceThemeService appearanceThemeService)
|
||||
{
|
||||
_services[typeof(IMaterialColorService)] = materialColorService;
|
||||
_services[typeof(IAppearanceThemeService)] = appearanceThemeService;
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType)
|
||||
{
|
||||
return _services.TryGetValue(serviceType, out var service) ? service : null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TrackingMaterialColorService(MaterialColorSnapshot snapshot) : IMaterialColorService
|
||||
{
|
||||
public int GetMaterialColorSnapshotCalls { get; private set; }
|
||||
|
||||
public MaterialColorSnapshot GetMaterialColorSnapshot()
|
||||
{
|
||||
GetMaterialColorSnapshotCalls++;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public MaterialColorSnapshot BuildMaterialColorPreview(ThemeAppearanceSettingsState pendingState)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public event EventHandler<MaterialColorSnapshot>? MaterialColorChanged
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
|
||||
public void ApplyThemeResources(IResourceDictionary resources)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public MaterialSurfaceSnapshot GetSurface(MaterialSurfaceRole role)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public void ApplyWindowMaterial(Window window, MaterialSurfaceRole role)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public void RefreshWallpaperColors()
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TrackingAppearanceThemeService(AppearanceThemeSnapshot snapshot) : IAppearanceThemeService
|
||||
{
|
||||
public int GetCurrentCalls { get; private set; }
|
||||
|
||||
public AppearanceThemeSnapshot GetCurrent()
|
||||
{
|
||||
GetCurrentCalls++;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
public AppearanceThemeSnapshot BuildPreview(ThemeAppearanceSettingsState pendingState)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public event EventHandler<AppearanceThemeSnapshot>? Changed
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
|
||||
public void ApplyThemeResources(IResourceDictionary resources)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public AppearanceMaterialSurface GetMaterialSurface(MaterialSurfaceRole role)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public void ApplyWindowMaterial(Window window, MaterialSurfaceRole role)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
}
|
||||
39
LanMountainDesktop.Tests/SettingsCatalogServiceTests.cs
Normal file
39
LanMountainDesktop.Tests/SettingsCatalogServiceTests.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using System.Linq;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class SettingsCatalogServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuiltInAppSectionsIncludeIndependentMaterialColorAndWallpaperEntries()
|
||||
{
|
||||
var catalog = new SettingsCatalogService();
|
||||
|
||||
var sections = catalog.GetSections(SettingsScope.App).ToList();
|
||||
|
||||
Assert.Equal(
|
||||
[
|
||||
"general",
|
||||
"material-color",
|
||||
"appearance",
|
||||
"wallpaper",
|
||||
"about"
|
||||
],
|
||||
sections.Select(section => section.Id));
|
||||
|
||||
var materialColor = sections.Single(section => section.Id == "material-color");
|
||||
Assert.Equal(SettingsCategories.Appearance, materialColor.Category);
|
||||
Assert.Equal(SettingsScope.App, materialColor.Scope);
|
||||
Assert.Equal("settings.material_color.title", materialColor.TitleLocalizationKey);
|
||||
Assert.Equal("Color", materialColor.IconKey);
|
||||
|
||||
var wallpaper = sections.Single(section => section.Id == "wallpaper");
|
||||
Assert.Equal(SettingsCategories.Appearance, wallpaper.Category);
|
||||
Assert.Equal(SettingsScope.App, wallpaper.Scope);
|
||||
Assert.Equal("settings.wallpaper.title", wallpaper.TitleLocalizationKey);
|
||||
Assert.Equal("Image", wallpaper.IconKey);
|
||||
}
|
||||
}
|
||||
27
LanMountainDesktop.Tests/SettingsSearchServiceTests.cs
Normal file
27
LanMountainDesktop.Tests/SettingsSearchServiceTests.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class SettingsSearchServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Filter_MatchesTitleAndPageMetadata()
|
||||
{
|
||||
var result = new SettingsSearchResult(
|
||||
"appearance",
|
||||
"Appearance",
|
||||
"Theme and material settings",
|
||||
"System material",
|
||||
"Choose Mica or Acrylic",
|
||||
"appearance:material",
|
||||
targetControl: null,
|
||||
isPageResult: false,
|
||||
keywords: ["fluent"]);
|
||||
|
||||
Assert.True(SettingsSearchService.Filter("material", result));
|
||||
Assert.True(SettingsSearchService.Filter("appearance", result));
|
||||
Assert.True(SettingsSearchService.Filter("fluent", result));
|
||||
Assert.False(SettingsSearchService.Filter("network", result));
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class SingleInstanceServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TryNotifyPrimaryInstance_ReturnsTrue_WhenPrimaryAcknowledges()
|
||||
{
|
||||
var mutexName = $"Local\\LanMountainDesktop.Tests.SingleInstance.{Guid.NewGuid():N}";
|
||||
var pipeName = $"LanMountainDesktop.Tests.Activate.{Guid.NewGuid():N}";
|
||||
|
||||
using var primary = CreateService(mutexName, pipeName);
|
||||
using var secondary = CreateSecondaryService(mutexName, pipeName);
|
||||
Assert.True(primary.IsPrimaryInstance);
|
||||
MarkAsSecondaryForTest(secondary);
|
||||
|
||||
var activated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
primary.StartActivationListener(() => activated.TrySetResult());
|
||||
|
||||
var acknowledged = secondary.TryNotifyPrimaryInstance(TimeSpan.FromSeconds(2), out var failureReason);
|
||||
|
||||
Assert.True(acknowledged);
|
||||
Assert.Null(failureReason);
|
||||
|
||||
var completed = await Task.WhenAny(activated.Task, Task.Delay(TimeSpan.FromSeconds(2)));
|
||||
Assert.Same(activated.Task, completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryNotifyPrimaryInstance_ReturnsFalse_WhenListenerIsNotRunning()
|
||||
{
|
||||
var mutexName = $"Local\\LanMountainDesktop.Tests.SingleInstance.{Guid.NewGuid():N}";
|
||||
var pipeName = $"LanMountainDesktop.Tests.Activate.{Guid.NewGuid():N}";
|
||||
|
||||
using var primary = CreateService(mutexName, pipeName);
|
||||
using var secondary = CreateSecondaryService(mutexName, pipeName);
|
||||
Assert.True(primary.IsPrimaryInstance);
|
||||
MarkAsSecondaryForTest(secondary);
|
||||
|
||||
var acknowledged = secondary.TryNotifyPrimaryInstance(TimeSpan.FromMilliseconds(300), out var failureReason);
|
||||
|
||||
Assert.False(acknowledged);
|
||||
Assert.False(string.IsNullOrWhiteSpace(failureReason));
|
||||
}
|
||||
|
||||
private static SingleInstanceService CreateService(string mutexName, string pipeName)
|
||||
{
|
||||
var ctor = typeof(SingleInstanceService).GetConstructor(
|
||||
BindingFlags.Instance | BindingFlags.NonPublic,
|
||||
binder: null,
|
||||
[typeof(string), typeof(string)],
|
||||
modifiers: null);
|
||||
|
||||
Assert.NotNull(ctor);
|
||||
return (SingleInstanceService)ctor!.Invoke([mutexName, pipeName]);
|
||||
}
|
||||
|
||||
private static SingleInstanceService CreateSecondaryService(string mutexName, string pipeName)
|
||||
{
|
||||
SingleInstanceService? created = null;
|
||||
Exception? creationError = null;
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
created = CreateService(mutexName, pipeName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
creationError = ex;
|
||||
}
|
||||
});
|
||||
|
||||
thread.IsBackground = true;
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
|
||||
if (creationError is not null)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to create secondary SingleInstanceService.", creationError);
|
||||
}
|
||||
|
||||
Assert.NotNull(created);
|
||||
return created!;
|
||||
}
|
||||
|
||||
private static void MarkAsSecondaryForTest(SingleInstanceService service)
|
||||
{
|
||||
var ownsMutexField = typeof(SingleInstanceService).GetField(
|
||||
"_ownsMutex",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.NotNull(ownsMutexField);
|
||||
ownsMutexField!.SetValue(service, false);
|
||||
Assert.False(service.IsPrimaryInstance);
|
||||
}
|
||||
}
|
||||
244
LanMountainDesktop.Tests/StudyComponentRenderingTests.cs
Normal file
244
LanMountainDesktop.Tests/StudyComponentRenderingTests.cs
Normal file
@@ -0,0 +1,244 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class StudyComponentRenderingTests
|
||||
{
|
||||
[Fact]
|
||||
public void RenderGate_ProcessesOnlyLatestSnapshot()
|
||||
{
|
||||
var rendered = new List<string>();
|
||||
using var gate = new StudySnapshotRenderGate(
|
||||
canRender: () => true,
|
||||
renderSnapshot: snapshot => rendered.Add(snapshot.LastError));
|
||||
|
||||
gate.Queue(CreateSnapshot("first"));
|
||||
gate.Queue(CreateSnapshot("second"));
|
||||
|
||||
Assert.True(gate.ProcessPending());
|
||||
Assert.Equal(["second"], rendered);
|
||||
Assert.False(gate.HasPendingSnapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RenderGate_DropsPendingSnapshot_WhenRenderIsBlocked()
|
||||
{
|
||||
var renderCount = 0;
|
||||
using var gate = new StudySnapshotRenderGate(
|
||||
canRender: () => false,
|
||||
renderSnapshot: _ => renderCount++);
|
||||
|
||||
gate.Queue(CreateSnapshot("blocked"));
|
||||
|
||||
Assert.False(gate.ProcessPending());
|
||||
Assert.Equal(0, renderCount);
|
||||
Assert.False(gate.HasPendingSnapshot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurveChart_SplitsStableHistoryFromDynamicTail()
|
||||
{
|
||||
var points = CreateRealtimePoints(count: 10, step: TimeSpan.FromSeconds(1));
|
||||
var counts = StudyNoiseCurveChartControl.ResolveLayerSourceCounts(points, TimeSpan.FromSeconds(4));
|
||||
|
||||
Assert.Equal(5, StudyNoiseCurveChartControl.ResolveFirstTailIndex(points, TimeSpan.FromSeconds(4)));
|
||||
Assert.Equal(5, counts.StaticSourceCount);
|
||||
Assert.Equal(6, counts.DynamicSourceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurveChart_UsesStableLogicalTimeCoordinates()
|
||||
{
|
||||
var origin = new DateTimeOffset(2026, 5, 6, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var x = StudyNoiseCurveChartControl.MapTimestampToLogicalX(
|
||||
origin.AddSeconds(3),
|
||||
origin,
|
||||
pixelsPerSecond: 12);
|
||||
|
||||
Assert.Equal(36, x);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributionAreaChart_BuildsAreaPathCache()
|
||||
{
|
||||
var points = CreateRealtimePoints(count: 24, step: TimeSpan.FromMilliseconds(500));
|
||||
var control = new StudyNoiseDistributionAreaChartControl();
|
||||
|
||||
control.UpdateSeries(points, baselineDb: 45);
|
||||
control.RebuildCacheForTesting(new Rect(1, 1, 320, 160));
|
||||
|
||||
Assert.True(control.CachedPathCount > 0);
|
||||
Assert.True(control.CachedPathCount <= 4);
|
||||
Assert.True(control.StaticSourceCount > 0);
|
||||
Assert.True(control.DynamicSourceCount > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributionAreaChart_UsesStableLogicalTimeCoordinates_WhenNewPointArrives()
|
||||
{
|
||||
var origin = new DateTimeOffset(2026, 5, 6, 12, 0, 0, TimeSpan.Zero);
|
||||
var oldPointTimestamp = origin.AddSeconds(3);
|
||||
|
||||
var before = StudyNoiseDistributionAreaChartControl.MapTimestampToLogicalX(
|
||||
oldPointTimestamp,
|
||||
origin,
|
||||
pixelsPerSecond: 20);
|
||||
var after = StudyNoiseDistributionAreaChartControl.MapTimestampToLogicalX(
|
||||
oldPointTimestamp,
|
||||
origin,
|
||||
pixelsPerSecond: 20);
|
||||
|
||||
Assert.Equal(before, after);
|
||||
Assert.Equal(60, after);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributionAreaChart_ReusesStaticAreaPath_WhenOnlyDynamicTailChanges()
|
||||
{
|
||||
var firstSeries = CreateRealtimePoints(
|
||||
new[]
|
||||
{
|
||||
(0d, 40d),
|
||||
(1d, 43d),
|
||||
(2d, 45d),
|
||||
(3d, 47d),
|
||||
(8d, 52d)
|
||||
});
|
||||
var secondSeries = CreateRealtimePoints(
|
||||
new[]
|
||||
{
|
||||
(0d, 40d),
|
||||
(1d, 43d),
|
||||
(2d, 45d),
|
||||
(3d, 47d),
|
||||
(8d, 52d),
|
||||
(8.05d, 54d)
|
||||
});
|
||||
var control = new StudyNoiseDistributionAreaChartControl();
|
||||
var plot = new Rect(1, 1, 320, 160);
|
||||
|
||||
control.UpdateSeries(firstSeries, baselineDb: 45);
|
||||
control.RebuildCacheForTesting(plot);
|
||||
var staticBuildVersion = control.StaticPathBuildVersion;
|
||||
|
||||
control.UpdateSeries(secondSeries, baselineDb: 45);
|
||||
control.RebuildCacheForTesting(plot);
|
||||
|
||||
Assert.Equal(staticBuildVersion, control.StaticPathBuildVersion);
|
||||
Assert.True(control.DynamicPathBuildVersion > 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributionAreaChart_SplitsStaticHistoryFromDynamicTail()
|
||||
{
|
||||
var points = CreateRealtimePoints(count: 10, step: TimeSpan.FromSeconds(1));
|
||||
var counts = StudyNoiseDistributionAreaChartControl.ResolveLayerSourceCounts(
|
||||
points,
|
||||
TimeSpan.FromSeconds(4));
|
||||
|
||||
Assert.Equal(5, counts.StaticSourceCount);
|
||||
Assert.Equal(6, counts.DynamicSourceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributionAreaChart_StaticReportKeepsWholeSeriesStatic()
|
||||
{
|
||||
var points = CreateRealtimePoints(count: 10, step: TimeSpan.FromSeconds(1));
|
||||
var counts = StudyNoiseDistributionAreaChartControl.ResolveLayerSourceCounts(
|
||||
points,
|
||||
TimeSpan.FromSeconds(4),
|
||||
isStaticSeries: true);
|
||||
|
||||
Assert.Equal(10, counts.StaticSourceCount);
|
||||
Assert.Equal(0, counts.DynamicSourceCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DistributionAreaChart_ResolvesLevelsFromBaseline()
|
||||
{
|
||||
Assert.Equal(NoiseDistributionLevel.Quiet, StudyNoiseDistributionAreaChartControl.ResolveLevel(44.9, 45));
|
||||
Assert.Equal(NoiseDistributionLevel.Normal, StudyNoiseDistributionAreaChartControl.ResolveLevel(45, 45));
|
||||
Assert.Equal(NoiseDistributionLevel.Noisy, StudyNoiseDistributionAreaChartControl.ResolveLevel(55, 45));
|
||||
Assert.Equal(NoiseDistributionLevel.Extreme, StudyNoiseDistributionAreaChartControl.ResolveLevel(65, 45));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NoiseRealtimePoint> CreateRealtimePoints(int count, TimeSpan step)
|
||||
{
|
||||
var start = new DateTimeOffset(2026, 5, 6, 12, 0, 0, TimeSpan.Zero);
|
||||
var points = new List<NoiseRealtimePoint>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var displayDb = 38 + i;
|
||||
points.Add(new NoiseRealtimePoint(
|
||||
Timestamp: start + TimeSpan.FromTicks(step.Ticks * i),
|
||||
Rms: 0.2,
|
||||
Dbfs: -60 + i,
|
||||
DisplayDb: displayDb,
|
||||
Peak: 0.3,
|
||||
IsOverThreshold: displayDb > 50));
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<NoiseRealtimePoint> CreateRealtimePoints(IReadOnlyList<(double OffsetSeconds, double DisplayDb)> samples)
|
||||
{
|
||||
var start = new DateTimeOffset(2026, 5, 6, 12, 0, 0, TimeSpan.Zero);
|
||||
var points = new List<NoiseRealtimePoint>(samples.Count);
|
||||
for (var i = 0; i < samples.Count; i++)
|
||||
{
|
||||
var sample = samples[i];
|
||||
points.Add(new NoiseRealtimePoint(
|
||||
Timestamp: start + TimeSpan.FromSeconds(sample.OffsetSeconds),
|
||||
Rms: 0.2,
|
||||
Dbfs: -60 + i,
|
||||
DisplayDb: sample.DisplayDb,
|
||||
Peak: 0.3,
|
||||
IsOverThreshold: sample.DisplayDb > 50));
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
private static StudyAnalyticsSnapshot CreateSnapshot(string marker)
|
||||
{
|
||||
var config = new StudyAnalyticsConfig();
|
||||
var session = new StudySessionSnapshot(
|
||||
State: StudySessionRuntimeState.Idle,
|
||||
SessionId: null,
|
||||
Label: string.Empty,
|
||||
StartedAt: null,
|
||||
EndedAt: null,
|
||||
Elapsed: TimeSpan.Zero,
|
||||
Metrics: new StudySessionMetrics(
|
||||
CurrentScore: 0,
|
||||
AvgScore: 0,
|
||||
MinScore: 0,
|
||||
MaxScore: 0,
|
||||
WeightedOverRatioDbfs: 0,
|
||||
TotalSegmentCount: 0,
|
||||
EffectiveDuration: TimeSpan.Zero,
|
||||
SliceCount: 0),
|
||||
LastError: string.Empty);
|
||||
|
||||
return new StudyAnalyticsSnapshot(
|
||||
State: StudyAnalyticsRuntimeState.Ready,
|
||||
StreamStatus: NoiseStreamStatus.Initializing,
|
||||
DataMode: StudyDataMode.Realtime,
|
||||
Config: config,
|
||||
LatestRealtimePoint: null,
|
||||
LatestSlice: null,
|
||||
RealtimeBuffer: Array.Empty<NoiseRealtimePoint>(),
|
||||
Session: session,
|
||||
LastSessionReport: null,
|
||||
SelectedSessionReportId: null,
|
||||
SessionHistory: Array.Empty<StudySessionHistoryEntry>(),
|
||||
LastError: marker);
|
||||
}
|
||||
}
|
||||
40
LanMountainDesktop.Tests/ThemeAppearanceValuesTests.cs
Normal file
40
LanMountainDesktop.Tests/ThemeAppearanceValuesTests.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class ThemeAppearanceValuesTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("auto", ThemeAppearanceValues.MaterialAuto)]
|
||||
[InlineData("AUTO", ThemeAppearanceValues.MaterialAuto)]
|
||||
[InlineData("mica", ThemeAppearanceValues.MaterialMica)]
|
||||
[InlineData("acrylic", ThemeAppearanceValues.MaterialAcrylic)]
|
||||
[InlineData("unknown", ThemeAppearanceValues.MaterialNone)]
|
||||
[InlineData(null, ThemeAppearanceValues.MaterialNone)]
|
||||
public void NormalizeSystemMaterialMode_ReturnsKnownValue(string? input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ThemeAppearanceValues.NormalizeSystemMaterialMode(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeAvailableMaterialModes_AddsAutoAndNone()
|
||||
{
|
||||
var result = ThemeAppearanceValues.NormalizeAvailableMaterialModes([ThemeAppearanceValues.MaterialMica]);
|
||||
|
||||
Assert.Equal(ThemeAppearanceValues.MaterialAuto, result[0]);
|
||||
Assert.Equal(ThemeAppearanceValues.MaterialNone, result[1]);
|
||||
Assert.Contains(ThemeAppearanceValues.MaterialMica, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("auto", ThemeAppearanceValues.WallpaperColorSourceAuto)]
|
||||
[InlineData("APP", ThemeAppearanceValues.WallpaperColorSourceApp)]
|
||||
[InlineData("system", ThemeAppearanceValues.WallpaperColorSourceSystem)]
|
||||
[InlineData("unknown", ThemeAppearanceValues.WallpaperColorSourceAuto)]
|
||||
[InlineData(null, ThemeAppearanceValues.WallpaperColorSourceAuto)]
|
||||
public void NormalizeWallpaperColorSource_ReturnsKnownValue(string? input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, ThemeAppearanceValues.NormalizeWallpaperColorSource(input));
|
||||
}
|
||||
}
|
||||
613
LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs
Normal file
613
LanMountainDesktop.Tests/UpdateSystemRegressionTests.cs
Normal file
@@ -0,0 +1,613 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop;
|
||||
using LanMountainDesktop.Launcher;
|
||||
using LanMountainDesktop.Launcher.Models;
|
||||
using LanMountainDesktop.Launcher.Services;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Update;
|
||||
using LanMountainDesktop.Shared.Contracts.Update;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class UpdateEngineRollbackRegressionTests : IDisposable
|
||||
{
|
||||
private readonly UpdateTestDirectory _directory = new();
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyPlondsUpdate_KeepsPreviousDeploymentForManualRollback()
|
||||
{
|
||||
var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
|
||||
var newState = Encoding.UTF8.GetBytes("new-state");
|
||||
|
||||
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState));
|
||||
|
||||
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
|
||||
var result = await service.ApplyPendingUpdateAsync();
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.True(Directory.Exists(current));
|
||||
Assert.False(File.Exists(Path.Combine(current, ".current")));
|
||||
|
||||
var rollback = service.RollbackLatest();
|
||||
|
||||
Assert.True(rollback.Success, rollback.ErrorMessage);
|
||||
Assert.Equal("1.0.0", rollback.RolledBackTo);
|
||||
Assert.True(File.Exists(Path.Combine(current, ".current")));
|
||||
Assert.False(File.Exists(Path.Combine(current, ".destroy")));
|
||||
Assert.Equal("old-state", File.ReadAllText(Path.Combine(current, "state.txt")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyPlondsUpdate_WhenObjectHashMismatches_RollsBackToPreviousDeployment()
|
||||
{
|
||||
var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
|
||||
var newState = Encoding.UTF8.GetBytes("new-state");
|
||||
|
||||
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, new string('0', 64));
|
||||
|
||||
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
|
||||
var result = await service.ApplyPendingUpdateAsync();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("apply_failed", result.Code);
|
||||
Assert.Equal("1.0.0", result.RolledBackTo);
|
||||
Assert.True(File.Exists(Path.Combine(current, ".current")));
|
||||
Assert.False(File.Exists(Path.Combine(current, ".destroy")));
|
||||
Assert.Equal("old-state", File.ReadAllText(Path.Combine(current, "state.txt")));
|
||||
Assert.Empty(Directory.GetDirectories(_directory.AppRoot, "app-1.1.0-*"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RollbackLatest_WhenSnapshotSourceDirectoryIsMissing_ReturnsStructuredFailure()
|
||||
{
|
||||
_directory.CreateDeployment("1.1.0", "new-state", isCurrent: true);
|
||||
_directory.WriteSnapshot(
|
||||
sourceVersion: "1.0.0",
|
||||
sourceDirectory: Path.Combine(_directory.AppRoot, "app-1.0.0-0"),
|
||||
targetVersion: "1.1.0",
|
||||
targetDirectory: Path.Combine(_directory.AppRoot, "app-1.1.0-0"));
|
||||
|
||||
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
|
||||
var result = service.RollbackLatest();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("source_missing", result.Code);
|
||||
Assert.Contains("app-1.0.0-0", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyPlondsUpdate_WhenInstallCheckpointIsStale_ReturnsStructuredFailure()
|
||||
{
|
||||
_directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
|
||||
var newState = Encoding.UTF8.GetBytes("new-state");
|
||||
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState));
|
||||
_directory.WriteStaleInstallCheckpoint("9.9.9", "1.1.0");
|
||||
|
||||
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
|
||||
var result = await service.ApplyPendingUpdateAsync();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("resume_state_invalid", result.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyLegacyUpdate_WhenInstallCheckpointIsStale_ReturnsStructuredFailure()
|
||||
{
|
||||
_directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
|
||||
_directory.StageLegacyUpdate("1.0.0", "1.1.0", "new-state");
|
||||
_directory.WriteStaleInstallCheckpoint("9.9.9", "1.1.0");
|
||||
|
||||
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
|
||||
var result = await service.ApplyPendingUpdateAsync();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("resume_state_invalid", result.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyPlondsUpdate_WhenCheckpointIsValid_ResumesAndSucceeds()
|
||||
{
|
||||
var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
|
||||
var newState = Encoding.UTF8.GetBytes("new-state");
|
||||
_directory.StagePlondsUpdate("1.0.0", "1.1.0", newState, Sha256Hex(newState));
|
||||
_directory.WriteValidPlondsResumeCheckpoint("1.0.0", "1.1.0");
|
||||
|
||||
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
|
||||
var result = await service.ApplyPendingUpdateAsync();
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.Equal("1.1.0", result.TargetVersion);
|
||||
Assert.False(File.Exists(Path.Combine(current, ".current")));
|
||||
var resumedTarget = Path.Combine(_directory.AppRoot, "app-1.1.0-0");
|
||||
Assert.True(File.Exists(Path.Combine(resumedTarget, ".current")));
|
||||
Assert.Equal("new-state", File.ReadAllText(Path.Combine(resumedTarget, "state.txt")));
|
||||
Assert.False(File.Exists(UpdatePaths.GetInstallCheckpointPath(_directory.AppRoot)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyLegacyUpdate_WhenCheckpointIsValid_ResumesAndSucceeds()
|
||||
{
|
||||
var current = _directory.CreateDeployment("1.0.0", "old-state", isCurrent: true);
|
||||
_directory.StageLegacyUpdate("1.0.0", "1.1.0", "new-state");
|
||||
_directory.WriteValidLegacyResumeCheckpoint("1.0.0", "1.1.0");
|
||||
|
||||
var service = new UpdateEngineService(new DeploymentLocator(_directory.AppRoot));
|
||||
var result = await service.ApplyPendingUpdateAsync();
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.Equal("1.1.0", result.TargetVersion);
|
||||
Assert.False(File.Exists(Path.Combine(current, ".current")));
|
||||
var resumedTarget = Path.Combine(_directory.AppRoot, "app-1.1.0-0");
|
||||
Assert.True(File.Exists(Path.Combine(resumedTarget, ".current")));
|
||||
Assert.Equal("new-state", File.ReadAllText(Path.Combine(resumedTarget, "state.txt")));
|
||||
Assert.False(File.Exists(UpdatePaths.GetInstallCheckpointPath(_directory.AppRoot)));
|
||||
}
|
||||
|
||||
public void Dispose() => _directory.Dispose();
|
||||
|
||||
private static string Sha256Hex(byte[] bytes)
|
||||
{
|
||||
return Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed class UpdateTestDirectory : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
private readonly RSA _rsa = RSA.Create(2048);
|
||||
|
||||
public UpdateTestDirectory()
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), "LanMountainDesktop.UpdateRegression", Guid.NewGuid().ToString("N"));
|
||||
AppRoot = Path.Combine(_root, "app-root");
|
||||
Directory.CreateDirectory(AppRoot);
|
||||
|
||||
var resolver = new DataLocationResolver(AppRoot);
|
||||
LauncherRoot = resolver.ResolveLauncherDataPath();
|
||||
IncomingRoot = Path.Combine(LauncherRoot, "update", "incoming");
|
||||
SnapshotsRoot = Path.Combine(LauncherRoot, "snapshots");
|
||||
|
||||
Directory.CreateDirectory(Path.Combine(LauncherRoot, "update"));
|
||||
File.WriteAllText(Path.Combine(LauncherRoot, "update", "public-key.pem"), _rsa.ExportSubjectPublicKeyInfoPem());
|
||||
}
|
||||
|
||||
public string AppRoot { get; }
|
||||
|
||||
private string LauncherRoot { get; }
|
||||
|
||||
private string IncomingRoot { get; }
|
||||
|
||||
private string SnapshotsRoot { get; }
|
||||
|
||||
public string CreateDeployment(string version, string state, bool isCurrent)
|
||||
{
|
||||
var deployment = Path.Combine(AppRoot, $"app-{version}-0");
|
||||
Directory.CreateDirectory(deployment);
|
||||
File.WriteAllText(Path.Combine(deployment, ExecutableName), $"exe-{version}");
|
||||
File.WriteAllText(Path.Combine(deployment, "state.txt"), state);
|
||||
|
||||
if (isCurrent)
|
||||
{
|
||||
File.WriteAllText(Path.Combine(deployment, ".current"), string.Empty);
|
||||
}
|
||||
|
||||
return deployment;
|
||||
}
|
||||
|
||||
public void StagePlondsUpdate(string fromVersion, string toVersion, byte[] statePayload, string expectedStateSha256)
|
||||
{
|
||||
Directory.CreateDirectory(IncomingRoot);
|
||||
var objectsRoot = Path.Combine(IncomingRoot, "objects");
|
||||
Directory.CreateDirectory(objectsRoot);
|
||||
|
||||
var objectHash = Convert.ToHexString(SHA256.HashData(statePayload)).ToLowerInvariant();
|
||||
File.WriteAllBytes(Path.Combine(objectsRoot, objectHash), statePayload);
|
||||
|
||||
var currentExecutable = Path.Combine(AppRoot, $"app-{fromVersion}-0", ExecutableName);
|
||||
var fileMap = new PlondsFileMap
|
||||
{
|
||||
DistributionId = $"stable-{PlondsStaticUpdateService.ResolveCurrentPlatform()}-{toVersion}",
|
||||
FromVersion = fromVersion,
|
||||
ToVersion = toVersion,
|
||||
Platform = PlondsStaticUpdateService.ResolveCurrentPlatform(),
|
||||
Files =
|
||||
[
|
||||
new PlondsFileEntry
|
||||
{
|
||||
Path = ExecutableName,
|
||||
Action = "reuse",
|
||||
Sha256 = Sha256File(currentExecutable)
|
||||
},
|
||||
new PlondsFileEntry
|
||||
{
|
||||
Path = "state.txt",
|
||||
Action = "replace",
|
||||
Sha256 = expectedStateSha256,
|
||||
ObjectUrl = $"https://static.example/lanmountain/update/repo/sha256/{objectHash[..2]}/{objectHash}"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var fileMapPath = Path.Combine(IncomingRoot, "plonds-filemap.json");
|
||||
File.WriteAllText(fileMapPath, JsonSerializer.Serialize(fileMap, AppJsonContext.Default.PlondsFileMap));
|
||||
Sign(fileMapPath, Path.Combine(IncomingRoot, "plonds-filemap.sig"));
|
||||
|
||||
var deploymentLock = new DeploymentLock(
|
||||
SchemaVersion: 1,
|
||||
Kind: "delta",
|
||||
TargetVersion: toVersion,
|
||||
PayloadPath: fileMapPath,
|
||||
PayloadSha256: Sha256File(fileMapPath),
|
||||
CreatedAtUtc: DateTimeOffset.UtcNow);
|
||||
var deploymentLockPath = UpdatePaths.GetDeploymentLockPath(AppRoot);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(deploymentLockPath)!);
|
||||
File.WriteAllText(deploymentLockPath, JsonSerializer.Serialize(deploymentLock));
|
||||
|
||||
var markerPath = UpdatePaths.GetDownloadMarkerPath(AppRoot);
|
||||
File.WriteAllText(markerPath, UpdatePaths.GetDownloadMarkerContent(
|
||||
manifestSha256: Sha256File(fileMapPath),
|
||||
targetVersion: toVersion,
|
||||
objectCount: 1));
|
||||
}
|
||||
|
||||
public void StageLegacyUpdate(string fromVersion, string toVersion, string newState)
|
||||
{
|
||||
Directory.CreateDirectory(IncomingRoot);
|
||||
var extractRoot = Path.Combine(IncomingRoot, "legacy-src");
|
||||
Directory.CreateDirectory(extractRoot);
|
||||
|
||||
File.WriteAllText(Path.Combine(extractRoot, ExecutableName), $"exe-{toVersion}");
|
||||
File.WriteAllText(Path.Combine(extractRoot, "state.txt"), newState);
|
||||
|
||||
var archivePath = Path.Combine(IncomingRoot, "update.zip");
|
||||
if (File.Exists(archivePath))
|
||||
{
|
||||
File.Delete(archivePath);
|
||||
}
|
||||
|
||||
System.IO.Compression.ZipFile.CreateFromDirectory(extractRoot, archivePath);
|
||||
|
||||
var fileMap = new SignedFileMap
|
||||
{
|
||||
FromVersion = fromVersion,
|
||||
ToVersion = toVersion,
|
||||
Files =
|
||||
[
|
||||
new LanMountainDesktop.Launcher.Models.UpdateFileEntry
|
||||
{
|
||||
Path = ExecutableName,
|
||||
ArchivePath = ExecutableName,
|
||||
Action = "replace",
|
||||
Sha256 = Sha256File(Path.Combine(extractRoot, ExecutableName))
|
||||
},
|
||||
new LanMountainDesktop.Launcher.Models.UpdateFileEntry
|
||||
{
|
||||
Path = "state.txt",
|
||||
ArchivePath = "state.txt",
|
||||
Action = "replace",
|
||||
Sha256 = Sha256File(Path.Combine(extractRoot, "state.txt"))
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var fileMapPath = Path.Combine(IncomingRoot, "files.json");
|
||||
File.WriteAllText(fileMapPath, JsonSerializer.Serialize(fileMap, AppJsonContext.Default.SignedFileMap));
|
||||
Sign(fileMapPath, Path.Combine(IncomingRoot, "files.json.sig"));
|
||||
|
||||
var deploymentLock = new DeploymentLock(
|
||||
SchemaVersion: 1,
|
||||
Kind: "delta",
|
||||
TargetVersion: toVersion,
|
||||
PayloadPath: fileMapPath,
|
||||
PayloadSha256: Sha256File(fileMapPath),
|
||||
CreatedAtUtc: DateTimeOffset.UtcNow);
|
||||
var deploymentLockPath = UpdatePaths.GetDeploymentLockPath(AppRoot);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(deploymentLockPath)!);
|
||||
File.WriteAllText(deploymentLockPath, JsonSerializer.Serialize(deploymentLock));
|
||||
|
||||
Directory.Delete(extractRoot, true);
|
||||
}
|
||||
|
||||
public void WriteSnapshot(string sourceVersion, string sourceDirectory, string targetVersion, string targetDirectory)
|
||||
{
|
||||
Directory.CreateDirectory(SnapshotsRoot);
|
||||
var snapshot = new SnapshotMetadata
|
||||
{
|
||||
SnapshotId = Guid.NewGuid().ToString("N"),
|
||||
SourceVersion = sourceVersion,
|
||||
TargetVersion = targetVersion,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
SourceDirectory = sourceDirectory,
|
||||
TargetDirectory = targetDirectory,
|
||||
Status = "applied"
|
||||
};
|
||||
|
||||
File.WriteAllText(
|
||||
Path.Combine(SnapshotsRoot, $"{snapshot.SnapshotId}.json"),
|
||||
JsonSerializer.Serialize(snapshot, AppJsonContext.Default.SnapshotMetadata));
|
||||
}
|
||||
|
||||
public void WriteStaleInstallCheckpoint(string sourceVersion, string targetVersion)
|
||||
{
|
||||
var checkpoint = new InstallCheckpoint
|
||||
{
|
||||
SnapshotId = Guid.NewGuid().ToString("N"),
|
||||
SourceVersion = sourceVersion,
|
||||
TargetVersion = targetVersion,
|
||||
SourceDirectory = Path.Combine(AppRoot, $"app-{sourceVersion}-0"),
|
||||
TargetDirectory = Path.Combine(AppRoot, $"app-{targetVersion}-999"),
|
||||
IsInitialDeployment = false,
|
||||
AppliedCount = 1,
|
||||
VerifiedCount = 1
|
||||
};
|
||||
|
||||
var checkpointPath = UpdatePaths.GetInstallCheckpointPath(AppRoot);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(checkpointPath)!);
|
||||
File.WriteAllText(checkpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint));
|
||||
}
|
||||
|
||||
public void WriteValidPlondsResumeCheckpoint(string sourceVersion, string targetVersion)
|
||||
{
|
||||
var targetDeployment = Path.Combine(AppRoot, $"app-{targetVersion}-0");
|
||||
Directory.CreateDirectory(targetDeployment);
|
||||
File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty);
|
||||
File.WriteAllText(Path.Combine(targetDeployment, ExecutableName), $"exe-{sourceVersion}");
|
||||
|
||||
var checkpoint = new InstallCheckpoint
|
||||
{
|
||||
SnapshotId = Guid.NewGuid().ToString("N"),
|
||||
SourceVersion = sourceVersion,
|
||||
TargetVersion = targetVersion,
|
||||
SourceDirectory = Path.Combine(AppRoot, $"app-{sourceVersion}-0"),
|
||||
TargetDirectory = targetDeployment,
|
||||
IsInitialDeployment = false,
|
||||
AppliedCount = 1,
|
||||
VerifiedCount = 0
|
||||
};
|
||||
|
||||
var checkpointPath = UpdatePaths.GetInstallCheckpointPath(AppRoot);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(checkpointPath)!);
|
||||
File.WriteAllText(checkpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint));
|
||||
}
|
||||
|
||||
public void WriteValidLegacyResumeCheckpoint(string sourceVersion, string targetVersion)
|
||||
{
|
||||
var targetDeployment = Path.Combine(AppRoot, $"app-{targetVersion}-0");
|
||||
Directory.CreateDirectory(targetDeployment);
|
||||
File.WriteAllText(Path.Combine(targetDeployment, ".partial"), string.Empty);
|
||||
|
||||
var checkpoint = new InstallCheckpoint
|
||||
{
|
||||
SnapshotId = Guid.NewGuid().ToString("N"),
|
||||
SourceVersion = sourceVersion,
|
||||
TargetVersion = targetVersion,
|
||||
SourceDirectory = Path.Combine(AppRoot, $"app-{sourceVersion}-0"),
|
||||
TargetDirectory = targetDeployment,
|
||||
IsInitialDeployment = false,
|
||||
AppliedCount = 0,
|
||||
VerifiedCount = 0
|
||||
};
|
||||
|
||||
var checkpointPath = UpdatePaths.GetInstallCheckpointPath(AppRoot);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(checkpointPath)!);
|
||||
File.WriteAllText(checkpointPath, JsonSerializer.Serialize(checkpoint, AppJsonContext.Default.InstallCheckpoint));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_rsa.Dispose();
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void Sign(string payloadPath, string signaturePath)
|
||||
{
|
||||
var signature = _rsa.SignData(File.ReadAllBytes(payloadPath), HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
File.WriteAllText(signaturePath, Convert.ToBase64String(signature));
|
||||
}
|
||||
|
||||
private static string Sha256File(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ExecutableName => OperatingSystem.IsWindows()
|
||||
? "LanMountainDesktop.exe"
|
||||
: "LanMountainDesktop";
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PlondsStaticUpdateServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CheckForUpdatesAsync_ReadsStaticLatestDistributionAndBuildsPayloadUrls()
|
||||
{
|
||||
var platform = PlondsStaticUpdateService.ResolveCurrentPlatform();
|
||||
var handler = new StaticManifestHandler(request =>
|
||||
{
|
||||
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
|
||||
if (path.EndsWith($"/meta/channels/stable/{platform}/latest.json", StringComparison.Ordinal))
|
||||
{
|
||||
return Json("""{"distributionId":"dist-1","version":"1.2.0","channel":"stable","platform":"PLATFORM","publishedAt":"2026-05-06T00:00:00Z"}"""
|
||||
.Replace("PLATFORM", platform));
|
||||
}
|
||||
|
||||
if (path.EndsWith("/meta/distributions/dist-1.json", StringComparison.Ordinal))
|
||||
{
|
||||
return Json("""{"distributionId":"dist-1","version":"1.2.0","sourceVersion":"1.0.0","channel":"stable","platform":"PLATFORM","publishedAt":"2026-05-06T00:00:00Z","fileMapUrl":"https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json","fileMapSignatureUrl":"https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json.sig"}"""
|
||||
.Replace("PLATFORM", platform));
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
});
|
||||
|
||||
using var client = new HttpClient(handler);
|
||||
using var service = new PlondsStaticUpdateService("https://static.example/lanmountain/update", client);
|
||||
|
||||
var result = await service.CheckForUpdatesAsync(new Version(1, 0, 0), includePrerelease: false);
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
Assert.True(result.IsUpdateAvailable);
|
||||
Assert.Equal("1.2.0", result.LatestVersionText);
|
||||
Assert.NotNull(result.PlondsPayload);
|
||||
Assert.Equal("dist-1", result.PlondsPayload.DistributionId);
|
||||
Assert.Equal(platform, result.PlondsPayload.SubChannel);
|
||||
Assert.Equal("https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json", result.PlondsPayload.FileMapJsonUrl);
|
||||
Assert.Equal("https://static.example/lanmountain/update/manifests/dist-1/plonds-filemap.json.sig", result.PlondsPayload.FileMapSignatureUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckForUpdatesAsync_WhenLatestIsMissing_ReturnsFailureForFallback()
|
||||
{
|
||||
using var client = new HttpClient(new StaticManifestHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)));
|
||||
using var service = new PlondsStaticUpdateService("https://static.example/lanmountain/update", client);
|
||||
|
||||
var result = await service.CheckForUpdatesAsync(new Version(1, 0, 0), includePrerelease: false);
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.False(result.IsUpdateAvailable);
|
||||
Assert.Contains("latest manifest", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveCurrentPlatform_UsesCanonicalNames()
|
||||
{
|
||||
var platform = PlondsStaticUpdateService.ResolveCurrentPlatform();
|
||||
|
||||
Assert.DoesNotContain("win-", platform, StringComparison.OrdinalIgnoreCase);
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
Assert.StartsWith("windows-", platform, StringComparison.Ordinal);
|
||||
}
|
||||
else if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Assert.StartsWith("linux-", platform, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string json)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StaticManifestHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(responder(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class UpdatePathConsistencyTests
|
||||
{
|
||||
[Fact]
|
||||
public void HostAndSharedUpdatePathsUseLauncherDirectoryCasing()
|
||||
{
|
||||
var incoming = UpdatePaths.GetIncomingDirectory("root");
|
||||
var sharedIncoming = UpdatePaths.GetIncomingDirectory("root");
|
||||
|
||||
Assert.Contains($"{Path.DirectorySeparatorChar}.Launcher{Path.DirectorySeparatorChar}", incoming);
|
||||
Assert.Equal(
|
||||
Path.Combine("root", ".Launcher", "update", "incoming"),
|
||||
sharedIncoming);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PlondsApiManifestProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetLatestAsync_MapsCanonicalAndLegacyFileFields()
|
||||
{
|
||||
using var client = new HttpClient(new StaticManifestHandler(request =>
|
||||
{
|
||||
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
|
||||
if (path.EndsWith("/api/plonds/v1/channels/stable/windows-x64/latest", StringComparison.Ordinal))
|
||||
{
|
||||
return Json("""{"distributionId":"dist-2","version":"1.2.0","publishedAt":"2026-05-06T00:00:00Z"}""");
|
||||
}
|
||||
|
||||
if (path.EndsWith("/api/plonds/v1/distributions/dist-2", StringComparison.Ordinal))
|
||||
{
|
||||
return Json("""
|
||||
{
|
||||
"distributionId": "dist-2",
|
||||
"version": "1.2.0",
|
||||
"sourceVersion": "1.1.0",
|
||||
"publishedAt": "2026-05-06T00:00:00Z",
|
||||
"fileMapUrl": "https://static.example/filemap.json",
|
||||
"signatures": [{ "signature": "https://static.example/filemap.json.sig" }],
|
||||
"components": [
|
||||
{
|
||||
"files": [
|
||||
{
|
||||
"path": "LanMountainDesktop.exe",
|
||||
"action": "replace",
|
||||
"sha256": "abc123",
|
||||
"size": 42,
|
||||
"objectUrl": "https://static.example/repo/sha256/ab/abc123",
|
||||
"archiveSha256": "archive123"
|
||||
},
|
||||
{
|
||||
"path": "legacy.dll",
|
||||
"op": "add",
|
||||
"contentHash": "def456",
|
||||
"size": 7
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||
}));
|
||||
var provider = new PlondsApiManifestProvider("https://static.example", client);
|
||||
|
||||
var manifest = await provider.GetLatestAsync("stable", "windows-x64", new Version(1, 1, 0), CancellationToken.None);
|
||||
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal(UpdatePayloadKind.DeltaPlonds, manifest.Kind);
|
||||
Assert.Equal("https://static.example/filemap.json.sig", manifest.FileMapSignatureUrl);
|
||||
Assert.Collection(
|
||||
manifest.Files,
|
||||
first =>
|
||||
{
|
||||
Assert.Equal("replace", first.Action);
|
||||
Assert.Equal("abc123", first.Sha256);
|
||||
Assert.Equal("https://static.example/repo/sha256/ab/abc123", first.ObjectUrl);
|
||||
Assert.Equal("archive123", first.ArchiveSha256);
|
||||
},
|
||||
second =>
|
||||
{
|
||||
Assert.Equal("add", second.Action);
|
||||
Assert.Equal("def456", second.Sha256);
|
||||
});
|
||||
}
|
||||
|
||||
private static HttpResponseMessage Json(string json)
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StaticManifestHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(responder(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
169
LanMountainDesktop.Tests/WallpaperSettingsPageViewModelTests.cs
Normal file
169
LanMountainDesktop.Tests/WallpaperSettingsPageViewModelTests.cs
Normal file
@@ -0,0 +1,169 @@
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.PluginSdk;
|
||||
using LanMountainDesktop.Services;
|
||||
using LanMountainDesktop.Services.Settings;
|
||||
using LanMountainDesktop.Settings.Core;
|
||||
using LanMountainDesktop.ViewModels;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class WallpaperSettingsPageViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public void CustomColorRoundTripsThroughWallpaperColorField()
|
||||
{
|
||||
var initialWallpaperState = new WallpaperSettingsState(
|
||||
WallpaperPath: null,
|
||||
Type: "SolidColor",
|
||||
Color: "#FF123456",
|
||||
Placement: "Fill",
|
||||
SystemWallpaperRefreshIntervalSeconds: 900);
|
||||
var initialThemeState = CreateThemeState();
|
||||
var facade = new FakeSettingsFacade(initialWallpaperState, initialThemeState);
|
||||
var viewModel = new WallpaperSettingsPageViewModel(facade);
|
||||
|
||||
Assert.Equal("#FF123456", viewModel.SelectedColor);
|
||||
Assert.Equal(Color.Parse("#FF123456"), viewModel.CustomColor);
|
||||
|
||||
viewModel.CustomColor = Color.Parse("#FFABCDEF");
|
||||
|
||||
Assert.Equal("#FFABCDEF", facade.WallpaperState.Color);
|
||||
Assert.Equal(900, facade.WallpaperState.SystemWallpaperRefreshIntervalSeconds);
|
||||
Assert.Equal(0, facade.ThemeSaveCount);
|
||||
Assert.Equal(ThemeAppearanceValues.MaterialMica, facade.ThemeState.SystemMaterialMode);
|
||||
Assert.Equal("#FF998877", facade.ThemeState.SelectedWallpaperSeed);
|
||||
|
||||
var reloaded = new WallpaperSettingsPageViewModel(facade);
|
||||
Assert.Equal("#FFABCDEF", reloaded.SelectedColor);
|
||||
Assert.Equal(Color.Parse("#FFABCDEF"), reloaded.CustomColor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SavingWallpaperChanges_DoesNotTouchThemeMaterialFields()
|
||||
{
|
||||
var initialWallpaperState = new WallpaperSettingsState(
|
||||
WallpaperPath: @"C:\\wallpaper\\forest.png",
|
||||
Type: "Image",
|
||||
Color: "#FF123456",
|
||||
Placement: "Fill",
|
||||
SystemWallpaperRefreshIntervalSeconds: 1800);
|
||||
var initialThemeState = CreateThemeState();
|
||||
var facade = new FakeSettingsFacade(initialWallpaperState, initialThemeState);
|
||||
var viewModel = new WallpaperSettingsPageViewModel(facade);
|
||||
|
||||
viewModel.SelectedWallpaperPlacement = viewModel.WallpaperPlacements.Single(option => option.Value == "Tile");
|
||||
|
||||
Assert.Equal(0, facade.ThemeSaveCount);
|
||||
Assert.Equal(ThemeAppearanceValues.MaterialMica, facade.ThemeState.SystemMaterialMode);
|
||||
Assert.Equal("#FF998877", facade.ThemeState.SelectedWallpaperSeed);
|
||||
Assert.Equal(1800, facade.WallpaperState.SystemWallpaperRefreshIntervalSeconds);
|
||||
}
|
||||
|
||||
private static ThemeAppearanceSettingsState CreateThemeState()
|
||||
{
|
||||
return new ThemeAppearanceSettingsState(
|
||||
IsNightMode: false,
|
||||
ThemeColor: "#FF445566",
|
||||
UseSystemChrome: true,
|
||||
CornerRadiusStyle: GlobalAppearanceSettings.CornerRadiusStyleRounded,
|
||||
ThemeColorMode: ThemeAppearanceValues.ColorModeWallpaperMonet,
|
||||
SystemMaterialMode: ThemeAppearanceValues.MaterialMica,
|
||||
SelectedWallpaperSeed: "#FF998877",
|
||||
ThemeMode: ThemeAppearanceValues.ThemeModeLight,
|
||||
ThemeWallpaperColorSource: ThemeAppearanceValues.WallpaperColorSourceAuto,
|
||||
UseNativeWallpaperChangeEvents: true);
|
||||
}
|
||||
|
||||
private sealed class FakeSettingsFacade(
|
||||
WallpaperSettingsState wallpaperState,
|
||||
ThemeAppearanceSettingsState themeState) : ISettingsFacadeService
|
||||
{
|
||||
private readonly FakeWallpaperSettingsService _wallpaper = new(wallpaperState);
|
||||
private readonly FakeThemeAppearanceService _theme = new(themeState);
|
||||
private readonly FakeRegionSettingsService _region = new();
|
||||
|
||||
public WallpaperSettingsState WallpaperState => _wallpaper.State;
|
||||
public ThemeAppearanceSettingsState ThemeState => _theme.State;
|
||||
public int ThemeSaveCount => _theme.SaveCount;
|
||||
|
||||
public ISettingsService Settings => throw new NotSupportedException();
|
||||
public ISettingsCatalog Catalog => throw new NotSupportedException();
|
||||
public IGridSettingsService Grid => throw new NotSupportedException();
|
||||
public IWallpaperSettingsService Wallpaper => _wallpaper;
|
||||
public IWallpaperMediaService WallpaperMedia => new FakeWallpaperMediaService();
|
||||
public IThemeAppearanceService Theme => _theme;
|
||||
public IStatusBarSettingsService StatusBar => throw new NotSupportedException();
|
||||
public ITextCapsuleSettingsService TextCapsule => throw new NotSupportedException();
|
||||
public IWeatherSettingsService Weather => throw new NotSupportedException();
|
||||
public IRegionSettingsService Region => _region;
|
||||
public IPrivacySettingsService Privacy => throw new NotSupportedException();
|
||||
public IUpdateSettingsService Update => throw new NotSupportedException();
|
||||
public ILauncherCatalogService LauncherCatalog => throw new NotSupportedException();
|
||||
public ILauncherPolicyService LauncherPolicy => throw new NotSupportedException();
|
||||
public IPluginManagementSettingsService PluginManagement => throw new NotSupportedException();
|
||||
public IPluginCatalogSettingsService PluginCatalog => throw new NotSupportedException();
|
||||
public IApplicationInfoService ApplicationInfo => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed class FakeWallpaperSettingsService(WallpaperSettingsState state) : IWallpaperSettingsService
|
||||
{
|
||||
public WallpaperSettingsState State { get; private set; } = state;
|
||||
|
||||
public WallpaperSettingsState Get() => State;
|
||||
|
||||
public void Save(WallpaperSettingsState state)
|
||||
{
|
||||
State = state;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeThemeAppearanceService(ThemeAppearanceSettingsState state) : IThemeAppearanceService
|
||||
{
|
||||
public ThemeAppearanceSettingsState State { get; private set; } = state;
|
||||
|
||||
public int SaveCount { get; private set; }
|
||||
|
||||
public ThemeAppearanceSettingsState Get() => State;
|
||||
|
||||
public void Save(ThemeAppearanceSettingsState state)
|
||||
{
|
||||
SaveCount++;
|
||||
State = state;
|
||||
}
|
||||
|
||||
public MonetPalette BuildPalette(bool nightMode, string? wallpaperPath, string? preferredSeedColor = null)
|
||||
{
|
||||
var seed = Color.Parse(preferredSeedColor ?? "#FF3B82F6");
|
||||
return new MonetPalette([seed], seed, seed, seed, seed, seed, seed);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeRegionSettingsService : IRegionSettingsService
|
||||
{
|
||||
public RegionSettingsState Get() => new("en-US", null);
|
||||
|
||||
public void Save(RegionSettingsState state)
|
||||
{
|
||||
_ = state;
|
||||
}
|
||||
|
||||
public TimeZoneService GetTimeZoneService() => new();
|
||||
}
|
||||
|
||||
private sealed class FakeWallpaperMediaService : IWallpaperMediaService
|
||||
{
|
||||
public WallpaperMediaType DetectMediaType(string? path) => WallpaperMediaType.None;
|
||||
|
||||
public Task<string?> ImportAssetAsync(string sourcePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = sourcePath;
|
||||
_ = cancellationToken;
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
102
LanMountainDesktop.Tests/WeatherPreviewDataTests.cs
Normal file
102
LanMountainDesktop.Tests/WeatherPreviewDataTests.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class WeatherPreviewDataTests
|
||||
{
|
||||
[Fact]
|
||||
public void WeatherSnapshot_DefaultAlerts_IsEmpty()
|
||||
{
|
||||
var snapshot = new WeatherSnapshot(
|
||||
Provider: "Test",
|
||||
LocationKey: "test",
|
||||
LocationName: "Test City",
|
||||
Latitude: 0,
|
||||
Longitude: 0,
|
||||
FetchedAt: DateTimeOffset.UtcNow,
|
||||
ObservationTime: null,
|
||||
Current: new WeatherCurrentCondition(24, 25, 58, 42, 12, 180, 1, true, "Partly cloudy"),
|
||||
DailyForecasts: [],
|
||||
HourlyForecasts: []);
|
||||
|
||||
Assert.NotNull(snapshot.Alerts);
|
||||
Assert.Empty(snapshot.Alerts);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task XiaomiWeatherService_GetWeatherAsync_ParsesAlerts()
|
||||
{
|
||||
const string payload = """
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"current": {
|
||||
"temperature": { "value": 24 },
|
||||
"feelsLike": { "value": 26 },
|
||||
"humidity": { "value": 58 },
|
||||
"weather": { "value": 7, "text": "Light rain" },
|
||||
"wind": { "speed": { "value": 12 }, "direction": { "value": 180 } },
|
||||
"pubTime": "2026-05-18T10:00:00+08:00"
|
||||
},
|
||||
"aqi": { "value": 42 },
|
||||
"forecastDaily": {
|
||||
"temperature": { "value": [{ "from": 20, "to": 28 }] },
|
||||
"weather": { "value": [{ "from": 7, "to": 7 }] },
|
||||
"sunRiseSet": { "value": [{ "from": "05:42", "to": "18:54" }] },
|
||||
"precipitationProbability": { "value": [{ "value": 60 }] }
|
||||
},
|
||||
"alerts": [
|
||||
{
|
||||
"title": "Heavy rain warning",
|
||||
"detail": "Rain is expected within the next hour.",
|
||||
"type": "Rain",
|
||||
"level": "Yellow",
|
||||
"pubTime": "2026-05-18T09:30:00+08:00",
|
||||
"images": { "icon": "https://example.test/rain.webp" }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
using var httpClient = new HttpClient(new StubHandler(payload));
|
||||
var service = new XiaomiWeatherService(
|
||||
new XiaomiWeatherApiOptions { BaseUrl = "https://example.test" },
|
||||
httpClient);
|
||||
|
||||
var result = await service.GetWeatherAsync(new WeatherQuery(
|
||||
"101010100",
|
||||
39.9042,
|
||||
116.4074,
|
||||
ForecastDays: 3,
|
||||
Locale: "en_us",
|
||||
ForceRefresh: true));
|
||||
|
||||
Assert.True(result.Success, result.ErrorMessage);
|
||||
var alert = Assert.Single(result.Data!.Alerts);
|
||||
Assert.Equal("Heavy rain warning", alert.Title);
|
||||
Assert.Equal("Rain is expected within the next hour.", alert.Detail);
|
||||
Assert.Equal("Rain", alert.Type);
|
||||
Assert.Equal("Yellow", alert.Level);
|
||||
Assert.Equal("https://example.test/rain.webp", alert.IconUri);
|
||||
Assert.NotNull(alert.PublishedAt);
|
||||
}
|
||||
|
||||
private sealed class StubHandler(string responseText) : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(responseText)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using LanMountainDesktop.Models;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
@@ -9,24 +10,102 @@ namespace LanMountainDesktop.Tests;
|
||||
public sealed class WhiteboardNotePersistenceServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void SaveNote_ThenLoadNote_RoundTripsSnapshot()
|
||||
public void SaveNote_ThenLoadNote_RoundTripsFileSnapshot()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
var service = sandbox.CreateService();
|
||||
var snapshot = CreateSampleSnapshot();
|
||||
|
||||
service.SaveNote("DesktopWhiteboard", "whiteboard-1", snapshot, retentionDays: 15);
|
||||
|
||||
var saved = service.SaveNote("DesktopWhiteboard", "whiteboard-1", snapshot, retentionDays: 15);
|
||||
var loaded = service.LoadNote("DesktopWhiteboard", "whiteboard-1", retentionDays: 15);
|
||||
|
||||
Assert.True(saved);
|
||||
Assert.True(File.Exists(sandbox.GetNoteFilePath("DesktopWhiteboard", "whiteboard-1")));
|
||||
Assert.Equal(2, loaded.Version);
|
||||
Assert.Single(loaded.Strokes);
|
||||
Assert.Equal(2, loaded.Strokes[0].Points.Count);
|
||||
Assert.Equal("M 0 0 L 12 12", loaded.Strokes[0].PathSvgData);
|
||||
Assert.Equal("#FF112233", loaded.Strokes[0].Color);
|
||||
Assert.Equal(1.75d, loaded.ViewportZoom);
|
||||
Assert.Equal(-24d, loaded.ViewportOffsetX);
|
||||
Assert.Equal(-36d, loaded.ViewportOffsetY);
|
||||
Assert.True(loaded.SavedUtc > DateTimeOffset.MinValue);
|
||||
Assert.True(loaded.ExpiresUtc > loaded.SavedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadNote_RemovesExpiredSnapshot_WhenRetentionExceeded()
|
||||
public void SaveNote_WithReadOnlyExistingFile_ReturnsFalseAndKeepsOldFile()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
var service = sandbox.CreateService();
|
||||
var notePath = sandbox.GetNoteFilePath("DesktopWhiteboard", "read-only-board");
|
||||
|
||||
Assert.True(service.SaveNote("DesktopWhiteboard", "read-only-board", CreateSampleSnapshot("#FF112233"), retentionDays: 15));
|
||||
File.SetAttributes(notePath, File.GetAttributes(notePath) | FileAttributes.ReadOnly);
|
||||
|
||||
try
|
||||
{
|
||||
var saved = service.SaveNote("DesktopWhiteboard", "read-only-board", CreateSampleSnapshot("#FF445566"), retentionDays: 15);
|
||||
var loaded = service.LoadNote("DesktopWhiteboard", "read-only-board", retentionDays: 15);
|
||||
|
||||
Assert.False(saved);
|
||||
Assert.Equal("#FF112233", loaded.Strokes[0].Color);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.SetAttributes(notePath, FileAttributes.Normal);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SaveNote_WithEmptySnapshot_OverwritesOldContent()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
var service = sandbox.CreateService();
|
||||
|
||||
Assert.True(service.SaveNote("DesktopWhiteboard", "clear-board", CreateSampleSnapshot(), retentionDays: 15));
|
||||
Assert.True(service.SaveNote("DesktopWhiteboard", "clear-board", new WhiteboardNoteSnapshot
|
||||
{
|
||||
CanvasWidth = 320,
|
||||
CanvasHeight = 180,
|
||||
ViewportZoom = 2d,
|
||||
ViewportOffsetX = -40d,
|
||||
ViewportOffsetY = -20d
|
||||
}, retentionDays: 15));
|
||||
|
||||
var loaded = service.LoadNote("DesktopWhiteboard", "clear-board", retentionDays: 15);
|
||||
|
||||
Assert.Empty(loaded.Strokes);
|
||||
Assert.Equal(2d, loaded.ViewportZoom);
|
||||
Assert.Equal(-40d, loaded.ViewportOffsetX);
|
||||
Assert.Equal(-20d, loaded.ViewportOffsetY);
|
||||
Assert.True(File.Exists(sandbox.GetNoteFilePath("DesktopWhiteboard", "clear-board")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadNote_WithOldJsonWithoutViewport_UsesDefaultViewport()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
var service = sandbox.CreateService();
|
||||
sandbox.WriteRawNoteJson("DesktopWhiteboard", "old-json-board", """
|
||||
{
|
||||
"version": 2,
|
||||
"canvasWidth": 320,
|
||||
"canvasHeight": 180,
|
||||
"backgroundColor": "#FFFFFFFF",
|
||||
"strokes": []
|
||||
}
|
||||
""");
|
||||
|
||||
var loaded = service.LoadNote("DesktopWhiteboard", "old-json-board", retentionDays: 15);
|
||||
|
||||
Assert.Equal(1d, loaded.ViewportZoom);
|
||||
Assert.Equal(0d, loaded.ViewportOffsetX);
|
||||
Assert.Equal(0d, loaded.ViewportOffsetY);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadNote_RemovesExpiredFile_WhenRetentionExceeded()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
var service = sandbox.CreateService();
|
||||
@@ -37,11 +116,11 @@ public sealed class WhiteboardNotePersistenceServiceTests
|
||||
var loaded = service.LoadNote("DesktopWhiteboard", "expired-board", retentionDays: 7);
|
||||
|
||||
Assert.Empty(loaded.Strokes);
|
||||
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-board"));
|
||||
Assert.False(File.Exists(sandbox.GetNoteFilePath("DesktopWhiteboard", "expired-board")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteExpiredNotesBatch_RemovesExpiredRows_AndKeepsFreshRows()
|
||||
public void DeleteExpiredNotesBatch_RemovesExpiredFiles_AndKeepsFreshFiles()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
var service = sandbox.CreateService();
|
||||
@@ -57,22 +136,59 @@ public sealed class WhiteboardNotePersistenceServiceTests
|
||||
var deletedCount = service.DeleteExpiredNotesBatch(batchSize: 10);
|
||||
|
||||
Assert.Equal(2, deletedCount);
|
||||
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-a"));
|
||||
Assert.False(sandbox.Exists("DesktopWhiteboard", "expired-b"));
|
||||
Assert.True(sandbox.Exists("DesktopWhiteboard", "fresh-c"));
|
||||
Assert.False(File.Exists(sandbox.GetNoteFilePath("DesktopWhiteboard", "expired-a")));
|
||||
Assert.False(File.Exists(sandbox.GetNoteFilePath("DesktopWhiteboard", "expired-b")));
|
||||
Assert.True(File.Exists(sandbox.GetNoteFilePath("DesktopWhiteboard", "fresh-c")));
|
||||
}
|
||||
|
||||
private static WhiteboardNoteSnapshot CreateSampleSnapshot()
|
||||
[Fact]
|
||||
public void LoadNote_MigratesLegacyDatabaseSnapshot_WhenFileMissing()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
sandbox.SaveLegacyNote("DesktopWhiteboard", "legacy-board", CreateSampleSnapshot("#FF778899"), retentionDays: 15);
|
||||
var service = sandbox.CreateService();
|
||||
|
||||
var loaded = service.LoadNote("DesktopWhiteboard", "legacy-board", retentionDays: 15);
|
||||
|
||||
Assert.Single(loaded.Strokes);
|
||||
Assert.Equal("#FF778899", loaded.Strokes[0].Color);
|
||||
Assert.True(File.Exists(sandbox.GetNoteFilePath("DesktopWhiteboard", "legacy-board")));
|
||||
Assert.False(sandbox.LegacyExists("DesktopWhiteboard", "legacy-board"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteNote_RemovesFileAndLegacyRow()
|
||||
{
|
||||
using var sandbox = new WhiteboardNotePersistenceSandbox();
|
||||
sandbox.SaveLegacyNote("DesktopWhiteboard", "delete-board", CreateSampleSnapshot(), retentionDays: 15);
|
||||
var service = sandbox.CreateService();
|
||||
service.SaveNote("DesktopWhiteboard", "delete-board", CreateSampleSnapshot(), retentionDays: 15);
|
||||
|
||||
var deleted = service.DeleteNote("DesktopWhiteboard", "delete-board");
|
||||
|
||||
Assert.True(deleted);
|
||||
Assert.False(File.Exists(sandbox.GetNoteFilePath("DesktopWhiteboard", "delete-board")));
|
||||
Assert.False(sandbox.LegacyExists("DesktopWhiteboard", "delete-board"));
|
||||
}
|
||||
|
||||
private static WhiteboardNoteSnapshot CreateSampleSnapshot(string color = "#FF112233")
|
||||
{
|
||||
return new WhiteboardNoteSnapshot
|
||||
{
|
||||
CanvasWidth = 320,
|
||||
CanvasHeight = 180,
|
||||
BackgroundColor = "#FFFFFFFF",
|
||||
ViewportZoom = 1.75d,
|
||||
ViewportOffsetX = -24d,
|
||||
ViewportOffsetY = -36d,
|
||||
Strokes =
|
||||
[
|
||||
new WhiteboardStrokeSnapshot
|
||||
{
|
||||
Color = "#FF112233",
|
||||
Color = color,
|
||||
InkThickness = 3.5d,
|
||||
IgnorePressure = true,
|
||||
PathSvgData = "M 0 0 L 12 12",
|
||||
Points =
|
||||
[
|
||||
new WhiteboardStylusPointSnapshot { X = 12, Y = 34, Pressure = 0.4d, Width = 2, Height = 2 },
|
||||
@@ -91,40 +207,93 @@ public sealed class WhiteboardNotePersistenceServiceTests
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
private readonly string _databasePath;
|
||||
private readonly string _whiteboardsRootDirectory;
|
||||
|
||||
public WhiteboardNotePersistenceSandbox()
|
||||
{
|
||||
Directory.CreateDirectory(_directoryPath);
|
||||
_databasePath = Path.Combine(_directoryPath, "whiteboard-tests.db");
|
||||
_whiteboardsRootDirectory = Path.Combine(_directoryPath, "Whiteboards");
|
||||
}
|
||||
|
||||
public WhiteboardNotePersistenceService CreateService()
|
||||
{
|
||||
return new WhiteboardNotePersistenceService(new AppDatabaseService(_databasePath));
|
||||
return new WhiteboardNotePersistenceService(
|
||||
_whiteboardsRootDirectory,
|
||||
new AppDatabaseService(_databasePath));
|
||||
}
|
||||
|
||||
public string GetNoteFilePath(string componentId, string placementId)
|
||||
{
|
||||
return CreateService().GetNoteFilePathForTests(componentId, placementId);
|
||||
}
|
||||
|
||||
public void OverrideSavedTimestamp(string componentId, string placementId, DateTimeOffset savedUtc, int retentionDays)
|
||||
{
|
||||
var expiresUtc = savedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
|
||||
var notePath = GetNoteFilePath(componentId, placementId);
|
||||
var snapshot = JsonSerializer.Deserialize<WhiteboardNoteSnapshot>(
|
||||
File.ReadAllText(notePath),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new WhiteboardNoteSnapshot();
|
||||
snapshot.SavedUtc = savedUtc;
|
||||
snapshot.ExpiresUtc = savedUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
|
||||
File.WriteAllText(notePath, JsonSerializer.Serialize(snapshot, new JsonSerializerOptions { WriteIndented = true }));
|
||||
}
|
||||
|
||||
public void SaveLegacyNote(string componentId, string placementId, WhiteboardNoteSnapshot snapshot, int retentionDays)
|
||||
{
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
var expiresUtc = nowUtc.AddDays(WhiteboardNoteRetentionPolicy.NormalizeDays(retentionDays));
|
||||
using var connection = new AppDatabaseService(_databasePath).OpenConnection();
|
||||
using (var schemaCommand = connection.CreateCommand())
|
||||
{
|
||||
schemaCommand.CommandText = """
|
||||
CREATE TABLE IF NOT EXISTS whiteboard_notes (
|
||||
component_id TEXT NOT NULL,
|
||||
placement_id TEXT NOT NULL,
|
||||
note_json TEXT NOT NULL,
|
||||
saved_at_utc_ms INTEGER NOT NULL,
|
||||
expires_at_utc_ms INTEGER NOT NULL,
|
||||
updated_at_utc_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (component_id, placement_id)
|
||||
);
|
||||
""";
|
||||
schemaCommand.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
UPDATE whiteboard_notes
|
||||
SET saved_at_utc_ms = $savedAtUtcMs,
|
||||
expires_at_utc_ms = $expiresAtUtcMs,
|
||||
updated_at_utc_ms = $updatedAtUtcMs
|
||||
WHERE component_id = $componentId
|
||||
AND placement_id = $placementId;
|
||||
INSERT INTO whiteboard_notes(
|
||||
component_id,
|
||||
placement_id,
|
||||
note_json,
|
||||
saved_at_utc_ms,
|
||||
expires_at_utc_ms,
|
||||
updated_at_utc_ms)
|
||||
VALUES(
|
||||
$componentId,
|
||||
$placementId,
|
||||
$noteJson,
|
||||
$savedAtUtcMs,
|
||||
$expiresAtUtcMs,
|
||||
$updatedAtUtcMs);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$savedAtUtcMs", savedUtc.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$updatedAtUtcMs", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$componentId", componentId);
|
||||
command.Parameters.AddWithValue("$placementId", placementId);
|
||||
command.Parameters.AddWithValue("$noteJson", JsonSerializer.Serialize(snapshot));
|
||||
command.Parameters.AddWithValue("$savedAtUtcMs", nowUtc.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$expiresAtUtcMs", expiresUtc.ToUnixTimeMilliseconds());
|
||||
command.Parameters.AddWithValue("$updatedAtUtcMs", nowUtc.ToUnixTimeMilliseconds());
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
public bool Exists(string componentId, string placementId)
|
||||
public void WriteRawNoteJson(string componentId, string placementId, string json)
|
||||
{
|
||||
var notePath = GetNoteFilePath(componentId, placementId);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(notePath)!);
|
||||
File.WriteAllText(notePath, json);
|
||||
}
|
||||
|
||||
public bool LegacyExists(string componentId, string placementId)
|
||||
{
|
||||
using var connection = new AppDatabaseService(_databasePath).OpenConnection();
|
||||
using var command = connection.CreateCommand();
|
||||
|
||||
77
LanMountainDesktop.Tests/WhiteboardStrokePathBuilderTests.cs
Normal file
77
LanMountainDesktop.Tests/WhiteboardStrokePathBuilderTests.cs
Normal file
@@ -0,0 +1,77 @@
|
||||
using DotNetCampus.Inking.Primitive;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class WhiteboardStrokePathBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildPath_WithEmptyPointList_ReturnsEmptyPath()
|
||||
{
|
||||
using var path = WhiteboardStrokePathBuilder.BuildPath(Array.Empty<InkStylusPoint>(), inkThickness: 3d);
|
||||
|
||||
Assert.True(path.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPath_WithSinglePoint_CreatesVisibleStroke()
|
||||
{
|
||||
using var path = WhiteboardStrokePathBuilder.BuildPath(
|
||||
[CreatePoint(24, 32)],
|
||||
inkThickness: 6d);
|
||||
|
||||
Assert.False(path.IsEmpty);
|
||||
Assert.True(path.Bounds.Width >= 5.5f);
|
||||
Assert.True(path.Bounds.Height >= 5.5f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPath_WithMultiplePoints_CreatesFilledStroke()
|
||||
{
|
||||
using var path = WhiteboardStrokePathBuilder.BuildPath(
|
||||
[
|
||||
CreatePoint(10, 10),
|
||||
CreatePoint(30, 18),
|
||||
CreatePoint(52, 14)
|
||||
],
|
||||
inkThickness: 4d);
|
||||
|
||||
Assert.False(path.IsEmpty);
|
||||
Assert.True(path.Bounds.Width > 40f);
|
||||
Assert.True(path.Bounds.Height > 4f);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPath_WithThickerStroke_ExpandsStrokeBounds()
|
||||
{
|
||||
var points = new[]
|
||||
{
|
||||
CreatePoint(10, 10),
|
||||
CreatePoint(80, 10)
|
||||
};
|
||||
|
||||
using var thinPath = WhiteboardStrokePathBuilder.BuildPath(points, inkThickness: 1d);
|
||||
using var thickPath = WhiteboardStrokePathBuilder.BuildPath(points, inkThickness: 8d);
|
||||
|
||||
Assert.True(thickPath.Bounds.Height > thinPath.Bounds.Height);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPath_WithNonFinitePoints_UsesRemainingFinitePoints()
|
||||
{
|
||||
using var path = WhiteboardStrokePathBuilder.BuildPath(
|
||||
[
|
||||
CreatePoint(double.NaN, 10),
|
||||
CreatePoint(20, 20)
|
||||
],
|
||||
inkThickness: 4d);
|
||||
|
||||
Assert.False(path.IsEmpty);
|
||||
}
|
||||
|
||||
private static InkStylusPoint CreatePoint(double x, double y)
|
||||
{
|
||||
return new InkStylusPoint(x, y, pressure: 1f);
|
||||
}
|
||||
}
|
||||
65
LanMountainDesktop.Tests/WhiteboardSvgImportServiceTests.cs
Normal file
65
LanMountainDesktop.Tests/WhiteboardSvgImportServiceTests.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using LanMountainDesktop.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class WhiteboardSvgImportServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void Import_WithFilledPath_CreatesStaticStrokeSnapshot()
|
||||
{
|
||||
using var stream = ToStream("""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50">
|
||||
<path d="M 10 10 L 90 10 L 90 40 Z" fill="#112233" />
|
||||
</svg>
|
||||
""");
|
||||
|
||||
var result = WhiteboardSvgImportService.Import(stream, targetWidth: 200, targetHeight: 100);
|
||||
|
||||
Assert.Single(result.Strokes);
|
||||
Assert.Equal("#FF112233", result.Strokes[0].Color);
|
||||
Assert.Empty(result.Strokes[0].Points);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Strokes[0].PathSvgData));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_WithStrokePath_ConvertsStrokeToFilledPath()
|
||||
{
|
||||
using var stream = ToStream("""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<path d="M 10 10 L 90 90" fill="none" stroke="red" stroke-width="6" />
|
||||
</svg>
|
||||
""");
|
||||
|
||||
var result = WhiteboardSvgImportService.Import(stream, targetWidth: 100, targetHeight: 100);
|
||||
|
||||
Assert.Single(result.Strokes);
|
||||
Assert.Equal("#FFFF0000", result.Strokes[0].Color);
|
||||
Assert.True(result.Strokes[0].InkThickness >= 6d);
|
||||
Assert.Empty(result.Strokes[0].Points);
|
||||
Assert.False(string.IsNullOrWhiteSpace(result.Strokes[0].PathSvgData));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Import_WithStylePresentationAttributes_ParsesStyleValues()
|
||||
{
|
||||
using var stream = ToStream("""
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<path d="M 10 10 L 20 20" style="fill:none;stroke:#00FF00;stroke-width:4px" />
|
||||
</svg>
|
||||
""");
|
||||
|
||||
var result = WhiteboardSvgImportService.Import(stream, targetWidth: 100, targetHeight: 100);
|
||||
|
||||
Assert.Single(result.Strokes);
|
||||
Assert.Equal("#FF00FF00", result.Strokes[0].Color);
|
||||
Assert.True(result.Strokes[0].InkThickness >= 4d);
|
||||
}
|
||||
|
||||
private static MemoryStream ToStream(string svg)
|
||||
{
|
||||
return new MemoryStream(Encoding.UTF8.GetBytes(svg));
|
||||
}
|
||||
}
|
||||
68
LanMountainDesktop.Tests/WhiteboardViewportHelperTests.cs
Normal file
68
LanMountainDesktop.Tests/WhiteboardViewportHelperTests.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using Avalonia;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class WhiteboardViewportHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void ZoomAt_WithCenterAnchor_KeepsAnchorLogicalPointStable()
|
||||
{
|
||||
var viewportSize = new Size(200, 100);
|
||||
var canvasSize = new Size(400, 200);
|
||||
var state = new WhiteboardViewportState(1d, default);
|
||||
var anchor = new Point(100, 50);
|
||||
var before = WhiteboardViewportHelper.ToLogicalPoint(state, anchor);
|
||||
|
||||
var zoomed = WhiteboardViewportHelper.ZoomAt(state, 2d, anchor, viewportSize, canvasSize);
|
||||
var after = WhiteboardViewportHelper.ToLogicalPoint(zoomed, anchor);
|
||||
|
||||
Assert.Equal(before.X, after.X, precision: 3);
|
||||
Assert.Equal(before.Y, after.Y, precision: 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PanBy_ClampsToScaledCanvasBounds()
|
||||
{
|
||||
var viewportSize = new Size(100, 100);
|
||||
var canvasSize = new Size(200, 200);
|
||||
var state = new WhiteboardViewportState(2d, default);
|
||||
|
||||
var positive = WhiteboardViewportHelper.PanBy(state, new Vector(500, 500), viewportSize, canvasSize);
|
||||
var negative = WhiteboardViewportHelper.PanBy(state, new Vector(-500, -500), viewportSize, canvasSize);
|
||||
|
||||
Assert.Equal(0d, positive.Offset.X, precision: 3);
|
||||
Assert.Equal(0d, positive.Offset.Y, precision: 3);
|
||||
Assert.Equal(-300d, negative.Offset.X, precision: 3);
|
||||
Assert.Equal(-300d, negative.Offset.Y, precision: 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clamp_WhenCanvasIsSmallerThanViewport_CentersCanvas()
|
||||
{
|
||||
var state = new WhiteboardViewportState(1d, new Vector(-40, -40));
|
||||
|
||||
var clamped = WhiteboardViewportHelper.Clamp(
|
||||
state,
|
||||
new Size(300, 300),
|
||||
new Size(100, 100));
|
||||
|
||||
Assert.Equal(100d, clamped.Offset.X, precision: 3);
|
||||
Assert.Equal(100d, clamped.Offset.Y, precision: 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clamp_AfterViewportResize_KeepsOffsetInsideBounds()
|
||||
{
|
||||
var state = new WhiteboardViewportState(2d, new Vector(-220, -220));
|
||||
|
||||
var clamped = WhiteboardViewportHelper.Clamp(
|
||||
state,
|
||||
new Size(300, 300),
|
||||
new Size(200, 200));
|
||||
|
||||
Assert.Equal(-100d, clamped.Offset.X, precision: 3);
|
||||
Assert.Equal(-100d, clamped.Offset.Y, precision: 3);
|
||||
}
|
||||
}
|
||||
155
LanMountainDesktop.Tests/WhiteboardWidgetLayoutSyncTests.cs
Normal file
155
LanMountainDesktop.Tests/WhiteboardWidgetLayoutSyncTests.cs
Normal file
@@ -0,0 +1,155 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using LanMountainDesktop.Views.Components;
|
||||
using Xunit;
|
||||
|
||||
namespace LanMountainDesktop.Tests;
|
||||
|
||||
public sealed class WhiteboardWidgetLayoutSyncTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveViewportSize_PrefersViewportRootSize()
|
||||
{
|
||||
var resolution = WhiteboardWidget.ResolveViewportSize(
|
||||
viewportRootSize: new Size(320, 240),
|
||||
canvasBorderSize: new Size(200, 160),
|
||||
widgetSize: new Size(100, 80),
|
||||
currentCellSize: 48,
|
||||
baseWidthCells: 2);
|
||||
|
||||
Assert.Equal(new Size(320, 240), resolution.Size);
|
||||
Assert.Equal("ViewportRoot", resolution.Source);
|
||||
Assert.False(resolution.IsFallback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveViewportSize_FallsBackToCanvasBorderBeforeCellSize()
|
||||
{
|
||||
var resolution = WhiteboardWidget.ResolveViewportSize(
|
||||
viewportRootSize: new Size(0, 0),
|
||||
canvasBorderSize: new Size(260, 180),
|
||||
widgetSize: new Size(100, 80),
|
||||
currentCellSize: 48,
|
||||
baseWidthCells: 2);
|
||||
|
||||
Assert.Equal(new Size(260, 180), resolution.Size);
|
||||
Assert.Equal("CanvasBorder", resolution.Source);
|
||||
Assert.False(resolution.IsFallback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveViewportSize_UsesCellSizeFallbackOnlyWhenLayoutIsUnavailable()
|
||||
{
|
||||
var resolution = WhiteboardWidget.ResolveViewportSize(
|
||||
viewportRootSize: new Size(0, 0),
|
||||
canvasBorderSize: new Size(1, 1),
|
||||
widgetSize: new Size(0, 0),
|
||||
currentCellSize: 48,
|
||||
baseWidthCells: 2);
|
||||
|
||||
Assert.Equal(new Size(96, 96), resolution.Size);
|
||||
Assert.Equal("Fallback", resolution.Source);
|
||||
Assert.True(resolution.IsFallback);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToOpaqueInkColor_ForcesColorPickerAlphaToVisibleInk()
|
||||
{
|
||||
var color = Color.FromArgb(0, 20, 40, 60);
|
||||
|
||||
var inkColor = WhiteboardWidget.ToOpaqueInkColor(color);
|
||||
|
||||
Assert.Equal((byte)255, inkColor.Alpha);
|
||||
Assert.Equal((byte)20, inkColor.Red);
|
||||
Assert.Equal((byte)40, inkColor.Green);
|
||||
Assert.Equal((byte)60, inkColor.Blue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteboardWidget_DefinesDeferredViewportLayoutSynchronization()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "Components", "WhiteboardWidget.axaml.cs");
|
||||
var synchronizeSource = ExtractMethodSource(source, "SynchronizeViewportLayout");
|
||||
|
||||
Assert.Contains("ViewportRoot.SizeChanged += OnViewportRootSizeChanged", source);
|
||||
Assert.Contains("QueueViewportLayoutSync(\"attached-loaded\")", source);
|
||||
Assert.Contains("DispatcherPriority.Loaded", source);
|
||||
Assert.Contains("ResolveViewportSize(", source);
|
||||
Assert.DoesNotContain("QueueNoteSave(", synchronizeSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteboardWidget_RestoresInkInputAfterColorPopupCloses()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "Components", "WhiteboardWidget.axaml.cs");
|
||||
var restoreSource = ExtractMethodSource(source, "RestoreInkInputAfterToolPopup");
|
||||
|
||||
Assert.Contains("ColorPickerPopup.Closed += OnColorPickerPopupClosed", source);
|
||||
Assert.Contains("ColorPickerPopup.Closed -= OnColorPickerPopupClosed", source);
|
||||
Assert.Contains("ToOpaqueInkColor(e.NewColor)", source);
|
||||
Assert.Contains("SetToolMode(WhiteboardToolMode.Pen)", restoreSource);
|
||||
Assert.Contains("SynchronizeViewportLayout(reason)", restoreSource);
|
||||
Assert.Contains("InkCanvas.Focus", restoreSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteboardWidget_ColorPickerDoesNotPersistTransparentInk()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "Components", "WhiteboardWidget.axaml.cs");
|
||||
var colorChangedSource = ExtractMethodSource(source, "OnColorPickerColorChanged");
|
||||
|
||||
Assert.DoesNotContain("color.A", colorChangedSource);
|
||||
Assert.DoesNotContain("e.NewColor.A", colorChangedSource);
|
||||
Assert.Contains("byte.MaxValue", 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)}'.");
|
||||
}
|
||||
|
||||
private static string ExtractMethodSource(string source, string methodName)
|
||||
{
|
||||
var methodIndex = source.IndexOf($"private void {methodName}(", StringComparison.Ordinal);
|
||||
Assert.True(methodIndex >= 0, $"Could not locate method '{methodName}'.");
|
||||
|
||||
var braceIndex = source.IndexOf('{', methodIndex);
|
||||
Assert.True(braceIndex >= 0, $"Could not locate method body for '{methodName}'.");
|
||||
|
||||
var depth = 0;
|
||||
for (var i = braceIndex; i < source.Length; i++)
|
||||
{
|
||||
if (source[i] == '{')
|
||||
{
|
||||
depth++;
|
||||
}
|
||||
else if (source[i] == '}')
|
||||
{
|
||||
depth--;
|
||||
if (depth == 0)
|
||||
{
|
||||
return source.Substring(methodIndex, i - methodIndex + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Could not extract method '{methodName}'.");
|
||||
}
|
||||
}
|
||||
280
LanMountainDesktop.Tests/WindowLayerIsolationTests.cs
Normal file
280
LanMountainDesktop.Tests/WindowLayerIsolationTests.cs
Normal file
@@ -0,0 +1,280 @@
|
||||
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 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()
|
||||
{
|
||||
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("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()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "MainWindow.ComponentSystem.cs");
|
||||
var handlerSource = ExtractMethodSource(source, "OnDesktopComponentHostPointerPressed");
|
||||
|
||||
Assert.DoesNotContain("TryOpenAirAppFromDesktopComponent", source);
|
||||
Assert.DoesNotContain("OpenWorldClock(placement.PlacementId", source);
|
||||
Assert.DoesNotContain("OpenWhiteboard(", handlerSource);
|
||||
Assert.DoesNotContain("OpenWorldClock(", handlerSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnalogClockWidget_OpensWorldClockOnlyInLiveMode()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop", "Views", "Components", "AnalogClockWidget.axaml.cs");
|
||||
|
||||
Assert.Contains("IComponentRuntimeContextAware", source);
|
||||
Assert.Contains("DesktopComponentRenderMode.Live", source);
|
||||
Assert.Contains("OpenWorldClock(_componentId, _placementId)", source);
|
||||
Assert.Contains("BuiltInComponentIds.DesktopClock", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AirAppWindow_WhiteboardBranchReusesWidgetAndSavesOnClose()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppWindow.axaml.cs");
|
||||
|
||||
Assert.Contains("new WhiteboardWidget(baseWidthCells)", source);
|
||||
Assert.Contains("SetComponentPlacementContext(componentId, _options.SourcePlacementId)", source);
|
||||
Assert.Contains("SetSurfaceMode(", source);
|
||||
Assert.Contains("WhiteboardWidgetSurfaceMode.AirApp", source);
|
||||
Assert.Contains("ForceSaveNote()", source);
|
||||
Assert.Contains("widget.Dispose()", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AirAppHost_LoadsHostThemeForWhiteboardToolFlyouts()
|
||||
{
|
||||
var appXaml = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirApp.axaml");
|
||||
var projectFile = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "LanMountainDesktop.AirAppHost.csproj");
|
||||
|
||||
Assert.Contains("<sty:FluentAvaloniaTheme", appXaml);
|
||||
Assert.DoesNotContain("<FluentTheme", appXaml);
|
||||
Assert.Contains("Style Selector=\"fi|SymbolIcon\"", appXaml);
|
||||
Assert.Contains("Style Selector=\"ScrollViewer\"", appXaml);
|
||||
Assert.Contains("AppFontFamily", appXaml);
|
||||
Assert.Contains("FluentIcons.Avalonia", projectFile);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AirAppHost_ParsesAndReceivesSharedDataRoot()
|
||||
{
|
||||
var optionsSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "AirAppLaunchOptions.cs");
|
||||
var programSource = ReadRepositoryFile("LanMountainDesktop.AirAppHost", "Program.cs");
|
||||
var starterSource = ReadRepositoryFile("LanMountainDesktop.Launcher", "Services", "AirApp", "IAirAppProcessStarter.cs");
|
||||
var dataPathSource = ReadRepositoryFile("LanMountainDesktop", "Services", "AppDataPathProvider.cs");
|
||||
|
||||
Assert.Contains("DataRoot", optionsSource);
|
||||
Assert.Contains("IndexOf('=')", optionsSource);
|
||||
Assert.Contains("data-root", optionsSource);
|
||||
Assert.Contains("AppDataPathProvider.Initialize(args)", programSource);
|
||||
Assert.Contains("--data-root", starterSource);
|
||||
Assert.Contains("Path.GetFullPath(dataRoot)", starterSource);
|
||||
Assert.Contains("string.Equals(arg, \"--data-root\"", dataPathSource);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MainWindowDesktopLayerService_DoesNotUseFusedDesktopPassthroughBoundary()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop", "Services", "MainWindowDesktopLayerService.cs");
|
||||
|
||||
Assert.Contains("IMainWindowDesktopLayerService", source);
|
||||
Assert.Contains("SetParent", source);
|
||||
Assert.Contains("HWND_BOTTOM", source);
|
||||
Assert.DoesNotContain("WindowBottomMostServiceFactory", source);
|
||||
Assert.DoesNotContain("IRegionPassthroughService", source);
|
||||
Assert.DoesNotContain("SetInteractiveRegions", source);
|
||||
Assert.DoesNotContain("HTTRANSPARENT", source);
|
||||
Assert.DoesNotContain("WS_EX_NOACTIVATE", source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MainWindowRestorePaths_UseDesktopLayerAwareActivation()
|
||||
{
|
||||
var source = ReadRepositoryFile("LanMountainDesktop", "App.axaml.cs");
|
||||
var restoreSource = ExtractMethodSource(source, "RestoreOrCreateMainWindowCore");
|
||||
var trayFallbackSource = ExtractMethodSource(source, "RecoverFromTrayUnavailable");
|
||||
var applyLayerSource = ExtractMethodSource(source, "ApplyMainWindowDesktopLayerRuntimeState");
|
||||
|
||||
Assert.Contains("ActivateOrRefreshMainWindowLayer(mainWindow", restoreSource);
|
||||
Assert.DoesNotContain("Topmost = true", restoreSource);
|
||||
|
||||
Assert.Contains("ActivateOrRefreshMainWindowLayer(mainWindow", trayFallbackSource);
|
||||
Assert.DoesNotContain("Topmost = true", trayFallbackSource);
|
||||
|
||||
Assert.Contains("FusedDesktopManagerServiceFactory.GetOrCreate().Shutdown()", applyLayerSource);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AppSettingsSnapshot_MainWindowDesktopLayerDefaultsToDisabled()
|
||||
{
|
||||
Assert.False(new LanMountainDesktop.Models.AppSettingsSnapshot().EnableMainWindowDesktopLayer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeveloperSettings_DefinesMutuallyExclusiveDesktopLayerToggles()
|
||||
{
|
||||
var viewModelSource = ReadRepositoryFile("LanMountainDesktop", "ViewModels", "SettingsViewModels.cs");
|
||||
var pageSource = ReadRepositoryFile("LanMountainDesktop", "Views", "SettingsPages", "DevSettingsPage.axaml.cs");
|
||||
var xamlSource = ReadRepositoryFile("LanMountainDesktop", "Views", "SettingsPages", "DevSettingsPage.axaml");
|
||||
|
||||
Assert.Contains("EnableMainWindowDesktopLayer", viewModelSource);
|
||||
Assert.Contains("ApplyFusedDesktopPreference", viewModelSource);
|
||||
Assert.Contains("ApplyMainWindowDesktopLayerPreference", viewModelSource);
|
||||
Assert.Contains("nameof(AppSettingsSnapshot.EnableFusedDesktop)", viewModelSource);
|
||||
Assert.Contains("nameof(AppSettingsSnapshot.EnableMainWindowDesktopLayer)", viewModelSource);
|
||||
|
||||
Assert.Contains("ConfirmDesktopLayerSwitchAsync", pageSource);
|
||||
Assert.Contains("OnFusedDesktopToggleChanged", xamlSource);
|
||||
Assert.Contains("OnMainWindowDesktopLayerToggleChanged", xamlSource);
|
||||
Assert.Contains("Mode=OneWay", xamlSource);
|
||||
}
|
||||
|
||||
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)}'.");
|
||||
}
|
||||
|
||||
private static string ExtractMethodSource(string source, string methodName)
|
||||
{
|
||||
var methodIndex = source.IndexOf($"private bool {methodName}(", StringComparison.Ordinal);
|
||||
if (methodIndex < 0)
|
||||
{
|
||||
methodIndex = source.IndexOf($"private void {methodName}(", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
Assert.True(methodIndex >= 0, $"Could not locate method '{methodName}'.");
|
||||
|
||||
var braceIndex = source.IndexOf('{', methodIndex);
|
||||
Assert.True(braceIndex >= 0, $"Could not locate method body for '{methodName}'.");
|
||||
|
||||
var depth = 0;
|
||||
for (var i = braceIndex; i < source.Length; i++)
|
||||
{
|
||||
if (source[i] == '{')
|
||||
{
|
||||
depth++;
|
||||
}
|
||||
else if (source[i] == '}')
|
||||
{
|
||||
depth--;
|
||||
if (depth == 0)
|
||||
{
|
||||
return source.Substring(methodIndex, i - methodIndex + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Could not extract method '{methodName}'.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user