fix.在线安装器,启动器

This commit is contained in:
lincube
2026-06-05 11:08:11 +08:00
parent bb4e90ea8d
commit 8c88e305ee
42 changed files with 1507 additions and 393 deletions

View File

@@ -1,5 +1,6 @@
using Avalonia;
using LanMountainDesktop.DesktopEditing;
using LanMountainDesktop.Models;
using Xunit;
namespace LanMountainDesktop.Tests;
@@ -170,4 +171,123 @@ public sealed class DesktopPlacementMathTests
Assert.False(resizeSession.IsPreviewOccludedByComponentLibrary(new Rect(100, 100, 40, 40)));
Assert.True(resizeSession.CanCommit);
}
[Fact]
public void FusedCenteredPlacement_UsesGridCenterAndComponentSpan()
{
var grid = new DesktopGridGeometry(
Origin: new Point(12, 20),
CellSize: 80,
CellGap: 8,
ColumnCount: 8,
RowCount: 6);
var placement = FusedDesktopPlacementMath.CreateCenteredPlacement(
"placement-1",
"component-1",
grid,
widthCells: 4,
heightCells: 2);
Assert.Equal(2, placement.GridColumn);
Assert.Equal(2, placement.GridRow);
Assert.Equal(4, placement.GridWidthCells);
Assert.Equal(2, placement.GridHeightCells);
Assert.Equal(188, placement.X, 3);
Assert.Equal(196, placement.Y, 3);
Assert.Equal(344, placement.Width, 3);
Assert.Equal(168, placement.Height, 3);
}
[Fact]
public void FusedSnapToNearestCell_RoundsAndPersistsGridCoordinates()
{
var grid = new DesktopGridGeometry(
Origin: new Point(10, 10),
CellSize: 100,
CellGap: 12,
ColumnCount: 6,
RowCount: 5);
var placement = new FusedDesktopComponentPlacementSnapshot
{
PlacementId = "placement-1",
ComponentId = "component-1",
Width = 212,
Height = 100,
GridWidthCells = 2,
GridHeightCells = 1
};
var snapped = FusedDesktopPlacementMath.SnapToNearestCell(
placement,
grid,
requestedOrigin: new Point(255, 135));
Assert.Equal(2, snapped.GridColumn);
Assert.Equal(1, snapped.GridRow);
Assert.Equal(234, snapped.X, 3);
Assert.Equal(122, snapped.Y, 3);
Assert.Equal(212, snapped.Width, 3);
Assert.Equal(100, snapped.Height, 3);
}
[Fact]
public void FusedSnapToNearestCell_ClampsInsideGridBounds()
{
var grid = new DesktopGridGeometry(
Origin: default,
CellSize: 80,
CellGap: 8,
ColumnCount: 4,
RowCount: 3);
var placement = new FusedDesktopComponentPlacementSnapshot
{
PlacementId = "placement-1",
ComponentId = "component-1",
Width = 168,
Height = 168,
GridWidthCells = 2,
GridHeightCells = 2
};
var snapped = FusedDesktopPlacementMath.SnapToNearestCell(
placement,
grid,
requestedOrigin: new Point(900, 600));
Assert.Equal(2, snapped.GridColumn);
Assert.Equal(1, snapped.GridRow);
Assert.Equal(176, snapped.X, 3);
Assert.Equal(88, snapped.Y, 3);
}
[Fact]
public void FusedSnapToNearestCell_EstimatesMissingSpanFromPixelSize()
{
var grid = new DesktopGridGeometry(
Origin: default,
CellSize: 80,
CellGap: 8,
ColumnCount: 6,
RowCount: 6);
var placement = new FusedDesktopComponentPlacementSnapshot
{
PlacementId = "placement-1",
ComponentId = "component-1",
Width = 168,
Height = 256
};
var snapped = FusedDesktopPlacementMath.SnapToNearestCell(
placement,
grid,
requestedOrigin: new Point(90, 180));
Assert.Equal(2, snapped.GridWidthCells);
Assert.Equal(3, snapped.GridHeightCells);
Assert.Equal(1, snapped.GridColumn);
Assert.Equal(2, snapped.GridRow);
Assert.Equal(168, snapped.Width, 3);
Assert.Equal(256, snapped.Height, 3);
}
}

View File

@@ -56,13 +56,17 @@ public sealed class OnlineInstallerCoreTests : IDisposable
public async Task InstallerWorkflowNavigation_AllowsOnlyUnlockedSteps()
{
var vm = new MainWindowViewModel(new FakeInstallService(), new PrivacyDeviceIdentityProvider(Path.Combine(_tempRoot, "identity.json")));
var deployStep = vm.Steps.Single(step => step.StepId == InstallerStepId.Deploy);
vm.SelectedStep = vm.Steps.Single(step => step.StepId == InstallerStepId.Deploy);
Assert.False(deployStep.IsUnlocked);
vm.SelectStepCommand.Execute(deployStep);
Assert.Equal(InstallerStepId.Welcome, vm.CurrentStep);
Assert.True(vm.Steps.Single(step => step.StepId == InstallerStepId.Welcome).IsSelected);
await vm.NextCommand.ExecuteAsync(null);
vm.SelectedStep = vm.Steps.Single(step => step.StepId == InstallerStepId.Welcome);
vm.SelectStepCommand.Execute(vm.Steps.Single(step => step.StepId == InstallerStepId.Welcome));
Assert.Equal(InstallerStepId.Welcome, vm.CurrentStep);
}
@@ -86,7 +90,7 @@ public sealed class OnlineInstallerCoreTests : IDisposable
}
[Fact]
public async Task BrowseCommand_UsesSelectedLocalFolder()
public async Task BrowseCommand_UsesSelectedLocalFolderAsInstallParent()
{
var selectedPath = Path.Combine(_tempRoot, "selected-install-root");
var vm = new MainWindowViewModel(
@@ -98,6 +102,23 @@ public sealed class OnlineInstallerCoreTests : IDisposable
await vm.BrowseCommand.ExecuteAsync(null);
Assert.Equal(Path.Combine(selectedPath, InstallerPathGuard.ApplicationDirectoryName), vm.InstallPath);
Assert.Null(vm.ErrorMessage);
}
[Fact]
public async Task BrowseCommand_DoesNotDuplicateApplicationFolder()
{
var selectedPath = Path.Combine(_tempRoot, InstallerPathGuard.ApplicationDirectoryName);
var vm = new MainWindowViewModel(
new FakeInstallService(),
new PrivacyDeviceIdentityProvider(Path.Combine(_tempRoot, "identity.json")))
{
BrowseRequested = _ => Task.FromResult<string?>(selectedPath)
};
await vm.BrowseCommand.ExecuteAsync(null);
Assert.Equal(selectedPath, vm.InstallPath);
Assert.Null(vm.ErrorMessage);
}

View File

@@ -0,0 +1,118 @@
using LanMountainDesktop.Launcher;
using LanMountainDesktop.Launcher.Models;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class OobeSessionCommitServiceTests : IDisposable
{
private readonly string _tempRoot = Path.Combine(
Path.GetTempPath(),
"LanMountainDesktop.Tests",
nameof(OobeSessionCommitServiceTests),
Guid.NewGuid().ToString("N"));
[Fact]
public void ResolveDataRoot_ForChoice_DoesNotWriteConfigOrState()
{
var resolver = new DataLocationResolver(_tempRoot);
var dataRoot = resolver.ResolveDataRoot(DataLocationMode.Portable);
Assert.Equal(Path.Combine(_tempRoot, "Desktop"), dataRoot);
Assert.False(File.Exists(resolver.ResolveConfigPath()));
Assert.False(File.Exists(GetCompletedStatePath(dataRoot)));
}
[Fact]
public void Commit_WritesSettingsAndCompletedState_OnlyAfterFinalDraft()
{
var resolver = new DataLocationResolver(_tempRoot);
var oobeState = new OobeStateService(
_tempRoot,
executionSnapshot: new LauncherExecutionSnapshot(false, "tester", "S-1-5-test"));
var context = CommandContext.FromArgs(["launch"]);
var service = new OobeSessionCommitService(
resolver,
oobeState,
context,
setWindowsStartup: _ => true);
var draft = CreateDraft();
var result = service.Commit(draft);
var dataRoot = resolver.ResolveDataRoot();
Assert.True(result.Success);
Assert.True(File.Exists(resolver.ResolveConfigPath()));
Assert.True(File.Exists(HostAppSettingsOobeMerger.GetSettingsFilePath(dataRoot)));
Assert.True(File.Exists(Path.Combine(resolver.ResolveLauncherDataPath(), "privacy-config.json")));
Assert.True(File.Exists(Path.Combine(resolver.ResolveLauncherDataPath(), "privacy-agreement.state.json")));
Assert.True(File.Exists(GetCompletedStatePath(dataRoot)));
}
[Fact]
public void Commit_DoesNotWriteCompletedState_WhenFinalSaveFails()
{
if (!OperatingSystem.IsWindows())
{
return;
}
var resolver = new DataLocationResolver(_tempRoot);
var oobeState = new OobeStateService(
_tempRoot,
executionSnapshot: new LauncherExecutionSnapshot(false, "tester", "S-1-5-test"));
var context = CommandContext.FromArgs(["launch"]);
var service = new OobeSessionCommitService(
resolver,
oobeState,
context,
setWindowsStartup: _ => false);
var result = service.Commit(CreateDraft());
var dataRoot = resolver.ResolveDataRoot();
Assert.False(result.Success);
Assert.Equal("windows_startup_save_failed", result.ResultCode);
Assert.False(File.Exists(GetCompletedStatePath(dataRoot)));
}
public void Dispose()
{
try
{
if (Directory.Exists(_tempRoot))
{
Directory.Delete(_tempRoot, recursive: true);
}
}
catch
{
}
}
private static OobeSessionDraft CreateDraft() =>
new()
{
DataLocationMode = DataLocationMode.Portable,
MigrateExistingData = false,
StartupChoices = new HostAppSettingsStartupChoices(
ShowInTaskbar: true,
EnableFadeTransition: true,
EnableSlideTransition: false,
FusedPopupExperience: false,
AutoStartWithWindows: false),
PrivacyConfig = new PrivacyConfig
{
CrashTelemetryEnabled = false,
UsageTelemetryEnabled = false,
TelemetryId = "test-telemetry"
},
PrivacyAgreementAccepted = true,
PrivacyUserId = "test-telemetry",
PrivacyDeviceId = "test-device"
};
private static string GetCompletedStatePath(string dataRoot) =>
Path.Combine(dataRoot, "Launcher", "state", "oobe-state.json");
}

View File

@@ -66,16 +66,80 @@ public sealed class OobeStateServiceTests : IDisposable
}
[Fact]
public void Evaluate_SuppressesOobe_ForElevatedFirstRun()
public void Evaluate_ReturnsFirstRun_ForElevatedFirstRun()
{
var service = CreateService(new LauncherExecutionSnapshot(true, "tester", "S-1-5-test"));
var context = CommandContext.FromArgs(["launch"]);
var decision = service.Evaluate(context);
Assert.Equal(OobeStateStatus.FirstRun, decision.Status);
Assert.True(decision.ShouldShowOobe);
Assert.True(decision.IsElevated);
}
[Fact]
public void Evaluate_ReturnsFirstRun_ForElevatedPostInstall()
{
var service = CreateService(new LauncherExecutionSnapshot(true, "tester", "S-1-5-test"));
var context = CommandContext.FromArgs(["launch", "--launch-source", "postinstall"]);
var decision = service.Evaluate(context);
Assert.Equal(OobeStateStatus.FirstRun, decision.Status);
Assert.True(decision.ShouldShowOobe);
Assert.Equal("postinstall", decision.LaunchSource);
}
[Fact]
public void Evaluate_SuppressesOobe_ForMaintenanceLaunch()
{
var service = CreateService();
var context = CommandContext.FromArgs(["plugin", "install", "--source", "x", "--plugins-dir", "p"]);
var decision = service.Evaluate(context);
Assert.Equal(OobeStateStatus.Suppressed, decision.Status);
Assert.False(decision.ShouldShowOobe);
Assert.Equal("oobe_suppressed_elevated", decision.ResultCode);
Assert.Equal("oobe_suppressed_maintenance", decision.ResultCode);
}
[Fact]
public void Evaluate_SuppressesOobe_ForDebugPreview()
{
var service = CreateService();
var context = CommandContext.FromArgs(["preview-oobe"]);
var decision = service.Evaluate(context);
Assert.Equal(OobeStateStatus.Suppressed, decision.Status);
Assert.False(decision.ShouldShowOobe);
Assert.Equal("oobe_suppressed_debug_preview", decision.ResultCode);
}
[Fact]
public void Evaluate_MigratesLegacyStateFile_ToCurrentStatePath()
{
var legacyStatePath = GetLegacyStatePath();
Directory.CreateDirectory(Path.GetDirectoryName(legacyStatePath)!);
var state = new OobeStateFile
{
SchemaVersion = 1,
CompletedAtUtc = DateTimeOffset.UtcNow.ToString("O"),
UserName = "tester",
UserSid = "S-1-5-test",
LaunchSource = "normal"
};
File.WriteAllText(legacyStatePath, JsonSerializer.Serialize(state));
var service = CreateService();
var context = CommandContext.FromArgs(["launch"]);
var decision = service.Evaluate(context);
Assert.Equal(OobeStateStatus.Completed, decision.Status);
Assert.True(decision.MigratedLegacyMarker);
Assert.True(File.Exists(GetStatePath()));
}
[Fact]
@@ -119,5 +183,7 @@ public sealed class OobeStateServiceTests : IDisposable
private string GetStatePath() => Path.Combine(_tempRoot, "Launcher", "state", "oobe-state.json");
private string GetLegacyStatePath() => Path.Combine(_tempRoot, ".launcher", "state", "oobe-state.json");
private string GetLegacyMarkerPath() => Path.Combine(_tempRoot, ".launcher", "state", "first_run_completed");
}

View File

@@ -0,0 +1,47 @@
using System.IO;
using Xunit;
namespace LanMountainDesktop.Tests;
public sealed class UpdateInstallGatewayTests
{
[Fact]
public void GetDirectoryName_ReturnsNull_ForRootPath()
{
// 验证 Path.GetDirectoryName 在根路径场景下的行为
var rootPath = Path.GetPathRoot(Path.GetTempPath()) ?? "C:\\";
var installerPath = Path.Combine(rootPath, "installer.exe");
// 根路径下的文件GetDirectoryName 返回根路径本身(不是 null
var result = Path.GetDirectoryName(installerPath);
// 在 Windows 上,根路径文件返回根路径(如 "C:\"),不是 null
// 但如果 installerPath 本身就是根路径(无文件名),则返回 null
Assert.NotNull(result); // "C:\installer.exe" 的目录是 "C:\"
}
[Fact]
public void GetDirectoryName_ReturnsNull_ForPathWithoutDirectory()
{
// 验证极端场景:路径没有目录部分
// 这种情况在实际中很少发生,但代码应该能处理
var fileNameOnly = "installer.exe";
var result = Path.GetDirectoryName(fileNameOnly);
// 只有文件名没有路径时GetDirectoryName 返回 null
Assert.Null(result);
}
[Fact]
public void WorkingDirectoryFallback_ShouldUseValidDirectory()
{
// 验证修复后的逻辑:当 GetDirectoryName 返回 null 时,
// 应该使用 AppContext.BaseDirectory 作为后备值
var installerPath = "installer.exe"; // 模拟只有文件名的情况
var workingDir = Path.GetDirectoryName(installerPath) ?? AppContext.BaseDirectory;
// 后备值应该是有效的目录路径
Assert.NotNull(workingDir);
Assert.True(Directory.Exists(workingDir) || workingDir == AppContext.BaseDirectory);
}
}

View File

@@ -40,6 +40,8 @@ public sealed class UpdateSettingsInterfaceTests
Assert.Equal(1, update.CheckCalls);
Assert.Equal("1.2.3", viewModel.LatestVersionText);
Assert.True(viewModel.IsDeltaUpdate);
Assert.True(viewModel.CanDownload);
Assert.True(viewModel.IsProgressSectionVisible);
update.SetPhase(UpdatePhase.Checked);
await ((IAsyncRelayCommand)viewModel.DownloadCommand).ExecuteAsync(null);
@@ -62,6 +64,36 @@ public sealed class UpdateSettingsInterfaceTests
Assert.Equal(1, update.CancelCalls);
}
[Fact]
public async Task UpdateSettingsViewModel_WhenCheckFailsInCheckedPhase_DoesNotExposeDownload()
{
var update = new FakeUpdateSettingsService
{
CheckReport = new UpdateCheckReport(
false,
null,
"1.0.0",
null,
null,
null,
null,
null,
null,
"No usable update manifest was found.")
};
var viewModel = new UpdateSettingsViewModel(new FakeSettingsFacade(update));
viewModel.IsUpdateAvailable = true;
viewModel.LatestVersionText = "9.9.9";
await ((IAsyncRelayCommand)viewModel.CheckCommand).ExecuteAsync(null);
Assert.False(viewModel.IsUpdateAvailable);
Assert.Empty(viewModel.LatestVersionText);
Assert.False(viewModel.CanDownload);
Assert.False(viewModel.IsProgressSectionVisible);
Assert.Equal(0, update.DownloadCalls);
}
[Fact]
public void UpdateSettingsViewModel_SavesPreferencesThroughUpdateSettingsService()
{
@@ -140,6 +172,32 @@ public sealed class UpdateSettingsInterfaceTests
Assert.False(orchestratorCreated);
}
[Fact]
public async Task UpdateSettingsService_WhenPlondsCheckFails_ReturnsIdleAndNoDownload()
{
var settings = new FakeSettingsService
{
Snapshot =
{
UpdateDownloadSource = UpdateSettingsValues.DownloadSourcePlonds
}
};
var plonds = new FakePlondsService
{
LatestResult = PlondsLatestResult.Failed(new Version(1, 0, 0), "No usable PLONDS manifest was found.")
};
var service = new UpdateSettingsService(
settings,
orchestratorFactory: () => throw new InvalidOperationException("not used"),
plondsService: plonds);
var report = await service.CheckAsync(CancellationToken.None);
Assert.False(report.IsUpdateAvailable);
Assert.Equal("No usable PLONDS manifest was found.", report.ErrorMessage);
Assert.Equal(UpdatePhase.Idle, service.CurrentPhase);
}
[Fact]
public async Task UpdateSettingsService_WhenPlondsManifestRequiresCleanInstall_ReportsFullInstaller()
{