diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 00000000..fc62f73c --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,108 @@ +# GitHub Actions CI/CD 配置说明 + +## 概述 + +为VirtualPaper项目配置了完整的CI/CD流程,包含两个工作流文件: + +- **dev-ci.yml**: 当代码推送到dev分支时自动运行4个测试项目,并创建状态检查 +- **branch-protection.yml**: 确保只有dev分支可以合并到main分支 + +## 工作流程 + +### 1. Dev分支持续集成 (dev-ci.yml) + +**触发条件:** +- 推送到dev分支 +- 创建针对dev分支的PR + +**执行步骤:** +1. 设置.NET环境(8.0.x) +2. 恢复项目依赖 +3. 构建解决方案 +4. 按顺序运行4个测试项目: + - VirtualPaper.Core.Test + - VirtualPaper.UI.Test + - VirtualPaper.ML.Test + - VirtualPaper.Shader.Test +5. 发布测试结果报告 +6. 创建commit状态检查(context: `ci/dev-tests`) + +### 2. 分支保护检查 (branch-protection.yml) + +**触发条件:** +- 创建针对main分支的PR +- 更新针对main分支的PR + +**检查逻辑:** +1. 验证源分支是否为dev +2. 如果不是dev分支,创建失败状态并阻止合并 +3. 在PR中添加友好的提示评论 + +## 分支保护规则设置 + +为确保只有测试通过的代码才能合并到main分支,需要在GitHub仓库中设置分支保护规则: + +### 设置步骤: + +1. 进入GitHub仓库 → Settings → Branches +2. 为main分支添加或编辑保护规则: + +``` +分支名称模式: main + +☑️ Require a pull request before merging + ☑️ Require approvals: 1 + ☑️ Dismiss stale reviews when new commits are pushed + +☑️ Require status checks to pass before merging + ☑️ Require branches to be up to date before merging + 添加必需的状态检查: + - ci/dev-tests + - branch-protection/source-validation + +☑️ Include administrators +``` + +## 工作原理 + +1. **开发阶段**: + - Feature分支合并到dev时,自动运行测试 + - 只有dev分支可以创建到main的PR +2. **发布阶段**: + - 创建dev→main的PR时,检查两个状态: + - `ci/dev-tests`: dev分支最新commit的测试结果 + - `branch-protection/source-validation`: 源分支验证结果 +3. **合并控制**: + - 只有两个状态都为success时,PR才能被合并 + + +## 使用流程 + +### 日常开发: +1. Feature分支 → dev分支(触发测试) +2. 查看dev分支commit旁的测试状态 + +### 发布流程: +1. 创建dev → main的PR +2. 系统自动检查: + - 源分支是否为dev(`branch-protection/source-validation`) + - dev分支最新commit的测试结果(`ci/dev-tests`) +3. 两个检查都通过时才能合并 + +## 错误处理 + +### 如果从非dev分支创建PR到main: +- ❌ 工作流会失败并创建失败状态 +- 📝 PR中会显示友好的错误说明 +- 🔒 PR会被阻止合并 + +### 如果dev分支测试失败: +- ❌ `ci/dev-tests`状态为failure +- 🔒 PR会被阻止合并 +- 🛠️ 需要修复测试问题后重新推送到dev + +## 注意事项 + +- 确保dev分支有最新的测试结果 +- 分支保护规则中需要添加两个状态检查 +- 只有dev分支可以合并到main分支 \ No newline at end of file diff --git a/.github/workflows/branch-protection.yml b/.github/workflows/branch-protection.yml new file mode 100644 index 00000000..03d31faf --- /dev/null +++ b/.github/workflows/branch-protection.yml @@ -0,0 +1,129 @@ +name: Main Branch Protection + +on: + pull_request: + branches: + - main + types: [opened, synchronize, reopened, edited] + +permissions: + contents: read + statuses: write + pull-requests: write + issues: write + +jobs: + check-source-branch: + name: Check Source Branch + runs-on: ubuntu-latest + + steps: + - name: Validate source branch + run: | + SOURCE_BRANCH="${{ github.head_ref }}" + echo "Source branch: $SOURCE_BRANCH" + echo "Target branch: ${{ github.base_ref }}" + + if [ "$SOURCE_BRANCH" != "dev" ] && [ "$SOURCE_BRANCH" != "bugfix" ]; then + echo "❌ Error: Only dev and bugfix branches are allowed to merge into main branch." + echo "Current source branch: $SOURCE_BRANCH" + echo "" + echo "Please follow the correct workflow:" + echo "For features/enhancements:" + echo "1. Merge your feature branch into dev first" + echo "2. Ensure tests pass on dev branch" + echo "3. Then create PR from dev to main" + echo "" + echo "For urgent bugfixes:" + echo "1. Create bugfix branch from main" + echo "2. Make necessary fixes" + echo "3. Create PR from bugfix to main" + exit 1 + else + echo "✅ Source branch validation passed: $SOURCE_BRANCH → main" + fi + + - name: Add PR comment + if: always() + uses: actions/github-script@v7 + with: + script: | + const sourceBranch = context.payload.pull_request.head.ref; + + let body; + + if (sourceBranch === 'dev' || sourceBranch === 'bugfix') { + body = '## ✅ Branch Validation Passed\n\n' + + '**Source Branch**: `' + sourceBranch + '` → **Target Branch**: `main`\n\n' + + '✅ This PR follows the correct workflow: `' + sourceBranch + '` → `main`\n\n' + + '**Next Steps:**\n' + + '- Ensure all tests pass on the ' + sourceBranch + ' branch\n' + + '- Request code review\n' + + '- Merge when ready\n\n' + + '---\n' + + '*This check ensures only dev and bugfix branches can merge into main.*'; + } else { + body = '## ❌ Invalid Source Branch\n\n' + + '**Source Branch**: `' + sourceBranch + '` → **Target Branch**: `main`\n\n' + + '❌ **Error**: Only `dev` and `bugfix` branches are allowed to merge into `main` branch.\n\n' + + '**Correct Workflow:**\n\n' + + '**For Features/Enhancements:**\n' + + '1. 🔀 Merge your feature branch into `dev` first\n' + + '2. ✅ Ensure tests pass on `dev` branch\n' + + '3. 📝 Create PR from `dev` to `main`\n\n' + + '**For Urgent Bugfixes:**\n' + + '1. 🐛 Create `bugfix` branch from `main`\n' + + '2. 🔧 Make necessary fixes\n' + + '3. 📝 Create PR from `bugfix` to `main`\n\n' + + '**Please close this PR and follow the correct workflow.**\n\n' + + '---\n' + + '*This check ensures code quality and proper release flow.*'; + } + + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number + }); + + const existingComment = comments.data.find(c => + c.body.includes('Branch Validation') || c.body.includes('Invalid Source Branch') + ); + + if (existingComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: body + }); + } + + - name: Create status check + if: always() + uses: actions/github-script@v7 + with: + script: | + const sourceBranch = context.payload.pull_request.head.ref; + + const state = (sourceBranch === 'dev' || sourceBranch === 'bugfix') ? 'success' : 'failure'; + const description = (sourceBranch === 'dev' || sourceBranch === 'bugfix') + ? 'Source branch validation passed ✅' + : 'Only dev and bugfix branches allowed to merge to main ❌'; + + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.payload.pull_request.head.sha, + state: state, + target_url: context.payload.repository.html_url + '/actions/runs/' + context.runId, + description: description, + context: 'branch-protection/source-validation' + }); \ No newline at end of file diff --git a/.github/workflows/dev-ci.yml b/.github/workflows/dev-ci.yml new file mode 100644 index 00000000..f544aaf5 --- /dev/null +++ b/.github/workflows/dev-ci.yml @@ -0,0 +1,310 @@ +name: Dev Branch CI + +on: + push: + branches: + - dev + pull_request: + branches: + - dev + +permissions: + contents: read + statuses: write + checks: write + pull-requests: write + +jobs: + build: + name: Build Solution + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('src/**/*.csproj', 'src/**/*.props') }} + restore-keys: nuget-${{ runner.os }}- + + - name: Restore dependencies + run: dotnet restore src/VirtualPaper.sln + + - name: Build solution + run: dotnet build src/VirtualPaper.sln --configuration Release --no-restore + + - name: Upload build output + uses: actions/upload-artifact@v4 + with: + name: build-output + retention-days: 7 + path: src/ + + test-core: + name: Core Tests + runs-on: windows-latest + needs: build + + steps: + - name: Download build output + uses: actions/download-artifact@v4 + with: + name: build-output + path: src/ + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('src/**/*.csproj', 'src/**/*.props') }} + restore-keys: nuget-${{ runner.os }}- + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Run Core Tests + run: dotnet test src/VirtualPaper.Core.Test/VirtualPaper.Core.Test.csproj --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=core-tests.trx" --results-directory TestResults/Core + shell: pwsh + + - name: Verify test results exist + run: | + if (!(Test-Path "TestResults/Core/core-tests.trx")) { + Write-Error "Test result file not found! Tests may not have run." + exit 1 + } + Write-Host "Test results file found ✅" + shell: pwsh + + - name: Upload Core test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-core + path: TestResults/Core/ + retention-days: 7 + + test-ui: + name: UI Tests + runs-on: windows-latest + needs: build + + steps: + - name: Download build output + uses: actions/download-artifact@v4 + with: + name: build-output + path: src/ + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('src/**/*.csproj', 'src/**/*.props') }} + restore-keys: nuget-${{ runner.os }}- + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Run UI Tests + run: dotnet test src/VirtualPaper.UI.Test/VirtualPaper.UI.Test.csproj --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=ui-tests.trx" --results-directory TestResults/UI + shell: pwsh + + - name: Verify test results exist + run: | + if (!(Test-Path "TestResults/UI/ui-tests.trx")) { + Write-Error "Test result file not found! Tests may not have run." + exit 1 + } + Write-Host "Test results file found ✅" + shell: pwsh + + - name: Upload UI test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-ui + path: TestResults/UI/ + retention-days: 7 + + test-ml: + name: ML Tests + runs-on: windows-latest + needs: build + + steps: + - name: Download build output + uses: actions/download-artifact@v4 + with: + name: build-output + path: src/ + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('src/**/*.csproj', 'src/**/*.props') }} + restore-keys: nuget-${{ runner.os }}- + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Run ML Tests + run: dotnet test src/VirtualPaper.ML.Test/VirtualPaper.ML.Test.csproj --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=ml-tests.trx" --results-directory TestResults/ML + shell: pwsh + + - name: Verify test results exist + run: | + if (!(Test-Path "TestResults/ML/ml-tests.trx")) { + Write-Error "Test result file not found! Tests may not have run." + exit 1 + } + Write-Host "Test results file found ✅" + shell: pwsh + + - name: Upload ML test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-ml + path: TestResults/ML/ + retention-days: 7 + + test-shader: + name: Shader Tests + runs-on: windows-latest + needs: build + + steps: + - name: Download build output + uses: actions/download-artifact@v4 + with: + name: build-output + path: src/ + + - name: Cache NuGet packages + uses: actions/cache@v4 + with: + path: ~/.nuget/packages + key: nuget-${{ runner.os }}-${{ hashFiles('src/**/*.csproj', 'src/**/*.props') }} + restore-keys: nuget-${{ runner.os }}- + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Run Shader Tests + run: dotnet test src/VirtualPaper.Shader.Test/VirtualPaper.Shader.Test.csproj --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=shader-tests.trx" --results-directory TestResults/Shader + shell: pwsh + + - name: Verify test results exist + run: | + if (!(Test-Path "TestResults/Shader/shader-tests.trx")) { + Write-Error "Test result file not found! Tests may not have run." + exit 1 + } + Write-Host "Test results file found ✅" + shell: pwsh + + - name: Upload Shader test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-shader + path: TestResults/Shader/ + retention-days: 7 + + test-summary: + name: Test Summary + runs-on: ubuntu-latest + needs: [build, test-core, test-ui, test-ml, test-shader] + if: always() + + steps: + - name: Download all test results + uses: actions/download-artifact@v4 + with: + pattern: test-results-* + merge-multiple: true + path: TestResults/ + + - name: Verify all test results + run: | + echo "=== Checking test result files ===" + EXPECTED_FILES=("core-tests.trx" "ui-tests.trx" "ml-tests.trx" "shader-tests.trx") + MISSING=0 + + for file in "${EXPECTED_FILES[@]}"; do + FOUND=$(find TestResults/ -name "$file" 2>/dev/null) + if [ -z "$FOUND" ]; then + echo "❌ MISSING: $file" + MISSING=$((MISSING + 1)) + else + echo "✅ Found: $FOUND" + fi + done + + if [ $MISSING -gt 0 ]; then + echo "" + echo "ERROR: $MISSING test result file(s) missing!" + echo "TEST_RESULTS_VALID=false" >> $GITHUB_ENV + else + echo "" + echo "All test result files present ✅" + echo "TEST_RESULTS_VALID=true" >> $GITHUB_ENV + fi + + - name: Upload combined test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-combined-${{ github.sha }} + path: TestResults/ + retention-days: 30 + + - name: Set test status + if: always() + run: | + if [ "${{ needs.build.result }}" = "success" ] && \ + [ "${{ needs.test-core.result }}" = "success" ] && \ + [ "${{ needs.test-ui.result }}" = "success" ] && \ + [ "${{ needs.test-ml.result }}" = "success" ] && \ + [ "${{ needs.test-shader.result }}" = "success" ] && \ + [ "${{ env.TEST_RESULTS_VALID }}" = "true" ]; then + echo "TEST_STATUS=success" >> $GITHUB_ENV + echo "TEST_DESCRIPTION=All tests passed ✅" >> $GITHUB_ENV + else + echo "TEST_STATUS=failure" >> $GITHUB_ENV + echo "TEST_DESCRIPTION=Some tests failed or results missing ❌" >> $GITHUB_ENV + fi + + - name: Create Status Check + if: always() + uses: actions/github-script@v7 + with: + script: | + const state = process.env.TEST_STATUS || 'failure'; + const description = process.env.TEST_DESCRIPTION || 'Tests failed'; + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha: context.sha, + state: state, + target_url: `${context.payload.repository.html_url}/actions/runs/${context.runId}`, + description: description, + context: 'ci/dev-tests' + }); \ No newline at end of file diff --git a/src/VirtualPaper.AppSettingsPanel/ViewModels/GeneralSettingViewModel.cs b/src/VirtualPaper.AppSettingsPanel/ViewModels/GeneralSettingViewModel.cs index 17b15d46..6308e239 100644 --- a/src/VirtualPaper.AppSettingsPanel/ViewModels/GeneralSettingViewModel.cs +++ b/src/VirtualPaper.AppSettingsPanel/ViewModels/GeneralSettingViewModel.cs @@ -242,9 +242,9 @@ private void AppUpdater_UpdateChecked(object? sender, AppUpdaterEventArgs e) { private void MenuUpdate(AppUpdateStatus status, DateTime date, Version version) { Version = $"v{version}"; -#if DEBUG - CurrentVersionState = VersionState.FindNew; -#else +//#if DEBUG +// CurrentVersionState = VersionState.FindNew; +//#else switch (status) { case AppUpdateStatus.Uptodate: CurrentVersionState = VersionState.UptoNewest; @@ -259,7 +259,7 @@ private void MenuUpdate(AppUpdateStatus status, DateTime date, Version version) default: break; } -#endif +//#endif Version_LastCheckDate = LanguageUtil.GetI18n(Constants.I18n.Settings_General_Version_LastCheckDate); Version_LastCheckDate += status == AppUpdateStatus.Notchecked ? "" : $" {date}"; } @@ -315,7 +315,7 @@ private async Task WallpaperDirectoryChangeAsync(string destRootFolderPath) { UpdateSettingsConfigFile(); WallpaperInstallDirChanged?.Invoke(this, EventArgs.Empty); WallpaperDir = _userSettingsClient.Settings.WallpaperDir; - await _wpControlClient.ChangeWallpaperLayoutFolrderPathAsync(previousDirFolderPath, destFolderPath); + await _wpControlClient.ChangeWallpaperLayoutFolderPathAsync(previousDirFolderPath, destFolderPath); var response = await _wpControlClient.RestartAllWallpapersAsync(); if (!response.IsFinished) { throw new Exception("Restart all wallpapers failed"); diff --git a/src/VirtualPaper.AppSettingsPanel/ViewModels/SystemSettingViewModel.cs b/src/VirtualPaper.AppSettingsPanel/ViewModels/SystemSettingViewModel.cs index 386eff02..f1a56c1e 100644 --- a/src/VirtualPaper.AppSettingsPanel/ViewModels/SystemSettingViewModel.cs +++ b/src/VirtualPaper.AppSettingsPanel/ViewModels/SystemSettingViewModel.cs @@ -1,14 +1,16 @@ using System; using System.Globalization; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using System.Windows.Input; using VirtualPaper.Common.Logging; using VirtualPaper.Common.Utils; -using VirtualPaper.Common.Utils.Storage; +using VirtualPaper.Common.Utils.Storage.Adapter; using VirtualPaper.Grpc.Client.Interfaces; using VirtualPaper.Models.Mvvm; using VirtualPaper.UIComponent; using VirtualPaper.UIComponent.Utils; +using Windows.Storage; namespace VirtualPaper.AppSettingsPanel.ViewModels { public partial class SystemSettingViewModel { @@ -16,8 +18,10 @@ public partial class SystemSettingViewModel { public ICommand? LogCommand { get; set; } public SystemSettingViewModel( - ICommandsClient commandsClient) { + ICommandsClient commandsClient, + IStoragePicker storagePicker) { _commandClient = commandsClient; + _storagePicker = storagePicker; InitCommand(); } @@ -33,14 +37,8 @@ private void OpenDebugView() { _commandClient.ShowDebugView(); } - private async Task ExportLogsAsync() { - var saveFile = await WindowsStoragePickers.PickSaveFileAsync( - WindowConsts.WindowHandle, - "virtualpaper_log_" + DateTime.Now.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture), - new System.Collections.Generic.Dictionary() { - ["Compressed archive"] = [".zip"] - } - ); + public async Task ExportLogsAsync() { + var saveFile = await InternalExportLogsAsync(); if (saveFile != null) { try { @@ -53,6 +51,17 @@ private async Task ExportLogsAsync() { } } + public async Task InternalExportLogsAsync() { + return await _storagePicker.PickSaveFileAsync( + WindowConsts.WindowHandle, + "virtualpaper_log_" + DateTime.Now.ToString("yyyyMMdd_HHmmss", CultureInfo.InvariantCulture), + new System.Collections.Generic.Dictionary() { + ["Compressed archive"] = [".zip"] + } + ); + } + private readonly ICommandsClient _commandClient; + private readonly IStoragePicker _storagePicker; } } diff --git a/src/VirtualPaper.Common/Constants.cs b/src/VirtualPaper.Common/Constants.cs index 12427408..f8f9e67b 100644 --- a/src/VirtualPaper.Common/Constants.cs +++ b/src/VirtualPaper.Common/Constants.cs @@ -1,5 +1,5 @@ namespace VirtualPaper.Common { - public static class Constants { + public static class Constants { //public static string GetMemory(object o) // 获取引用类型的内存地址方法 //{ @@ -10,15 +10,31 @@ public static class Constants { // return "0x" + addr.ToString("X"); //} + /// + /// 避免在测试环境中使用真实的 AppData 目录,防止污染用户数据 + /// + public static bool IsTestMode { get; set; } = false; + public static class Runtime { public static nint MainWindowHwnd { get; set; } } public static class CommonPaths { + public static string TestRootDir { get; set; } = Path.Combine(Path.GetTempPath(), $"VirtualPaper_Test_{Guid.NewGuid():N}"); + private static string RootDir => + IsTestMode + ? TestRootDir + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "VirtualPaper"); + public static string LocalApplicationData => + IsTestMode + ? TestRootDir + : Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + /// /// 数据存储根目录 /// - public static string AppDataDir => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "VirtualPaper"); + public static string AppDataDir => RootDir; + //public static string AppDataDir => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "VirtualPaper"); public static string CommonDataDir => Path.Combine(AppDataDir, "data"); /// diff --git a/src/VirtualPaper.Common/Utils/Files/FileFilter.cs b/src/VirtualPaper.Common/Utils/Files/FileFilter.cs index 19d1722c..d9e3499c 100644 --- a/src/VirtualPaper.Common/Utils/Files/FileFilter.cs +++ b/src/VirtualPaper.Common/Utils/Files/FileFilter.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; namespace VirtualPaper.Common.Utils.Files { /// @@ -10,25 +10,47 @@ public static FileType GetFileType(string filePath) { return FileType.FUnknown; } - string extension = Path.GetExtension(filePath); - using FileStream fs = new(filePath, FileMode.Open, FileAccess.Read); + string extension = Path.GetExtension(filePath).ToLower(); + byte[] headerBytes = new byte[48]; - fs.Read(headerBytes, 0, 48); + int bytesRead; + using (FileStream fs = new(filePath, FileMode.Open, FileAccess.Read)) { + bytesRead = fs.Read(headerBytes, 0, 48); + } + + if (bytesRead < 4) { + return FileType.FUnknown; + } - string headerHex = BitConverter.ToString(headerBytes).Replace("-", "").ToUpper(); + string headerHex = BitConverter.ToString(headerBytes, 0, bytesRead) + .Replace("-", "").ToUpper(); - foreach (var entry in _fileHeaderMap) { - if (headerHex.Contains(entry.Key, StringComparison.OrdinalIgnoreCase) - && FileTypeToExtension[entry.Value].Contains(extension.ToLower())) { - return entry.Value; + // WebP 特殊处理:RIFF 容器 + WEBP 标识 + if (headerHex.StartsWith("52494646", StringComparison.OrdinalIgnoreCase) + && bytesRead >= 12 + && headerHex.Length >= 24 + && headerHex.Substring(16, 8) == "57454250" + && extension == ".webp") { + return FileType.FImage; + } + + // 通用签名匹配 + foreach (var sig in _signatures) { + bool matched = sig.MustStartWith + ? headerHex.StartsWith(sig.MagicHex, StringComparison.OrdinalIgnoreCase) + : headerHex.Contains(sig.MagicHex, StringComparison.OrdinalIgnoreCase); + + if (matched && sig.ValidExtensions.Contains(extension)) { + return sig.Type; } } + // APNG 特殊处理:PNG 签名 + acTL chunk if (extension == ".apng" && headerHex.StartsWith("89504E470D0A1A0A", StringComparison.OrdinalIgnoreCase)) { - string headerText = Encoding.ASCII.GetString(headerBytes); + string headerText = Encoding.ASCII.GetString(headerBytes, 0, bytesRead); if (headerText.Contains("acTL")) { - return FileType.FGif; // .apng + return FileType.FGif; } } @@ -49,26 +71,33 @@ public static FileType GetRuntimeFileType(string extension) { [FileType.FGif] = [".gif", ".apng"], [FileType.FVideo] = [".mp4", ".webm"], [FileType.FDesign] = [FileExtension.FE_Design], - //[FileType.FProject] = [FileExtension.FE_Project], }; public static string[] AvatarFilter => [".jpg", ".bmp", ".png", ".jpe", ".gif", ".tif", ".tiff", ".heic", ".heif", ".heics", ".heifs", ".avif", ".avifs"]; - private static readonly Dictionary _fileHeaderMap = new() - { - {"FFD8FF", FileType.FImage}, // .jpg .jpeg - {"424D", FileType.FImage}, // .bmp - {"89504E470D0A1A0A", FileType.FImage}, // .png - {"3C737667", FileType.FImage}, // .svg - {"3C3F786D", FileType.FImage}, // .svg - {"52494646", FileType.FImage}, // .webp + private record FileSignature( + string MagicHex, + FileType Type, + string[] ValidExtensions, + bool MustStartWith = false + ); - {"474946383961", FileType.FGif}, // .gif - {"acTL", FileType.FGif}, // .anpg + private static readonly FileSignature[] _signatures = [ + // Image + new("FFD8FF", FileType.FImage, [".jpg", ".jpeg"], MustStartWith: true), + new("424D", FileType.FImage, [".bmp"], MustStartWith: true), + new("89504E470D0A1A0A", FileType.FImage, [".png"], MustStartWith: true), + new("3C737667", FileType.FImage, [".svg"], MustStartWith: true), // (string path, JsonSerializerContext context); + void Save(string path, T value, JsonSerializerContext context); + } +} diff --git a/src/VirtualPaper.Common/Utils/Storage/Adapter/IStoragePicker.cs b/src/VirtualPaper.Common/Utils/Storage/Adapter/IStoragePicker.cs new file mode 100644 index 00000000..9a9dd174 --- /dev/null +++ b/src/VirtualPaper.Common/Utils/Storage/Adapter/IStoragePicker.cs @@ -0,0 +1,9 @@ +using Windows.Storage; + +namespace VirtualPaper.Common.Utils.Storage.Adapter { + public interface IStoragePicker { + Task PickFilesAsync(IntPtr hwnd, string[] extensions, bool multiSelect); + Task PickFolderAsync(IntPtr hwnd); + Task PickSaveFileAsync(nint hwnd, string suggestedFileName, Dictionary fileTypeChoices); + } +} diff --git a/src/VirtualPaper.Common/Utils/Storage/Adapter/JsonSaverWrapper.cs b/src/VirtualPaper.Common/Utils/Storage/Adapter/JsonSaverWrapper.cs new file mode 100644 index 00000000..ad13219c --- /dev/null +++ b/src/VirtualPaper.Common/Utils/Storage/Adapter/JsonSaverWrapper.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace VirtualPaper.Common.Utils.Storage.Adapter { + public class JsonSaverWrapper : IJsonSaver { + public T Load(string path, JsonSerializerContext context) => + JsonSaver.Load(path, context); + + public void Save(string path, T value, JsonSerializerContext context) => + JsonSaver.Save(path, value, context); + } +} diff --git a/src/VirtualPaper.Common/Utils/Storage/Adapter/StoragePickerWrapper.cs b/src/VirtualPaper.Common/Utils/Storage/Adapter/StoragePickerWrapper.cs new file mode 100644 index 00000000..d6c8435c --- /dev/null +++ b/src/VirtualPaper.Common/Utils/Storage/Adapter/StoragePickerWrapper.cs @@ -0,0 +1,20 @@ +using Windows.Storage; + +namespace VirtualPaper.Common.Utils.Storage.Adapter { + public class StoragePickerWrapper : IStoragePicker { + public async Task PickFilesAsync( + IntPtr hwnd, + string[] extensions, + bool multiSelect) { + return await WindowsStoragePickers.PickFilesAsync(hwnd, extensions, multiSelect); + } + + public async Task PickFolderAsync(IntPtr hwnd) { + return await WindowsStoragePickers.PickFolderAsync(hwnd); + } + + public async Task PickSaveFileAsync(nint hwnd, string suggestedFileName, Dictionary fileTypeChoices) { + return await WindowsStoragePickers.PickSaveFileAsync(hwnd, suggestedFileName, fileTypeChoices); + } + } +} diff --git a/src/VirtualPaper.Common/Utils/Storage/FileShared.cs b/src/VirtualPaper.Common/Utils/Storage/FileShared.cs index 13ae4f60..e4e680fe 100644 --- a/src/VirtualPaper.Common/Utils/Storage/FileShared.cs +++ b/src/VirtualPaper.Common/Utils/Storage/FileShared.cs @@ -1,11 +1,10 @@ -using System.Text; +using System.Text; using System.Text.Json; namespace VirtualPaper.Common.Utils.Storage { public static class FileShared { private static readonly string SharedFilePath = - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - Constants.CoreField.AppName, "shared_data.json"); + Path.Combine(Constants.CommonPaths.LocalApplicationData, Constants.CoreField.AppName, "shared_data.json"); private const string MutexName = "Global\\VirtualPaper_FileShared"; diff --git a/src/VirtualPaper.Core.Test/Infrastructure/MockFactory.cs b/src/VirtualPaper.Core.Test/Infrastructure/MockFactory.cs new file mode 100644 index 00000000..3be2685e --- /dev/null +++ b/src/VirtualPaper.Core.Test/Infrastructure/MockFactory.cs @@ -0,0 +1,71 @@ +using System.Collections.ObjectModel; +using System.Drawing; +using Moq; +using VirtualPaper.Common; +using VirtualPaper.Cores.Monitor; +using VirtualPaper.Models.Cores.Interfaces; +using VirtualPaper.Services.Interfaces; +using VirtualPaper.Utils.Interfcaes; + +namespace VirtualPaper.Core.Test.Infrastructure { + internal static class MockFactory { + public static Mock CreateUserSettings( + WallpaperArrangement arrangement = WallpaperArrangement.Per) { + var mock = new Mock(); + + var settings = new Mock(); + settings.Setup(s => s.WallpaperArrangement).Returns(arrangement); + settings.Setup(s => s.AppName).Returns(Constants.CoreField.AppName); + settings.Setup(s => s.AppVersion).Returns("0.4.0.1"); + + mock.Setup(m => m.Settings).Returns(settings.Object); + mock.Setup(m => m.WallpaperLayouts).Returns(new List()); + + return mock; + } + + public static Mock CreateMonitorManager( + int monitorCount = 1) { + var mock = new Mock(); + var monitors = BuildMonitors(monitorCount); + + mock.Setup(m => m.Monitors).Returns(monitors); + mock.Setup(m => m.PrimaryMonitor).Returns(monitors[0]); + mock.Setup(m => m.MonitorExists(It.IsAny())).Returns(true); + mock.Setup(m => m.IsMultiScreen()).Returns(monitorCount > 1); + + return mock; + } + + public static Mock CreateDesktopService( + bool workerWValid = true) { + var mock = new Mock(); + var fakeWorkerW = workerWValid ? new nint(0x12345) : nint.Zero; + + mock.Setup(m => m.CreateWorkerW()).Returns(fakeWorkerW); + mock.Setup(m => m.TrySetParentWorkerW(It.IsAny(), It.IsAny())) + .Returns(true); + mock.Setup(m => m.SetWindowPos( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(true); + + return mock; + } + + // ------------------------------------------------------- + private static ObservableCollection BuildMonitors(int count) { + var list = new ObservableCollection(); + for (int i = 0; i < count; i++) { + var m = new Models.Cores.Monitor { + DeviceId = $"MONITOR_{i}", + IsPrimary = i == 0, + Content = $"Content_{i}", + Bounds = new Rectangle(i * 1920, 0, 1920, 1080) + }; + list.Add(m); + } + return list; + } + } +} diff --git a/src/VirtualPaper.Core.Test/Infrastructure/TestDataBuilder.cs b/src/VirtualPaper.Core.Test/Infrastructure/TestDataBuilder.cs new file mode 100644 index 00000000..5a402606 --- /dev/null +++ b/src/VirtualPaper.Core.Test/Infrastructure/TestDataBuilder.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Moq; +using VirtualPaper.Common; +using VirtualPaper.Cores; +using VirtualPaper.Models.Cores.Interfaces; + +namespace VirtualPaper.Core.Test.Infrastructure { + // WpPlayerData + // Tests/Infrastructure/TestDataBuilder.cs + internal static class TestDataBuilder { + public static Mock CreateValidPlayerData( + RuntimeType rtype = RuntimeType.RImage, + string wpId = "wp_test_001") { + var mock = new Mock(); + mock.Setup(d => d.WallpaperUid).Returns(wpId); + mock.Setup(d => d.FilePath).Returns(CreateTempFile()); + mock.Setup(d => d.FolderPath).Returns(Path.GetTempPath()); + mock.Setup(d => d.RType).Returns(rtype); + mock.Setup(d => d.ThumbnailPath).Returns(string.Empty); + mock.Setup(d => d.WpEffectFilePathUsing).Returns(string.Empty); + mock.Setup(d => d.Arrangement) + .Returns(WallpaperArrangement.Per); + return mock; + } + + public static Mock CreateWpPlayer( + IWpPlayerData data, + IMonitor monitor, + bool startSuccess = true) { + var mock = new Mock(); + mock.Setup(p => p.Data).Returns(data); + mock.Setup(p => p.Monitor).Returns(monitor); + mock.Setup(p => p.IsExited).Returns(false); + mock.Setup(p => p.ProcWindowHandle).Returns(new nint(0xABCD)); + mock.Setup(p => p.ShowAsync(It.IsAny())) + .ReturnsAsync(startSuccess); + mock.Setup(p => p.Proc).Returns(Process.GetCurrentProcess()); + + EventHandler? closingHandler = null; + mock.SetupGet(p => p.Closing).Returns(() => closingHandler); + mock.SetupSet(p => p.Closing = It.IsAny()) + .Callback(h => closingHandler = h); + mock.Setup(p => p.Close()) + .Callback(() => { + closingHandler?.Invoke(mock.Object, EventArgs.Empty); + }); + + return mock; + } + + private static string CreateTempFile() { + var path = Path.GetTempFileName(); + File.WriteAllBytes(path, new byte[100]); // 确保 File.Exists 为 true + return path; + } + } +} diff --git a/src/VirtualPaper.Core.Test/MSTestSettings.cs b/src/VirtualPaper.Core.Test/MSTestSettings.cs new file mode 100644 index 00000000..aaf278c8 --- /dev/null +++ b/src/VirtualPaper.Core.Test/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/src/VirtualPaper.Core.Test/T_AppUpdate/GithubUpdaterServiceTests.cs b/src/VirtualPaper.Core.Test/T_AppUpdate/GithubUpdaterServiceTests.cs new file mode 100644 index 00000000..94539c6a --- /dev/null +++ b/src/VirtualPaper.Core.Test/T_AppUpdate/GithubUpdaterServiceTests.cs @@ -0,0 +1,151 @@ +using Moq; +using VirtualPaper.Common.Events; +using VirtualPaper.Cores.AppUpdate; +using VirtualPaper.Utils.Interfcaes; + +namespace VirtualPaper.Core.Test.T_AppUpdate { + [TestClass] + public class GithubUpdaterServiceTests { + private Mock _mockClient = null!; + private Mock _mockComparer = null!; + private GithubUpdaterService _service = null!; + + private static readonly Uri FakeUri = new("https://fake/setup.exe"); + private static readonly Uri FakeShaUri = new("https://fake/SHA256.txt"); + private static readonly Version FakeVersion = new(1, 2, 3, 4); + private const string FakeChangelog = "- bug fix"; + + [TestInitialize] + public void TestInitialize() { + _mockClient = new Mock(); + _mockComparer = new Mock(); + + _mockClient + .Setup(c => c.GetLatestRelease(It.IsAny())) + .ReturnsAsync((FakeUri, FakeShaUri, FakeVersion, FakeChangelog)); + + _service = new GithubUpdaterService(_mockClient.Object, _mockComparer.Object); + } + + // ------------------------------------------------------- + // CheckUpdate - 版本比较分支 + // ------------------------------------------------------- + + [TestMethod] + public async Task CheckUpdate_WhenNewerVersion_ShouldReturnAvailable() { + _mockComparer.Setup(c => c.CompareAssemblyVersion(FakeVersion)).Returns(1); + + var result = await _service.CheckUpdate(fetchDelay: 0); + + Assert.AreEqual(AppUpdateStatus.Available, result); + Assert.AreEqual(AppUpdateStatus.Available, _service.Status); + } + + [TestMethod] + public async Task CheckUpdate_WhenSameVersion_ShouldReturnUptodate() { + _mockComparer.Setup(c => c.CompareAssemblyVersion(FakeVersion)).Returns(0); + + var result = await _service.CheckUpdate(fetchDelay: 0); + + Assert.AreEqual(AppUpdateStatus.Uptodate, result); + Assert.AreEqual(AppUpdateStatus.Uptodate, _service.Status); + } + + [TestMethod] + public async Task CheckUpdate_WhenOlderVersion_ShouldReturnInvalid() { + _mockComparer.Setup(c => c.CompareAssemblyVersion(FakeVersion)).Returns(-1); + + var result = await _service.CheckUpdate(fetchDelay: 0); + + Assert.AreEqual(AppUpdateStatus.Invalid, result); + Assert.AreEqual(AppUpdateStatus.Invalid, _service.Status); + } + + [TestMethod] + public async Task CheckUpdate_WhenExceptionThrown_ShouldReturnError() { + _mockClient + .Setup(c => c.GetLatestRelease(It.IsAny())) + .ThrowsAsync(new HttpRequestException("network error")); + + var result = await _service.CheckUpdate(fetchDelay: 0); + + Assert.AreEqual(AppUpdateStatus.Error, result); + Assert.AreEqual(AppUpdateStatus.Error, _service.Status); + } + + // ------------------------------------------------------- + // CheckUpdate - LastCheckTime 更新 + // ------------------------------------------------------- + + [TestMethod] + public async Task CheckUpdate_AfterSuccess_ShouldUpdateLastCheckTime() { + _mockComparer.Setup(c => c.CompareAssemblyVersion(FakeVersion)).Returns(0); + var before = DateTime.Now; + + await _service.CheckUpdate(fetchDelay: 0); + + Assert.IsTrue(_service.LastCheckTime >= before); + } + + [TestMethod] + public async Task CheckUpdate_WhenExceptionThrown_ShouldStillUpdateLastCheckTime() { + _mockClient + .Setup(c => c.GetLatestRelease(It.IsAny())) + .ThrowsAsync(new Exception()); + var before = DateTime.Now; + + await _service.CheckUpdate(fetchDelay: 0); + + Assert.IsTrue(_service.LastCheckTime >= before); + } + + // ------------------------------------------------------- + // CheckUpdate - UpdateChecked 事件触发 + // ------------------------------------------------------- + + [TestMethod] + public async Task CheckUpdate_AfterSuccess_ShouldFireUpdateCheckedEvent() { + _mockComparer.Setup(c => c.CompareAssemblyVersion(FakeVersion)).Returns(1); + AppUpdaterEventArgs? received = null; + _service.UpdateChecked += (_, args) => received = args; + + await _service.CheckUpdate(fetchDelay: 0); + + Assert.IsNotNull(received); + Assert.AreEqual(AppUpdateStatus.Available, received.UpdateStatus); + Assert.AreEqual(FakeVersion, received.UpdateVersion); + Assert.AreEqual(FakeUri, received.UpdateUri); + Assert.AreEqual(FakeShaUri, received.UpdateSHAUri); + Assert.AreEqual(FakeChangelog, received.ChangeLog); + } + + [TestMethod] + public async Task CheckUpdate_WhenExceptionThrown_ShouldStillFireUpdateCheckedEvent() { + _mockClient + .Setup(c => c.GetLatestRelease(It.IsAny())) + .ThrowsAsync(new Exception()); + AppUpdaterEventArgs? received = null; + _service.UpdateChecked += (_, args) => received = args; + + await _service.CheckUpdate(fetchDelay: 0); + + Assert.IsNotNull(received); + Assert.AreEqual(AppUpdateStatus.Error, received.UpdateStatus); + } + + // ------------------------------------------------------- + // Start / Stop + // ------------------------------------------------------- + + [TestMethod] + public void Stop_WhenNotStarted_ShouldNotThrow() { + _service.Stop(); + } + + [TestMethod] + public void Start_ThenStop_ShouldNotThrow() { + _service.Start(); + _service.Stop(); + } + } +} diff --git a/src/VirtualPaper.Core.Test/T_Playback/PlaybackTests.cs b/src/VirtualPaper.Core.Test/T_Playback/PlaybackTests.cs new file mode 100644 index 00000000..5c3259ef --- /dev/null +++ b/src/VirtualPaper.Core.Test/T_Playback/PlaybackTests.cs @@ -0,0 +1,293 @@ +using Moq; +using VirtualPaper.Common; +using VirtualPaper.Common.Utils.Hardware; +using VirtualPaper.Cores; +using VirtualPaper.Cores.Monitor; +using VirtualPaper.Cores.PlaybackControl; +using VirtualPaper.Cores.ScreenSaver; +using VirtualPaper.Cores.WpControl; +using VirtualPaper.Models.Cores.Interfaces; +using VirtualPaper.Services.Interfaces; +using VirtualPaper.Utils.Interfcaes; + +namespace VirtualPaper.Core.Test.T_Playback { + [TestClass] + public class PlaybackTests { + private Mock _mockUserSettings = null!; + private Mock _mockWpControl = null!; + private Mock _mockScrControl = null!; + private Mock _mockMonitorManager = null!; + private Mock _mockPowerService = null!; + private Mock _mockSettings = null!; + private Mock _mockWallpaper1 = null!; + private Mock _mockWallpaper2 = null!; + private List _wallpapers = null!; + + [TestInitialize] + public void TestInitialize() { + _mockSettings = new Mock(); + _mockSettings.Setup(s => s.ProcessTimerInterval).Returns(500); + _mockSettings.Setup(s => s.RemoteDesktop).Returns(AppWpRunRulesEnum.KeepRun); + _mockSettings.Setup(s => s.BatteryPoweredn).Returns(AppWpRunRulesEnum.KeepRun); + _mockSettings.Setup(s => s.PowerSaving).Returns(AppWpRunRulesEnum.KeepRun); + + _mockUserSettings = new Mock(); + _mockUserSettings.Setup(u => u.Settings).Returns(_mockSettings.Object); + _mockUserSettings.Setup(u => u.AppRules).Returns(new List()); + + _mockWpControl = new Mock(); + _mockScrControl = new Mock(); + _mockMonitorManager = new Mock(); + _mockPowerService = new Mock(); + + _mockPowerService.Setup(p => p.GetACPowerStatus()) + .Returns(PowerUtil.ACLineStatus.Online); + _mockPowerService.Setup(p => p.GetBatterySaverStatus()) + .Returns(PowerUtil.SystemStatusFlag.Off); + + _mockScrControl.Setup(s => s.IsRunning).Returns(false); + + _mockWallpaper1 = new Mock(); + _mockWallpaper2 = new Mock(); + _wallpapers = new List { _mockWallpaper1.Object, _mockWallpaper2.Object }; + _mockWpControl.Setup(w => w.Wallpapers).Returns(_wallpapers.AsReadOnly()); + } + + private Playback CreatePlayback() { + return new Playback( + _mockUserSettings.Object, + _mockWpControl.Object, + _mockScrControl.Object, + _mockMonitorManager.Object, + _mockPowerService.Object); + } + + // ------------------------------------------------------- + // WallpaperPlaybackMode setter + // ------------------------------------------------------- + + [TestMethod] + public void WallpaperPlaybackMode_WhenSet_ShouldFirePlaybackModeChangedEvent() { + var playback = CreatePlayback(); + PlaybackMode? received = null; + playback.PlaybackModeChanged += (_, mode) => received = mode; + + playback.WallpaperPlaybackMode = PlaybackMode.Paused; + + Assert.AreEqual(PlaybackMode.Paused, received); + } + + [TestMethod] + public void WallpaperPlaybackMode_SetSameValue_ShouldStillFireEvent() { + var playback = CreatePlayback(); + int callCount = 0; + playback.PlaybackModeChanged += (_, _) => callCount++; + + playback.WallpaperPlaybackMode = PlaybackMode.Play; + + Assert.AreEqual(1, callCount); + } + + // ------------------------------------------------------- + // RunPlayback - 壁纸列表为空 + // ------------------------------------------------------- + + [TestMethod] + public void RunPlayback_WhenNoWallpapers_ShouldNotChangeAnyState() { + _mockWpControl.Setup(w => w.Wallpapers).Returns(new List().AsReadOnly()); + var playback = CreatePlayback(); + + playback.InvokeRunPlayback(); + + _mockWallpaper1.Verify(w => w.Pause(), Times.Never); + _mockWallpaper1.Verify(w => w.Play(), Times.Never); + } + + // ------------------------------------------------------- + // RunPlayback - 屏保运行 + // ------------------------------------------------------- + + [TestMethod] + public void RunPlayback_WhenScrControlIsRunning_ShouldPauseAllWallpapers() { + _mockScrControl.Setup(s => s.IsRunning).Returns(true); + var playback = CreatePlayback(); + + playback.InvokeRunPlayback(); + + _mockWallpaper1.Verify(w => w.Pause(), Times.Once); + _mockWallpaper2.Verify(w => w.Pause(), Times.Once); + } + + // ------------------------------------------------------- + // RunPlayback - WallpaperPlaybackMode + // ------------------------------------------------------- + + [TestMethod] + public void RunPlayback_WhenPlaybackModePaused_ShouldPauseAllWallpapers() { + var playback = CreatePlayback(); + playback.WallpaperPlaybackMode = PlaybackMode.Paused; + + playback.InvokeRunPlayback(); + + _mockWallpaper1.Verify(w => w.Pause(), Times.Once); + _mockWallpaper2.Verify(w => w.Pause(), Times.Once); + } + + [TestMethod] + public void RunPlayback_WhenPlaybackModeSilence_ShouldPlayAndMuteAllWallpapers() { + var playback = CreatePlayback(); + playback.WallpaperPlaybackMode = PlaybackMode.Silence; + + playback.InvokeRunPlayback(); + + _mockWallpaper1.Verify(w => w.Play(), Times.Once); + _mockWallpaper1.Verify(w => w.SetMute(true), Times.Once); + _mockWallpaper2.Verify(w => w.Play(), Times.Once); + _mockWallpaper2.Verify(w => w.SetMute(true), Times.Once); + } + + // ------------------------------------------------------- + // RunPlayback - 锁屏 + // ------------------------------------------------------- + + [TestMethod] + public void RunPlayback_WhenLockScreen_ShouldPauseAllWallpapers() { + var playback = CreatePlayback(); + playback.SimulateLockScreen(true); + + playback.InvokeRunPlayback(); + + _mockWallpaper1.Verify(w => w.Pause(), Times.Once); + _mockWallpaper2.Verify(w => w.Pause(), Times.Once); + } + + // ------------------------------------------------------- + // RunPlayback - 远程桌面 + // ------------------------------------------------------- + + [TestMethod] + public void RunPlayback_WhenRemoteSession_AndSettingIsPause_ShouldPauseAllWallpapers() { + _mockSettings.Setup(s => s.RemoteDesktop).Returns(AppWpRunRulesEnum.Pause); + var playback = CreatePlayback(); + playback.SimulateRemoteSession(true); + + playback.InvokeRunPlayback(); + + _mockWallpaper1.Verify(w => w.Pause(), Times.Once); + _mockWallpaper2.Verify(w => w.Pause(), Times.Once); + } + + [TestMethod] + public void RunPlayback_WhenRemoteSession_AndSettingIsSilence_ShouldSilenceAllWallpapers() { + _mockSettings.Setup(s => s.RemoteDesktop).Returns(AppWpRunRulesEnum.Silence); + var playback = CreatePlayback(); + playback.SimulateRemoteSession(true); + + playback.InvokeRunPlayback(); + + _mockWallpaper1.Verify(w => w.Play(), Times.Once); + _mockWallpaper1.Verify(w => w.SetMute(true), Times.Once); + _mockWallpaper2.Verify(w => w.Play(), Times.Once); + _mockWallpaper2.Verify(w => w.SetMute(true), Times.Once); + } + + [TestMethod] + public void RunPlayback_WhenNotRemoteSession_AndSettingIsPause_ShouldNotPause() { + _mockSettings.Setup(s => s.RemoteDesktop).Returns(AppWpRunRulesEnum.Pause); + var playback = CreatePlayback(); + playback.SimulateRemoteSession(false); + + playback.InvokeRunPlayback(); + + _mockWallpaper1.Verify(w => w.Pause(), Times.Never); + } + + // ------------------------------------------------------- + // RunPlayback - 电池供电 + // ------------------------------------------------------- + + [TestMethod] + public void RunPlayback_WhenBatteryOffline_AndSettingIsPause_ShouldPauseAllWallpapers() { + _mockPowerService.Setup(p => p.GetACPowerStatus()) + .Returns(PowerUtil.ACLineStatus.Offline); + _mockSettings.Setup(s => s.BatteryPoweredn).Returns(AppWpRunRulesEnum.Pause); + var playback = CreatePlayback(); + + playback.InvokeRunPlayback(); + + _mockWallpaper1.Verify(w => w.Pause(), Times.Once); + _mockWallpaper2.Verify(w => w.Pause(), Times.Once); + } + + [TestMethod] + public void RunPlayback_WhenBatteryOffline_AndSettingIsSilence_ShouldSilenceAllWallpapers() { + _mockPowerService.Setup(p => p.GetACPowerStatus()) + .Returns(PowerUtil.ACLineStatus.Offline); + _mockSettings.Setup(s => s.BatteryPoweredn).Returns(AppWpRunRulesEnum.Silence); + var playback = CreatePlayback(); + + playback.InvokeRunPlayback(); + + _mockWallpaper1.Verify(w => w.Play(), Times.Once); + _mockWallpaper1.Verify(w => w.SetMute(true), Times.Once); + _mockWallpaper2.Verify(w => w.Play(), Times.Once); + _mockWallpaper2.Verify(w => w.SetMute(true), Times.Once); + } + + [TestMethod] + public void RunPlayback_WhenBatteryOnline_AndSettingIsPause_ShouldNotPause() { + _mockPowerService.Setup(p => p.GetACPowerStatus()) + .Returns(PowerUtil.ACLineStatus.Online); + _mockSettings.Setup(s => s.BatteryPoweredn).Returns(AppWpRunRulesEnum.Pause); + var playback = CreatePlayback(); + + playback.InvokeRunPlayback(); + + _mockWallpaper1.Verify(w => w.Pause(), Times.Never); + } + + // ------------------------------------------------------- + // RunPlayback - 省电模式 + // ------------------------------------------------------- + + [TestMethod] + public void RunPlayback_WhenPowerSavingOn_AndSettingIsPause_ShouldPauseAllWallpapers() { + _mockPowerService.Setup(p => p.GetBatterySaverStatus()) + .Returns(PowerUtil.SystemStatusFlag.On); + _mockSettings.Setup(s => s.PowerSaving).Returns(AppWpRunRulesEnum.Pause); + var playback = CreatePlayback(); + + playback.InvokeRunPlayback(); + + _mockWallpaper1.Verify(w => w.Pause(), Times.Once); + _mockWallpaper2.Verify(w => w.Pause(), Times.Once); + } + + [TestMethod] + public void RunPlayback_WhenPowerSavingOn_AndSettingIsSilence_ShouldSilenceAllWallpapers() { + _mockPowerService.Setup(p => p.GetBatterySaverStatus()) + .Returns(PowerUtil.SystemStatusFlag.On); + _mockSettings.Setup(s => s.PowerSaving).Returns(AppWpRunRulesEnum.Silence); + var playback = CreatePlayback(); + + playback.InvokeRunPlayback(); + + _mockWallpaper1.Verify(w => w.Play(), Times.Once); + _mockWallpaper1.Verify(w => w.SetMute(true), Times.Once); + _mockWallpaper2.Verify(w => w.Play(), Times.Once); + _mockWallpaper2.Verify(w => w.SetMute(true), Times.Once); + } + + [TestMethod] + public void RunPlayback_WhenPowerSavingOff_AndSettingIsPause_ShouldNotPause() { + _mockPowerService.Setup(p => p.GetBatterySaverStatus()) + .Returns(PowerUtil.SystemStatusFlag.Off); + _mockSettings.Setup(s => s.PowerSaving).Returns(AppWpRunRulesEnum.Pause); + var playback = CreatePlayback(); + + playback.InvokeRunPlayback(); + + _mockWallpaper1.Verify(w => w.Pause(), Times.Never); + } + } +} diff --git a/src/VirtualPaper.Core.Test/T_ScrSreen/ScrControlTests.cs b/src/VirtualPaper.Core.Test/T_ScrSreen/ScrControlTests.cs new file mode 100644 index 00000000..9f736aa0 --- /dev/null +++ b/src/VirtualPaper.Core.Test/T_ScrSreen/ScrControlTests.cs @@ -0,0 +1,473 @@ +using System.Diagnostics; +using System.Text.Json; +using Moq; +using VirtualPaper.Common; +using VirtualPaper.Common.Utils.IPC; +using VirtualPaper.Common.Utils.PInvoke; +using VirtualPaper.Cores.ScreenSaver; +using VirtualPaper.Cores.WpControl; +using VirtualPaper.Models.Cores; +using VirtualPaper.Services.Interfaces; +using VirtualPaper.Utils.Interfcaes; +using MockFactory = VirtualPaper.Core.Test.Infrastructure.MockFactory; + +namespace VirtualPaper.Core.Test.T_ScrSreen { + [TestClass] + [TestCategory("Backend")] + public class ScrControlTests { + private ScrControl _sut = null!; + private Mock _settings = null!; + private Mock _wpControl = null!; + private Mock _msgWindow = null!; + private Mock _timer = null!; + private Mock _native = null!; + private Mock _launcher = null!; + private Mock _jobService = null!; + + private EventHandler? _capturedTick; + + [TestInitialize] + public void Setup() { + _settings = MockFactory.CreateUserSettings(); + _wpControl = new Mock(); + _msgWindow = new Mock(); + _native = new Mock(); + _launcher = new Mock(); + _jobService = new Mock(); + + _timer = new Mock(); + _timer + .SetupAdd(t => t.Tick += It.IsAny()) + .Callback(h => _capturedTick += h); + _timer + .SetupRemove(t => t.Tick -= It.IsAny()) + .Callback(h => _capturedTick -= h); + + // 默认:主壁纸存在 + _wpControl + .Setup(w => w.GetPrimaryWpFilePathRType()) + .Returns(("C:\\wp\\valid.jpg", RuntimeType.RImage)); + + // 默认:系统通知状态正常 + var normalState = Native.QUERY_USER_NOTIFICATION_STATE.QUNS_ACCEPTS_NOTIFICATIONS; + _native + .Setup(n => n.SHQueryUserNotificationState(out normalState)) + .Returns(0); + + // 默认:前台进程不在白名单 + _native.Setup(n => n.GetForegroundWindow()).Returns(new nint(1)); + _native.Setup(n => n.GetWindowThreadProcessId(It.IsAny(), out It.Ref.IsAny)) + .Returns(0); + _native.Setup(n => n.GetProcessNameById(It.IsAny())).Returns("explorer"); + + // 默认:进程未退出 + _launcher.Setup(l => l.HasExited).Returns(false); + + _settings.Setup(s => s.Settings).Returns(new Settings() { + IsScreenSaverOn = true, + }); + + _sut = BuildSut(); + } + + private ScrControl BuildSut() => new( + _settings.Object, _wpControl.Object, _msgWindow.Object, + _timer.Object, _native.Object, _launcher.Object, _jobService.Object); + + private void FireTick() => + _capturedTick?.Invoke(_timer.Object, EventArgs.Empty); + + /// + /// 模拟屏保进程通过 OutputDataReceived 发出 WpLoaded 信号 + /// + private void SimulateWpLoaded() { + var msg = new VirtualPaperMessageWallpaperLoaded() { Success = true }; + var json = JsonSerializer.Serialize(msg, IpcMessageContext.Default.IpcMessage); + _launcher.Raise( + l => l.OutputDataReceived += null, + new ProcessOutputEventArgs(json)); + } + + /// + /// 模拟屏保进程退出 + /// + private void SimulateProcessExited() { + _launcher.Setup(l => l.HasExited).Returns(true); + _launcher.Raise(l => l.Exited += null, EventArgs.Empty); + } + + // ---------------------------------------------------------------- + // Start / Stop 生命周期 + // ---------------------------------------------------------------- + + [TestMethod] + [Description("Start should configure timer interval from settings and start it")] + public void Start_ShouldConfigureAndStartTimer() { + _settings.Object.Settings.WaitingTime = 5; + + _sut.Start(); + + _timer.VerifySet(t => t.Interval = TimeSpan.FromMinutes(5), Times.Once); + _timer.Verify(t => t.Start(), Times.Once); + } + + [TestMethod] + [Description("Start should not start the timer if it is already timing")] + public void Start_WhenAlreadyTiming_ShouldNotStartAgain() { + _sut.Start(); + _sut.Start(); + + _timer.Verify(t => t.Start(), Times.Once, + "Timer should only be started once"); + } + + [TestMethod] + [Description("Start should not start the timer if screensaver is already running")] + public void Start_WhenAlreadyRunning_ShouldNotStartTimer() { + _sut.Start(); + FireTick(); + SimulateWpLoaded(); // IsRunning = true + + _sut.Start(); + + _timer.Verify(t => t.Start(), Times.Once); + } + + [TestMethod] + [Description("Stop should stop the timer")] + public void Stop_ShouldStopTimer() { + _sut.Start(); + + _sut.Stop(); + + _timer.Verify(t => t.Stop(), Times.AtLeastOnce); + } + + // ---------------------------------------------------------------- + // Timer Tick 触发逻辑 + // ---------------------------------------------------------------- + + [TestMethod] + [Description("Tick should stop the timer before evaluating launch conditions")] + public void Tick_ShouldStopTimerFirst() { + _sut.Start(); + + FireTick(); + + _timer.Verify(t => t.Stop(), Times.AtLeastOnce); + } + + [TestMethod] + [Description("Tick should not launch screensaver when primary wallpaper is null")] + public void Tick_WhenNoPrimaryWallpaper_ShouldNotLaunch() { + _wpControl + .Setup(w => w.GetPrimaryWpFilePathRType()) + .Returns((null, RuntimeType.RUnknown)); + _settings.Object.Settings.IsScreenSaverOn = true; + _sut.Start(); + + FireTick(); + + _launcher.Verify(l => l.Launch(It.IsAny()), Times.Never); + } + + [TestMethod] + [Description("Tick should not launch when system notification state is busy")] + public void Tick_WhenSystemIsBusy_ShouldNotLaunch() { + var busyState = Native.QUERY_USER_NOTIFICATION_STATE.QUNS_BUSY; + _native + .Setup(n => n.SHQueryUserNotificationState(out busyState)) + .Returns(0); + _settings.Object.Settings.IsScreenSaverOn = true; + _sut.Start(); + + FireTick(); + + _launcher.Verify(l => l.Launch(It.IsAny()), Times.Never); + } + + [TestMethod] + [Description("Tick should not launch when foreground process is in the whitelist")] + public void Tick_WhenForegroundProcInWhitelist_ShouldNotLaunch() { + _native.Setup(n => n.GetProcessNameById(It.IsAny())).Returns("chrome"); + _sut.AddToWhiteList("chrome"); + _settings.Object.Settings.IsScreenSaverOn = true; + _sut.Start(); + + FireTick(); + + _launcher.Verify(l => l.Launch(It.IsAny()), Times.Never); + } + + [TestMethod] + [Description("Tick should launch screensaver when all conditions are met")] + public void Tick_WhenAllConditionsMet_ShouldLaunchProcess() { + _sut.Start(); + + FireTick(); + + _launcher.Verify(l => l.Launch(It.IsAny()), Times.Once); + } + + [TestMethod] + [Description("Tick should call BeginOutputReadLine after launch to start reading stdout")] + public void Tick_AfterLaunch_ShouldBeginOutputReadLine() { + _sut.Start(); + + FireTick(); + + _launcher.Verify(l => l.BeginOutputReadLine(), Times.Once, + "BeginOutputReadLine must be called so OutputDataReceived events fire"); + } + + [TestMethod] + [Description("Tick should pass correct file path and rtype as process arguments")] + public void Tick_ShouldPassCorrectArgsToProcess() { + _wpControl + .Setup(w => w.GetPrimaryWpFilePathRType()) + .Returns(("C:\\wp\\test.mp4", RuntimeType.RVideo)); + + ProcessStartInfo? capturedInfo = null; + _launcher + .Setup(l => l.Launch(It.IsAny())) + .Callback(info => capturedInfo = info); + + _sut.Start(); + FireTick(); + + Assert.IsNotNull(capturedInfo); + Assert.Contains("C:\\wp\\test.mp4", capturedInfo.Arguments); + Assert.Contains(RuntimeType.RVideo.ToString(), capturedInfo.Arguments); + } + + [TestMethod] + [Description("Launcher should be called with a valid .exe path")] + public void Tick_ShouldLaunchCorrectExecutable() { + ProcessStartInfo? captured = null; + _launcher + .Setup(l => l.Launch(It.IsAny())) + .Callback(psi => captured = psi); + _sut.Start(); + + FireTick(); + + Assert.IsNotNull(captured); + Assert.EndsWith(".exe", captured.FileName, + "Launcher should point to a valid executable"); + } + + // ---------------------------------------------------------------- + // Launch 失败处理 + // ---------------------------------------------------------------- + + [TestMethod] + [Description("When launcher throws, should not propagate exception and should restart timer")] + public void Tick_WhenLaunchThrows_ShouldNotCrashAndRestartTimer() { + _launcher + .Setup(l => l.Launch(It.IsAny())) + .Throws(new InvalidOperationException("Process start failed")); + _sut.Start(); + + try { + FireTick(); + } + catch { + Assert.Fail("Exception should be handled internally, not propagated to caller"); + } + + // 计时器应被重新启动,恢复等待状态 + _timer.Verify(t => t.Start(), Times.AtLeast(2), + "Timer should be restarted after launch failure"); + } + + [TestMethod] + [Description("When launcher throws, IsRunning should remain false")] + public void Tick_WhenLaunchThrows_IsRunningShouldRemainFalse() { + _launcher + .Setup(l => l.Launch(It.IsAny())) + .Throws(new InvalidOperationException("Process start failed")); + _sut.Start(); + + try { FireTick(); } catch { /* ignored */ } + + Assert.IsFalse(_sut.IsRunning); + } + + // ---------------------------------------------------------------- + // OutputDataReceived → WpLoaded → 状态变化 + // ---------------------------------------------------------------- + + [TestMethod] + [Description("After WpLoaded signal received, IsRunning should become true")] + public void OutputDataReceived_WpLoaded_IsRunningShouldBecomeTrue() { + _sut.Start(); + + Assert.IsNotNull(_capturedTick, "Tick未被注册"); + + FireTick(); + + _launcher.Verify(l => l.Launch(It.IsAny()), Times.Once, "Launch未被调用,Tick内部提前return"); + + SimulateWpLoaded(); + + Assert.IsTrue(_sut.IsRunning, + "IsRunning should be true after process signals WpLoaded"); + } + + [TestMethod] + [Description("After WpLoaded, timer should not restart (screensaver is active)")] + public void OutputDataReceived_WpLoaded_TimerShouldNotRestart() { + _sut.Start(); + FireTick(); + int startCountAfterTick = _timer.Invocations + .Count(i => i.Method.Name == nameof(IDispatcherTimer.Start)); + + SimulateWpLoaded(); + + int startCountAfterLoaded = _timer.Invocations + .Count(i => i.Method.Name == nameof(IDispatcherTimer.Start)); + + Assert.AreEqual(startCountAfterTick, startCountAfterLoaded, + "Timer should not restart while screensaver is actively running"); + } + + [TestMethod] + [Description("OutputDataReceived with null data should not throw")] + public void OutputDataReceived_NullData_ShouldNotThrow() { + _sut.Start(); + FireTick(); + + try { + _launcher.Raise( + l => l.OutputDataReceived += null, + new ProcessOutputEventArgs(null)); + } + catch { + Assert.Fail("Null OutputData should be handled gracefully"); + } + } + + [TestMethod] + [Description("OutputDataReceived with unrecognized data should not affect IsRunning")] + public void OutputDataReceived_UnrecognizedData_IsRunningShouldRemainFalse() { + _sut.Start(); + FireTick(); + + _launcher.Raise( + l => l.OutputDataReceived += null, + new ProcessOutputEventArgs("some_random_unrecognized_output")); + + Assert.IsFalse(_sut.IsRunning); + } + + // ---------------------------------------------------------------- + // 进程退出处理 + // ---------------------------------------------------------------- + + [TestMethod] + [Description("When process exits normally, IsRunning should become false")] + public void ProcessExited_Normal_IsRunningShouldBecomeFalse() { + _sut.Start(); + FireTick(); + SimulateWpLoaded(); // IsRunning = true + + SimulateProcessExited(); + + Assert.IsFalse(_sut.IsRunning, + "IsRunning should be false after process exits"); + } + + [TestMethod] + [Description("When process exits normally, timer should restart to allow re-trigger")] + public void ProcessExited_Normal_TimerShouldRestart() { + _sut.Start(); + FireTick(); + SimulateWpLoaded(); + + SimulateProcessExited(); + + _timer.Verify(t => t.Start(), Times.AtLeast(2), + "Timer should restart after process exits to allow next trigger"); + } + + [TestMethod] + [Description("When process exits unexpectedly (before WpLoaded), state should still reset")] + public void ProcessExited_BeforeWpLoaded_ShouldResetState() { + _sut.Start(); + FireTick(); + // 不调用 SimulateWpLoaded,直接退出 + + SimulateProcessExited(); + + Assert.IsFalse(_sut.IsRunning); + _timer.Verify(t => t.Start(), Times.AtLeast(2)); + } + + [TestMethod] + [Description("When process exits, it should be disposed")] + public void ProcessExited_ShouldDisposeProcess() { + _sut.Start(); + FireTick(); + SimulateWpLoaded(); + + SimulateProcessExited(); + + _launcher.Verify(l => l.Dispose(), Times.Once, + "Process should be disposed after exit"); + } + + // ---------------------------------------------------------------- + // 白名单管理 + // ---------------------------------------------------------------- + + [TestMethod] + [Description("AddToWhiteList should block launch when that process is in foreground")] + public void AddToWhiteList_ShouldBlockLaunch() { + _native.Setup(n => n.GetProcessNameById(It.IsAny())).Returns("notepad"); + + _sut.AddToWhiteList("notepad"); + _sut.Start(); + FireTick(); + + _launcher.Verify(l => l.Launch(It.IsAny()), Times.Never); + } + + [TestMethod] + [Description("RemoveFromWhiteList should allow launch after removal")] + public void RemoveFromWhiteList_ShouldAllowLaunchAfterRemoval() { + _native.Setup(n => n.GetProcessNameById(It.IsAny())).Returns("notepad"); + _sut.AddToWhiteList("notepad"); + + _sut.RemoveFromWhiteList("notepad"); + _sut.Start(); + FireTick(); + + _launcher.Verify(l => l.Launch(It.IsAny()), Times.Once); + } + + [TestMethod] + [Description("AddToWhiteList should be case-insensitive")] + public void AddToWhiteList_CaseInsensitive_ShouldBlockLaunch() { + _native.Setup(n => n.GetProcessNameById(It.IsAny())).Returns("Notepad"); + + _sut.AddToWhiteList("notepad"); // 小写加入 + _sut.Start(); + FireTick(); + + _launcher.Verify(l => l.Launch(It.IsAny()), Times.Never, + "Whitelist check should be case-insensitive"); + } + + [TestMethod] + [Description("Adding duplicate entries to whitelist should not cause issues")] + public void AddToWhiteList_Duplicate_ShouldNotThrow() { + try { + _sut.AddToWhiteList("notepad"); + _sut.AddToWhiteList("notepad"); + } + catch { + Assert.Fail("Adding duplicate whitelist entry should not throw"); + } + } + } +} \ No newline at end of file diff --git a/src/VirtualPaper.Core.Test/T_Tray/TrayCommandTests.cs b/src/VirtualPaper.Core.Test/T_Tray/TrayCommandTests.cs new file mode 100644 index 00000000..30e34f6b --- /dev/null +++ b/src/VirtualPaper.Core.Test/T_Tray/TrayCommandTests.cs @@ -0,0 +1,169 @@ +using Moq; +using VirtualPaper.Cores.TrayControl; +using VirtualPaper.Utils.Interfcaes; + +namespace VirtualPaper.Core.Test.T_Tray { + [TestClass] + [TestCategory("Backend")] + public class TrayCommandTests { + private Mock _factory = null!; + private Mock _pipeClient = null!; + private TrayCommand _sut = null!; + + [TestInitialize] + public void Setup() { + _pipeClient = new Mock(); + _factory = new Mock(); + + // 默认:Connect 成功,Writer 返回可写流 + _factory + .Setup(f => f.Create("localhost", "TRAY_CMD")) + .Returns(_pipeClient.Object); + + _pipeClient + .Setup(p => p.ConnectAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + _pipeClient + .Setup(p => p.CreateWriter()) + .Returns(new StreamWriter(Stream.Null) { AutoFlush = true }); + + _sut = new TrayCommand(_factory.Object); + } + + // ---------------------------------------------------------------- + // 正常路径 + // ---------------------------------------------------------------- + + [TestMethod] + [Description("SendMsgToUIAsync should connect to the named pipe with correct parameters")] + public async Task SendMsgToUIAsync_ShouldConnectToPipe_WithCorrectName() { + // Act + await _sut.SendMsgToUIAsync("hello"); + + // Assert + _factory.Verify( + f => f.Create("localhost", "TRAY_CMD"), + Times.Once, + "Should connect to pipe named TRAY_CMD on localhost"); + } + + [TestMethod] + [Description("SendMsgToUIAsync should write the message to the pipe writer")] + public async Task SendMsgToUIAsync_ShouldWriteMessageToPipe() { + // Arrange + var stream = new MemoryStream(); + var writer = new StreamWriter(stream, leaveOpen: true) { AutoFlush = true }; + + _pipeClient.Setup(p => p.CreateWriter()).Returns(writer); + + // Act + await _sut.SendMsgToUIAsync("TEST_MSG"); + + // Assert + stream.Position = 0; + var content = await new StreamReader(stream).ReadToEndAsync(); + Assert.Contains("TEST_MSG", content, + "Message content should be written to the pipe"); + stream.Dispose(); + } + + [TestMethod] + [Description("SendMsgToUIAsync should call WaitForPipeDrain after writing")] + public async Task SendMsgToUIAsync_ShouldCallWaitForPipeDrain() { + // Act + await _sut.SendMsgToUIAsync("any"); + + // Assert + _pipeClient.Verify(p => p.WaitForPipeDrain(), Times.Once, + "Should wait for pipe drain after writing"); + } + + [TestMethod] + [Description("SendMsgToUIAsync should dispose the pipe client after completion")] + public async Task SendMsgToUIAsync_ShouldDisposePipeClient() { + // Act + await _sut.SendMsgToUIAsync("any"); + + // Assert + _pipeClient.Verify(p => p.Dispose(), Times.Once, + "Pipe client should be disposed after use"); + } + + // ---------------------------------------------------------------- + // 异常路径 + // ---------------------------------------------------------------- + + [TestMethod] + [Description("SendMsgToUIAsync should not throw when ConnectAsync times out")] + public async Task SendMsgToUIAsync_WhenConnectTimesOut_ShouldNotThrow() { + // Arrange + _pipeClient + .Setup(p => p.ConnectAsync(It.IsAny())) + .ThrowsAsync(new TimeoutException("Pipe connect timeout")); + + // Act & Assert: async void 下异常会吞掉,Task 版本应安全处理 + await _sut.SendMsgToUIAsync("msg"); + // 不抛出即通过 + } + + [TestMethod] + [Description("SendMsgToUIAsync should not throw when pipe is broken")] + public async Task SendMsgToUIAsync_WhenPipeBroken_ShouldNotThrow() { + // Arrange + _pipeClient + .Setup(p => p.ConnectAsync(It.IsAny())) + .ThrowsAsync(new IOException("Pipe is broken")); + + // Act & Assert + await _sut.SendMsgToUIAsync("msg"); + } + + [TestMethod] + [Description("SendMsgToUIAsync should not throw when CancellationToken is cancelled")] + public async Task SendMsgToUIAsync_WhenCancelled_ShouldNotThrow() { + // Arrange + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + _pipeClient + .Setup(p => p.ConnectAsync(It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + // Act & Assert + await _sut.SendMsgToUIAsync("msg", cts.Token); + } + + [TestMethod] + [Description("SendMsgToUIAsync should not create a writer when ConnectAsync fails")] + public async Task SendMsgToUIAsync_WhenConnectFails_ShouldNotCallCreateWriter() { + // Arrange + _pipeClient + .Setup(p => p.ConnectAsync(It.IsAny())) + .ThrowsAsync(new IOException()); + + // Act + await _sut.SendMsgToUIAsync("msg"); + + // Assert + _pipeClient.Verify(p => p.CreateWriter(), Times.Never, + "Writer should not be created if connection failed"); + } + + // ---------------------------------------------------------------- + // 边界输入 + // ---------------------------------------------------------------- + + [TestMethod] + [Description("SendMsgToUIAsync should handle empty string without throwing")] + public async Task SendMsgToUIAsync_WithEmptyMessage_ShouldNotThrow() { + await _sut.SendMsgToUIAsync(string.Empty); + } + + [TestMethod] + [Description("SendMsgToUIAsync should handle null message without throwing")] + public async Task SendMsgToUIAsync_WithNullMessage_ShouldNotThrow() { + await _sut.SendMsgToUIAsync(null!); + } + } +} diff --git a/src/VirtualPaper.Core.Test/T_UserSettings/UserSettingsServiceTests.cs b/src/VirtualPaper.Core.Test/T_UserSettings/UserSettingsServiceTests.cs new file mode 100644 index 00000000..30fd2230 --- /dev/null +++ b/src/VirtualPaper.Core.Test/T_UserSettings/UserSettingsServiceTests.cs @@ -0,0 +1,151 @@ +using System.Reflection; +using System.Text.Json.Serialization; +using Moq; +using VirtualPaper.Common; +using VirtualPaper.Common.Utils.Storage.Adapter; +using VirtualPaper.Cores.Monitor; +using VirtualPaper.Models.Cores; +using VirtualPaper.Services; +using Monitor = VirtualPaper.Models.Cores.Monitor; + +namespace VirtualPaper.Core.Test.T_UserSettings { + [TestClass] + public class UserSettingsServiceTests { + private Mock _mockMonitorManager = null!; + private Mock _mockJsonSaver = null!; + private Monitor _primaryMonitor = null!; + + [TestInitialize] + public void TestInitialize() { + _primaryMonitor = new Monitor { IsPrimary = true }; + + _mockMonitorManager = new Mock(); + _mockMonitorManager.Setup(m => m.PrimaryMonitor).Returns(_primaryMonitor); + _mockMonitorManager.Setup(m => m.Monitors).Returns([_primaryMonitor]); + + _mockJsonSaver = new Mock(); + _mockJsonSaver + .Setup(s => s.Load(It.IsAny(), It.IsAny())) + .Returns(new Settings()); + } + + // Load 非法类型应抛出 InvalidCastException + [TestMethod] + public void Load_WithUnsupportedType_ShouldThrowInvalidCastException() { + var service = CreateService(); + + Assert.Throws(() => service.Load()); + } + + // Save 非法类型应抛出 InvalidCastException + [TestMethod] + public void Save_WithUnsupportedType_ShouldThrowInvalidCastException() { + var service = CreateService(); + + Assert.Throws(() => service.Save()); + } + + // Load 失败时降级为默认 Settings + [TestMethod] + public void Load_Settings_WhenJsonCorrupted_ShouldFallbackToDefault() { + _mockJsonSaver + .Setup(s => s.Load(It.IsAny(), It.IsAny())) + .Throws(); + + var service = CreateService(); + + Assert.IsNotNull(service.Settings); + // 降级后得到 new Settings(),不应为 null + } + + // Load> 失败时降级包含默认规则 + [TestMethod] + public void Load_AppRules_WhenJsonCorrupted_ShouldFallbackToDefaultRule() { + _mockJsonSaver + .Setup(s => s.Load>(It.IsAny(), It.IsAny())) + .Throws(); + + var service = CreateService(); + + Assert.HasCount(1, service.AppRules); + Assert.AreEqual(Constants.CoreField.AppName, ((ApplicationRules)service.AppRules[0]).AppName); + } + + // Load> 失败时降级为空列表 + [TestMethod] + public void Load_WallpaperLayouts_WhenJsonCorrupted_ShouldFallbackToEmptyList() { + _mockJsonSaver + .Setup(s => s.Load>(It.IsAny(), It.IsAny())) + .Throws(); + + var service = CreateService(); + + Assert.IsEmpty(service.WallpaperLayouts); + } + + // SelectedMonitor:Settings 中存有匹配的 Monitor 时应使用它 + [TestMethod] + public void Constructor_WhenSettingsMonitorMatchesKnown_ShouldUseMatchedMonitor() { + var savedMonitor = Mock.Of(); + var settings = new Settings { SelectedMonitor = savedMonitor }; + + _mockMonitorManager.Setup(m => m.Monitors).Returns([savedMonitor, _primaryMonitor]); + _mockJsonSaver + .Setup(s => s.Load(It.IsAny(), It.IsAny())) + .Returns(settings); + + var service = CreateService(); + + Assert.AreEqual(savedMonitor, service.Settings.SelectedMonitor); + } + + // SelectedMonitor:Settings 中 Monitor 不在列表时应回退到 Primary + [TestMethod] + public void Constructor_WhenSettingsMonitorNotFound_ShouldFallbackToPrimary() { + var unknownMonitor = Mock.Of(); + var settings = new Settings { SelectedMonitor = unknownMonitor }; + + _mockMonitorManager.Setup(m => m.Monitors).Returns([_primaryMonitor]); // 不包含 unknownMonitor + _mockJsonSaver + .Setup(s => s.Load(It.IsAny(), It.IsAny())) + .Returns(settings); + + var service = CreateService(); + + Assert.AreEqual(_primaryMonitor, service.Settings.SelectedMonitor); + } + + // 版本号不同时应更新版本相关字段并标记 IsUpdated + [TestMethod] + public void Constructor_WhenAppVersionDiffers_ShouldUpdateVersionFields() { + var settings = new Settings { AppVersion = "0.0.0.0" }; // 旧版本 + _mockJsonSaver + .Setup(s => s.Load(It.IsAny(), It.IsAny())) + .Returns(settings); + + var service = CreateService(); + + Assert.IsTrue(service.Settings.IsUpdated); + Assert.AreEqual( + Assembly.GetAssembly(typeof(UserSettingsService))!.GetName().Version!.ToString(), + service.Settings.AppVersion); + } + + // 版本号相同时不应标记 IsUpdated + [TestMethod] + public void Constructor_WhenAppVersionMatches_ShouldNotMarkAsUpdated() { + var currentVersion = Assembly.GetAssembly(typeof(UserSettingsService))!.GetName().Version!.ToString(); + var settings = new Settings { AppVersion = currentVersion, IsUpdated = false }; + _mockJsonSaver + .Setup(s => s.Load(It.IsAny(), It.IsAny())) + .Returns(settings); + + var service = CreateService(); + + Assert.IsFalse(service.Settings.IsUpdated); + } + + private UserSettingsService CreateService() => + new UserSettingsService(_mockMonitorManager.Object, _mockJsonSaver.Object); + } +} diff --git a/src/VirtualPaper.Core.Test/T_WallpaperControl/WallpaperControl_CloseTests.cs b/src/VirtualPaper.Core.Test/T_WallpaperControl/WallpaperControl_CloseTests.cs new file mode 100644 index 00000000..29518476 --- /dev/null +++ b/src/VirtualPaper.Core.Test/T_WallpaperControl/WallpaperControl_CloseTests.cs @@ -0,0 +1,110 @@ +using Moq; +using VirtualPaper.Core.Test.Infrastructure; +using VirtualPaper.Cores; +using VirtualPaper.Cores.Monitor; +using VirtualPaper.Cores.WpControl; +using VirtualPaper.Factories.Interfaces; +using VirtualPaper.Models.Cores.Interfaces; +using VirtualPaper.Services.Interfaces; +using MockFactory = VirtualPaper.Core.Test.Infrastructure.MockFactory; + +namespace VirtualPaper.Core.Test.T_WallpaperControl { + [TestClass] + [TestCategory("Backend")] + [DoNotParallelize] + public class WallpaperControl_CloseTests { + private WallpaperControl _sut = null!; + private Mock _monitorMgr = null!; + private Mock _factory = null!; + private Mock _settings = null!; + + [TestInitialize] + public void Setup() { + _settings = MockFactory.CreateUserSettings(); + _monitorMgr = MockFactory.CreateMonitorManager(monitorCount: 2); + _factory = new Mock(); + var desktop = MockFactory.CreateDesktopService(); + var jobService = new Mock(); + + _sut = new WallpaperControl( + _settings.Object, _monitorMgr.Object, + _factory.Object, desktop.Object, jobService.Object); + } + + [TestMethod] + [Description("CloseAllWallpapers should close all running wallpaper instances")] + public async Task CloseAllWallpapers_ShouldCloseAllInstances() { + // Arrange: set up 2 wallpaper instances + var monitor1 = _monitorMgr.Object.Monitors[0]; + var monitor2 = _monitorMgr.Object.Monitors[1]; + var player1 = await AddRunningWallpaper(monitor1); + Assert.HasCount(1, _sut.Wallpapers, "After adding 1st wallpaper"); + + var player2 = await AddRunningWallpaper(monitor2); + Assert.HasCount(2, _sut.Wallpapers, "After adding 2nd wallpaper"); + + // Act + _sut.CloseAllWallpapers(); + + // Assert + player1.Verify(p => p.Close(), Times.Once, "Wallpaper 1 should be closed"); + player2.Verify(p => p.Close(), Times.Once, "Wallpaper 2 should be closed"); + Assert.HasCount(0, _sut.Wallpapers, "Wallpaper list should be empty after closing all"); + } + + [TestMethod] + [Description("CloseAllWallpapers should clear thumbnail paths for all monitors")] + public void CloseAllWallpapers_ShouldClearThumbnailPaths() { + // Arrange + var monitors = _monitorMgr.Object.Monitors.ToList(); + + // Act + _sut.CloseAllWallpapers(); + + // Assert + foreach (var monitor in monitors) { + Assert.AreEqual(string.Empty, monitor.ThumbnailPath); + } + } + + [TestMethod] + [Description("CloseWallpaper should return safely without throwing when passed null")] + public void CloseWallpaper_WithNullMonitor_ShouldNotThrow() { + // Act & Assert + try { + _sut.CloseWallpaper(null); + } + catch (Exception ex) { + Assert.Fail($"No exception should be thrown, but got: {ex.GetType().Name}: {ex.Message}"); + } + } + + [TestMethod] + [Description("CloseWallpaper should only close the wallpaper on the specified monitor without affecting others")] + public async Task CloseWallpaper_ShouldOnlyCloseTargetMonitor() { + // Arrange + var monitor1 = _monitorMgr.Object.Monitors[0]; + var monitor2 = _monitorMgr.Object.Monitors[1]; + var player1 = await AddRunningWallpaper(monitor1); + var player2 = await AddRunningWallpaper(monitor2); + + // Act: close monitor1 only + _sut.CloseWallpaper(monitor1); + + // Assert + player1.Verify(p => p.Close(), Times.Once, "Target monitor wallpaper should be closed"); + player2.Verify(p => p.Close(), Times.Never, "Other monitor wallpaper should not be closed"); + Assert.HasCount(1, _sut.Wallpapers); + } + + // Helper: simulate adding a running wallpaper + private async Task> AddRunningWallpaper(IMonitor monitor) { + var data = TestDataBuilder.CreateValidPlayerData().Object; + var player = TestDataBuilder.CreateWpPlayer(data, monitor); + _factory.Setup(f => f.CreatePlayer(It.IsAny(), monitor)) + .Returns(player.Object); + await _sut.SetWallpaperAsync(data, monitor); + return player; + } + } +} diff --git a/src/VirtualPaper.Core.Test/T_WallpaperControl/WallpaperControl_LayoutTests.cs b/src/VirtualPaper.Core.Test/T_WallpaperControl/WallpaperControl_LayoutTests.cs new file mode 100644 index 00000000..f6d77b61 --- /dev/null +++ b/src/VirtualPaper.Core.Test/T_WallpaperControl/WallpaperControl_LayoutTests.cs @@ -0,0 +1,96 @@ +using Moq; +using VirtualPaper.Common; +using VirtualPaper.Core.Test.Infrastructure; +using VirtualPaper.Cores.Monitor; +using VirtualPaper.Cores.WpControl; +using VirtualPaper.Factories.Interfaces; +using VirtualPaper.Models.Cores.Interfaces; +using VirtualPaper.Services.Interfaces; +using MockFactory = VirtualPaper.Core.Test.Infrastructure.MockFactory; + +namespace VirtualPaper.Core.Test.T_WallpaperControl { + [TestClass] + [TestCategory("Backend")] + [DoNotParallelize] + public class WallpaperControl_LayoutTests { + private WallpaperControl _sut = null!; + private Mock _settings = null!; + private Mock _monitorMgr = null!; + private Mock _jobService = null!; + private Mock _factory = null!; + private List _capturedLayouts = []; + + [TestInitialize] + public void Setup() { + _capturedLayouts = []; + _settings = MockFactory.CreateUserSettings(); + _jobService = new Mock(); + _settings.Setup(s => s.WallpaperLayouts).Returns(_capturedLayouts); + _monitorMgr = MockFactory.CreateMonitorManager(2); + _factory = new Mock(); + + _sut = new WallpaperControl( + _settings.Object, _monitorMgr.Object, + _factory.Object, MockFactory.CreateDesktopService().Object, _jobService.Object); + } + + [TestMethod] + [Description("Layout information should be saved correctly after a wallpaper is set successfully")] + public async Task WallpaperChanged_ShouldSaveLayoutWithCorrectMonitorId() { + // Arrange + var monitor = _monitorMgr.Object.PrimaryMonitor; + var data = TestDataBuilder.CreateValidPlayerData().Object; + var player = TestDataBuilder.CreateWpPlayer(data, monitor); + _factory.Setup(f => f.CreatePlayer(data, monitor)).Returns(player.Object); + + // Act + await _sut.SetWallpaperAsync(data, monitor); + + // Assert + _settings.Verify(s => s.Save>(), Times.AtLeastOnce); + Assert.IsTrue(_capturedLayouts.Any(l => l.MonitorDeviceId == monitor.DeviceId), + "Layout for the corresponding monitor should be saved"); + } + + [TestMethod] + [Description("The saved layout list should be empty after all wallpapers are closed")] + public async Task CloseAllWallpapers_ShouldSaveEmptyLayout() { + // Arrange: add a wallpaper first + var monitor = _monitorMgr.Object.PrimaryMonitor; + var data = TestDataBuilder.CreateValidPlayerData().Object; + _factory.Setup(f => f.CreatePlayer(data, monitor)) + .Returns(TestDataBuilder.CreateWpPlayer(data, monitor).Object); + await _sut.SetWallpaperAsync(data, monitor); + + // Act + _sut.CloseAllWallpapers(); + + // Assert + Assert.HasCount(0, _capturedLayouts, "Layout should be empty after closing all wallpapers"); + } + + [TestMethod] + [Description("In Expand mode, the MonitorContent of the layout should be 'Expand'")] + public async Task SaveLayout_ExpandMode_ShouldUseExpandAsMonitorContent() { + // Arrange + _settings = MockFactory.CreateUserSettings(WallpaperArrangement.Expand); + _settings.Setup(s => s.WallpaperLayouts).Returns(_capturedLayouts); + var desktop = MockFactory.CreateDesktopService(); + _sut = new WallpaperControl( + _settings.Object, _monitorMgr.Object, _factory.Object, desktop.Object, _jobService.Object); + + var monitor = _monitorMgr.Object.PrimaryMonitor; + var data = TestDataBuilder.CreateValidPlayerData().Object; + _factory.Setup(f => f.CreatePlayer(data, monitor)) + .Returns(TestDataBuilder.CreateWpPlayer(data, monitor).Object); + + // Act + await _sut.SetWallpaperAsync(data, monitor); + + // Assert + Assert.IsTrue( + _capturedLayouts.All(l => l.MonitorContent == "Expand"), + "In Expand mode, MonitorContent of all layouts should be 'Expand'"); + } + } +} diff --git a/src/VirtualPaper.Core.Test/T_WallpaperControl/WallpaperControl_SetWallpaperTests.cs b/src/VirtualPaper.Core.Test/T_WallpaperControl/WallpaperControl_SetWallpaperTests.cs new file mode 100644 index 00000000..7f04c69c --- /dev/null +++ b/src/VirtualPaper.Core.Test/T_WallpaperControl/WallpaperControl_SetWallpaperTests.cs @@ -0,0 +1,167 @@ +using Moq; +using NLog.Config; +using VirtualPaper.Common; +using VirtualPaper.Core.Test.Infrastructure; +using VirtualPaper.Cores.Monitor; +using VirtualPaper.Cores.WpControl; +using VirtualPaper.Factories.Interfaces; +using VirtualPaper.Models.Cores.Interfaces; +using VirtualPaper.Services.Interfaces; +using static VirtualPaper.Common.Errors; +using MockFactory = VirtualPaper.Core.Test.Infrastructure.MockFactory; + +namespace VirtualPaper.Core.Test.T_WallpaperControl { + [TestClass] + [TestCategory("Backend")] + [DoNotParallelize] + public class WallpaperControl_SetWallpaperTests { + private WallpaperControl _sut = null!; + private Mock _monitorMgr = null!; + private Mock _factory = null!; + private Mock _settings = null!; + + [TestInitialize] + public void Setup() { + _settings = MockFactory.CreateUserSettings(WallpaperArrangement.Per); + _monitorMgr = MockFactory.CreateMonitorManager(1); + _factory = new Mock(); + var desktop = MockFactory.CreateDesktopService(); + var jobService = new Mock(); + + _sut = new WallpaperControl( + _settings.Object, _monitorMgr.Object, + _factory.Object, desktop.Object, jobService.Object); + } + + [TestCleanup] + public void Cleanup() { + _sut.CloseAllWallpapers(); + } + + [TestMethod] + [Description("SetWallpaperAsync should return failure immediately when monitor is null")] + public async Task SetWallpaperAsync_WithNullMonitor_ShouldReturnFailure() { + // Arrange + var data = TestDataBuilder.CreateValidPlayerData().Object; + + // Act + var result = await _sut.SetWallpaperAsync(data, monitor: null); + + // Assert + Assert.IsFalse(result.IsFinished, "Should return failure when monitor is null"); + _factory.Verify( + f => f.CreatePlayer(It.IsAny(), It.IsAny()), + Times.Never, "Player should not be created"); + } + + [TestMethod] + [Description("Should fire WallpaperError event and return failure when the file does not exist")] + public async Task SetWallpaperAsync_WhenFileMissing_ShouldFireErrorEvent() { + // Arrange + var data = TestDataBuilder.CreateValidPlayerData().Object; + Mock.Get(data).Setup(d => d.FilePath).Returns("C:\\not_exist.jpg"); + Exception? capturedError = null; + _sut.WallpaperError += (s, e) => capturedError = e; + + // Act + var result = await _sut.SetWallpaperAsync(data, _monitorMgr.Object.PrimaryMonitor); + + // Assert + Assert.IsFalse(result.IsFinished); + Assert.IsNotNull(capturedError); + Assert.IsInstanceOfType(capturedError, typeof(WallpaperNotFoundException)); + } + + [TestMethod] + [Description("Should throw an exception and fire WallpaperError when RType is Unknown")] + public async Task SetWallpaperAsync_WithUnknownRType_ShouldFireError() { + // Arrange + var data = TestDataBuilder.CreateValidPlayerData( + rtype: RuntimeType.RUnknown).Object; + Exception? capturedError = null; + _sut.WallpaperError += (_, e) => capturedError = e; + + // Act + var result = await _sut.SetWallpaperAsync(data, _monitorMgr.Object.PrimaryMonitor); + + // Assert + Assert.IsFalse(result.IsFinished); + Assert.IsNotNull(capturedError, "Error event should be fired"); + } + + [TestMethod] + [Description("In Per mode, setting a wallpaper on the same monitor again should call Update instead of recreating")] + public async Task SetWallpaperAsync_PerMode_SameMontior_ShouldUpdate_NotRecreate() { + // Arrange + var monitor = _monitorMgr.Object.PrimaryMonitor; + var data1 = TestDataBuilder.CreateValidPlayerData(wpId: "wp_001").Object; + var data2 = TestDataBuilder.CreateValidPlayerData(wpId: "wp_002").Object; + + var player = TestDataBuilder.CreateWpPlayer(data1, monitor); + _factory.Setup(f => f.CreatePlayer(It.IsAny(), monitor)) + .Returns(player.Object); + + // Set the first wallpaper + var r1 = await _sut.SetWallpaperAsync(data1, monitor); + Assert.IsTrue(r1.IsFinished, "first step failed: data1 was not set successfully"); + + // Act: set a second wallpaper on the same monitor + var r2 = await _sut.SetWallpaperAsync(data2, monitor); + Assert.IsTrue(r2.IsFinished, "second step failed: data2 was not set successfully"); + + // Assert + player.Verify(p => p.Update(data2), Times.Once, "Should call Update to refresh the wallpaper"); + _factory.Verify( + f => f.CreatePlayer(It.IsAny(), monitor), + Times.Once, "Player should not be recreated"); + } + + [TestMethod] + [Description("WallpaperChanged event should be fired after a wallpaper is set successfully")] + public async Task SetWallpaperAsync_OnSuccess_ShouldFireChangedEvent() { + // Arrange + var monitor = _monitorMgr.Object.PrimaryMonitor; + var data = TestDataBuilder.CreateValidPlayerData().Object; + bool changed = false; + _sut.WallpaperChanged += (_, _) => changed = true; + + var player = TestDataBuilder.CreateWpPlayer(data, monitor); + _factory.Setup(f => f.CreatePlayer(data, monitor)).Returns(player.Object); + + // Act + var result = await _sut.SetWallpaperAsync(data, monitor); + + // Assert + Assert.IsTrue(result.IsFinished); + Assert.IsTrue(changed, "WallpaperChanged event should be fired on success"); + } + + [TestMethod] + [Description("In Duplicate mode, setting a wallpaper should create one instance per monitor")] + public async Task SetWallpaperAsync_DuplicateMode_ShouldCreatePlayerPerMonitor() { + // Arrange: 3 monitors + _settings = MockFactory.CreateUserSettings(WallpaperArrangement.Duplicate); + _monitorMgr = MockFactory.CreateMonitorManager(3); + var desktop = MockFactory.CreateDesktopService(); + var jobService = new Mock(); + _sut = new WallpaperControl( + _settings.Object, _monitorMgr.Object, + _factory.Object, desktop.Object, jobService.Object); + + var data = TestDataBuilder.CreateValidPlayerData().Object; + foreach (var m in _monitorMgr.Object.Monitors) { + var p = TestDataBuilder.CreateWpPlayer(data, m); + _factory.Setup(f => f.CreatePlayer(It.IsAny(), m)) + .Returns(p.Object); + } + + // Act + await _sut.SetWallpaperAsync(data, _monitorMgr.Object.PrimaryMonitor); + + // Assert + _factory.Verify( + f => f.CreatePlayer(It.IsAny(), It.IsAny()), + Times.Exactly(3), "Duplicate mode should create a Player for each monitor"); + } + } +} diff --git a/src/VirtualPaper.Core.Test/T_WindowsService/WindowServiceTests.cs b/src/VirtualPaper.Core.Test/T_WindowsService/WindowServiceTests.cs new file mode 100644 index 00000000..0b1a2e4c --- /dev/null +++ b/src/VirtualPaper.Core.Test/T_WindowsService/WindowServiceTests.cs @@ -0,0 +1,115 @@ +using System.Windows; +using Moq; +using VirtualPaper.Services.Interfaces; + +namespace VirtualPaper.Core.Test.T_WindowsService; + +[TestClass] +public class WindowServiceConsumerTests { + private Mock _mockWindowService = null!; + + [TestInitialize] + public void TestInitialize() { + _mockWindowService = new Mock(); + } + + #region TryGet Tests + + [TestMethod] + public void TryGet_WhenWindowNotOpen_ShouldReturnFalse() { + // Setup: TryGet 返回 false,out 参数为 null + _mockWindowService + .Setup(s => s.TryGet(out It.Ref.IsAny)) + .Returns(false); + + var service = _mockWindowService.Object; + var result = service.TryGet(out var window); + + Assert.IsFalse(result); + Assert.IsNull(window); + } + + [STATestMethod] + public void TryGet_WhenWindowIsOpen_ShouldReturnTrueAndInstance() { + var expected = new FakeWindow(); + _mockWindowService + .Setup(s => s.TryGet(out expected)) + .Returns(true); + + var service = _mockWindowService.Object; + var result = service.TryGet(out var window); + + Assert.IsTrue(result); + Assert.AreSame(expected, window); + } + + #endregion + + #region Show Tests + + [TestMethod] + public void Show_ShouldBeCallable() { + var service = _mockWindowService.Object; + + // 不抛出异常即通过 + service.Show(); + + _mockWindowService.Verify(s => s.Show(null), Times.Once); + } + + [TestMethod] + public void Show_WithParameter_ShouldPassParameter() { + var param = new object(); + var service = _mockWindowService.Object; + + service.Show(param); + + _mockWindowService.Verify(s => s.Show(param), Times.Once); + } + + #endregion + + #region ShowDialogAsync Tests + + [TestMethod] + public async Task ShowDialogAsync_WhenConfirmed_ShouldReturnTrue() { + _mockWindowService + .Setup(s => s.ShowDialogAsync(null)) + .ReturnsAsync(true); + + var result = await _mockWindowService.Object.ShowDialogAsync(); + + Assert.IsTrue(result); + } + + [TestMethod] + public async Task ShowDialogAsync_WhenCancelled_ShouldReturnFalse() { + _mockWindowService + .Setup(s => s.ShowDialogAsync(null)) + .ReturnsAsync(false); + + var result = await _mockWindowService.Object.ShowDialogAsync(); + + Assert.IsFalse(result); + } + + [TestMethod] + public async Task ShowDialogAsync_WithParameter_ShouldPassParameter() { + var param = "test-param"; + _mockWindowService + .Setup(s => s.ShowDialogAsync(param)) + .ReturnsAsync(true); + + var result = await _mockWindowService.Object.ShowDialogAsync(param); + + Assert.IsTrue(result); + _mockWindowService.Verify(s => s.ShowDialogAsync(param), Times.Once); + } + + #endregion +} + +/// +/// 仅用作泛型类型标识,测试中不会被实例化 +/// +public class FakeWindow : Window { } \ No newline at end of file diff --git a/src/VirtualPaper.Core.Test/TestSetup.cs b/src/VirtualPaper.Core.Test/TestSetup.cs new file mode 100644 index 00000000..d84208bc --- /dev/null +++ b/src/VirtualPaper.Core.Test/TestSetup.cs @@ -0,0 +1,11 @@ +using VirtualPaper.Common; + +namespace VirtualPaper.Core.Test { + [TestClass] + public class TestSetup { + [AssemblyInitialize] + public static void AssemblyInit(TestContext context) { + Constants.IsTestMode = true; + } + } +} diff --git a/src/VirtualPaper.Core.Test/VirtualPaper.Core.Test.csproj b/src/VirtualPaper.Core.Test/VirtualPaper.Core.Test.csproj new file mode 100644 index 00000000..e6ee48a0 --- /dev/null +++ b/src/VirtualPaper.Core.Test/VirtualPaper.Core.Test.csproj @@ -0,0 +1,22 @@ + + + + net8.0-windows10.0.19041.0 + latest + enable + enable + true + + + + + + + + + + + + $(MSBuildProjectDirectory)\test.runsettings + + diff --git a/src/VirtualPaper.Core.Test/test.runsettings b/src/VirtualPaper.Core.Test/test.runsettings new file mode 100644 index 00000000..d3cc9b1d --- /dev/null +++ b/src/VirtualPaper.Core.Test/test.runsettings @@ -0,0 +1,6 @@ + + + + true + + \ No newline at end of file diff --git a/src/VirtualPaper.DraftPanel/ViewModels/GetStartViewModel.cs b/src/VirtualPaper.DraftPanel/ViewModels/GetStartViewModel.cs index c2212a84..4aa3a9ee 100644 --- a/src/VirtualPaper.DraftPanel/ViewModels/GetStartViewModel.cs +++ b/src/VirtualPaper.DraftPanel/ViewModels/GetStartViewModel.cs @@ -49,21 +49,21 @@ private void InitCommand() { }); } - internal void InitCollection() { + public void InitCollection() { RecentUseds.Clear(); RecentUseds.AddRange(_userSettingsClient.RecentUseds); _recentUseds = [.. RecentUseds]; } #region filter - internal void ApplyFilter(string keyword) { + public void ApplyFilter(string keyword) { FilterByTitle(keyword); } - internal void FilterByTitle(string keyword) { + public void FilterByTitle(string keyword) { var filtered = _recentUseds?.Where(recentUsed => recentUsed.FileName != null && recentUsed.FileName.Contains(keyword, StringComparison.InvariantCultureIgnoreCase) - ); + ).ToList(); if (filtered == null) return; Remove_NonMatching(filtered); AddBack_Procs(filtered); diff --git a/src/VirtualPaper.DraftPanel/ViewModels/WorkSpaceViewModel.cs b/src/VirtualPaper.DraftPanel/ViewModels/WorkSpaceViewModel.cs index 6c4a628e..3c76ee14 100644 --- a/src/VirtualPaper.DraftPanel/ViewModels/WorkSpaceViewModel.cs +++ b/src/VirtualPaper.DraftPanel/ViewModels/WorkSpaceViewModel.cs @@ -22,7 +22,9 @@ using VirtualPaper.UIComponent; using VirtualPaper.UIComponent.Navigation; using VirtualPaper.UIComponent.Navigation.TabView; +using VirtualPaper.UIComponent.Navigation.TabView.Interfaces; using VirtualPaper.UIComponent.Utils; +using VirtualPaper.UIComponent.Utils.Adapter.Interfaces; using Windows.Storage; using Workloads.Creation.StaticImg.Models.SerializableData; using Workloads.Utils.DraftUtils.Interfaces; @@ -30,7 +32,7 @@ namespace VirtualPaper.DraftPanel.ViewModels { public partial class WorkSpaceViewModel : ObservableObject, IDisposable { - public ObservableCollection TabViewItems { get; set; } = []; + public ObservableCollection TabViewItems { get; set; } = []; int _selectedTabIndex = -1; public int SelectedTabIndex { @@ -48,8 +50,9 @@ public int SelectedTabIndex { public ICommand? MFI_ManualCommand { get; private set; } public ICommand? MFI_AboutCommand { get; private set; } - public WorkSpaceViewModel(IUserSettingsClient userSettings) { + public WorkSpaceViewModel(IUserSettingsClient userSettings, IGlobalDialogService globalDialogService) { this._userSettings = userSettings; + this._globalDialogService = globalDialogService; InitCommand(); } @@ -82,7 +85,7 @@ private void InitCommand() { }); } - internal void OnTabItemsChanged(TabView sender, Windows.Foundation.Collections.IVectorChangedEventArgs args) { + public void OnTabItemsChanged(TabView sender, Windows.Foundation.Collections.IVectorChangedEventArgs args) { if (TabViewItems.Count == 0) { SelectedTabIndex = -1; return; @@ -160,14 +163,14 @@ private void RefreshHeaderAsync(IRuntime runtime) { private async Task RedoAsync() => await ExecuteRuntimeCommandAsync(x => x.RedoAsync()); - private Task ExecuteRuntimeCommandAsync(Func command, TabViewItem? specificItem = null) { + private Task ExecuteRuntimeCommandAsync(Func command, IArcTabViewItem? specificItem = null) { var runtime = (specificItem?.Tag as IRuntime) ?? GetSelectedRuntime(); return runtime != null ? command(runtime) : Task.CompletedTask; } - private async Task ExecuteRuntimeCommandAsync(Func> command, TabViewItem? specificItem = null) { + private async Task ExecuteRuntimeCommandAsync(Func> command, IArcTabViewItem? specificItem = null) { var runtime = (specificItem?.Tag as IRuntime) ?? GetSelectedRuntime(); return runtime != null ? await command(runtime) @@ -365,7 +368,7 @@ public void Dispose() { GC.SuppressFinalize(this); } - internal async IAsyncEnumerable HandleExitItemsAsync() { + public async IAsyncEnumerable HandleExitItemsAsync() { var tabsToClose = TabViewItems.ToList(); foreach (var tabItem in tabsToClose) { @@ -376,7 +379,7 @@ internal async IAsyncEnumerable HandleExitItemsAsync() { var header = value.Header; if (!header.IsSaved) { - var res = await GlobalDialogUtils.ShowDialogAsync( + var res = await _globalDialogService.ShowDialogAsync( content: $"\"{runtime.FileName}\" {LanguageUtil.GetI18n(nameof(Constants.I18n.Project_Unsave_Intercept_Content))}", title: $"{LanguageUtil.GetI18n(nameof(Constants.I18n.Project_Unsave_Intercept_Title))}", primaryBtnText: $"{LanguageUtil.GetI18n(nameof(Constants.I18n.Text_Save))}", @@ -399,7 +402,7 @@ internal async IAsyncEnumerable HandleExitItemsAsync() { } } - internal async Task CheckSaveStatusAsync(IRuntime runtime) { + public async Task CheckSaveStatusAsync(IRuntime runtime) { bool flag; var header = _runtimeToArcTab[runtime].Header; @@ -407,7 +410,7 @@ internal async Task CheckSaveStatusAsync(IRuntime runtime) { flag = true; } else { - var res = await GlobalDialogUtils.ShowDialogAsync( + var res = await _globalDialogService.ShowDialogAsync( content: $"\"{runtime.FileName}\" {LanguageUtil.GetI18n(nameof(Constants.I18n.Project_Unsave_Intercept_Content))}", title: $"{LanguageUtil.GetI18n(nameof(Constants.I18n.Project_Unsave_Intercept_Title))}", primaryBtnText: $"{LanguageUtil.GetI18n(nameof(Constants.I18n.Text_Save))}", @@ -431,13 +434,13 @@ internal async Task CheckSaveStatusAsync(IRuntime runtime) { return flag; } - internal async Task CheckAllSaveStatusAsync() { + public async Task CheckAllSaveStatusAsync() { foreach (var kvp in _runtimeToArcTab) { var runtime = kvp.Key; var header = kvp.Value.Header; if (!header.IsSaved) { - var res = await GlobalDialogUtils.ShowDialogAsync( + var res = await _globalDialogService.ShowDialogAsync( content: $"\"{runtime.FileName}\" {LanguageUtil.GetI18n(nameof(Constants.I18n.Project_Unsave_Intercept_Content))}", title: $"{LanguageUtil.GetI18n(nameof(Constants.I18n.Project_Unsave_Intercept_Title))}", primaryBtnText: $"{LanguageUtil.GetI18n(nameof(Constants.I18n.Text_Save))}", @@ -460,13 +463,13 @@ internal async Task CheckAllSaveStatusAsync() { return true; } - private void CloseWorkSpaceTab(IRuntime runtime, ArcTabViewItem item) { + private void CloseWorkSpaceTab(IRuntime runtime, IArcTabViewItem item) { runtime.IsSavedChanged -= Runtime_IsSavedChanged; _runtimeToArcTab.Remove(runtime); TabViewItems.Remove(item); } - internal IRuntime? GetSelectedRuntime() { + public IRuntime? GetSelectedRuntime() { if (SelectedTabIndex < 0 || SelectedTabIndex >= TabViewItems.Count) return null; return TabViewItems[SelectedTabIndex].Tag as IRuntime; } @@ -474,7 +477,8 @@ private void CloseWorkSpaceTab(IRuntime runtime, ArcTabViewItem item) { internal readonly ObservableCollection _middleMenuItems = []; private readonly IUserSettingsClient _userSettings; + private readonly IGlobalDialogService _globalDialogService; private readonly ConcurrentBag _tempRecentUsed = []; - private readonly Dictionary _runtimeToArcTab = []; + private readonly Dictionary _runtimeToArcTab = []; } } diff --git a/src/VirtualPaper.DraftPanel/Views/WorkSpace.xaml.cs b/src/VirtualPaper.DraftPanel/Views/WorkSpace.xaml.cs index 84c8cd0a..53f8320b 100644 --- a/src/VirtualPaper.DraftPanel/Views/WorkSpace.xaml.cs +++ b/src/VirtualPaper.DraftPanel/Views/WorkSpace.xaml.cs @@ -15,6 +15,7 @@ using VirtualPaper.UIComponent.Attributes; using VirtualPaper.UIComponent.Navigation; using VirtualPaper.UIComponent.Navigation.Interfaces; +using VirtualPaper.UIComponent.Navigation.TabView.Interfaces; using VirtualPaper.UIComponent.Templates; using VirtualPaper.UIComponent.Utils; using Workloads.Utils.DraftUtils.Interfaces; @@ -187,9 +188,9 @@ private async void MFI_Exit_Clicked(object sender, RoutedEventArgs e) { _draftPage?.NavigateByState(DraftPanelState.ConfigSpace); } - private void CleanUpTabUI(ArcTabViewItem tabViewItem) { - if (_tabToFrame.TryGetValue(tabViewItem, out var frame)) { - workspaceContentPool.Children.Remove(tabViewItem); + private void CleanUpTabUI(IArcTabViewItem tabViewItem) { + if (_tabToFrame.TryGetValue(tabViewItem, out var frame) && tabViewItem is ArcTabViewItem arcTab) { + workspaceContentPool.Children.Remove(arcTab); _tabToFrame.Remove(tabViewItem); frame.Content = null; } @@ -224,6 +225,6 @@ await loadingCtx.RunAsync( private Draft? _draftPage; private readonly WorkSpaceViewModel _viewModel; private PreProjectData[]? _preProjectDatas; - private readonly Dictionary _tabToFrame = []; + private readonly Dictionary _tabToFrame = []; } } diff --git a/src/VirtualPaper.Grpc.Client/Interfaces/IWallpaperControlClient.cs b/src/VirtualPaper.Grpc.Client/Interfaces/IWallpaperControlClient.cs index 78a7b63b..b31c685f 100644 --- a/src/VirtualPaper.Grpc.Client/Interfaces/IWallpaperControlClient.cs +++ b/src/VirtualPaper.Grpc.Client/Interfaces/IWallpaperControlClient.cs @@ -29,7 +29,7 @@ public interface IWallpaperControlClient : IDisposable { #endregion #region utils - Task ChangeWallpaperLayoutFolrderPathAsync(string previousDir, string newDir); + Task ChangeWallpaperLayoutFolderPathAsync(string previousDir, string newDir); Task GetRunMonitorByWallpaperAsync(string wpUid); Task SendMessageWallpaperAsync(string deviceId, IpcMessage msg); Task TakeScreenshotAsync(string monitorId, string savePath); diff --git a/src/VirtualPaper.Grpc.Client/WallpaperControlClient.cs b/src/VirtualPaper.Grpc.Client/WallpaperControlClient.cs index 2c59a529..b0d62557 100644 --- a/src/VirtualPaper.Grpc.Client/WallpaperControlClient.cs +++ b/src/VirtualPaper.Grpc.Client/WallpaperControlClient.cs @@ -142,7 +142,7 @@ public async Task CreateBasicDataInMemAsync( #endregion #region utils - public async Task ChangeWallpaperLayoutFolrderPathAsync(string previousDir, string newDir) { + public async Task ChangeWallpaperLayoutFolderPathAsync(string previousDir, string newDir) { Grpc_ChangePathRequest request = new() { PreviousDir = previousDir, NewDir = newDir, diff --git a/src/VirtualPaper.ML.Test/MSTestSettings.cs b/src/VirtualPaper.ML.Test/MSTestSettings.cs new file mode 100644 index 00000000..aaf278c8 --- /dev/null +++ b/src/VirtualPaper.ML.Test/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/src/VirtualPaper.ML.Test/T_MiDas/MiDaS_NormaliseOutputTests.cs b/src/VirtualPaper.ML.Test/T_MiDas/MiDaS_NormaliseOutputTests.cs new file mode 100644 index 00000000..f3bde992 --- /dev/null +++ b/src/VirtualPaper.ML.Test/T_MiDas/MiDaS_NormaliseOutputTests.cs @@ -0,0 +1,321 @@ +using System.Reflection; +using VirtualPaper.ML.DepthEstimate; + +namespace VirtualPaper.ML.Test.T_MiDas { + // ==================================================================== + // 辅助:生成测试用图片(不依赖任何外部资源) + // ==================================================================== + internal static class TestImageHelper { + /// + /// 使用 OpenCvSharp 在临时目录生成一张纯色 JPEG,返回路径。 + /// + public static string CreateSolidColorJpeg( + int width = 64, + int height = 64, + string? dir = null) { + dir ??= Path.GetTempPath(); + string path = Path.Combine(dir, $"test_{Guid.NewGuid():N}.jpg"); + + using var mat = new OpenCvSharp.Mat( + height, width, + OpenCvSharp.MatType.CV_8UC3, + new OpenCvSharp.Scalar(128, 64, 32)); // BGR + mat.SaveImage(path); + + return path; + } + } + + // ==================================================================== + // NormaliseOutput — 纯数学逻辑(反射访问私有静态方法) + // ==================================================================== + [TestClass] + [TestCategory("Unit")] + public class MiDaS_NormaliseOutputTests { + + private static float[] InvokeNormalise(float[] data) { + var method = typeof(MiDaS).GetMethod( + "NormaliseOutput", + BindingFlags.NonPublic | BindingFlags.Static) + ?? throw new MissingMethodException("NormaliseOutput not found"); + + return (float[])method.Invoke(null, [data])!; + } + + [TestMethod] + [Description("所有值相同时,输出应全为 NaN 或 0(range = 0,由实现决定)")] + public void NormaliseOutput_AllSameValues_DoesNotThrow() { + float[] input = [1f, 1f, 1f, 1f]; + + // 仅验证不抛异常,具体值取决于 depthRange = 0 的处理方式 + float[] act() => _ = InvokeNormalise(input); + + act(); // 不抛即通过;如需断言可根据实现补充 + } + + [TestMethod] + [Description("最小值应被归一化为 0,最大值应被归一化为 1")] + public void NormaliseOutput_MinMaxBoundary_CorrectlyNormalised() { + float[] input = [0f, 0.5f, 1f]; + + var result = InvokeNormalise(input); + + Assert.AreEqual(3, result.Length); + Assert.AreEqual(0f, result[0], 1e-5f, "min should map to 0"); + Assert.AreEqual(0.5f, result[1], 1e-5f, "mid should map to 0.5"); + Assert.AreEqual(1f, result[2], 1e-5f, "max should map to 1"); + } + + [TestMethod] + [Description("归一化后所有值应在 [0, 1] 范围内")] + public void NormaliseOutput_AllValuesInRange() { + float[] input = [3f, 1f, 4f, 1f, 5f, 9f, 2f, 6f]; + + var result = InvokeNormalise(input); + + foreach (var v in result) { + Assert.IsTrue(v >= 0f && v <= 1f, + $"Value {v} is out of [0, 1] range"); + } + } + + [TestMethod] + [Description("负数输入应正确归一化")] + public void NormaliseOutput_NegativeValues_CorrectlyNormalised() { + float[] input = [-10f, 0f, 10f]; + + var result = InvokeNormalise(input); + + Assert.AreEqual(0f, result[0], 1e-5f); + Assert.AreEqual(0.5f, result[1], 1e-5f); + Assert.AreEqual(1f, result[2], 1e-5f); + } + + [TestMethod] + [Description("单元素数组应归一化为 NaN 或 0(range = 0)")] + public void NormaliseOutput_SingleElement_DoesNotThrow() { + float[] input = [42f]; + + var act = () => InvokeNormalise(input); + + act(); // 不抛即通过 + } + + [TestMethod] + [Description("归一化结果长度应与输入相同")] + public void NormaliseOutput_OutputLengthMatchesInput() { + float[] input = [1f, 2f, 3f, 4f, 5f]; + + var result = InvokeNormalise(input); + + Assert.AreEqual(input.Length, result.Length); + } + } + + // ==================================================================== + // SaveDepthMap — 文件系统 I/O(不需要模型) + // ==================================================================== + [TestClass] + [TestCategory("Unit")] + public class MiDaS_SaveDepthMapTests { + private string _tempDir = null!; + + [TestInitialize] + public void Setup() { + _tempDir = Path.Combine(Path.GetTempPath(), $"midas_test_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + } + + [TestCleanup] + public void Cleanup() { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + private static float[] MakeSolidDepth(int width, int height, float value = 0.5f) + => Enumerable.Repeat(value, width * height).ToArray(); + + [TestMethod] + [Description("正常调用应在指定目录生成深度图文件")] + public void SaveDepthMap_ValidInput_CreatesFile() { + int w = 32, h = 32; + var depth = MakeSolidDepth(w, h); + + string outputPath = MiDaS.SaveDepthMap(depth, w, h, w, h, _tempDir); + + Assert.IsTrue(File.Exists(outputPath), + $"Expected output file at: {outputPath}"); + } + + [TestMethod] + [Description("返回路径应指向有内容的文件")] + public void SaveDepthMap_ValidInput_FileHasContent() { + int w = 32, h = 32; + var depth = MakeSolidDepth(w, h); + + string outputPath = MiDaS.SaveDepthMap(depth, w, h, w, h, _tempDir); + + var info = new FileInfo(outputPath); + Assert.IsGreaterThan(0, info.Length, "Output file should not be empty"); + } + + [TestMethod] + [Description("resize 到不同的原始尺寸应仍能正常生成文件")] + public void SaveDepthMap_DifferentOriginalSize_Succeeds() { + int modelW = 32, modelH = 32; + int origW = 128, origH = 96; // 不同于模型输出尺寸 + var depth = MakeSolidDepth(modelW, modelH); + + string outputPath = MiDaS.SaveDepthMap(depth, modelW, modelH, origW, origH, _tempDir); + + Assert.IsTrue(File.Exists(outputPath)); + } + + [TestMethod] + [Description("全零深度(最近处)应正常生成文件")] + public void SaveDepthMap_AllZeroDepth_Succeeds() { + int w = 16, h = 16; + var depth = MakeSolidDepth(w, h, value: 0f); + + string outputPath = MiDaS.SaveDepthMap(depth, w, h, w, h, _tempDir); + + Assert.IsTrue(File.Exists(outputPath)); + } + + [TestMethod] + [Description("全一深度(最远处)应正常生成文件")] + public void SaveDepthMap_AllOneDepth_Succeeds() { + int w = 16, h = 16; + var depth = MakeSolidDepth(w, h, value: 1f); + + string outputPath = MiDaS.SaveDepthMap(depth, w, h, w, h, _tempDir); + + Assert.IsTrue(File.Exists(outputPath)); + } + + [TestMethod] + [Description("连续两次调用同一目录,第二次应覆盖或共存(不抛异常)")] + public void SaveDepthMap_CalledTwice_DoesNotThrow() { + int w = 16, h = 16; + var depth = MakeSolidDepth(w, h); + + MiDaS.SaveDepthMap(depth, w, h, w, h, _tempDir); + var act = () => MiDaS.SaveDepthMap(depth, w, h, w, h, _tempDir); + + act(); // 不抛即通过 + } + } + + // ==================================================================== + // Run — 异常分支(不触发模型推理) + // ==================================================================== + [TestClass] + [TestCategory("Unit")] + public class MiDaS_RunExceptionTests { + + [TestMethod] + [Description("传入不存在的文件路径应抛出 FileNotFoundException")] + public void Run_FileNotFound_ThrowsFileNotFoundException() { + Assert.Throws( + () => MiDaS.Run("C:\\nonexistent\\path.jpg")); + } + + [TestMethod] + [Description("传入空字符串应抛出 FileNotFoundException(modelPath guard)")] + public void Run_EmptyPath_ThrowsFileNotFoundException() { + Assert.Throws( + () => MiDaS.Run(string.Empty)); + } + + [TestMethod] + [Description("传入 null 应抛出 ArgumentNullException 或 FileNotFoundException")] + public void Run_NullPath_ThrowsArgumentException() { + Assert.Throws(() => MiDaS.Run(null!)); + } + } + + // ==================================================================== + // Run + LoadModel — 集成测试(需要真实模型文件 + 测试图片) + // ==================================================================== + [TestClass] + [TestCategory("Integration")] + public class MiDaS_IntegrationTests { + private string _tempDir = null!; + private string _testImagePath = null!; + + [TestInitialize] + public void Setup() { + _tempDir = Path.Combine(Path.GetTempPath(), $"midas_int_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _testImagePath = TestImageHelper.CreateSolidColorJpeg(dir: _tempDir); + } + + [TestCleanup] + public void Cleanup() { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, recursive: true); + } + + [TestMethod] + [Description("Run 应返回非空的 ModelOutput")] + public void Run_ValidImage_ReturnsModelOutput() { + var result = MiDaS.Run(_testImagePath); + + Assert.IsNotNull(result); + } + + [TestMethod] + [Description("ModelOutput.Depth 数组长度应等于模型输出的 width × height")] + public void Run_ValidImage_DepthArrayLengthIsCorrect() { + var result = MiDaS.Run(_testImagePath); + + Assert.AreEqual( + result.Width * result.Height, + result.Depth.Length, + "Depth array length should equal Width * Height"); + } + + [TestMethod] + [Description("ModelOutput 的 OriginalWidth/Height 应与输入图片一致")] + public void Run_ValidImage_OriginalDimensionsMatchInput() { + using var mat = OpenCvSharp.Cv2.ImRead(_testImagePath); + int expectedW = mat.Width; + int expectedH = mat.Height; + + var result = MiDaS.Run(_testImagePath); + + Assert.AreEqual(expectedW, result.OriginalWidth, + "OriginalWidth should match source image width"); + Assert.AreEqual(expectedH, result.OriginalHeight, + "OriginalHeight should match source image height"); + } + + [TestMethod] + [Description("归一化后的 Depth 值应全部在 [0, 1] 范围内")] + public void Run_ValidImage_AllDepthValuesInRange() { + var result = MiDaS.Run(_testImagePath); + + foreach (var v in result.Depth) { + Assert.IsTrue(v >= 0f && v <= 1f, + $"Depth value {v} is outside [0, 1]"); + } + } + + [TestMethod] + [Description("Run → SaveDepthMap 完整流程应生成可读文件")] + public void Run_ThenSaveDepthMap_ProducesValidFile() { + var result = MiDaS.Run(_testImagePath); + string outputPath = MiDaS.SaveDepthMap( + result.Depth, + result.Width, + result.Height, + result.OriginalWidth, + result.OriginalHeight, + _tempDir); + + Assert.IsTrue(File.Exists(outputPath)); + Assert.IsGreaterThan(0, new FileInfo(outputPath).Length); + + } + } +} diff --git a/src/VirtualPaper.ML.Test/TestSetup.cs b/src/VirtualPaper.ML.Test/TestSetup.cs new file mode 100644 index 00000000..0602f634 --- /dev/null +++ b/src/VirtualPaper.ML.Test/TestSetup.cs @@ -0,0 +1,11 @@ +using VirtualPaper.Common; + +namespace VirtualPaper.ML.Test { + [TestClass] + public class TestSetup { + [AssemblyInitialize] + public static void AssemblyInit(TestContext context) { + Constants.IsTestMode = true; + } + } +} diff --git a/src/VirtualPaper.ML.Test/VirtualPaper.ML.Test.csproj b/src/VirtualPaper.ML.Test/VirtualPaper.ML.Test.csproj new file mode 100644 index 00000000..94316a43 --- /dev/null +++ b/src/VirtualPaper.ML.Test/VirtualPaper.ML.Test.csproj @@ -0,0 +1,15 @@ + + + + net8.0-windows10.0.19041.0 + latest + enable + enable + true + + + + + + + diff --git a/src/VirtualPaper.ML/DepthEstimate/MiDaS.cs b/src/VirtualPaper.ML/DepthEstimate/MiDaS.cs index 16337692..563d5463 100644 --- a/src/VirtualPaper.ML/DepthEstimate/MiDaS.cs +++ b/src/VirtualPaper.ML/DepthEstimate/MiDaS.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using System.Diagnostics; using Microsoft.ML.OnnxRuntime; using Microsoft.ML.OnnxRuntime.Tensors; using OpenCvSharp; @@ -12,7 +12,9 @@ static MiDaS() { AppDomain.CurrentDomain.BaseDirectory, Constants.WorkingDir.ML, Utils.Fileds.ModelName); - LoadModel(_modelPath); + + if (File.Exists(_modelPath)) + LoadModel(_modelPath); } public static ModelOutput Run(string imagePath) { diff --git a/src/VirtualPaper.PlayerWeb/Program.cs b/src/VirtualPaper.PlayerWeb/Program.cs index 6b49a979..5252fc97 100644 --- a/src/VirtualPaper.PlayerWeb/Program.cs +++ b/src/VirtualPaper.PlayerWeb/Program.cs @@ -19,7 +19,7 @@ static void Main() { SetupUnhandledExceptionLogging(); - //string _args = "{\"isDebug\":true,\"isPreview\":false,\"filePath\":\"C:\\\\Users\\\\liurui18\\\\AppData\\\\Local\\\\VirtualPaper\\\\Library\\\\wallpapers\\\\vhxkeafp.4o5\\\\vhxkeafp.4o5.jpg\",\"depthFilePath\":null,\"wpBasicDataFilePath\":\"C:\\\\Users\\\\liurui18\\\\AppData\\\\Local\\\\VirtualPaper\\\\Library\\\\wallpapers\\\\vhxkeafp.4o5\\\\wp_metadata_basic.json\",\"wpEffectFilePathUsing\":\"C:\\\\Users\\\\liurui18\\\\AppData\\\\Local\\\\VirtualPaper\\\\Library\\\\wallpapers\\\\vhxkeafp.4o5\\\\2\\\\RImage\\\\wpEffectFilePathUsing.json\",\"wpEffectFilePathTemporary\":\"C:\\\\Users\\\\liurui18\\\\AppData\\\\Local\\\\VirtualPaper\\\\Library\\\\wallpapers\\\\vhxkeafp.4o5\\\\2\\\\RImage\\\\wpEffectFilePathTemporary.json\",\"wpEffectFilePathTemplate\":\"C:\\\\Users\\\\liurui18\\\\AppData\\\\Local\\\\VirtualPaper\\\\Library\\\\wallpapers\\\\vhxkeafp.4o5\\\\wpEffectFilePathTemplate.json\",\"runtimeType\":\"RImage\",\"systemBackdrop\":0,\"applicationTheme\":2,\"language\":\"en-US\"}"; + //string msg = "{\"isDebug\":true,\"isPreview\":false,\"filePath\":\"C:\\\\Users\\\\liurui18\\\\AppData\\\\Local\\\\VirtualPaper\\\\Library\\\\wallpapers\\\\4spe4u2n.4q1\\\\4spe4u2n.4q1.jpg\",\"depthFilePath\":null,\"wpBasicDataFilePath\":\"C:\\\\Users\\\\liurui18\\\\AppData\\\\Local\\\\VirtualPaper\\\\Library\\\\wallpapers\\\\4spe4u2n.4q1\\\\wp_metadata_basic.json\",\"wpEffectFilePathUsing\":\"C:\\\\Users\\\\liurui18\\\\AppData\\\\Local\\\\VirtualPaper\\\\Library\\\\wallpapers\\\\4spe4u2n.4q1\\\\1\\\\RImage\\\\wpEffectFilePathUsing.json\",\"wpEffectFilePathTemporary\":\"C:\\\\Users\\\\liurui18\\\\AppData\\\\Local\\\\VirtualPaper\\\\Library\\\\wallpapers\\\\4spe4u2n.4q1\\\\1\\\\RImage\\\\wpEffectFilePathTemporary.json\",\"wpEffectFilePathTemplate\":\"C:\\\\Users\\\\liurui18\\\\AppData\\\\Local\\\\VirtualPaper\\\\Library\\\\wallpapers\\\\4spe4u2n.4q1\\\\wpEffectFilePathTemplate.json\",\"runtimeType\":\"RImage\",\"systemBackdrop\":0,\"applicationTheme\":2,\"language\":\"en-US\"}"; string? msg; using (var reader = new StreamReader(Console.OpenStandardInput())) { @@ -29,11 +29,7 @@ static void Main() { throw new NoNullAllowedException($"The argument for {nameof(msg)} is null. Please check the command msg arguments."); } - _startArgs = JsonSerializer.Deserialize(msg); - if (_startArgs == null) { - throw new NoNullAllowedException($"The argument for {nameof(StartArgsWeb)} is null. Please check the command msg arguments."); - } - + _startArgs = JsonSerializer.Deserialize(msg) ?? throw new NoNullAllowedException($"The argument for {nameof(StartArgsWeb)} is null. Please check the command msg arguments."); ApplicationConfiguration.Initialize(); Application.Run(new Form1(_startArgs)); } diff --git a/src/VirtualPaper.Shader.Test/Infrastructure/ShaderTestConfig.cs b/src/VirtualPaper.Shader.Test/Infrastructure/ShaderTestConfig.cs new file mode 100644 index 00000000..e24ed131 --- /dev/null +++ b/src/VirtualPaper.Shader.Test/Infrastructure/ShaderTestConfig.cs @@ -0,0 +1,42 @@ +namespace VirtualPaper.Shader.Test.Infrastructure { + /// + /// 统一管理测试资源路径与环境变量 + /// + internal static class ShaderTestConfig { + public static string FxcPath => + Path.Combine(ShaderProjDir, "Tools", "fxc", "fxc.exe"); + public static bool IsFxcAvailable() => + File.Exists(FxcPath); + + public static string ShaderIncludeDir => + Path.Combine(ShaderProjDir, "Tools", "include"); + + public static string ShaderSourceDir => + Path.Combine(ShaderProjDir, "Shaders"); + + /// + /// HLSL 源文件目录(可通过环境变量 SHADER_SOURCE_DIR 覆盖) + /// + public static string ShaderProjDir { + get { + // 从 bin/Debug/net8.0-windows.../ 向上找项目根 + var baseDir = AppContext.BaseDirectory; + var projectRoot = Path.GetFullPath( + Path.Combine(baseDir, @"..\..\..\..\")); + return Path.Combine(projectRoot, "VirtualPaper.Shader"); + } + } + + /// + /// 编译输出临时目录 + /// + public static string CompileOutputDir => + Path.Combine(Path.GetTempPath(), "VirtualPaper_ShaderTest"); + + /// + /// 判断 ShaderSourceDir 是否存在 + /// + public static bool IsShaderSourceDirAvailable() => + Directory.Exists(ShaderSourceDir); + } +} diff --git a/src/VirtualPaper.Shader.Test/MSTestSettings.cs b/src/VirtualPaper.Shader.Test/MSTestSettings.cs new file mode 100644 index 00000000..aaf278c8 --- /dev/null +++ b/src/VirtualPaper.Shader.Test/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/src/VirtualPaper.Shader.Test/ShaderCompiler_CompileTests.cs b/src/VirtualPaper.Shader.Test/ShaderCompiler_CompileTests.cs new file mode 100644 index 00000000..69b4a1a6 --- /dev/null +++ b/src/VirtualPaper.Shader.Test/ShaderCompiler_CompileTests.cs @@ -0,0 +1,173 @@ +using System.Diagnostics; +using VirtualPaper.Shader.Test.Infrastructure; + +namespace VirtualPaper.Shader.Test { + [TestClass] + [TestCategory("Integration")] + [DoNotParallelize] + public class ShaderCompiler_CompileTests { + private static string? _classSkipReason; + private string _outputDir = null!; + + public required TestContext TestContext { get; set; } + + [ClassInitialize] + public static void ClassSetup(TestContext context) { + if (!ShaderTestConfig.IsFxcAvailable()) { + _classSkipReason = $"fxc.exe not available at: {ShaderTestConfig.FxcPath}"; + return; + } + + if (!ShaderTestConfig.IsShaderSourceDirAvailable()) { + _classSkipReason = $"Shader source directory not found: {ShaderTestConfig.ShaderSourceDir}"; + return; + } + + if (!File.Exists(Path.Combine(ShaderTestConfig.ShaderIncludeDir, "d2d1effecthelpers.hlsli"))) { + _classSkipReason = $"d2d1effecthelpers.hlsli not found in: {ShaderTestConfig.ShaderIncludeDir}"; + } + } + + [TestInitialize] + public void Setup() { + if (_classSkipReason is not null) + Assert.Inconclusive(_classSkipReason); + + _outputDir = Path.Combine( + ShaderTestConfig.CompileOutputDir, + $"run_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_outputDir); + } + + [TestCleanup] + public void Cleanup() { + if (Directory.Exists(_outputDir)) { + try { Directory.Delete(_outputDir, recursive: true); } + catch { /* 清理失败不影响测试结果 */ } + } + } + + // ── 动态测试数据 ───────────────────────────────────────────── + + public static IEnumerable AllShaderTypes => + Enum.GetValues() + .Where(t => t != ShaderType.None) + .Select(t => new object[] { t }); + + // ── 逐类型编译测试 ─────────────────────────────────────────── + + [TestMethod] + [DynamicData(nameof(AllShaderTypes))] + [Description("每个 ShaderType 对应的 HLSL 文件应能被 fxc 成功编译,生成 .bin 文件")] + public void Compile_EachShaderType_Succeeds(ShaderType type) { + string tempDir = PrepareWorkDir(type, out string? skipReason); + if (skipReason is not null) + Assert.Inconclusive(skipReason); + + string binPath = RunBat(tempDir, out int exitCode, out string stdout, out string stderr); + + TestContext.WriteLine($"ExitCode : {exitCode}"); + TestContext.WriteLine($"stdout :\n{stdout}"); + TestContext.WriteLine($"stderr :\n{stderr}"); + + Assert.AreEqual(0, exitCode, + $"Bat exited with {exitCode}.\nstderr: {stderr}"); + + Assert.IsTrue(File.Exists(binPath), + $".bin not generated for {type}: {binPath}"); + + Assert.IsGreaterThan(0, new FileInfo(binPath).Length, $"Compiled .bin is empty for {type}"); + } + + [TestMethod] + [DynamicData(nameof(AllShaderTypes))] + [Description("fxc 编译输出的 .bin 文件应以 DXBC magic number 开头")] + public void Compile_EachShaderType_OutputIsDxbc(ShaderType type) { + string tempDir = PrepareWorkDir(type, out string? skipReason); + if (skipReason is not null) + Assert.Inconclusive(skipReason); + + string binPath = RunBat(tempDir, out int exitCode, out _, out string stderr); + + Assert.AreEqual(0, exitCode, + $"Bat exited with {exitCode}.\nstderr: {stderr}"); + + byte[] bytes = File.ReadAllBytes(binPath); + + // DXBC 文件头 magic: 44 58 42 43 + Assert.IsGreaterThanOrEqualTo(4, bytes.Length, "BIN file too small"); + Assert.AreEqual(0x44, bytes[0], "DXBC magic[0]"); + Assert.AreEqual(0x58, bytes[1], "DXBC magic[1]"); + Assert.AreEqual(0x42, bytes[2], "DXBC magic[2]"); + Assert.AreEqual(0x43, bytes[3], "DXBC magic[3]"); + } + + // ── 私有辅助 ───────────────────────────────────────────────── + + /// + /// 把对应 ShaderType 的 HLSL 和 bat 脚本复制到独立临时目录。 + /// skipReason 非 null 表示应 Inconclusive。 + /// + private string PrepareWorkDir(ShaderType type, out string? skipReason) { + string hlslName = Path.ChangeExtension(ShaderTypeManager.GetShaderName(type), ".hlsl"); + string srcHlsl = Path.Combine(ShaderTestConfig.ShaderSourceDir, hlslName); + + if (!File.Exists(srcHlsl)) { + skipReason = $"HLSL source not found for {type}: {srcHlsl}"; + return _outputDir; + } + + skipReason = null; + + string tempDir = Path.Combine( + ShaderTestConfig.CompileOutputDir, + $"compile_{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + + File.Copy(srcHlsl, + Path.Combine(tempDir, hlslName), overwrite: true); + + File.Copy( + Path.Combine(ShaderTestConfig.ShaderSourceDir, "_CompileShaders.cmd"), + Path.Combine(tempDir, "_CompileShaders.cmd"), overwrite: true); + + return tempDir; + } + + /// + /// 在指定目录执行 _CompileShaders.cmd,返回预期的 .bin 路径。 + /// + private static string RunBat( + string tempDir, + out int exitCode, + out string stdout, + out string stderr) { + + string batPath = Path.Combine(tempDir, "_CompileShaders.cmd"); + string fxcPath = ShaderTestConfig.FxcPath; + string includeDir = ShaderTestConfig.ShaderIncludeDir; + + var psi = new ProcessStartInfo { + FileName = "cmd.exe", + Arguments = $"/c \"\"{batPath}\" \"{fxcPath}\" \"{includeDir}\"\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WorkingDirectory = tempDir, + }; + + using var process = Process.Start(psi)!; + stdout = process.StandardOutput.ReadToEnd(); + stderr = process.StandardError.ReadToEnd(); + process.WaitForExit(); + exitCode = process.ExitCode; + + string hlslName = Directory + .EnumerateFiles(tempDir, "*.hlsl") + .Select(Path.GetFileNameWithoutExtension) + .FirstOrDefault() ?? string.Empty; + + return Path.Combine(tempDir, hlslName + ".bin"); + } + } +} \ No newline at end of file diff --git a/src/VirtualPaper.Shader.Test/ShaderLoader_ConcurrencyTests.cs b/src/VirtualPaper.Shader.Test/ShaderLoader_ConcurrencyTests.cs new file mode 100644 index 00000000..406ff0cc --- /dev/null +++ b/src/VirtualPaper.Shader.Test/ShaderLoader_ConcurrencyTests.cs @@ -0,0 +1,94 @@ +using System.Collections.Concurrent; + +namespace VirtualPaper.Shader.Test { + [TestClass] + [TestCategory("Unit")] + [DoNotParallelize] + public class ShaderLoader_ConcurrencyTests { + [TestCleanup] + public void Cleanup() { + ShaderLoader.ClearCache(); + } + + /// + /// 已初始化时,100 个并发线程调用 GetShader 不应抛出任何异常。 + /// 使用真实加载确保 _isInited 对所有线程可见。 + /// + [TestMethod] + [Description("已初始化时并发调用 GetShader 不应出现竞态异常")] + public async Task GetShader_ConcurrentAccess_DoesNotThrow() { + await ShaderLoader.LoadAllShadersAsync(); + + var types = Enum.GetValues() + .Where(t => t != ShaderType.None) + .ToArray(); + + var exceptions = new ConcurrentBag(); + + Parallel.For(0, 100, i => { + try { + _ = ShaderLoader.GetShader(types[i % types.Length]); + } + catch (Exception ex) { + exceptions.Add(ex); + } + }); + + Assert.IsEmpty(exceptions, + $"Concurrent GetShader threw {exceptions.Count} exception(s): " + + $"{exceptions.FirstOrDefault()?.Message}"); + } + + /// + /// LoadAllShadersAsync 已加载后,20 个并发调用均应立即返回(幂等快路径), + /// 且 IsLoaded 不被意外重置。 + /// 使用真实加载保证初始状态对所有线程可见。 + /// + [TestMethod] + [Description("LoadAllShadersAsync 已加载后并发调用应全部完成,IsLoaded 保持 true")] + public async Task LoadAllShadersAsync_ConcurrentCallsWhenAlreadyLoaded_AllCompleteAndIsLoadedTrue() { + await ShaderLoader.LoadAllShadersAsync(); // 真实加载,确保内存可见性 + + var tasks = Enumerable.Range(0, 20) + .Select(_ => ShaderLoader.LoadAllShadersAsync()); + + await Task.WhenAll(tasks); // 全部命中 if (_isInited) return 快路径 + + Assert.IsTrue(ShaderLoader.IsLoaded, + "IsLoaded should remain true after concurrent idempotent calls"); + } + + /// + /// 未加载时,多个线程并发调用 LoadAllShadersAsync, + /// 信号量保证内部逻辑只执行一次(double-check lock)。 + /// 通过 IsLoaded 最终为 true 且无异常来验证。 + /// + [TestMethod] + [Description("未加载时并发调用 LoadAllShadersAsync,内部逻辑只应执行一次")] + public async Task LoadAllShadersAsync_ConcurrentCallsWhenNotLoaded_LoadOnlyOnce() { + // 确保未加载状态(Cleanup 已清理,此处为防御性断言) + Assert.IsFalse(ShaderLoader.IsLoaded, "Precondition: should not be loaded"); + + var exceptions = new ConcurrentBag(); + + var tasks = Enumerable.Range(0, 20) + .Select(_ => Task.Run(async () => { + try { + await ShaderLoader.LoadAllShadersAsync(); + } + catch (Exception ex) { + exceptions.Add(ex); + } + })); + + await Task.WhenAll(tasks); + + Assert.IsEmpty(exceptions, + $"Concurrent LoadAllShadersAsync threw {exceptions.Count} exception(s): " + + $"{exceptions.FirstOrDefault()?.Message}"); + + Assert.IsTrue(ShaderLoader.IsLoaded, + "IsLoaded should be true after concurrent loads complete"); + } + } +} \ No newline at end of file diff --git a/src/VirtualPaper.Shader.Test/ShaderLoader_LoadTests.cs b/src/VirtualPaper.Shader.Test/ShaderLoader_LoadTests.cs new file mode 100644 index 00000000..1b43ca85 --- /dev/null +++ b/src/VirtualPaper.Shader.Test/ShaderLoader_LoadTests.cs @@ -0,0 +1,79 @@ +namespace VirtualPaper.Shader.Test { + [TestClass] + [TestCategory("Integration")] + [DoNotParallelize] + public class ShaderLoader_LoadTests { + [TestInitialize] + public async Task TestSetup() { + await ShaderLoader.LoadAllShadersAsync(); + } + + [TestCleanup] + public void TestCleanup() { + ShaderLoader.ClearCache(); + } + + /// + /// 合并自:SetsIsLoadedTrue + IsIdempotent + /// 验证 LoadAllShadersAsync 的幂等性:首次设置 IsLoaded,重复调用不改变状态 + /// + [TestMethod] + [Description("LoadAllShadersAsync 应设置 IsLoaded,且多次调用保持幂等")] + public async Task LoadAllShadersAsync_IsIdempotentAndSetsIsLoaded() { + Assert.IsTrue(ShaderLoader.IsLoaded, "首次加载后 IsLoaded 应为 true"); + + await ShaderLoader.LoadAllShadersAsync(); // 第二次调用 + Assert.IsTrue(ShaderLoader.IsLoaded, "重复调用后 IsLoaded 应仍为 true"); + } + + /// + /// 合并自:AllTypesAccessible + StartsWithDxbcMagic + /// 遍历所有 ShaderType,验证可访问且数据格式合法 + /// + [TestMethod] + [Description("加载后每个 ShaderType 均可访问,且数据不为空")] + public void LoadAllShadersAsync_AllTypes_Accessible() { + var allTypes = Enum.GetValues() + .Where(t => t != ShaderType.None) + .ToArray(); + + foreach (var type in allTypes) { + byte[] data; + try { + data = ShaderLoader.GetShader(type); + } + catch (KeyNotFoundException) { + Assert.Fail( + $"ShaderType.{type} was not loaded. " + + $"Check that the corresponding shader file exists."); + return; + } + + Assert.IsNotEmpty(data, $"{type}: shader data should not be empty"); + } + } + + [TestMethod] + [Description("ReloadAllShadersAsync 应重新填充缓存,IsLoaded 保持 true")] + public async Task ReloadAllShadersAsync_RefreshesCache() { + await ShaderLoader.ReloadAllShadersAsync(); + + Assert.IsTrue(ShaderLoader.IsLoaded); + } + + [TestMethod] + [Description("ReloadShaderAsync 应返回与缓存一致的数据")] + public async Task ReloadShaderAsync_UpdatesCache() { + var type = Enum.GetValues() + .FirstOrDefault(t => t != ShaderType.None); + if (type == ShaderType.None) + Assert.Inconclusive("No ShaderType available"); + + var original = ShaderLoader.GetShader(type); + var reloaded = await ShaderLoader.ReloadShaderAsync(type); + + CollectionAssert.AreEqual(original, reloaded, + "Reloaded data should match the cached content"); + } + } +} diff --git a/src/VirtualPaper.Shader.Test/ShaderLoader_StateTests.cs b/src/VirtualPaper.Shader.Test/ShaderLoader_StateTests.cs new file mode 100644 index 00000000..06b6f459 --- /dev/null +++ b/src/VirtualPaper.Shader.Test/ShaderLoader_StateTests.cs @@ -0,0 +1,73 @@ +namespace VirtualPaper.Shader.Test { + [TestClass] + [TestCategory("Unit")] + [DoNotParallelize] + public class ShaderLoader_StateTests { + + [TestCleanup] + public void Cleanup() { + // 每个测试后重置缓存,避免状态污染 + ShaderLoader.ClearCache(); + } + + // ── IsLoaded ──────────────────────────────────────────────── + + [TestMethod] + [Description("初始状态下 IsLoaded 应为 false")] + public void IsLoaded_BeforeAnyLoad_IsFalse() { + ShaderLoader.ClearCache(); + + Assert.IsFalse(ShaderLoader.IsLoaded); + } + + [TestMethod] + [Description("ClearCache 后 IsLoaded 应重置为 false")] + public void ClearCache_ResetsIsLoadedToFalse() { + // 直接操作 _isInited 需要通过行为验证 + // 先 ClearCache,再确认 IsLoaded = false + ShaderLoader.ClearCache(); + + Assert.IsFalse(ShaderLoader.IsLoaded, + "ClearCache should reset IsLoaded to false"); + } + + // ── GetShader 未初始化 ────────────────────────────────────── + + [TestMethod] + [Description("未调用 LoadAllShadersAsync 时 GetShader 应抛出 InvalidOperationException")] + public void GetShader_BeforeLoad_ThrowsInvalidOperationException() { + ShaderLoader.ClearCache(); // 确保未初始化 + + Assert.Throws( + () => ShaderLoader.GetShader(ShaderType.GeometryAlphaEraseEffect), + "Should throw InvalidOperationException when shaders are not loaded"); + } + + [TestMethod] + [Description("未初始化时异常消息应包含指导性文字")] + public void GetShader_BeforeLoad_ExceptionMessageIsDescriptive() { + ShaderLoader.ClearCache(); + + try { + ShaderLoader.GetShader(ShaderType.GeometryAlphaEraseEffect); + Assert.Fail("Expected InvalidOperationException"); + } + catch (InvalidOperationException ex) { + Assert.Contains("LoadAllShadersAsync", ex.Message, + "Exception message should tell caller to call LoadAllShadersAsync"); + } + } + + // ── ClearCache ────────────────────────────────────────────── + + [TestMethod] + [Description("ClearCache 连续调用不应抛出异常")] + public void ClearCache_CalledMultipleTimes_DoesNotThrow() { + ShaderLoader.ClearCache(); + ShaderLoader.ClearCache(); + ShaderLoader.ClearCache(); + + // 不抛即通过 + } + } +} diff --git a/src/VirtualPaper.Shader.Test/TestSetup.cs b/src/VirtualPaper.Shader.Test/TestSetup.cs new file mode 100644 index 00000000..14def7f2 --- /dev/null +++ b/src/VirtualPaper.Shader.Test/TestSetup.cs @@ -0,0 +1,20 @@ +using VirtualPaper.Common; +using VirtualPaper.Common.Utils.Storage; + +namespace VirtualPaper.Shader.Test { + [TestClass] + public class TestSetup { + [AssemblyInitialize] + public static void AssemblyInit(TestContext context) { + Constants.IsTestMode = true; + InitMemorySharedContext(); + } + + private static void InitMemorySharedContext() { + var context = new SharedContext { + BaseDir = AppDomain.CurrentDomain.BaseDirectory, + }; + FileShared.Write(context); // 写到磁盘某个共享位置 + } + } +} diff --git a/src/VirtualPaper.Shader.Test/VirtualPaper.Shader.Test.csproj b/src/VirtualPaper.Shader.Test/VirtualPaper.Shader.Test.csproj new file mode 100644 index 00000000..50550ad0 --- /dev/null +++ b/src/VirtualPaper.Shader.Test/VirtualPaper.Shader.Test.csproj @@ -0,0 +1,23 @@ + + + + net8.0-windows10.0.19041.0 + latest + enable + enable + true + + + + + + + + + + + diff --git a/src/VirtualPaper.Shader/ShaderLoader.cs b/src/VirtualPaper.Shader/ShaderLoader.cs index d6e58b45..8aa2eee2 100644 --- a/src/VirtualPaper.Shader/ShaderLoader.cs +++ b/src/VirtualPaper.Shader/ShaderLoader.cs @@ -19,7 +19,7 @@ static ShaderLoader() { } /// - /// 确保所有Shader已加载完成(阻塞直到完成) + /// 确保所有 Shader 已加载完成 /// public static async Task LoadAllShadersAsync() { if (_isInited) return; @@ -28,25 +28,7 @@ public static async Task LoadAllShadersAsync() { try { if (_isInited) return; - var allTypes = Enum.GetValues(typeof(ShaderType)).Cast(); - var loadingTasks = new List(); - - foreach (var type in allTypes) { - if (type == ShaderType.None) continue; - var loadTask = LoadShaderInternalAsync(type).ContinueWith(t => { - if (t.IsCompletedSuccessfully) { - _shaderCache[type] = t.Result; - } - else if (t.IsFaulted) { - // 记录错误但继续加载其他Shader - ArcLog.GetLogger().Error(t.Exception); - } - }); - - loadingTasks.Add(loadTask); - } - - await Task.WhenAll(loadingTasks); + await LoadShadersInternalAsync(); _isInited = true; } finally { @@ -55,7 +37,7 @@ public static async Task LoadAllShadersAsync() { } /// - /// 获取已加载的Shader数据(同步方法) + /// 获取已加载的 Shader 数据(同步方法) /// public static byte[] GetShader(ShaderType type) { if (!_isInited) @@ -64,7 +46,7 @@ public static byte[] GetShader(ShaderType type) { if (_shaderCache.TryGetValue(type, out var shaderData)) return shaderData; - throw new KeyNotFoundException($"Shader {type} not found in cache"); + throw new KeyNotFoundException($"Shader {type} not found in cache."); } /// @@ -77,75 +59,143 @@ public static async Task ReloadShaderAsync(ShaderType type) { } /// - /// 重新加载所有 Shader + /// 重新加载所有 Shader(持锁操作,加载完成前 GetShader 不受影响) /// public static async Task ReloadAllShadersAsync() { - _isInited = false; - _shaderCache.Clear(); - await LoadAllShadersAsync(); - } + await _loadingSemaphore.WaitAsync(); + try { + // 先加载到临时字典,完成后再原子替换,全程 _isInited 保持 true + var newCache = new ConcurrentDictionary(); - private static async Task LoadShaderInternalAsync(ShaderType type) { - bool isPackaged = Constants.ApplicationType.IsMSIX; + var allTypes = Enum.GetValues(typeof(ShaderType)) + .Cast() + .Where(t => t != ShaderType.None); - string shaderPath; - if (isPackaged) { - shaderPath = await ResolveShaderPathForPackagedAsync(type); + var loadingTasks = allTypes.Select(type => + LoadShaderInternalAsync(type).ContinueWith(t => { + if (t.IsCompletedSuccessfully) { + newCache[type] = t.Result; + } + else if (t.IsFaulted) { + ArcLog.GetLogger().Error(t.Exception); + } + }, TaskScheduler.Default) + ); + + await Task.WhenAll(loadingTasks); + + // 原子替换:批量写入,不清空旧缓存、不改动 _isInited + foreach (var (k, v) in newCache) + _shaderCache[k] = v; + + _isInited = true; // 防御性赋值(首次调用路径兼容) } - else { - shaderPath = ResolveShaderPathForUnpackaged(type); + finally { + _loadingSemaphore.Release(); } + } + + /// + /// 清空 Shader 缓存 + /// + public static void ClearCache() { + _shaderCache.Clear(); + _isInited = false; + } + + /// + /// 加载所有 ShaderType(不含 None),结果写入缓存 + /// + private static async Task LoadShadersInternalAsync() { + var allTypes = Enum.GetValues(typeof(ShaderType)) + .Cast() + .Where(t => t != ShaderType.None); + + var failedTypes = new ConcurrentBag<(ShaderType type, Exception ex)>(); + var loadingTasks = allTypes.Select(type => + LoadShaderInternalAsync(type).ContinueWith(t => { + if (t.IsCompletedSuccessfully) { + _shaderCache[type] = t.Result; + } + else if (t.IsFaulted) { + var ex = t.Exception!.InnerException ?? t.Exception; + ArcLog.GetLogger().Error(ex); + failedTypes.Add((type, ex)); + } + }, TaskScheduler.Default) + ); + + await Task.WhenAll(loadingTasks); + + if (!failedTypes.IsEmpty) { + throw new AggregateException( + $"Failed to load {failedTypes.Count} shader(s): " + + string.Join(", ", failedTypes.Select(f => f.type)), + failedTypes.Select(f => f.ex)); + } + } + + private static async Task LoadShaderInternalAsync(ShaderType type) { + string shaderPath = Constants.ApplicationType.IsMSIX + ? await ResolveShaderPathForPackagedAsync(type) + : ResolveShaderPathForUnpackaged(type); if (!File.Exists(shaderPath)) - throw new FileNotFoundException($"Shader file not found:{shaderPath}"); + throw new FileNotFoundException($"Shader file not found: {shaderPath}", shaderPath); return await File.ReadAllBytesAsync(shaderPath); } /// - /// Packaged 模式下加载 shader + /// Packaged 模式:优先从 ms-appx 包内加载,失败则退回 LocalFolder /// private static async Task ResolveShaderPathForPackagedAsync(ShaderType type) { string fileName = ShaderTypeManager.GetShaderName(type); + try { var uri = new Uri($"ms-appx:///{DefaultShaderFolderName}/{fileName}"); StorageFile file = await StorageFile.GetFileFromApplicationUriAsync(uri); - return file.Path; } - catch { - // 退回 LocalFolder - StorageFolder localFolder = ApplicationData.Current.LocalFolder; - string folderPath = Path.Combine(localFolder.Path, DefaultShaderFolderName); - Directory.CreateDirectory(folderPath); - - return Path.Combine(folderPath, fileName); + catch (FileNotFoundException) { + // 包内资源不存在,退回 LocalFolder } + catch (Exception ex) { + ArcLog.GetLogger().Warn( + $"Failed to resolve packaged shader path for {type}: {ex.Message}"); + } + + // Fallback:LocalFolder + StorageFolder localFolder = ApplicationData.Current.LocalFolder; + string folderPath = Path.Combine(localFolder.Path, DefaultShaderFolderName); + Directory.CreateDirectory(folderPath); + return Path.Combine(folderPath, fileName); } /// - /// Unpackaged 模式下加载 shader + /// Unpackaged 模式:从配置的 BaseDir 加载 /// private static string ResolveShaderPathForUnpackaged(ShaderType type) { - string fileName = ShaderTypeManager.GetShaderName(type); - if (_baseDir == null || fileName == string.Empty) return string.Empty; - - string filePath = Path.Combine(_baseDir, Constants.WorkingDir.Shader, fileName); + if (_baseDir == null) + throw new InvalidOperationException( + "BaseDir is not configured. Check FileShared.Read()."); - return filePath; - } + string fileName = ShaderTypeManager.GetShaderName(type); + if (string.IsNullOrEmpty(fileName)) + throw new ArgumentException( + $"No shader file name mapped for ShaderType: {type}", nameof(type)); - /// - /// 清空 shader 缓存 - /// - public static void ClearCache() { - _shaderCache.Clear(); - _isInited = false; + if (Constants.IsTestMode) { + return Path.Combine(_baseDir, "Shaders", fileName); + } + else { + return Path.Combine(_baseDir, Constants.WorkingDir.Shader, fileName); + } } private static readonly ConcurrentDictionary _shaderCache = new(); private static readonly SemaphoreSlim _loadingSemaphore = new(1, 1); - private static bool _isInited = false; + private static volatile bool _isInited = false; private static readonly string? _baseDir; } -} \ No newline at end of file +} diff --git a/src/VirtualPaper.Shader/Shaders/_CompileShaders.cmd b/src/VirtualPaper.Shader/Shaders/_CompileShaders.cmd index f9898a9c..031ec3c9 100644 --- a/src/VirtualPaper.Shader/Shaders/_CompileShaders.cmd +++ b/src/VirtualPaper.Shader/Shaders/_CompileShaders.cmd @@ -2,20 +2,30 @@ setlocal pushd "%~dp0" -where /q fxc >nul -if %errorlevel% neq 0 ( - echo fxc not found. - goto WRONG_COMMAND_PROMPT +:: ------------------------------------------------------- +:: 允许外部通过参数传入,优先使用参数 +:: 用法: CompileShaders.bat [FXC_PATH] [INCLUDE_PATH] +:: ------------------------------------------------------- +if not "%~1"=="" ( + set "FXC=%~1" +) else ( + set "FXC=fxc" + where /q fxc >nul + if errorlevel 1 ( + echo fxc not found. + goto WRONG_COMMAND_PROMPT + ) ) -if "%WindowsSdkDir%" == "" ( - goto WRONG_COMMAND_PROMPT +if not "%~2"=="" ( + set "INCLUDEPATH=%~2" +) else ( + if "%WindowsSdkDir%"=="" goto WRONG_COMMAND_PROMPT + set "INCLUDEPATH=%WindowsSdkDir%Include\%WindowsSDKVersion%um" ) -set INCLUDEPATH="%WindowsSdkDir%\Include\%WindowsSDKVersion%\um" - -if not exist %INCLUDEPATH%\d2d1effecthelpers.hlsli ( - echo d2d1effecthelpers.hlsli not found. +if not exist "%INCLUDEPATH%\d2d1effecthelpers.hlsli" ( + echo d2d1effecthelpers.hlsli not found in %INCLUDEPATH% goto WRONG_COMMAND_PROMPT ) @@ -27,14 +37,14 @@ goto END echo. echo Compiling %1 - fxc %1 /nologo /T lib_4_0_level_9_3_ps_only /D D2D_FUNCTION /D D2D_ENTRY=main /Fl %~n1.fxlib /I %INCLUDEPATH% || exit /b - fxc %1 /nologo /T ps_4_0_level_9_3 /D D2D_FULL_SHADER /D D2D_ENTRY=main /E main /setprivate %~n1.fxlib /Fo:%~n1.bin /I %INCLUDEPATH% || exit /b + "%FXC%" %1 /nologo /T lib_4_0_level_9_3_ps_only /D D2D_FUNCTION /D D2D_ENTRY=main /Fl %~n1.fxlib /I "%INCLUDEPATH%" || exit /b + "%FXC%" %1 /nologo /T ps_4_0_level_9_3 /D D2D_FULL_SHADER /D D2D_ENTRY=main /E main /setprivate %~n1.fxlib /Fo:%~n1.bin /I "%INCLUDEPATH%" || exit /b del %~n1.fxlib exit /b :WRONG_COMMAND_PROMPT -echo Please run from a Developer Command Prompt for VS2017. +echo Please run from a Developer Command Prompt for VS2017, or pass [FXC_PATH] [INCLUDE_PATH] as arguments. :END popd diff --git a/src/VirtualPaper.Shader/Tools/fxc/d3dcompiler_47.dll b/src/VirtualPaper.Shader/Tools/fxc/d3dcompiler_47.dll new file mode 100644 index 00000000..124a9236 Binary files /dev/null and b/src/VirtualPaper.Shader/Tools/fxc/d3dcompiler_47.dll differ diff --git a/src/VirtualPaper.Shader/Tools/fxc/fxc.exe b/src/VirtualPaper.Shader/Tools/fxc/fxc.exe new file mode 100644 index 00000000..987eb4ca Binary files /dev/null and b/src/VirtualPaper.Shader/Tools/fxc/fxc.exe differ diff --git a/src/VirtualPaper.Shader/Tools/include/d2d1effecthelpers.hlsli b/src/VirtualPaper.Shader/Tools/include/d2d1effecthelpers.hlsli new file mode 100644 index 00000000..aaa56597 --- /dev/null +++ b/src/VirtualPaper.Shader/Tools/include/d2d1effecthelpers.hlsli @@ -0,0 +1,316 @@ +//--------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// +// This file is automatically generated. Please do not edit it directly. +// +//--------------------------------------------------------------------------- + +// File contents: +// - Helpers methods for authoring D2D Effect shader code. +// These are located at the end of the file (D2DGetInput, etc.). +// - The top portion contains definitions and initialization required by the helpers. +// These elements are prefaced with "__D2D" and can be safely ignored. +// +// To use these helpers, the following values must be defined before inclusion: +// D2D_INPUT_COUNT - The number of texture inputs to the effect. +// D2D_INPUT[N]_SIMPLE or D2D_INPUT[N]_COMPLEX - How the effect will sample each input. (If unspecificed, defaults to _COMPLEX.) +// D2D_ENTRY - The name of the entry point being compiled. This will usually be defined on the command line at compilation time. +// +// The following values can be optionally defined: +// D2D_FUNCTION - Compile the entry point as an export function. This will usually be defined on the command line at compilation time. +// D2D_FULL_SHADER - Compile the entry point as a full shader. This will usually be defined on the command line at compilation time. +// D2D_FULL_SHADER_ONLY - Only compile the in-scope entry points to full shaders, never to export functions. +// + +#define __D2D_DEFINE_PS_GLOBALS(inputIndex) \ +Texture2D InputTexture##inputIndex : register(t##inputIndex); \ +SamplerState InputSampler##inputIndex : register(s##inputIndex); \ + +// Define a texture and sampler pair for each D2D effect input. +#if (D2D_INPUT_COUNT >= 1) +__D2D_DEFINE_PS_GLOBALS(0) +#endif +#if (D2D_INPUT_COUNT >= 2) +__D2D_DEFINE_PS_GLOBALS(1) +#endif +#if (D2D_INPUT_COUNT >= 3) +__D2D_DEFINE_PS_GLOBALS(2) +#endif +#if (D2D_INPUT_COUNT >= 4) +__D2D_DEFINE_PS_GLOBALS(3) +#endif +#if (D2D_INPUT_COUNT >= 5) +__D2D_DEFINE_PS_GLOBALS(4) +#endif +#if (D2D_INPUT_COUNT >= 6) +__D2D_DEFINE_PS_GLOBALS(5) +#endif +#if (D2D_INPUT_COUNT >= 7) +__D2D_DEFINE_PS_GLOBALS(6) +#endif +#if (D2D_INPUT_COUNT >= 8) +__D2D_DEFINE_PS_GLOBALS(7) +#endif + +#define __D2D_MAXIMUM_INPUT_COUNT 8 + +// Validate that all required shader information has been defined. +#ifndef D2D_INPUT_COUNT +#error D2D_INPUT_COUNT is undefined. +#endif + +#if (D2D_INPUT_COUNT > __D2D_MAXIMUM_INPUT_COUNT) +#error D2D_INPUT_COUNT exceeds the maximum input count. +#endif + +// Define global statics to hold the values needed by intrinsic methods. +// These values are initialized by the entry point wrapper before calling into the +// effect's shader implementation. +#if !defined(D2D_FUNCTION) || defined(D2D_REQUIRES_SCENE_POSITION) +static float4 __d2dstatic_scenePos = float4(0, 0, 0, 0); +#endif + +#define __D2D_DEFINE_INPUT_STATICS(inputIndex) \ +static float4 __d2dstatic_input##inputIndex = float4(0, 0, 0, 0); \ +static float4 __d2dstatic_uv##inputIndex = float4(0, 0, 0, 0); \ + +#if (D2D_INPUT_COUNT >= 1) +__D2D_DEFINE_INPUT_STATICS(0) +#endif +#if (D2D_INPUT_COUNT >= 2) +__D2D_DEFINE_INPUT_STATICS(1) +#endif +#if (D2D_INPUT_COUNT >= 3) +__D2D_DEFINE_INPUT_STATICS(2) +#endif +#if (D2D_INPUT_COUNT >= 4) +__D2D_DEFINE_INPUT_STATICS(3) +#endif +#if (D2D_INPUT_COUNT >= 5) +__D2D_DEFINE_INPUT_STATICS(4) +#endif +#if (D2D_INPUT_COUNT >= 6) +__D2D_DEFINE_INPUT_STATICS(5) +#endif +#if (D2D_INPUT_COUNT >= 7) +__D2D_DEFINE_INPUT_STATICS(6) +#endif +#if (D2D_INPUT_COUNT >= 8) +__D2D_DEFINE_INPUT_STATICS(7) +#endif + +// Define the scene position parameter according to whether the shader requires it, +// and whether it is the only parameter. +// The scene position input always needs to be defined for full shaders. +#if (!defined(D2D_FUNCTION) || defined(D2D_REQUIRES_SCENE_POSITION)) +#if (D2D_INPUT_COUNT == 0) +#define __D2D_SCENE_POS float4 __d2dinput_scenePos : SCENE_POSITION +#define __D2D_INIT_STATIC_SCENE_POS __d2dstatic_scenePos = __d2dinput_scenePos +#else +#define __D2D_SCENE_POS float4 __d2dinput_scenePos : SCENE_POSITION, +#define __D2D_INIT_STATIC_SCENE_POS __d2dstatic_scenePos = __d2dinput_scenePos; + #endif +#else + #define __D2D_SCENE_POS + #define __D2D_INIT_STATIC_SCENE_POS +#endif + +// When compiling a function version, simple and complex inputs have different definitions. +// When compiling a full shader, they have the same definition. +// Access to input parameters also differs between functions and full shaders. +#if defined(D2D_FUNCTION) +#define __D2D_SIMPLE_INPUT(index) float4 __d2dinput_color##index : INPUT##index +#define __D2D_INIT_SIMPLE_STATIC(index) __d2dstatic_input##index = __d2dinput_color##index +#else +#define __D2D_SIMPLE_INPUT(index) float4 __d2dinput_uv##index : TEXCOORD##index +#define __D2D_INIT_SIMPLE_STATIC(index) __d2dstatic_uv##index = __d2dinput_uv##index +#endif + +#define __D2D_COMPLEX_INPUT(index) float4 __d2dinput_uv##index : TEXCOORD##index +#define __D2D_INIT_COMPLEX_STATIC(index) __d2dstatic_uv##index = __d2dinput_uv##index + +#define __D2D_SAMPLE_INPUT(index) InputTexture##index.Sample(InputSampler##index, __d2dstatic_uv##index.xy) + +// Define each input as either simple or complex. +#if defined(D2D_INPUT0_SIMPLE) +#define __D2D_INPUT0 __D2D_SIMPLE_INPUT(0) +#define __D2D_INIT_STATIC0 __D2D_INIT_SIMPLE_STATIC(0) +#define __D2D_GET_INPUT0 __d2dstatic_input0 +#else +#define __D2D_INPUT0 __D2D_COMPLEX_INPUT(0) +#define __D2D_INIT_STATIC0 __D2D_INIT_COMPLEX_STATIC(0) +#define __D2D_GET_INPUT0 __D2D_SAMPLE_INPUT(0) +#endif +#if defined(D2D_INPUT1_SIMPLE) +#define __D2D_INPUT1 __D2D_SIMPLE_INPUT(1) +#define __D2D_INIT_STATIC1 __D2D_INIT_SIMPLE_STATIC(1) +#define __D2D_GET_INPUT1 __d2dstatic_input1 +#else +#define __D2D_INPUT1 __D2D_COMPLEX_INPUT(1) +#define __D2D_INIT_STATIC1 __D2D_INIT_COMPLEX_STATIC(1) +#define __D2D_GET_INPUT1 __D2D_SAMPLE_INPUT(1) +#endif +#if defined(D2D_INPUT2_SIMPLE) +#define __D2D_INPUT2 __D2D_SIMPLE_INPUT(2) +#define __D2D_INIT_STATIC2 __D2D_INIT_SIMPLE_STATIC(2) +#define __D2D_GET_INPUT2 __d2dstatic_input2 +#else +#define __D2D_INPUT2 __D2D_COMPLEX_INPUT(2) +#define __D2D_INIT_STATIC2 __D2D_INIT_COMPLEX_STATIC(2) +#define __D2D_GET_INPUT2 __D2D_SAMPLE_INPUT(2) +#endif +#if defined(D2D_INPUT3_SIMPLE) +#define __D2D_INPUT3 __D2D_SIMPLE_INPUT(3) +#define __D2D_INIT_STATIC3 __D2D_INIT_SIMPLE_STATIC(3) +#define __D2D_GET_INPUT3 __d2dstatic_input3 +#else +#define __D2D_INPUT3 __D2D_COMPLEX_INPUT(3) +#define __D2D_INIT_STATIC3 __D2D_INIT_COMPLEX_STATIC(3) +#define __D2D_GET_INPUT3 __D2D_SAMPLE_INPUT(3) +#endif +#if defined(D2D_INPUT4_SIMPLE) +#define __D2D_INPUT4 __D2D_SIMPLE_INPUT(4) +#define __D2D_INIT_STATIC4 __D2D_INIT_SIMPLE_STATIC(4) +#define __D2D_GET_INPUT4 __d2dstatic_input4 +#else +#define __D2D_INPUT4 __D2D_COMPLEX_INPUT(4) +#define __D2D_INIT_STATIC4 __D2D_INIT_COMPLEX_STATIC(4) +#define __D2D_GET_INPUT4 __D2D_SAMPLE_INPUT(4) +#endif +#if defined(D2D_INPUT5_SIMPLE) +#define __D2D_INPUT5 __D2D_SIMPLE_INPUT(5) +#define __D2D_INIT_STATIC5 __D2D_INIT_SIMPLE_STATIC(5) +#define __D2D_GET_INPUT5 __d2dstatic_input5 +#else +#define __D2D_INPUT5 __D2D_COMPLEX_INPUT(5) +#define __D2D_INIT_STATIC5 __D2D_INIT_COMPLEX_STATIC(5) +#define __D2D_GET_INPUT5 __D2D_SAMPLE_INPUT(5) +#endif +#if defined(D2D_INPUT6_SIMPLE) +#define __D2D_INPUT6 __D2D_SIMPLE_INPUT(6) +#define __D2D_INIT_STATIC6 __D2D_INIT_SIMPLE_STATIC(6) +#define __D2D_GET_INPUT6 __d2dstatic_input6 +#else +#define __D2D_INPUT6 __D2D_COMPLEX_INPUT(6) +#define __D2D_INIT_STATIC6 __D2D_INIT_COMPLEX_STATIC(6) +#define __D2D_GET_INPUT6 __D2D_SAMPLE_INPUT(6) +#endif +#if defined(D2D_INPUT7_SIMPLE) +#define __D2D_INPUT7 __D2D_SIMPLE_INPUT(7) +#define __D2D_INIT_STATIC7 __D2D_INIT_SIMPLE_STATIC(7) +#define __D2D_GET_INPUT7 __d2dstatic_input7 +#else +#define __D2D_INPUT7 __D2D_COMPLEX_INPUT(7) +#define __D2D_INIT_STATIC7 __D2D_INIT_COMPLEX_STATIC(7) +#define __D2D_GET_INPUT7 __D2D_SAMPLE_INPUT(7) +#endif +#if defined(D2D_INPUT8_SIMPLE) +#define __D2D_INPUT8 __D2D_SIMPLE_INPUT(8) +#define __D2D_INIT_STATIC8 __D2D_INIT_SIMPLE_STATIC(8) +#define __D2D_GET_INPUT8 __d2dstatic_input8 +#else +#define __D2D_INPUT8 __D2D_COMPLEX_INPUT(8) +#define __D2D_INIT_STATIC8 __D2D_INIT_COMPLEX_STATIC(8) +#define __D2D_GET_INPUT8 __D2D_SAMPLE_INPUT(8) +#endif + +// Define the export function inputs based on the defined input count and types. +#if (D2D_INPUT_COUNT == 0) +#define __D2D_FUNCTION_INPUTS __D2D_SCENE_POS +#define __D2D_INIT_STATICS __D2D_INIT_STATIC_SCENE_POS +#elif (D2D_INPUT_COUNT == 1) +#define __D2D_FUNCTION_INPUTS __D2D_SCENE_POS __D2D_INPUT0 +#define __D2D_INIT_STATICS __D2D_INIT_STATIC_SCENE_POS __D2D_INIT_STATIC0 +#elif (D2D_INPUT_COUNT == 2) +#define __D2D_FUNCTION_INPUTS __D2D_SCENE_POS __D2D_INPUT0, __D2D_INPUT1 +#define __D2D_INIT_STATICS __D2D_INIT_STATIC_SCENE_POS __D2D_INIT_STATIC0; __D2D_INIT_STATIC1 +#elif (D2D_INPUT_COUNT == 3) +#define __D2D_FUNCTION_INPUTS __D2D_SCENE_POS __D2D_INPUT0, __D2D_INPUT1, __D2D_INPUT2 +#define __D2D_INIT_STATICS __D2D_INIT_STATIC_SCENE_POS __D2D_INIT_STATIC0; __D2D_INIT_STATIC1; __D2D_INIT_STATIC2 +#elif (D2D_INPUT_COUNT == 4) +#define __D2D_FUNCTION_INPUTS __D2D_SCENE_POS __D2D_INPUT0, __D2D_INPUT1, __D2D_INPUT2, __D2D_INPUT3 +#define __D2D_INIT_STATICS __D2D_INIT_STATIC_SCENE_POS __D2D_INIT_STATIC0; __D2D_INIT_STATIC1; __D2D_INIT_STATIC2; __D2D_INIT_STATIC3 +#elif (D2D_INPUT_COUNT == 5) +#define __D2D_FUNCTION_INPUTS __D2D_SCENE_POS __D2D_INPUT0, __D2D_INPUT1, __D2D_INPUT2, __D2D_INPUT3, __D2D_INPUT4 +#define __D2D_INIT_STATICS __D2D_INIT_STATIC_SCENE_POS __D2D_INIT_STATIC0; __D2D_INIT_STATIC1; __D2D_INIT_STATIC2; __D2D_INIT_STATIC3; __D2D_INIT_STATIC4 +#elif (D2D_INPUT_COUNT == 6) +#define __D2D_FUNCTION_INPUTS __D2D_SCENE_POS __D2D_INPUT0, __D2D_INPUT1, __D2D_INPUT2, __D2D_INPUT3, __D2D_INPUT4, __D2D_INPUT5 +#define __D2D_INIT_STATICS __D2D_INIT_STATIC_SCENE_POS __D2D_INIT_STATIC0; __D2D_INIT_STATIC1; __D2D_INIT_STATIC2; __D2D_INIT_STATIC3; __D2D_INIT_STATIC4; __D2D_INIT_STATIC5 +#elif (D2D_INPUT_COUNT == 7) +#define __D2D_FUNCTION_INPUTS __D2D_SCENE_POS __D2D_INPUT0, __D2D_INPUT1, __D2D_INPUT2, __D2D_INPUT3, __D2D_INPUT4, __D2D_INPUT5, __D2D_INPUT6 +#define __D2D_INIT_STATICS __D2D_INIT_STATIC_SCENE_POS __D2D_INIT_STATIC0; __D2D_INIT_STATIC1; __D2D_INIT_STATIC2; __D2D_INIT_STATIC3; __D2D_INIT_STATIC4; __D2D_INIT_STATIC5; __D2D_INIT_STATIC6 +#elif (D2D_INPUT_COUNT == 8) +#define __D2D_FUNCTION_INPUTS __D2D_SCENE_POS __D2D_INPUT0, __D2D_INPUT1, __D2D_INPUT2, __D2D_INPUT3, __D2D_INPUT4, __D2D_INPUT5, __D2D_INPUT6, __D2D_INPUT7 +#define __D2D_INIT_STATICS __D2D_INIT_STATIC_SCENE_POS __D2D_INIT_STATIC0; __D2D_INIT_STATIC1; __D2D_INIT_STATIC2; __D2D_INIT_STATIC3; __D2D_INIT_STATIC4; __D2D_INIT_STATIC5; __D2D_INIT_STATIC6; __D2D_INIT_STATIC7 +#endif + +#if !defined(CONCAT) +#define CONCAT(str1, str2) str1##str2 +#endif + +// Rename the entry point target function so that the actual entry point can use the name. +// This expansion is the same for both full shaders and functions. +#define D2D_PS_ENTRY(name) float4 CONCAT(name, _Impl)() + +// If neither D2D_FUNCTION or D2D_FULL_SHADER is defined, behave as if D2D_FULL_SHADER is defined. +#if defined(D2D_FUNCTION) && !defined(D2D_FULL_SHADER_ONLY) + + // Replaces simple samples with either static variable or an actual sample, + // depending on whether the input is declared as simple or complex. + #define D2DGetInput(index) __D2D_GET_INPUT##index + + #if !defined(D2D_CUSTOM_ENTRY) + // Declare function prototype for the target function so that it can be referenced before definition. + // D2D_ENTRY is a macro whose actual name resolves to the effect's target "entry point" function. + float4 CONCAT(D2D_ENTRY, _Impl)(); + + // This is the actual entry point definition, which forwards the call to the target function. + export float4 D2D_func_entry(__D2D_FUNCTION_INPUTS) + { + __D2D_INIT_STATICS; + return CONCAT(D2D_ENTRY, _Impl)(); + } + + #endif + +#else // !defined(D2D_FUNCTION) + + // Replaces simple samples with actual samples. + #define D2DGetInput(index) __D2D_SAMPLE_INPUT(index) + + #if !defined(D2D_CUSTOM_ENTRY) + // Declare function prototype for the target function so that it can be referenced before definition. + // D2D_ENTRY is a macro whose actual name resolves to the effect's target "entry point" function. + float4 CONCAT(D2D_ENTRY, _Impl)(); + + // This is the actual entry point definition, which forwards the call to the target function. + float4 D2D_ENTRY (float4 pos : SV_POSITION, __D2D_FUNCTION_INPUTS) : SV_TARGET + { + __D2D_INIT_STATICS; + return CONCAT(D2D_ENTRY, _Impl)(); + } + + #endif + +#endif // D2D_FUNCTION + +//=============================================================== +// Along with D2DGetInput defined above, the following macros and +// methods define D2D intrinsics for use in effect shader code. +//=============================================================== + +#if !defined(D2D_FUNCTION) || defined(D2D_REQUIRES_SCENE_POSITION) +inline float4 D2DGetScenePosition() +{ + return __d2dstatic_scenePos; +} +#endif + +#define D2DGetInputCoordinate(index) __d2dstatic_uv##index + +#define D2DSampleInput(index, position) InputTexture##index.Sample(InputSampler##index, position) + +#define D2DSampleInputAtOffset(index, offset) InputTexture##index.Sample(InputSampler##index, __d2dstatic_uv##index.xy + offset * __d2dstatic_uv##index.zw) + +#define D2DSampleInputAtPosition(index, pos) InputTexture##index.Sample(InputSampler##index, __d2dstatic_uv##index.xy + __d2dstatic_uv##index.zw * (pos - __d2dstatic_scenePos.xy)) + diff --git a/src/VirtualPaper.Shader/VirtualPaper.Shader.csproj b/src/VirtualPaper.Shader/VirtualPaper.Shader.csproj index 3eab13a9..e491ac50 100644 --- a/src/VirtualPaper.Shader/VirtualPaper.Shader.csproj +++ b/src/VirtualPaper.Shader/VirtualPaper.Shader.csproj @@ -14,7 +14,7 @@ - Always + PreserveNewest Never diff --git a/src/VirtualPaper.UI.Test/MSTestSettings.cs b/src/VirtualPaper.UI.Test/MSTestSettings.cs new file mode 100644 index 00000000..300f5b1a --- /dev/null +++ b/src/VirtualPaper.UI.Test/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/src/VirtualPaper.UI.Test/T_AppSettings/GeneralSettingViewModelTests.cs b/src/VirtualPaper.UI.Test/T_AppSettings/GeneralSettingViewModelTests.cs new file mode 100644 index 00000000..191a512c --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_AppSettings/GeneralSettingViewModelTests.cs @@ -0,0 +1,325 @@ +using Grpc.Core; +using Moq; +using VirtualPaper.AppSettingsPanel.ViewModels; +using VirtualPaper.Common; +using VirtualPaper.Common.Events; +using VirtualPaper.Common.Utils.Localization; +using VirtualPaper.Common.Utils.Storage; +using VirtualPaper.Common.Utils.ThreadContext; +using VirtualPaper.Grpc.Client.Interfaces; +using VirtualPaper.Grpc.Service.CommonModels; +using VirtualPaper.Models.Cores; +using VirtualPaper.Models.Cores.Interfaces; +using VirtualPaper.UI.Test.Utils; + +namespace VirtualPaper.UI.Test.T_AppSettings { + [TestClass] + public class GeneralSettingViewModelTests { + private Mock _appUpdater = null!; + private Mock _userSettingsClient = null!; + private Mock _wpControlClient = null!; + private Mock _settings = null!; + private GeneralSettingViewModel _vm = null!; + + [TestInitialize] + public void Setup() { + CrossThreadInvoker.Initialize(new T_UiSynchronizationContext()); + + _appUpdater = new Mock(); + _userSettingsClient = new Mock(); + _wpControlClient = new Mock(); + _settings = new Mock(); + + _settings.SetupProperty(s => s.IsAutoStart, false); + _settings.SetupProperty(s => s.SystemBackdrop, AppSystemBackdrop.Default); + _settings.SetupProperty(s => s.Language, "en-US"); + _settings.SetupProperty(s => s.WallpaperDir, @"C:\Wallpapers"); + + _userSettingsClient.Setup(u => u.Settings).Returns(_settings.Object); + _userSettingsClient + .Setup(u => u.SaveAsync()) + .Returns(Task.CompletedTask); + + _vm = new GeneralSettingViewModel( + _appUpdater.Object, + _userSettingsClient.Object, + _wpControlClient.Object); + } + + [TestCleanup] + public void Cleanup() { + _vm.Dispose(); + } + + // ── MenuUpdate ─────────────────────────────────────────────── + + [TestMethod] + public void MenuUpdate_WhenUptodate_SetsUptoNewest() { + _appUpdater.Raise(a => a.UpdateChecked += null, + new AppUpdaterEventArgs( + AppUpdateStatus.Uptodate, + new Version(1, 0), + DateTime.Now, + new Uri("https://example.com/update"), + new Uri("https://example.com/update.sha"), + string.Empty)); + + Assert.AreEqual(VersionState.UptoNewest, _vm.CurrentVersionState); + } + + [TestMethod] + public void MenuUpdate_WhenAvailable_SetsVersionAndFindNew() { + var version = new Version(2, 0); + _appUpdater.Raise(a => a.UpdateChecked += null, + new AppUpdaterEventArgs( + AppUpdateStatus.Available, + version, + DateTime.Now, + new Uri("https://example.com/update"), + new Uri("https://example.com/update.sha"), + string.Empty)); + + Assert.AreEqual(VersionState.FindNew, _vm.CurrentVersionState); + Assert.AreEqual("v2.0", _vm.Version); + } + + [TestMethod] + [DataRow(AppUpdateStatus.Invalid)] + [DataRow(AppUpdateStatus.Error)] + public void MenuUpdate_WhenInvalidOrError_SetsUpdateErr(AppUpdateStatus status) { + _appUpdater.Raise(a => a.UpdateChecked += null, + new AppUpdaterEventArgs( + status, + new Version(1, 0), + DateTime.Now, + new Uri("https://example.com/update"), + new Uri("https://example.com/update.sha"), + string.Empty)); + + Assert.AreEqual(VersionState.UpdateErr, _vm.CurrentVersionState); + } + + // ── IsAutoStart setter ─────────────────────────────────────── + + [TestMethod] + public void IsAutoStart_WhenValueUnchanged_DoesNotCallSave() { + _settings.Object.IsAutoStart = true; + _vm.IsAutoStart = true; + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Never); + } + + [TestMethod] + public void IsAutoStart_WhenValueChanged_CallsSave() { + _settings.Object.IsAutoStart = false; + _vm.IsAutoStart = true; + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Once); + } + + // ── SeletedSystemBackdropIndx setter ───────────────────────── + + [TestMethod] + public void SeletedSystemBackdropIndx_WhenValueUnchanged_DoesNotCallSave() { + _settings.Object.SystemBackdrop = AppSystemBackdrop.Mica; // index = 1 + _vm.SeletedSystemBackdropIndx = 1; + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Never); + } + + [TestMethod] + public void SeletedSystemBackdropIndx_WhenValueChanged_CallsSave() { + _settings.Object.SystemBackdrop = AppSystemBackdrop.Default; // index = 0 + _vm.SeletedSystemBackdropIndx = 1; + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Once); + } + + // ── SelectedLanguage setter ─────────────────────────────────── + + [TestMethod] + public void SelectedLanguage_WhenLanguageCodeMatches_DoesNotUpdate() { + _settings.Object.Language = "en-US"; + var lang = new LanguagesModel("English", new[] { "en-US" }); + _vm.SelectedLanguage = lang; + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Never); + } + + [TestMethod] + public void SelectedLanguage_WhenLanguageCodeDiffers_UpdatesAndSaves() { + _settings.Object.Language = "en-US"; + var lang = new LanguagesModel("Chinese", new[] { "zh-CN" }); + _vm.SelectedLanguage = lang; + + Assert.AreEqual("zh-CN", _settings.Object.Language); + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Once); + } + + // ── WallpaperDirectoryChangeAsync ──────────────────────────── + + [TestMethod] + public async Task WallpaperDirectoryChangeAsync_WhenSucceeds_UpdatesWallpaperDir() { + var srcRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var destRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + + // 预期的最终 WallpaperDir + var expectedWallpaperDir = Path.Combine(destRoot, Constants.FolderName.WpStoreFolderName); + + try { + // ── 构造源目录结构 ────────────────────────────────────── + var subDir = Directory.CreateDirectory(Path.Combine(srcRoot, "wp001")); + var thumbnailPath = Path.Combine(subDir.FullName, "thumbnail.png"); + var filePath = Path.Combine(subDir.FullName, "wallpaper.mp4"); + await File.WriteAllBytesAsync(thumbnailPath, [0xFF, 0xD8]); + await File.WriteAllBytesAsync(filePath, [0x00]); + + var basicData = new WpBasicData { /* ... */ FolderName = "wp001", FolderPath = subDir.FullName }; + await JsonSaver.SaveAsync( + Path.Combine(subDir.FullName, Constants.Field.WpBasicDataFileName), + basicData, WpBasicDataContext.Default); + + // 目标根目录需要存在(让 CopyDirectory 能创建子目录) + Directory.CreateDirectory(destRoot); + + // ── Setup ─────────────────────────────────────────────── + _settings.Object.WallpaperDir = srcRoot; + + _wpControlClient + .Setup(w => w.ChangeWallpaperLayoutFolderPathAsync( + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + _wpControlClient + .Setup(w => w.RestartAllWallpapersAsync()) + .ReturnsAsync(new Grpc_RestartWallpaperResponse { IsFinished = true }); + + // ── 执行 ──────────────────────────────────────────────── + await InvokeWallpaperDirectoryChangeAsync(_vm, destRoot); + + // ── 断言 ──────────────────────────────────────────────── + Assert.AreEqual(expectedWallpaperDir, _vm.WallpaperDir); // ← 修正点 + Assert.IsFalse(_vm.WallpaperDirectoryChangeOngoing); + } + finally { + if (Directory.Exists(srcRoot)) Directory.Delete(srcRoot, true); + if (Directory.Exists(destRoot)) Directory.Delete(destRoot, true); + } + } + + [TestMethod] + public async Task WallpaperDirectoryChangeAsync_WhenRpcCancelled_OngoingRestored() { + _wpControlClient + .Setup(w => w.RestartAllWallpapersAsync()) + .ThrowsAsync(new RpcException(new Status(StatusCode.Cancelled, ""))); + + await InvokeWallpaperDirectoryChangeAsync(_vm, @"D:\NewPath"); + + Assert.IsFalse(_vm.WallpaperDirectoryChangeOngoing); + } + + [TestMethod] + public async Task WallpaperDirectoryChangeAsync_WhenExceptionThrown_OngoingRestored() { + _wpControlClient + .Setup(w => w.RestartAllWallpapersAsync()) + .ThrowsAsync(new Exception("unexpected")); + + await InvokeWallpaperDirectoryChangeAsync(_vm, @"D:\NewPath"); + + Assert.IsFalse(_vm.WallpaperDirectoryChangeOngoing); + } + + // ── GetWpBasicDataByInstallFoldersAsync ─────────────────────── + + [TestMethod] + public async Task GetWpBasicData_WhenFolderHasValidData_YieldsItems() { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var subDir = Directory.CreateDirectory(Path.Combine(tempDir, "wp001")); + + // 创建缩略图占位文件(ThumbnailPath 需指向真实存在的文件) + var thumbnailPath = Path.Combine(subDir.FullName, "thumbnail.png"); + await File.WriteAllBytesAsync(thumbnailPath, [0xFF, 0xD8]); // 最小占位内容 + + // 创建壁纸主文件占位 + var filePath = Path.Combine(subDir.FullName, "wallpaper.mp4"); + await File.WriteAllBytesAsync(filePath, [0x00]); + + var basicData = new WpBasicData {// ── IsAvailable() 要求的四个字段 ────────────────────── + WallpaperUid = Guid.NewGuid().ToString(), + FType = FileType.FVideo, + ThumbnailPath = thumbnailPath, + AppInfo = new ApplicationInfo { AppVersion = "1.0.0" }, + + // ── 路径相关 ────────────────────────────────────────── + FolderName = "wp001", + FolderPath = subDir.FullName, + FilePath = filePath, + + // ── 可选描述字段 ────────────────────────────────────── + Title = "Test Wallpaper", + Status = WallpaperStatus.Normal, + }; + await JsonSaver.SaveAsync( + Path.Combine(subDir.FullName, Constants.Field.WpBasicDataFileName), + basicData, + WpBasicDataContext.Default); + + var results = new List(); + await foreach (var item in InvokeGetWpBasicData([tempDir])) { + results.Add(item); + } + + Assert.HasCount(1, results); + + Directory.Delete(tempDir, true); + } + + [TestMethod] + public async Task GetWpBasicData_WhenFolderEmpty_YieldsNothing() { + var tempDir = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString())).FullName; + + var results = new List(); + await foreach (var item in InvokeGetWpBasicData([tempDir])) { + results.Add(item); + } + + Assert.HasCount(0, results); + + Directory.Delete(tempDir, true); + } + + // ── 辅助方法 ───────────────────────────────────────────────── + + /// + /// 通过反射调用 internal WallpaperDirectoryChangeAsync + /// (推荐改为 internal + InternalsVisibleTo 替代反射) + /// + private static async Task InvokeWallpaperDirectoryChangeAsync( + GeneralSettingViewModel vm, string newPath) { + var method = typeof(GeneralSettingViewModel) + .GetMethod("WallpaperDirectoryChangeAsync", + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance); + + Assert.IsNotNull(method, "WallpaperDirectoryChangeAsync 方法未找到,请确认方法名或改为 internal"); + + var task = (Task)method.Invoke(vm, [newPath])!; + await task; + } + + private static async IAsyncEnumerable InvokeGetWpBasicData( + IEnumerable folders) { + var method = typeof(GeneralSettingViewModel) + .GetMethod("GetWpBasicDataByInstallFoldersAsync", + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Static); + + Assert.IsNotNull(method, "GetWpBasicDataByInstallFoldersAsync 方法未找到"); + + var result = (IAsyncEnumerable)method.Invoke(null, [folders.ToList()])!; + await foreach (var item in result) { + yield return item; + } + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_AppSettings/PerformanceSettingViewModelTests.cs b/src/VirtualPaper.UI.Test/T_AppSettings/PerformanceSettingViewModelTests.cs new file mode 100644 index 00000000..960093e2 --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_AppSettings/PerformanceSettingViewModelTests.cs @@ -0,0 +1,153 @@ +using Moq; +using VirtualPaper.AppSettingsPanel.ViewModels; +using VirtualPaper.Common; +using VirtualPaper.Grpc.Client.Interfaces; +using VirtualPaper.Models.Cores.Interfaces; +using VirtualPaper.UIComponent.Utils; + +namespace VirtualPaper.UI.Test.T_AppSettings { + [TestClass] + public class PerformanceSettingViewModelTests { + private Mock _userSettingsClient = null!; + private Mock _settings = null!; + private PerformanceSettingViewModel _vm = null!; + + [TestInitialize] + public void Setup() { + _userSettingsClient = new Mock(); + _settings = new Mock(); + + _settings.SetupProperty(s => s.AppFullscreen, AppWpRunRulesEnum.Silence); + _settings.SetupProperty(s => s.AppFocus, AppWpRunRulesEnum.Silence); + _settings.SetupProperty(s => s.BatteryPoweredn, AppWpRunRulesEnum.Silence); + _settings.SetupProperty(s => s.PowerSaving, AppWpRunRulesEnum.Silence); + _settings.SetupProperty(s => s.RemoteDesktop, AppWpRunRulesEnum.Silence); + _settings.SetupProperty(s => s.StatuMechanism, StatuMechanismEnum.Per); + _settings.SetupProperty(s => s.IsAudioOnlyOnDesktop, false); + + _userSettingsClient.Setup(u => u.Settings).Returns(_settings.Object); + + _vm = new PerformanceSettingViewModel(_userSettingsClient.Object); + } + + // ── AppWpRunRulesEnum setter 参数化测试 ─────────────────────── + + [TestMethod] + [DataRow("SelectedFullScreenPlayStatuIndex", nameof(ISettings.AppFullscreen))] + [DataRow("SelectedFocusPlayStatuIndex", nameof(ISettings.AppFocus))] + [DataRow("SelectedBatteryPowerednPlayStatuIndex", nameof(ISettings.BatteryPoweredn))] + [DataRow("SelectedPowerSavingPlayStatuIndex", nameof(ISettings.PowerSaving))] + [DataRow("SelectedRemoteDesktopPlayStatuIndex", nameof(ISettings.RemoteDesktop))] + public void RunRulesSetter_WhenValueUnchanged_DoesNotCallSave( + string vmProp, string settingsProp) { + // 当前值为 Silence(0),再设一次 0 + SetVmProperty(_vm, vmProp, 0); + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Never); + } + + [TestMethod] + [DataRow("SelectedFullScreenPlayStatuIndex", nameof(ISettings.AppFullscreen))] + [DataRow("SelectedFocusPlayStatuIndex", nameof(ISettings.AppFocus))] + [DataRow("SelectedBatteryPowerednPlayStatuIndex", nameof(ISettings.BatteryPoweredn))] + [DataRow("SelectedPowerSavingPlayStatuIndex", nameof(ISettings.PowerSaving))] + [DataRow("SelectedRemoteDesktopPlayStatuIndex", nameof(ISettings.RemoteDesktop))] + public void RunRulesSetter_WhenValueChanged_CallsSave( + string vmProp, string settingsProp) { + // 从 Silence(0) 改为 Pause(1) + SetVmProperty(_vm, vmProp, 1); + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Once); + } + + [TestMethod] + [DataRow("SelectedFullScreenPlayStatuIndex", nameof(ISettings.AppFullscreen), 1)] + [DataRow("SelectedFocusPlayStatuIndex", nameof(ISettings.AppFocus), 2)] + [DataRow("SelectedBatteryPowerednPlayStatuIndex", nameof(ISettings.BatteryPoweredn), 1)] + [DataRow("SelectedPowerSavingPlayStatuIndex", nameof(ISettings.PowerSaving), 2)] + [DataRow("SelectedRemoteDesktopPlayStatuIndex", nameof(ISettings.RemoteDesktop), 1)] + public void RunRulesSetter_WhenValueChanged_UpdatesSettingsProperty( + string vmProp, string settingsProp, int newIndex) { + SetVmProperty(_vm, vmProp, newIndex); + + var actual = (int)GetSettingsProperty(_settings.Object, settingsProp); + Assert.AreEqual(newIndex, actual); + } + + // ── StatuMechanism setter ───────────────────────────────────── + + [TestMethod] + public void SelectedStatuMechanismIndex_WhenValueUnchanged_DoesNotCallSave() { + _settings.Object.StatuMechanism = StatuMechanismEnum.Per; // index = 0 + _vm.SelectedStatuMechanismPlayStatuIndex = 0; + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Never); + } + + [TestMethod] + public void SelectedStatuMechanismIndex_WhenValueChanged_CallsSave() { + _settings.Object.StatuMechanism = StatuMechanismEnum.Per; // index = 0 + _vm.SelectedStatuMechanismPlayStatuIndex = 1; + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Once); + } + + [TestMethod] + public void SelectedStatuMechanismIndex_WhenValueChanged_UpdatesSettings() { + _settings.Object.StatuMechanism = StatuMechanismEnum.Per; + _vm.SelectedStatuMechanismPlayStatuIndex = 1; + + Assert.AreEqual(StatuMechanismEnum.All, _settings.Object.StatuMechanism); + } + + // ── IsAudioOnlyOnDesktop setter ─────────────────────────────── + + [TestMethod] + public void IsAudioOnlyOnDesktop_WhenValueUnchanged_DoesNotCallSave() { + _settings.Object.IsAudioOnlyOnDesktop = false; + _vm.IsAudioOnlyOnDesktop = false; + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Never); + } + + [TestMethod] + public void IsAudioOnlyOnDesktop_WhenValueChanged_CallsSave() { + _settings.Object.IsAudioOnlyOnDesktop = false; + _vm.IsAudioOnlyOnDesktop = true; + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Once); + } + + [TestMethod] + public void IsAudioOnlyOnDesktop_WhenTrue_AudioStatuShowsOn() { + _vm.IsAudioOnlyOnDesktop = true; + + Assert.AreEqual( + LanguageUtil.GetI18n(Constants.I18n.Text_On), + _vm.AudioStatu); + } + + [TestMethod] + public void IsAudioOnlyOnDesktop_WhenFalse_AudioStatuShowsOff() { + _vm.IsAudioOnlyOnDesktop = false; + + Assert.AreEqual( + LanguageUtil.GetI18n(Constants.I18n.Text_Off), + _vm.AudioStatu); + } + + // ── 辅助方法 ───────────────────────────────────────────────── + + private static void SetVmProperty(object vm, string propName, int value) { + var prop = vm.GetType().GetProperty(propName); + Assert.IsNotNull(prop, $"属性 {propName} 未找到"); + prop.SetValue(vm, value); + } + + private static object GetSettingsProperty(ISettings settings, string propName) { + var prop = typeof(ISettings).GetProperty(propName); + Assert.IsNotNull(prop, $"ISettings 属性 {propName} 未找到"); + return prop.GetValue(settings)!; + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_AppSettings/SystemSettingViewModelTests.cs b/src/VirtualPaper.UI.Test/T_AppSettings/SystemSettingViewModelTests.cs new file mode 100644 index 00000000..730f685e --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_AppSettings/SystemSettingViewModelTests.cs @@ -0,0 +1,204 @@ +using System.Globalization; +using Moq; +using VirtualPaper.AppSettingsPanel.ViewModels; +using VirtualPaper.Common.Utils.Storage.Adapter; +using VirtualPaper.Grpc.Client.Interfaces; +using Windows.Storage; + +namespace VirtualPaper.UI.Test.T_AppSettings { + [TestClass] + public class SystemSettingViewModelTests { + private Mock _commandsClient = null!; + private Mock _storagePicker = null!; + private SystemSettingViewModel _vm = null!; + + [TestInitialize] + public void Setup() { + _commandsClient = new Mock(); + _storagePicker = new Mock(); + + _vm = new SystemSettingViewModel(_commandsClient.Object, _storagePicker.Object); + } + + // ── DebugCommand ───────────────────────────────────────────── + + [TestMethod] + public void DebugCommand_IsNotNull_AfterConstruction() { + Assert.IsNotNull(_vm.DebugCommand); + } + + [TestMethod] + public void DebugCommand_CanExecute_ReturnsTrue() { + Assert.IsTrue(_vm.DebugCommand!.CanExecute(null)); + } + + [TestMethod] + public void DebugCommand_WhenExecuted_CallsShowDebugView() { + _vm.DebugCommand!.Execute(null); + + _commandsClient.Verify(c => c.ShowDebugView(), Times.Once); + } + + // ── LogCommand ─────────────────────────────────────────────── + + [TestMethod] + public void LogCommand_IsNotNull_AfterConstruction() { + Assert.IsNotNull(_vm.LogCommand); + } + + [TestMethod] + public void LogCommand_CanExecute_ReturnsTrue() { + Assert.IsTrue(_vm.LogCommand!.CanExecute(null)); + } + + [TestMethod] + public async Task InternalExportLogsAsync_PickerCalledOnce() { + _storagePicker + .Setup(p => p.PickSaveFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Returns(Task.FromResult(null)); + + await _vm.InternalExportLogsAsync(); + + _storagePicker.Verify(p => p.PickSaveFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>()), Times.Once); + } + + // ── 建议文件名前缀 ──────────────────────────────────────────────── + + [TestMethod] + public async Task InternalExportLogsAsync_SuggestedFileNameStartsWithPrefix() { + string? capturedName = null; + _storagePicker + .Setup(p => p.PickSaveFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Callback>( + (_, name, _) => capturedName = name) + .Returns(Task.FromResult(null)); + + await _vm.InternalExportLogsAsync(); + + Assert.IsNotNull(capturedName); + StringAssert.StartsWith(capturedName, "virtualpaper_log_"); + } + + // ── 建议文件名包含合法时间戳 ────────────────────────────────────── + + [TestMethod] + public async Task InternalExportLogsAsync_SuggestedFileNameContainsValidTimestamp() { + string? capturedName = null; + _storagePicker + .Setup(p => p.PickSaveFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Callback>( + (_, name, _) => capturedName = name) + .Returns(Task.FromResult(null)); + + await _vm.InternalExportLogsAsync(); + + Assert.IsNotNull(capturedName); + string timestamp = capturedName!["virtualpaper_log_".Length..]; + bool isParsed = DateTime.TryParseExact( + timestamp, + "yyyyMMdd_HHmmss", + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out _); + Assert.IsTrue(isParsed, $"时间戳格式不符,实际值:{timestamp}"); + } + + // ── fileTypeChoices key 为 "Compressed archive" ─────────────────── + + [TestMethod] + public async Task InternalExportLogsAsync_FileTypeChoicesKeyIsCompressedArchive() { + Dictionary? capturedChoices = null; + _storagePicker + .Setup(p => p.PickSaveFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Callback>( + (_, _, choices) => capturedChoices = choices) + .Returns(Task.FromResult(null)); + + await _vm.InternalExportLogsAsync(); + + Assert.IsNotNull(capturedChoices); + Assert.IsTrue( + capturedChoices!.ContainsKey("Compressed archive"), + "fileTypeChoices 中未找到键 'Compressed archive'"); + } + + // ── fileTypeChoices 包含 .zip ───────────────────────────────────── + + [TestMethod] + public async Task InternalExportLogsAsync_FileTypeChoicesContainZip() { + Dictionary? capturedChoices = null; + _storagePicker + .Setup(p => p.PickSaveFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Callback>( + (_, _, choices) => capturedChoices = choices) + .Returns(Task.FromResult(null)); + + await _vm.InternalExportLogsAsync(); + + Assert.IsNotNull(capturedChoices); + Assert.IsTrue( + capturedChoices!.Values.Any(exts => exts.Contains(".zip")), + "fileTypeChoices 中未找到 .zip"); + } + + // ── Picker 返回 null 时方法也返回 null ──────────────────────────── + + [TestMethod] + public async Task InternalExportLogsAsync_WhenPickerReturnsNull_ReturnsNull() { + _storagePicker + .Setup(p => p.PickSaveFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .Returns(Task.FromResult(null)); + + IStorageFile? result = await _vm.InternalExportLogsAsync(); + + Assert.IsNull(result); + } + + // ── Picker 返回文件时方法透传该文件 ────────────────────────────── + + [TestMethod] + public async Task InternalExportLogsAsync_WhenPickerReturnsFile_ReturnsSameFile() { + var tempPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.log"); + await File.WriteAllTextAsync(tempPath, string.Empty); + + try { + var expectedFile = await StorageFile.GetFileFromPathAsync(tempPath); + + _storagePicker + .Setup(p => p.PickSaveFileAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .ReturnsAsync(expectedFile); + + IStorageFile? result = await _vm.InternalExportLogsAsync(); + + Assert.AreSame(expectedFile, result); + } + finally { + if (File.Exists(tempPath)) File.Delete(tempPath); + } + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_Draft/DraftConfigViewModel_CardUIStateChangedTests.cs b/src/VirtualPaper.UI.Test/T_Draft/DraftConfigViewModel_CardUIStateChangedTests.cs new file mode 100644 index 00000000..72644ef6 --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_Draft/DraftConfigViewModel_CardUIStateChangedTests.cs @@ -0,0 +1,37 @@ +using VirtualPaper.DraftPanel.ViewModels; +using VirtualPaper.Models.DraftPanel; + +namespace VirtualPaper.UI.Test.T_Draft { + [TestClass] + public class DraftConfigViewModel_CardUIStateChangedTests { + private DraftConfigViewModel _vm = null!; + + [TestInitialize] + public void Setup() { + _vm = new DraftConfigViewModel(); + } + + [TestMethod] + public void IsNextEnable_Changed_CardUIStateChangedInvoked() { + int invokeCount = 0; + _vm.CardUIStateChanged = () => invokeCount++; + + _vm.ProjectName = "MyProject"; + _vm.SelectedTemplate = new ProjectTemplate { Name = "Template1" }; + + Assert.IsGreaterThan(0, invokeCount); + } + + [TestMethod] + public void IsNextEnable_SameValue_CardUIStateChangedStillInvoked() { + // IsNextEnable setter 没有防重复触发,每次赋值都会调用 + int invokeCount = 0; + _vm.CardUIStateChanged = () => invokeCount++; + + _vm.IsNextEnable = false; + _vm.IsNextEnable = false; + + Assert.AreEqual(2, invokeCount); + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_Draft/DraftConfigViewModel_FilterTests.cs b/src/VirtualPaper.UI.Test/T_Draft/DraftConfigViewModel_FilterTests.cs new file mode 100644 index 00000000..2e5c0831 --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_Draft/DraftConfigViewModel_FilterTests.cs @@ -0,0 +1,63 @@ +using VirtualPaper.Common.Utils.ThreadContext; +using VirtualPaper.DraftPanel.ViewModels; +using VirtualPaper.Models.DraftPanel; +using VirtualPaper.Models.Mvvm; +using VirtualPaper.UI.Test.Utils; + +namespace VirtualPaper.UI.Test.T_Draft { + [TestClass] + public class DraftConfigViewModel_FilterTests { + private DraftConfigViewModel _vm = null!; + + + [TestInitialize] + public void Setup() { + CrossThreadInvoker.Initialize(new T_UiSynchronizationContext()); + _vm = new DraftConfigViewModel(); + + var templates = new List { + new() { Name = "Image Template" }, + new() { Name = "Video Template" }, + new() { Name = "Audio Config" }, + }; + + // 初始化内部 _availableTemplates 和 AvailableTemplates + // 需要 InitContentAsync 或直接反射/internal 赋值 + _vm.AvailableTemplates.SetRange(templates); + typeof(DraftConfigViewModel) + .GetField("_availableTemplates", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .SetValue(_vm, templates.AsEnumerable()); + } + + [TestMethod] + public void ApplyFilter_MatchingKeyword_OnlyMatchingTemplatesRemain() { + _vm.ApplyFilter("Template"); + + Assert.HasCount(2, _vm.AvailableTemplates); + Assert.IsTrue(_vm.AvailableTemplates.All(t => t.Name!.Contains("Template"))); + } + + [TestMethod] + public void ApplyFilter_EmptyKeyword_AllTemplatesShown() { + _vm.ApplyFilter("Template"); // 先过滤 + _vm.ApplyFilter(""); // 再清空过滤 + + Assert.HasCount(3, _vm.AvailableTemplates); + } + + [TestMethod] + public void ApplyFilter_NoMatchingKeyword_CollectionEmpty() { + _vm.ApplyFilter("xyz_no_match"); + + Assert.HasCount(0, _vm.AvailableTemplates); + } + + [TestMethod] + public void ApplyFilter_CaseInsensitive_Matches() { + _vm.ApplyFilter("template"); // 小写 + + Assert.HasCount(2, _vm.AvailableTemplates); + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_Draft/DraftConfigViewModel_ProjectNameTests.cs b/src/VirtualPaper.UI.Test/T_Draft/DraftConfigViewModel_ProjectNameTests.cs new file mode 100644 index 00000000..7458c71a --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_Draft/DraftConfigViewModel_ProjectNameTests.cs @@ -0,0 +1,53 @@ +using VirtualPaper.DraftPanel.ViewModels; +using VirtualPaper.Models.DraftPanel; + +namespace VirtualPaper.UI.Test.T_Draft { + [TestClass] + public class DraftConfigViewModel_ProjectNameTests { + private DraftConfigViewModel _vm = null!; + + [TestInitialize] + public void Setup() { + _vm = new DraftConfigViewModel(); + } + + [TestMethod] + public void ProjectName_ValidName_IsNameOkTrue() { + _vm.ProjectName = "MyProject"; + Assert.IsTrue(_vm.IsNameOk); + } + + [TestMethod] + public void ProjectName_NullName_IsNameOkFalse() { + _vm.ProjectName = null; + Assert.IsFalse(_vm.IsNameOk); + } + + [TestMethod] + public void ProjectName_EmptyName_IsNameOkFalse() { + _vm.ProjectName = ""; + Assert.IsFalse(_vm.IsNameOk); + } + + [TestMethod] + public void ProjectName_ValidName_WithNoTemplate_IsNextEnableFalse() { + _vm.SelectedTemplate = null; + _vm.ProjectName = "MyProject"; + Assert.IsFalse(_vm.IsNextEnable); + } + + [TestMethod] + public void ProjectName_ValidName_WithTemplate_IsNextEnableTrue() { + _vm.SelectedTemplate = new ProjectTemplate { Name = "Template1" }; + _vm.ProjectName = "MyProject"; + Assert.IsTrue(_vm.IsNextEnable); + } + + [TestMethod] + public void ProjectName_InvalidName_WithTemplate_IsNextEnableFalse() { + _vm.SelectedTemplate = new ProjectTemplate { Name = "Template1" }; + _vm.ProjectName = ""; + Assert.IsFalse(_vm.IsNextEnable); + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_Draft/DraftConfigViewModel_SelectedTemplateTests.cs b/src/VirtualPaper.UI.Test/T_Draft/DraftConfigViewModel_SelectedTemplateTests.cs new file mode 100644 index 00000000..1c9f63a9 --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_Draft/DraftConfigViewModel_SelectedTemplateTests.cs @@ -0,0 +1,35 @@ +using VirtualPaper.DraftPanel.ViewModels; +using VirtualPaper.Models.DraftPanel; + +namespace VirtualPaper.UI.Test.T_Draft { + [TestClass] + public class DraftConfigViewModel_SelectedTemplateTests { + private DraftConfigViewModel _vm = null!; + + [TestInitialize] + public void Setup() { + _vm = new DraftConfigViewModel(); + } + + [TestMethod] + public void SelectedTemplate_SetWithValidName_IsNextEnableTrue() { + _vm.ProjectName = "MyProject"; + _vm.SelectedTemplate = new ProjectTemplate { Name = "Template1" }; + Assert.IsTrue(_vm.IsNextEnable); + } + + [TestMethod] + public void SelectedTemplate_SetToNull_IsNextEnableFalse() { + _vm.ProjectName = "MyProject"; + _vm.SelectedTemplate = null; + Assert.IsFalse(_vm.IsNextEnable); + } + + [TestMethod] + public void SelectedTemplate_SetWithInvalidName_IsNextEnableFalse() { + _vm.ProjectName = ""; + _vm.SelectedTemplate = new ProjectTemplate { Name = "Template1" }; + Assert.IsFalse(_vm.IsNextEnable); + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_Draft/DraftConfigViewModel_TCSTests.cs b/src/VirtualPaper.UI.Test/T_Draft/DraftConfigViewModel_TCSTests.cs new file mode 100644 index 00000000..e08d7123 --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_Draft/DraftConfigViewModel_TCSTests.cs @@ -0,0 +1,42 @@ +using VirtualPaper.DraftPanel.Model; +using VirtualPaper.DraftPanel.ViewModels; +using VirtualPaper.Models.DraftPanel; + +namespace VirtualPaper.UI.Test.T_Draft { + [TestClass] + public class DraftConfigViewModel_TCSTests { + private DraftConfigViewModel _vm = null!; + + [TestInitialize] + public void Setup() { + _vm = new DraftConfigViewModel(); + _vm.ProjectName = "MyProject"; + _vm.SelectedTemplate = new ProjectTemplate { Name = "Template1" }; + _vm.IsFromWorkSpace_AddProj = true; + } + + [TestMethod] + public async Task OnNextStepClickedAsync_WorkSpaceMode_TCSSetWithData() { + var tcs = new TaskCompletionSource(); + _vm.DraftConfigTCS = tcs; + + await _vm.OnNextStepClickedAsync(); + + Assert.IsTrue(tcs.Task.IsCompleted); + var result = await tcs.Task; + Assert.IsNotNull(result); + Assert.AreEqual("MyProject", result[0].Identity); + } + + [TestMethod] + public async Task OnPreviousStepClickedAsync_WorkSpaceMode_TCSSetWithNull() { + var tcs = new TaskCompletionSource(); + _vm.DraftConfigTCS = tcs; + + await _vm.OnPreviousStepClickedAsync(); + + Assert.IsTrue(tcs.Task.IsCompleted); + Assert.IsNull(await tcs.Task); + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_Draft/GetStartViewModelTests.cs b/src/VirtualPaper.UI.Test/T_Draft/GetStartViewModelTests.cs new file mode 100644 index 00000000..da2646c6 --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_Draft/GetStartViewModelTests.cs @@ -0,0 +1,245 @@ +using Moq; +using VirtualPaper.Common.Utils.ThreadContext; +using VirtualPaper.DraftPanel.ViewModels; +using VirtualPaper.Grpc.Client.Interfaces; +using VirtualPaper.Models.Cores.Interfaces; +using VirtualPaper.UI.Test.Utils; + +namespace VirtualPaper.UI.Test.T_Draft { + [TestClass] + public class GetStartViewModelTests { + private Mock _userSettingsClient = null!; + private GetStartViewModel _vm = null!; + + [TestInitialize] + public void Setup() { + CrossThreadInvoker.Initialize(new T_UiSynchronizationContext()); + _userSettingsClient = new Mock(); + _userSettingsClient.Setup(u => u.RecentUseds) + .Returns(new List()); + + _vm = new GetStartViewModel(_userSettingsClient.Object); + } + + // ── InitCollection ──────────────────────────────────────────── + + [TestMethod] + public void InitCollection_WhenCalled_PopulatesRecentUseds() { + var item1 = MakeRecentUsed("Alpha.jpg", @"C:\wp\Alpha.jpg"); + var item2 = MakeRecentUsed("Beta.jpg", @"C:\wp\Beta.jpg"); + _userSettingsClient.Setup(u => u.RecentUseds) + .Returns(new List { item1, item2 }); + + _vm.InitCollection(); + + Assert.HasCount(2, _vm.RecentUseds); + } + + [TestMethod] + public void InitCollection_WhenCalledTwice_DoesNotDuplicate() { + var item = MakeRecentUsed("Alpha.jpg", @"C:\wp\Alpha.jpg"); + _userSettingsClient.Setup(u => u.RecentUseds) + .Returns(new List { item }); + + _vm.InitCollection(); + _vm.InitCollection(); + + Assert.HasCount(1, _vm.RecentUseds); + } + + [TestMethod] + public void InitCollection_WhenSourceIsEmpty_RecentUsedsIsEmpty() { + _userSettingsClient.Setup(u => u.RecentUseds) + .Returns(new List()); + + _vm.InitCollection(); + + Assert.HasCount(0, _vm.RecentUseds); + } + + // ── FilterByTitle ───────────────────────────────────────────── + + [TestMethod] + public void FilterByTitle_WhenKeywordMatches_ShowsMatchingItems() { + var match = MakeRecentUsed("Sunset.jpg", @"C:\wp\Sunset.jpg"); + var noMatch = MakeRecentUsed("CityNight.jpg", @"C:\wp\CityNight.jpg"); + PopulateCollection(match, noMatch); + + _vm.FilterByTitle("Sunset"); + + Assert.HasCount(1, _vm.RecentUseds); + Assert.AreSame(match, _vm.RecentUseds[0]); + } + + [TestMethod] + public void FilterByTitle_WhenKeywordEmpty_ShowsAllItems() { + var a = MakeRecentUsed("Alpha.jpg", @"C:\wp\Alpha.jpg"); + var b = MakeRecentUsed("Beta.jpg", @"C:\wp\Beta.jpg"); + PopulateCollection(a, b); + + _vm.FilterByTitle(string.Empty); + + Assert.HasCount(2, _vm.RecentUseds); + } + + [TestMethod] + public void FilterByTitle_IsCaseInsensitive() { + var item = MakeRecentUsed("Forest.jpg", @"C:\wp\Forest.jpg"); + PopulateCollection(item); + + _vm.FilterByTitle("forest"); + + Assert.HasCount(1, _vm.RecentUseds); + } + + [TestMethod] + public void FilterByTitle_WhenNoMatch_RecentUsedsIsEmpty() { + var item = MakeRecentUsed("Mountain.jpg", @"C:\wp\Mountain.jpg"); + PopulateCollection(item); + + _vm.FilterByTitle("Ocean"); + + Assert.HasCount(0, _vm.RecentUseds); + } + + [TestMethod] + public void FilterByTitle_WhenCalledTwice_SecondFilterOverridesFirst() { + var a = MakeRecentUsed("Alpha.jpg", @"C:\wp\Alpha.jpg"); + var b = MakeRecentUsed("Beta.jpg", @"C:\wp\Beta.jpg"); + PopulateCollection(a, b); + + _vm.FilterByTitle("Alpha"); + _vm.FilterByTitle("Beta"); + + Assert.HasCount(1, _vm.RecentUseds); + Assert.AreSame(b, _vm.RecentUseds[0]); + } + + [TestMethod] + public void FilterByTitle_WhenFileNameIsNull_ItemIsExcluded() { + var nullName = MakeRecentUsed(null, @"C:\wp\unknown.jpg"); + PopulateCollection(nullName); + + _vm.FilterByTitle("any"); + + Assert.HasCount(0, _vm.RecentUseds); + } + + // ── ApplyFilter ─────────────────────────────────────────────── + + [TestMethod] + public void ApplyFilter_DelegatesToFilterByTitle() { + var item = MakeRecentUsed("Cherry.jpg", @"C:\wp\Cherry.jpg"); + PopulateCollection(item); + + _vm.ApplyFilter("Cherry"); + + Assert.HasCount(1, _vm.RecentUseds); + } + + // ── RemoveFromListCommand ───────────────────────────────────── + + [TestMethod] + public async Task RemoveFromListCommand_WhenItemNotNull_RemovesFromRecentUseds() { + var item = MakeRecentUsed("Alpha.jpg", @"C:\wp\Alpha.jpg"); + PopulateCollection(item); + _userSettingsClient.Setup(u => u.DeleteRecetUsedAsync(item)) + .Returns(Task.CompletedTask); + + await InvokeRemoveCommandAsync(item); + + Assert.HasCount(0, _vm.RecentUseds); + } + + [TestMethod] + public async Task RemoveFromListCommand_WhenItemNotNull_CallsDeleteRecetUsedAsync() { + var item = MakeRecentUsed("Alpha.jpg", @"C:\wp\Alpha.jpg"); + PopulateCollection(item); + _userSettingsClient.Setup(u => u.DeleteRecetUsedAsync(item)) + .Returns(Task.CompletedTask); + + await InvokeRemoveCommandAsync(item); + + _userSettingsClient.Verify(u => u.DeleteRecetUsedAsync(item), Times.Once); + } + + [TestMethod] + public async Task RemoveFromListCommand_WhenItemIsNull_DoesNotCallDelete() { + await InvokeRemoveCommandAsync(null); + + _userSettingsClient.Verify( + u => u.DeleteRecetUsedAsync(It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task RemoveFromListCommand_WhenItemNotNull_AlsoRemovesFromInternalList() { + var item = MakeRecentUsed("Alpha.jpg", @"C:\wp\Alpha.jpg"); + PopulateCollection(item); + _userSettingsClient.Setup(u => u.DeleteRecetUsedAsync(item)) + .Returns(Task.CompletedTask); + + await InvokeRemoveCommandAsync(item); + + // 过滤后内部列表已空,FilterByTitle 不会把 item 加回来 + _vm.FilterByTitle(string.Empty); + Assert.HasCount(0, _vm.RecentUseds); + } + + // ── Command 初始化 ───────────────────────────────────────────── + + [TestMethod] + public void RemoveFromListCommand_IsNotNull_AfterConstruction() { + Assert.IsNotNull(_vm.RemoveFromListCommand); + } + + [TestMethod] + public void CopyPathCommand_IsNotNull_AfterConstruction() { + Assert.IsNotNull(_vm.CopyPathCommand); + } + + [TestMethod] + public void ShowOnDiskCommand_IsNotNull_AfterConstruction() { + Assert.IsNotNull(_vm.ShowOnDiskCommand); + } + + [TestMethod] + public void RemoveFromListCommand_CanExecute_ReturnsTrue() { + Assert.IsTrue(_vm.RemoveFromListCommand!.CanExecute(null)); + } + + // ── IsElevated ───────────────────────────────────────────────── + + [TestMethod] + public void IsElevated_IsAssigned_AfterConstruction() { + var _ = _vm.IsElevated; + Assert.IsInstanceOfType(_vm.IsElevated, typeof(bool)); + } + + // ── 辅助方法 ────────────────────────────────────────────────── + + /// 同时写入 RecentUseds 和内部 _recentUseds + private void PopulateCollection(params IRecentUsed[] items) { + _userSettingsClient.Setup(u => u.RecentUseds) + .Returns(new List(items)); + _vm.InitCollection(); + } + + private static IRecentUsed MakeRecentUsed( + string? fileName, + string filePath) { + var mock = new Mock(); + mock.Setup(r => r.FileName).Returns(fileName); + mock.Setup(r => r.FilePath).Returns(filePath); + return mock.Object; + } + + /// RelayCommand<T> 内部为 async void,需等待一个 tick 让 Task 完成 + private async Task InvokeRemoveCommandAsync(IRecentUsed? item) { + // RelayCommand.Execute 触发 async void lambda + // 用 Task.Yield() 让控制权交还事件循环,等待异步完成 + _vm.RemoveFromListCommand!.Execute(item); + await Task.Yield(); + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_CheckAllSaveStatusTests.cs b/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_CheckAllSaveStatusTests.cs new file mode 100644 index 00000000..ec46015a --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_CheckAllSaveStatusTests.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Moq; +using VirtualPaper.Common; +using VirtualPaper.DraftPanel.ViewModels; +using VirtualPaper.Grpc.Client.Interfaces; +using VirtualPaper.UIComponent.Navigation; +using VirtualPaper.UIComponent.Navigation.TabView; +using VirtualPaper.UIComponent.Navigation.TabView.Interfaces; +using VirtualPaper.UIComponent.Utils.Adapter.Interfaces; +using Workloads.Utils.DraftUtils.Interfaces; + +namespace VirtualPaper.UI.Test.T_Draft { + [TestClass] + public class WorkSpaceViewModel_CheckAllSaveStatusTests { + private WorkSpaceViewModel _vm = null!; + private Mock _dialogService = null!; + + [TestInitialize] + public void Setup() { + _dialogService = new Mock(); + _vm = new WorkSpaceViewModel( + Mock.Of(), + _dialogService.Object); + } + + private Mock RegisterRuntime(bool isSaved, string fileName = "file.vp") { + var mockRuntime = new Mock(); + mockRuntime.Setup(r => r.FileName).Returns(fileName); + + var mockHeader = new Mock(); + mockHeader.SetupProperty(h => h.IsSaved, isSaved); + + var mockTabItem = new Mock(); + mockTabItem.SetupProperty(t => t.Tag, mockRuntime.Object); + + var dict = (Dictionary) + typeof(WorkSpaceViewModel) + .GetField("_runtimeToArcTab", + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance)! + .GetValue(_vm)!; + + dict[mockRuntime.Object] = (mockHeader.Object, mockTabItem.Object); + _vm.TabViewItems.Add(mockTabItem.Object); + + return mockRuntime; + } + + // ── 全部已保存 ──────────────────────────────────────────────────── + + [TestMethod] + public async Task CheckAllSaveStatusAsync_AllSaved_ReturnsTrue_NoDialog() { + RegisterRuntime(isSaved: true, "a.vp"); + RegisterRuntime(isSaved: true, "b.vp"); + + bool result = await _vm.CheckAllSaveStatusAsync(); + + Assert.IsTrue(result); + _dialogService.Verify( + d => d.ShowDialogAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task CheckAllSaveStatusAsync_AllSaved_ClearsAllTabs() { + RegisterRuntime(isSaved: true, "a.vp"); + RegisterRuntime(isSaved: true, "b.vp"); + + await _vm.CheckAllSaveStatusAsync(); + + Assert.IsEmpty(_vm.TabViewItems); + } + + // ── 有未保存,用户选 Primary(保存成功)────────────────────────── + + [TestMethod] + public async Task CheckAllSaveStatusAsync_Unsaved_UserSavesAll_ReturnsTrue() { + var r1 = RegisterRuntime(isSaved: false, "a.vp"); + var r2 = RegisterRuntime(isSaved: false, "b.vp"); + r1.Setup(r => r.SaveAsync()).ReturnsAsync(true); + r2.Setup(r => r.SaveAsync()).ReturnsAsync(true); + + _dialogService + .Setup(d => d.ShowDialogAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(DialogResult.Primary); + + bool result = await _vm.CheckAllSaveStatusAsync(); + + Assert.IsTrue(result); + r1.Verify(r => r.SaveAsync(), Times.Once); + r2.Verify(r => r.SaveAsync(), Times.Once); + } + + // ── 有未保存,用户选 Primary(保存失败)────────────────────────── + + [TestMethod] + public async Task CheckAllSaveStatusAsync_Unsaved_SaveFails_ReturnsFalse() { + var r1 = RegisterRuntime(isSaved: false, "a.vp"); + r1.Setup(r => r.SaveAsync()).ReturnsAsync(false); + + _dialogService + .Setup(d => d.ShowDialogAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(DialogResult.Primary); + + bool result = await _vm.CheckAllSaveStatusAsync(); + + Assert.IsFalse(result); + } + + // ── 有未保存,用户选 Secondary(不保存,继续)──────────────────── + + [TestMethod] + public async Task CheckAllSaveStatusAsync_Unsaved_UserDontSave_SkipsSave_ReturnsTrue() { + var r1 = RegisterRuntime(isSaved: false, "a.vp"); + + _dialogService + .Setup(d => d.ShowDialogAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(DialogResult.Secondary); + + bool result = await _vm.CheckAllSaveStatusAsync(); + + r1.Verify(r => r.SaveAsync(), Times.Never); + Assert.IsTrue(result); + } + + // ── 有未保存,用户选 None(取消)───────────────────────────────── + + [TestMethod] + public async Task CheckAllSaveStatusAsync_Unsaved_UserCancels_ReturnsFalse() { + RegisterRuntime(isSaved: false, "a.vp"); + + _dialogService + .Setup(d => d.ShowDialogAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(DialogResult.None); + + bool result = await _vm.CheckAllSaveStatusAsync(); + + Assert.IsFalse(result); + } + + // ── 混合:第一个已保存,第二个未保存且取消 ─────────────────────── + + [TestMethod] + public async Task CheckAllSaveStatusAsync_Mixed_SecondCancels_ReturnsFalse() { + RegisterRuntime(isSaved: true, "a.vp"); + RegisterRuntime(isSaved: false, "b.vp"); + + _dialogService + .Setup(d => d.ShowDialogAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(DialogResult.None); + + bool result = await _vm.CheckAllSaveStatusAsync(); + + Assert.IsFalse(result); + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_CheckSaveStatusTests.cs b/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_CheckSaveStatusTests.cs new file mode 100644 index 00000000..fd4981f3 --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_CheckSaveStatusTests.cs @@ -0,0 +1,153 @@ +using Moq; +using VirtualPaper.Common; +using VirtualPaper.DraftPanel.ViewModels; +using VirtualPaper.Grpc.Client.Interfaces; +using VirtualPaper.UIComponent.Navigation.TabView.Interfaces; +using VirtualPaper.UIComponent.Utils.Adapter.Interfaces; +using Workloads.Utils.DraftUtils.Interfaces; + +namespace VirtualPaper.UI.Test.T_Draft { + [TestClass] + public class WorkSpaceViewModel_CheckSaveStatusTests { + private WorkSpaceViewModel _vm = null!; + private Mock _dialogService = null!; + + [TestInitialize] + public void Setup() { + _dialogService = new Mock(); + _vm = new WorkSpaceViewModel( + Mock.Of(), + _dialogService.Object); + } + + /// + /// 向 ViewModel 内部 _runtimeToArcTab 注册一个 runtime, + /// 并同步加入 TabViewItems,模拟 AddToWorkSpace 效果。 + /// + private (Mock, IArcTabViewItem) RegisterRuntime(bool isSaved) { + var mockRuntime = new Mock(); + + var mockHeader = new Mock(); + mockHeader.SetupProperty(h => h.IsSaved, isSaved); + + var mockTabItem = new Mock(); + mockTabItem.SetupProperty(t => t.Tag, mockRuntime.Object); + + // 通过反射写入私有字典 + var dict = (Dictionary) + typeof(WorkSpaceViewModel) + .GetField("_runtimeToArcTab", + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance)! + .GetValue(_vm)!; + + dict[mockRuntime.Object] = (mockHeader.Object, mockTabItem.Object); + _vm.TabViewItems.Add(mockTabItem.Object); + + return (mockRuntime, mockTabItem.Object); + } + + // ── 已保存,直接关闭 ────────────────────────────────────────────── + + [TestMethod] + public async Task CheckSaveStatusAsync_AlreadySaved_ReturnsTrue_NoDialog() { + var (mockRuntime, _) = RegisterRuntime(isSaved: true); + + bool result = await _vm.CheckSaveStatusAsync(mockRuntime.Object); + + Assert.IsTrue(result); + _dialogService.Verify( + d => d.ShowDialogAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task CheckSaveStatusAsync_AlreadySaved_RemovesTabFromCollection() { + var (mockRuntime, tabItem) = RegisterRuntime(isSaved: true); + + await _vm.CheckSaveStatusAsync(mockRuntime.Object); + + Assert.DoesNotContain(tabItem, _vm.TabViewItems); + } + + // ── 未保存,用户选 Primary(保存)──────────────────────────────── + + [TestMethod] + public async Task CheckSaveStatusAsync_Unsaved_UserSaves_CallsSaveAsync() { + var (mockRuntime, _) = RegisterRuntime(isSaved: false); + mockRuntime.Setup(r => r.FileName).Returns("test.vp"); + mockRuntime.Setup(r => r.SaveAsync()).ReturnsAsync(true); + + _dialogService + .Setup(d => d.ShowDialogAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(DialogResult.Primary); + + bool result = await _vm.CheckSaveStatusAsync(mockRuntime.Object); + + mockRuntime.Verify(r => r.SaveAsync(), Times.Once); + Assert.IsTrue(result); + } + + [TestMethod] + public async Task CheckSaveStatusAsync_Unsaved_UserSaves_SaveFails_ReturnsFalse() { + var (mockRuntime, tabItem) = RegisterRuntime(isSaved: false); + mockRuntime.Setup(r => r.FileName).Returns("test.vp"); + mockRuntime.Setup(r => r.SaveAsync()).ReturnsAsync(false); + + _dialogService + .Setup(d => d.ShowDialogAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(DialogResult.Primary); + + bool result = await _vm.CheckSaveStatusAsync(mockRuntime.Object); + + Assert.IsFalse(result); + // 保存失败,Tab 不应被关闭 + Assert.Contains(tabItem, _vm.TabViewItems); + } + + // ── 未保存,用户选 Secondary(不保存直接关闭)──────────────────── + + [TestMethod] + public async Task CheckSaveStatusAsync_Unsaved_UserDontSave_ReturnsTrue_NoSave() { + var (mockRuntime, _) = RegisterRuntime(isSaved: false); + mockRuntime.Setup(r => r.FileName).Returns("test.vp"); + + _dialogService + .Setup(d => d.ShowDialogAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(DialogResult.Secondary); + + bool result = await _vm.CheckSaveStatusAsync(mockRuntime.Object); + + mockRuntime.Verify(r => r.SaveAsync(), Times.Never); + Assert.IsTrue(result); + } + + // ── 未保存,用户选 Close(取消)────────────────────────────────── + + [TestMethod] + public async Task CheckSaveStatusAsync_Unsaved_UserCancels_ReturnsFalse() { + var (mockRuntime, tabItem) = RegisterRuntime(isSaved: false); + mockRuntime.Setup(r => r.FileName).Returns("test.vp"); + + _dialogService + .Setup(d => d.ShowDialogAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(DialogResult.None); + + bool result = await _vm.CheckSaveStatusAsync(mockRuntime.Object); + + Assert.IsFalse(result); + // 取消时 Tab 不应被关闭 + Assert.Contains(tabItem, _vm.TabViewItems); + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_DisposeTests.cs b/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_DisposeTests.cs new file mode 100644 index 00000000..ce46aa4f --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_DisposeTests.cs @@ -0,0 +1,69 @@ +using Moq; +using VirtualPaper.DraftPanel.ViewModels; +using VirtualPaper.Grpc.Client.Interfaces; +using VirtualPaper.UIComponent.Navigation.TabView.Interfaces; +using VirtualPaper.UIComponent.Utils.Adapter.Interfaces; +using Workloads.Utils.DraftUtils.Interfaces; + +namespace VirtualPaper.UI.Test.T_Draft { + [TestClass] + public class WorkSpaceViewModel_DisposeTests { + private WorkSpaceViewModel _vm = null!; + + [TestInitialize] + public void Setup() { + _vm = new WorkSpaceViewModel( + Mock.Of(), + Mock.Of()); + } + + [TestMethod] + public void Dispose_ClearsTabViewItems() { + var mockTabItem1 = new Mock(); + var mockTabItem2 = new Mock(); + + _vm.TabViewItems.Add(mockTabItem1.Object); + _vm.TabViewItems.Add(mockTabItem2.Object); + + _vm.Dispose(); + + Assert.IsEmpty(_vm.TabViewItems); + } + + [TestMethod] + public void Dispose_NullsOutCommands() { + _vm.Dispose(); + + Assert.IsNull(_vm.MFI_SaveCommand); + Assert.IsNull(_vm.MFI_UndoCommand); + Assert.IsNull(_vm.MFI_RedoCommand); + Assert.IsNull(_vm.MFI_ManualCommand); + Assert.IsNull(_vm.MFI_AboutCommand); + } + + [TestMethod] + public void Dispose_CalledTwice_DoesNotThrow() { + _vm.Dispose(); + _vm.Dispose(); // 不应抛异常 + } + + [TestMethod] + public void Dispose_ClearsRuntimeToArcTabDict() { + var dict = (Dictionary) + typeof(WorkSpaceViewModel) + .GetField("_runtimeToArcTab", + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance)! + .GetValue(_vm)!; + + var mockHeader = new Mock(); + var mockTabItem = new Mock(); + + dict[Mock.Of()] = (mockHeader.Object, mockTabItem.Object); + + _vm.Dispose(); + + Assert.IsEmpty(dict); + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_GetSelectedRuntimeTests.cs b/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_GetSelectedRuntimeTests.cs new file mode 100644 index 00000000..9871c49c --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_GetSelectedRuntimeTests.cs @@ -0,0 +1,67 @@ +using Moq; +using VirtualPaper.DraftPanel.ViewModels; +using VirtualPaper.Grpc.Client.Interfaces; +using VirtualPaper.UIComponent.Navigation.TabView.Interfaces; +using VirtualPaper.UIComponent.Utils.Adapter.Interfaces; +using Workloads.Utils.DraftUtils.Interfaces; + +namespace VirtualPaper.UI.Test.T_Draft { + [TestClass] + public class WorkSpaceViewModel_GetSelectedRuntimeTests { + private WorkSpaceViewModel _vm = null!; + + [TestInitialize] + public void Setup() { + _vm = new WorkSpaceViewModel( + Mock.Of(), + Mock.Of()); + } + + [TestMethod] + public void GetSelectedRuntime_IndexIsMinusOne_ReturnsNull() { + _vm.SelectedTabIndex = -1; + + Assert.IsNull(_vm.GetSelectedRuntime()); + } + + [TestMethod] + public void GetSelectedRuntime_IndexOutOfRange_ReturnsNull() { + _vm.SelectedTabIndex = 99; + + Assert.IsNull(_vm.GetSelectedRuntime()); + } + + [TestMethod] + public void GetSelectedRuntime_ValidIndex_ReturnsRuntimeFromTag() { + var mockRuntime = new Mock(); + var tabItem = new Mock(); + tabItem.SetupProperty(t => t.Tag, mockRuntime.Object); + _vm.TabViewItems.Add(tabItem.Object); + _vm.SelectedTabIndex = 0; + + var result = _vm.GetSelectedRuntime(); + + Assert.AreSame(mockRuntime.Object, result); + } + + [TestMethod] + public void GetSelectedRuntime_TagIsNotIRuntime_ReturnsNull() { + var tabItem = new Mock(); + tabItem.SetupProperty(t => t.Tag, "not_a_runtime"); + _vm.TabViewItems.Add(tabItem.Object); + _vm.SelectedTabIndex = 0; + + Assert.IsNull(_vm.GetSelectedRuntime()); + } + + [TestMethod] + public void GetSelectedRuntime_TagIsNull_ReturnsNull() { + var tabItem = new Mock(); + tabItem.SetupProperty(t => t.Tag, null); + _vm.TabViewItems.Add(tabItem.Object); + _vm.SelectedTabIndex = 0; + + Assert.IsNull(_vm.GetSelectedRuntime()); + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_HandleExitItemsTests.cs b/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_HandleExitItemsTests.cs new file mode 100644 index 00000000..6fdd4200 --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_HandleExitItemsTests.cs @@ -0,0 +1,159 @@ +using Moq; +using VirtualPaper.Common; +using VirtualPaper.DraftPanel.ViewModels; +using VirtualPaper.Grpc.Client.Interfaces; +using VirtualPaper.UIComponent.Navigation.TabView.Interfaces; +using VirtualPaper.UIComponent.Utils.Adapter.Interfaces; +using Workloads.Utils.DraftUtils.Interfaces; + +namespace VirtualPaper.UI.Test.T_Draft { + [TestClass] + public class WorkSpaceViewModel_HandleExitItemsTests { + private WorkSpaceViewModel _vm = null!; + private Mock _dialogService = null!; + + [TestInitialize] + public void Setup() { + _dialogService = new Mock(); + _vm = new WorkSpaceViewModel( + Mock.Of(), + _dialogService.Object); + } + + private Mock RegisterRuntime(bool isSaved, string fileName = "file.vp") { + var mockRuntime = new Mock(); + mockRuntime.Setup(r => r.FileName).Returns(fileName); + + var mockHeader = new Mock(); + mockHeader.SetupProperty(h => h.IsSaved, isSaved); + + var mockTabItem = new Mock(); + mockTabItem.SetupProperty(t => t.Tag, mockRuntime.Object); + + var dict = (Dictionary) + typeof(WorkSpaceViewModel) + .GetField("_runtimeToArcTab", + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance)! + .GetValue(_vm)!; + + dict[mockRuntime.Object] = (mockHeader.Object, mockTabItem.Object); + _vm.TabViewItems.Add(mockTabItem.Object); + + return mockRuntime; + } + + private static async Task> CollectAsync(IAsyncEnumerable source) { + var list = new List(); + await foreach (var item in source) list.Add(item); + return list; + } + + // ── 已保存,不弹窗,不 yield ────────────────────────────────────── + + [TestMethod] + public async Task HandleExitItemsAsync_SavedTabs_NotYielded() { + RegisterRuntime(isSaved: true, "a.vp"); + + var yielded = await CollectAsync(_vm.HandleExitItemsAsync()); + + Assert.IsEmpty(yielded); + _dialogService.Verify( + d => d.ShowDialogAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + // ── 未保存,用户选 Primary(保存成功),yield ───────────────────── + + [TestMethod] + public async Task HandleExitItemsAsync_Unsaved_UserSaves_TabYielded() { + var r = RegisterRuntime(isSaved: false, "a.vp"); + r.Setup(x => x.SaveAsync()).ReturnsAsync(true); + + _dialogService + .Setup(d => d.ShowDialogAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(DialogResult.Primary); + + var yielded = await CollectAsync(_vm.HandleExitItemsAsync()); + + Assert.HasCount(1, yielded); + r.Verify(x => x.SaveAsync(), Times.Once); + } + + // ── 未保存,用户选 Primary(保存失败),不 yield ─────────────────── + + [TestMethod] + public async Task HandleExitItemsAsync_Unsaved_SaveFails_TabNotYielded() { + var r = RegisterRuntime(isSaved: false, "a.vp"); + r.Setup(x => x.SaveAsync()).ReturnsAsync(false); + + _dialogService + .Setup(d => d.ShowDialogAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(DialogResult.Primary); + + var yielded = await CollectAsync(_vm.HandleExitItemsAsync()); + + Assert.IsEmpty(yielded); + } + + // ── 未保存,用户选 Secondary(不保存),yield ───────────────────── + + [TestMethod] + public async Task HandleExitItemsAsync_Unsaved_UserDontSave_TabYielded() { + RegisterRuntime(isSaved: false, "a.vp"); + + _dialogService + .Setup(d => d.ShowDialogAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(DialogResult.Secondary); + + var yielded = await CollectAsync(_vm.HandleExitItemsAsync()); + + Assert.HasCount(1, yielded); + } + + // ── 未保存,用户关闭弹窗(None),不 yield ──────────────────────── + + [TestMethod] + public async Task HandleExitItemsAsync_Unsaved_UserClosesDialog_TabNotYielded() { + RegisterRuntime(isSaved: false, "a.vp"); + + _dialogService + .Setup(d => d.ShowDialogAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(DialogResult.None); + + var yielded = await CollectAsync(_vm.HandleExitItemsAsync()); + + Assert.IsEmpty(yielded); + } + + // ── 多个 Tab,部分关闭 ──────────────────────────────────────────── + + [TestMethod] + public async Task HandleExitItemsAsync_Mixed_OnlyConfirmedTabsYielded() { + RegisterRuntime(isSaved: true, "saved.vp"); + var unsaved = RegisterRuntime(isSaved: false, "unsaved.vp"); + unsaved.Setup(x => x.SaveAsync()).ReturnsAsync(true); + + _dialogService + .Setup(d => d.ShowDialogAsync( + It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync(DialogResult.Primary); + + var yielded = await CollectAsync(_vm.HandleExitItemsAsync()); + + // saved 不弹窗不 yield,unsaved 保存成功后 yield + Assert.HasCount(1, yielded); + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_OnTabItemsChangedTests.cs b/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_OnTabItemsChangedTests.cs new file mode 100644 index 00000000..64c8e72b --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_OnTabItemsChangedTests.cs @@ -0,0 +1,194 @@ +using Moq; +using VirtualPaper.DraftPanel.ViewModels; +using VirtualPaper.Grpc.Client.Interfaces; +using VirtualPaper.UIComponent.Navigation; +using VirtualPaper.UIComponent.Navigation.TabView.Interfaces; +using VirtualPaper.UIComponent.Utils.Adapter.Interfaces; +using Windows.Foundation.Collections; + +namespace VirtualPaper.UI.Test.T_Draft { + [TestClass] + public class WorkSpaceViewModel_OnTabItemsChangedTests { + private WorkSpaceViewModel _vm = null!; + + [TestInitialize] + public void Setup() { + _vm = new WorkSpaceViewModel( + Mock.Of(), + Mock.Of()); + } + + private static IVectorChangedEventArgs MakeArgs(CollectionChange change, uint index) { + var mock = new Mock(); + mock.Setup(a => a.CollectionChange).Returns(change); + mock.Setup(a => a.Index).Returns(index); + return mock.Object; + } + + // ── 空集合 ──────────────────────────────────────────────────────── + + [TestMethod] + public void OnTabItemsChanged_EmptyCollection_SetsMinusOne() { + // TabViewItems 为空,无论任何事件类型都应重置为 -1 + var args = MakeArgs(CollectionChange.ItemInserted, 0); + + _vm.OnTabItemsChanged(null!, args); + + Assert.AreEqual(-1, _vm.SelectedTabIndex); + } + + // ── ItemInserted ────────────────────────────────────────────────── + + [TestMethod] + public void OnTabItemsChanged_ItemInserted_SetsSelectedToInsertedIndex() { + var mockTabItem1 = new Mock(); + var mockTabItem2 = new Mock(); + + _vm.TabViewItems.Add(mockTabItem1.Object); + _vm.TabViewItems.Add(mockTabItem2.Object); + var args = MakeArgs(CollectionChange.ItemInserted, 1); + + _vm.OnTabItemsChanged(null!, args); + + Assert.AreEqual(1, _vm.SelectedTabIndex); + } + + [TestMethod] + public void OnTabItemsChanged_ItemInserted_FirstItem_SetsSelectedToZero() { + var mockTabItem1 = new Mock(); + _vm.TabViewItems.Add(mockTabItem1.Object); + var args = MakeArgs(CollectionChange.ItemInserted, 0); + + _vm.OnTabItemsChanged(null!, args); + + Assert.AreEqual(0, _vm.SelectedTabIndex); + } + + // ── ItemRemoved:移除当前选中项 ─────────────────────────────────── + + [TestMethod] + public void OnTabItemsChanged_ItemRemoved_WasSelected_HasPrevious_SelectsPrevious() { + var mockTabItem1 = new Mock(); + var mockTabItem2 = new Mock(); + + _vm.TabViewItems.Add(mockTabItem1.Object); + _vm.TabViewItems.Add(mockTabItem2.Object); + _vm.SelectedTabIndex = 1; + + _vm.TabViewItems.RemoveAt(1); + var args = MakeArgs(CollectionChange.ItemRemoved, 1); + _vm.OnTabItemsChanged(null!, args); + + Assert.AreEqual(0, _vm.SelectedTabIndex); + } + + [TestMethod] + public void OnTabItemsChanged_ItemRemoved_WasSelectedFirst_HasNext_SelectsZero() { + var mockTabItem1 = new Mock(); + var mockTabItem2 = new Mock(); + + _vm.TabViewItems.Add(mockTabItem1.Object); + _vm.TabViewItems.Add(mockTabItem2.Object); + _vm.SelectedTabIndex = 0; + + _vm.TabViewItems.RemoveAt(0); + var args = MakeArgs(CollectionChange.ItemRemoved, 0); + _vm.OnTabItemsChanged(null!, args); + + // 前一个不存在,选后一个(newIndex 从 -1 → 0) + Assert.AreEqual(0, _vm.SelectedTabIndex); + } + + [TestMethod] + public void OnTabItemsChanged_ItemRemoved_LastItem_SetsMinusOne() { + var mockTabItem1 = new Mock(); + _vm.TabViewItems.Add(mockTabItem1.Object); + _vm.SelectedTabIndex = 0; + + _vm.TabViewItems.RemoveAt(0); + var args = MakeArgs(CollectionChange.ItemRemoved, 0); + _vm.OnTabItemsChanged(null!, args); + + Assert.AreEqual(-1, _vm.SelectedTabIndex); + } + + // ── ItemRemoved:移除非选中项(在选中项之前)────────────────────── + + [TestMethod] + public void OnTabItemsChanged_ItemRemoved_BeforeSelected_DecrementsIndex() { + var mockTabItem1 = new Mock(); + var mockTabItem2 = new Mock(); + var mockTabItem3 = new Mock(); + + _vm.TabViewItems.Add(mockTabItem1.Object); + _vm.TabViewItems.Add(mockTabItem2.Object); + _vm.TabViewItems.Add(mockTabItem3.Object); + _vm.SelectedTabIndex = 2; + + _vm.TabViewItems.RemoveAt(0); + var args = MakeArgs(CollectionChange.ItemRemoved, 0); + _vm.OnTabItemsChanged(null!, args); + + Assert.AreEqual(1, _vm.SelectedTabIndex); + } + + // ── ItemRemoved:移除非选中项(在选中项之后)────────────────────── + + [TestMethod] + public void OnTabItemsChanged_ItemRemoved_AfterSelected_IndexUnchanged() { + var mockTabItem1 = new Mock(); + var mockTabItem2 = new Mock(); + var mockTabItem3 = new Mock(); + + _vm.TabViewItems.Add(mockTabItem1.Object); + _vm.TabViewItems.Add(mockTabItem2.Object); + _vm.TabViewItems.Add(mockTabItem3.Object); + _vm.SelectedTabIndex = 0; + + _vm.TabViewItems.RemoveAt(2); + var args = MakeArgs(CollectionChange.ItemRemoved, 2); + _vm.OnTabItemsChanged(null!, args); + + Assert.AreEqual(0, _vm.SelectedTabIndex); + } + + // ── Reset ───────────────────────────────────────────────────────── + + [TestMethod] + public void OnTabItemsChanged_Reset_NonEmptyCollection_SelectsZero() { + var mockTabItem1 = new Mock(); + var mockTabItem2 = new Mock(); + + _vm.TabViewItems.Add(mockTabItem1.Object); + _vm.TabViewItems.Add(mockTabItem2.Object); + var args = MakeArgs(CollectionChange.Reset, 0); + + _vm.OnTabItemsChanged(null!, args); + + Assert.AreEqual(0, _vm.SelectedTabIndex); + } + + [TestMethod] + public void OnTabItemsChanged_Reset_EmptyCollection_SetsMinusOne() { + var args = MakeArgs(CollectionChange.Reset, 0); + + _vm.OnTabItemsChanged(null!, args); + + Assert.AreEqual(-1, _vm.SelectedTabIndex); + } + + // ── ItemChanged(不处理)───────────────────────────────────────── + + [TestMethod] + public void OnTabItemsChanged_ItemChanged_IndexUnchanged() { + var mockTabItem1 = new Mock(); + _vm.TabViewItems.Add(mockTabItem1.Object); + _vm.SelectedTabIndex = 0; + var args = MakeArgs(CollectionChange.ItemChanged, 0); + + _vm.OnTabItemsChanged(null!, args); + + Assert.AreEqual(0, _vm.SelectedTabIndex); + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_SelectedTabIndexTests.cs b/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_SelectedTabIndexTests.cs new file mode 100644 index 00000000..65368d0a --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_Draft/WorkSpaceViewModel_SelectedTabIndexTests.cs @@ -0,0 +1,49 @@ +using Moq; +using VirtualPaper.DraftPanel.ViewModels; +using VirtualPaper.Grpc.Client.Interfaces; +using VirtualPaper.UIComponent.Utils.Adapter.Interfaces; + +namespace VirtualPaper.UI.Test.T_Draft { + [TestClass] + public class WorkSpaceViewModel_SelectedTabIndexTests { + private WorkSpaceViewModel _vm = null!; + + [TestInitialize] + public void Setup() { + _vm = new WorkSpaceViewModel( + Mock.Of(), + Mock.Of()); + } + + [TestMethod] + public void SelectedTabIndex_SameValue_DoesNotFirePropertyChanged() { + _vm.SelectedTabIndex = 0; + int changeCount = 0; + _vm.PropertyChanged += (_, e) => { + if (e.PropertyName == nameof(_vm.SelectedTabIndex)) changeCount++; + }; + + _vm.SelectedTabIndex = 0; + + Assert.AreEqual(0, changeCount); + } + + [TestMethod] + public void SelectedTabIndex_DifferentValue_FiresPropertyChanged() { + _vm.SelectedTabIndex = 0; + int changeCount = 0; + _vm.PropertyChanged += (_, e) => { + if (e.PropertyName == nameof(_vm.SelectedTabIndex)) changeCount++; + }; + + _vm.SelectedTabIndex = 1; + + Assert.AreEqual(1, changeCount); + } + + [TestMethod] + public void SelectedTabIndex_DefaultValue_IsMinusOne() { + Assert.AreEqual(-1, _vm.SelectedTabIndex); + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_WpSettings/AddToLibViewModelTests.cs b/src/VirtualPaper.UI.Test/T_WpSettings/AddToLibViewModelTests.cs new file mode 100644 index 00000000..c76a1ba0 --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_WpSettings/AddToLibViewModelTests.cs @@ -0,0 +1,301 @@ +using Moq; +using VirtualPaper.Common.Utils.Storage.Adapter; +using VirtualPaper.WpSettingsPanel.ViewModels; +using Windows.Storage; + +namespace VirtualPaper.UI.Test.T_WpSettings { + [TestClass] + public class AddToLibViewModelTests { + private AddToLibViewModel _vm = null!; + private Mock _storagePicker = null!; + + [TestInitialize] + public void Setup() { + _storagePicker = new Mock(); + + _vm = new AddToLibViewModel(_storagePicker.Object); + } + + // ── Command 初始化 ───────────────────────────────────────────── + + [TestMethod] + public void HandleAddFilesCommand_IsNotNull_AfterConstruction() { + Assert.IsNotNull(_vm.HandleAddFilesCommand); + } + + [TestMethod] + public void HandleAddFoldersCommand_IsNotNull_AfterConstruction() { + Assert.IsNotNull(_vm.HandleAddFoldersCommand); + } + + [TestMethod] + public void HandleAddFilesCommand_CanExecute_ReturnsTrue() { + Assert.IsTrue(_vm.HandleAddFilesCommand!.CanExecute(null)); + } + + [TestMethod] + public void HandleAddFoldersCommand_CanExecute_ReturnsTrue() { + Assert.IsTrue(_vm.HandleAddFoldersCommand!.CanExecute(null)); + } + + // ── IsElevated ───────────────────────────────────────────────── + + [TestMethod] + public void IsElevated_IsAssigned_AfterConstruction() { + // UAC.IsElevated 为静态,只能验证属性已被赋值(bool 默认 false) + // 在非提权环境下通常为 false,仅做类型安全断言 + var _ = _vm.IsElevated; // 不抛异常即通过 + Assert.IsInstanceOfType(_vm.IsElevated, typeof(bool)); + } + + // ── AddWallpaperFiles → OnRequestAddFile 事件 ────────────────── + + [TestMethod] + public void AddWallpaperFiles_WhenCalled_RaisesOnRequestAddFileEvent() { + IReadOnlyList? received = null; + _vm.OnRequestAddFile += (_, e) => received = e; + + var files = new List().AsReadOnly(); + _vm.AddWallpaperFiles(files); + + Assert.IsNotNull(received); + } + + [TestMethod] + public void AddWallpaperFiles_WhenCalled_PassesCorrectFilesInEvent() { + IReadOnlyList? received = null; + _vm.OnRequestAddFile += (_, e) => received = e; + + var mockItem = new Mock(); + var files = new List { mockItem.Object }.AsReadOnly(); + _vm.AddWallpaperFiles(files); + + Assert.AreSame(files, received); + } + + [TestMethod] + public void AddWallpaperFiles_WhenNoSubscriber_DoesNotThrow() { + var files = new List().AsReadOnly(); + + // 无订阅者,不应抛出 NullReferenceException + _vm.AddWallpaperFiles(files); + } + + [TestMethod] + public void AddWallpaperFiles_SenderIsViewModel() { + object? senderReceived = null; + _vm.OnRequestAddFile += (s, _) => senderReceived = s; + + _vm.AddWallpaperFiles(new List().AsReadOnly()); + + Assert.AreSame(_vm, senderReceived); + } + + // ── AddWallpaperFolder → OnRequestAddFolder 事件 ─────────────── + + [TestMethod] + public void AddWallpaperFolder_WhenCalled_RaisesOnRequestAddFolderEvent() { + IStorageFolder? received = null; + _vm.OnRequestAddFolder += (_, e) => received = e; + + var mockFolder = new Mock(); + _vm.AddWallpaperFolder(mockFolder.Object); + + Assert.IsNotNull(received); + } + + [TestMethod] + public void AddWallpaperFolder_WhenCalled_PassesCorrectFolderInEvent() { + IStorageFolder? received = null; + _vm.OnRequestAddFolder += (_, e) => received = e; + + var mockFolder = new Mock(); + _vm.AddWallpaperFolder(mockFolder.Object); + + Assert.AreSame(mockFolder.Object, received); + } + + [TestMethod] + public void AddWallpaperFolder_WhenNoSubscriber_DoesNotThrow() { + var mockFolder = new Mock(); + + _vm.AddWallpaperFolder(mockFolder.Object); + } + + [TestMethod] + public void AddWallpaperFolder_SenderIsViewModel() { + object? senderReceived = null; + _vm.OnRequestAddFolder += (s, _) => senderReceived = s; + + var mockFolder = new Mock(); + _vm.AddWallpaperFolder(mockFolder.Object); + + Assert.AreSame(_vm, senderReceived); + } + + // ── 多订阅者 ──────────────────────────────────────────────────── + + [TestMethod] + public void AddWallpaperFiles_WhenMultipleSubscribers_AllReceiveEvent() { + int callCount = 0; + _vm.OnRequestAddFile += (_, _) => callCount++; + _vm.OnRequestAddFile += (_, _) => callCount++; + + _vm.AddWallpaperFiles(new List().AsReadOnly()); + + Assert.AreEqual(2, callCount); + } + + [TestMethod] + public void AddWallpaperFolder_WhenMultipleSubscribers_AllReceiveEvent() { + int callCount = 0; + _vm.OnRequestAddFolder += (_, _) => callCount++; + _vm.OnRequestAddFolder += (_, _) => callCount++; + + _vm.AddWallpaperFolder(new Mock().Object); + + Assert.AreEqual(2, callCount); + } + + // ── FileBrowseActionAsync ────────────────────────────────────── + + [TestMethod] + public async Task FileBrowseActionAsync_WhenPickerReturnsNull_DoesNotRaiseEvent() { + var picker = new Mock(); + picker.Setup(p => p.PickFilesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IStorageItem[]?)null); + var vm = new AddToLibViewModel(picker.Object); + + bool raised = false; + vm.OnRequestAddFile += (_, _) => raised = true; + + await InvokeFileBrowseAsync(vm); + + Assert.IsFalse(raised); + } + + [TestMethod] + public async Task FileBrowseActionAsync_WhenPickerReturnsEmpty_DoesNotRaiseEvent() { + var picker = new Mock(); + picker.Setup(p => p.PickFilesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + var vm = new AddToLibViewModel(picker.Object); + + bool raised = false; + vm.OnRequestAddFile += (_, _) => raised = true; + + await InvokeFileBrowseAsync(vm); + + Assert.IsFalse(raised); + } + + [TestMethod] + public async Task FileBrowseActionAsync_WhenPickerReturnsFiles_RaisesOnRequestAddFileEvent() { + var mockItem = new Mock(); + var picker = new Mock(); + picker.Setup(p => p.PickFilesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new[] { mockItem.Object }); + var vm = new AddToLibViewModel(picker.Object); + + IReadOnlyList? received = null; + vm.OnRequestAddFile += (_, e) => received = e; + + await InvokeFileBrowseAsync(vm); + + Assert.IsNotNull(received); + Assert.AreEqual(1, received!.Count); + } + + // ── FolderBrowseActionAsync ──────────────────────────────────── + + [TestMethod] + public async Task FolderBrowseActionAsync_WhenPickerReturnsNull_DoesNotRaiseEvent() { + var picker = new Mock(); + picker.Setup(p => p.PickFolderAsync(It.IsAny())) + .ReturnsAsync((IStorageFolder?)null); + var vm = new AddToLibViewModel(picker.Object); + + bool raised = false; + vm.OnRequestAddFolder += (_, _) => raised = true; + + await InvokeFolderBrowseAsync(vm); + + Assert.IsFalse(raised); + } + + [TestMethod] + public async Task FolderBrowseActionAsync_WhenPickerReturnsFolder_RaisesOnRequestAddFolderEvent() { + var mockFolder = new Mock(); + var picker = new Mock(); + picker.Setup(p => p.PickFolderAsync(It.IsAny())) + .ReturnsAsync(mockFolder.Object); + var vm = new AddToLibViewModel(picker.Object); + + IStorageFolder? received = null; + vm.OnRequestAddFolder += (_, e) => received = e; + + await InvokeFolderBrowseAsync(vm); + + Assert.AreSame(mockFolder.Object, received); + } + + // ── 私有方法反射调用辅助 ─────────────────────────────────────── + + private static Task InvokeFileBrowseAsync(AddToLibViewModel vm) { + var method = typeof(AddToLibViewModel) + .GetMethod("FileBrowseActionAsync", + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance)!; + return (Task)method.Invoke(vm, null)!; + } + + private static Task InvokeFolderBrowseAsync(AddToLibViewModel vm) { + var method = typeof(AddToLibViewModel) + .GetMethod("FolderBrowseActionAsync", + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance)!; + return (Task)method.Invoke(vm, null)!; + } + + // ── FileBrowseActionAsync 扩展名硬编码验证 ──────────────────── + + [TestMethod] + public async Task FileBrowseActionAsync_ExtensionsMatchExpectedExactly() { + string[]? capturedExtensions = null; + _storagePicker + .Setup(p => p.PickFilesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, exts, _) => capturedExtensions = exts) + .ReturnsAsync((IStorageItem[]?)null); + + await InvokeFileBrowseAsync(_vm); + + // 只要 FileTypeToExtension 里任何一个后缀被增删改,测试立即失败 + string[] expected = [ + // FImage + ".jpg", ".jpeg", ".bmp", ".png", ".svg", ".webp", + // FGif + ".gif", ".apng", + // FVideo + ".mp4", ".webm", + ]; + + Assert.IsNotNull(capturedExtensions); + CollectionAssert.AreEquivalent(expected, capturedExtensions, + $"Picker received: [{string.Join(", ", capturedExtensions!)}]\n" + + $"Expected: [{string.Join(", ", expected)}]"); + } + + [TestMethod] + public async Task FileBrowseActionAsync_PassesMultiSelectTrueToPickerAsync() { + bool? capturedMultiSelect = null; + _storagePicker + .Setup(p => p.PickFilesAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((_, _, multi) => capturedMultiSelect = multi) + .ReturnsAsync((IStorageItem[]?)null); + + await InvokeFileBrowseAsync(_vm); + + Assert.IsTrue(capturedMultiSelect, "multiSelect should be true."); + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_WpSettings/FileFilterExtensionMapTests.cs b/src/VirtualPaper.UI.Test/T_WpSettings/FileFilterExtensionMapTests.cs new file mode 100644 index 00000000..16581214 --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_WpSettings/FileFilterExtensionMapTests.cs @@ -0,0 +1,37 @@ +using VirtualPaper.Common; +using VirtualPaper.Common.Utils.Files; + +namespace VirtualPaper.UI.Test.T_WpSettings { + // ── FileFilter.FileTypeToExtension 内容锁定 ─────────────────── + + [TestClass] + public class FileFilterExtensionMapTests { + + [TestMethod] + public void FileTypeToExtension_FImage_ContainsExactExpectedExtensions() { + string[] expected = [".jpg", ".jpeg", ".bmp", ".png", ".svg", ".webp"]; + CollectionAssert.AreEquivalent( + expected, + FileFilter.FileTypeToExtension[FileType.FImage], + "FImage extensions changed. Update the picker filter and this test together."); + } + + [TestMethod] + public void FileTypeToExtension_FGif_ContainsExactExpectedExtensions() { + string[] expected = [".gif", ".apng"]; + CollectionAssert.AreEquivalent( + expected, + FileFilter.FileTypeToExtension[FileType.FGif], + "FGif extensions changed."); + } + + [TestMethod] + public void FileTypeToExtension_FVideo_ContainsExactExpectedExtensions() { + string[] expected = [".mp4", ".webm"]; + CollectionAssert.AreEquivalent( + expected, + FileFilter.FileTypeToExtension[FileType.FVideo], + "FVideo extensions changed."); + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_WpSettings/FileFilterGetFileTypeTests.cs b/src/VirtualPaper.UI.Test/T_WpSettings/FileFilterGetFileTypeTests.cs new file mode 100644 index 00000000..a6a0b4f0 --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_WpSettings/FileFilterGetFileTypeTests.cs @@ -0,0 +1,245 @@ +using VirtualPaper.Common; +using VirtualPaper.Common.Utils.Files; + +namespace VirtualPaper.UI.Test.T_WpSettings { + [TestClass] + public class FileFilterGetFileTypeTests { + + private string _testDir = null!; + + [TestInitialize] + public void Setup() { + _testDir = Path.Combine(Path.GetTempPath(), $"FileFilterTest_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testDir); + } + + [TestCleanup] + public void Cleanup() { + if (Directory.Exists(_testDir)) + Directory.Delete(_testDir, recursive: true); + } + + // ── FImage ──────────────────────────────────────────────────── + + [TestMethod] + public void GetFileType_JpgWithValidHeader_ReturnsFImage() { + var path = CreateFile("test.jpg", Hex("FFD8FF E0 00 10 4A 46 49 46")); + Assert.AreEqual(FileType.FImage, FileFilter.GetFileType(path)); + } + + [TestMethod] + public void GetFileType_JpegWithValidHeader_ReturnsFImage() { + var path = CreateFile("test.jpeg", Hex("FFD8FF E0 00 10 4A 46 49 46")); + Assert.AreEqual(FileType.FImage, FileFilter.GetFileType(path)); + } + + [TestMethod] + public void GetFileType_BmpWithValidHeader_ReturnsFImage() { + var path = CreateFile("test.bmp", Hex("424D 3A 00 00 00 00 00")); + Assert.AreEqual(FileType.FImage, FileFilter.GetFileType(path)); + } + + [TestMethod] + public void GetFileType_PngWithValidHeader_ReturnsFImage() { + // PNG 头但不含 acTL(不是 APNG) + var path = CreateFile("test.png", PngHeaderOnly()); + Assert.AreEqual(FileType.FImage, FileFilter.GetFileType(path)); + } + + [TestMethod] + public void GetFileType_SvgXmlWithValidHeader_ReturnsFImage() { + // 3C3F786D = " + /// 创建临时文件并写入指定字节,返回路径。 + /// 若 content 不足 48 字节,自动补零至 48 字节(保证 fs.Read 正常)。 + /// + private string CreateFile(string fileName, byte[] content) { + var path = Path.Combine(_testDir, fileName); + var padded = new byte[Math.Max(48, content.Length)]; + Array.Copy(content, padded, content.Length); + File.WriteAllBytes(path, padded); + return path; + } + + /// + /// 将带空格的 Hex 字符串转换为字节数组,方便直接写魔数。 + /// 例如 "FFD8FF E0" → [0xFF, 0xD8, 0xFF, 0xE0] + /// + private static byte[] Hex(string hex) { + var clean = hex.Replace(" ", ""); + var bytes = new byte[clean.Length / 2]; + for (int i = 0; i < bytes.Length; i++) + bytes[i] = Convert.ToByte(clean.Substring(i * 2, 2), 16); + return bytes; + } + + /// + /// 标准 PNG 头(不含 acTL),对应普通 .png 文件。 + /// 89504E470D0A1A0A + IHDR chunk 标识 + /// + private static byte[] PngHeaderOnly() { + // 8字节PNG签名 + IHDR chunk(不含acTL) + return Hex("89504E47 0D0A1A0A 00000001 49484452"); + } + + /// + /// 简化的 APNG 头:PNG 签名 + 前 48 字节内嵌入 "acTL" ASCII 字符串。 + /// GetFileType 用 ASCII 解码后检查 headerText.Contains("acTL")。 + /// 注意:真实 APNG 中 acTL chunk 的位置在 IHDR 之后,这里为测试简化处理。 + /// + private static byte[] ApngHeader() { + var header = new byte[48]; + + // PNG 签名(8 字节) + var sig = Hex("89504E470D0A1A0A"); + Array.Copy(sig, 0, header, 0, 8); + + // 模拟 IHDR chunk length + type(8字节) + var ihdrLen = Hex("0000000D"); // IHDR 长度 = 13 + var ihdrType = Hex("49484452"); // "IHDR" + Array.Copy(ihdrLen, 0, header, 8, 4); + Array.Copy(ihdrType, 0, header, 12, 4); + + // 在偏移 29 处写入 acTL(模拟 acTL chunk type) + // IHDR chunk: 4(len) + 4(type) + 13(data) + 4(crc) = 25 字节 + // 所以 acTL chunk 从偏移 8 + 25 = 33 开始 + var acTLLen = Hex("00000008"); // acTL 数据长度 + var acTL = System.Text.Encoding.ASCII.GetBytes("acTL"); + Array.Copy(acTLLen, 0, header, 33, 4); + Array.Copy(acTL, 0, header, 37, 4); + + return header; + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_WpSettings/LibraryContentsViewModelTests.cs b/src/VirtualPaper.UI.Test/T_WpSettings/LibraryContentsViewModelTests.cs new file mode 100644 index 00000000..e143ded2 --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_WpSettings/LibraryContentsViewModelTests.cs @@ -0,0 +1,296 @@ +using Moq; +using VirtualPaper.Common; +using VirtualPaper.Common.Utils.Storage.Adapter; +using VirtualPaper.Common.Utils.ThreadContext; +using VirtualPaper.Grpc.Client.Interfaces; +using VirtualPaper.Models.Cores; +using VirtualPaper.Models.Cores.Interfaces; +using VirtualPaper.UI.Test.Utils; +using VirtualPaper.WpSettingsPanel.Utils; +using VirtualPaper.WpSettingsPanel.Utils.Interfaces; +using VirtualPaper.WpSettingsPanel.ViewModels; + +namespace VirtualPaper.UI.Test.T_WpSettings { + [TestClass] + public class LibraryContentsViewModelTests { + private Mock _userSettingsClient = null!; + private Mock _wpControlClient = null!; + private Mock _wallpaperIndexService = null!; + private Mock _monitorManagerClient = null!; + private Mock _settings = null!; + private LibraryContentsViewModel _vm = null!; + private Mock _storagePicker = null!; + private Mock _primaryMonitor = null!; + + [TestInitialize] + public void Setup() { + CrossThreadInvoker.Initialize(new T_UiSynchronizationContext()); + _userSettingsClient = new Mock(); + _wpControlClient = new Mock(); + _wallpaperIndexService = new Mock(); + _settings = new Mock(); + + _settings.SetupProperty(s => s.WallpaperDir, @"C:\Wallpapers"); + _userSettingsClient.Setup(u => u.Settings).Returns(_settings.Object); + + SetupWpSettings(); + + _vm = new LibraryContentsViewModel( + _userSettingsClient.Object, + _wpControlClient.Object, + CreateVm(), + _wallpaperIndexService.Object); + } + + private void SetupWpSettings() { + _monitorManagerClient = new Mock(); + _primaryMonitor = new Mock(); + _storagePicker = new Mock(); + + _settings.SetupProperty(s => s.WallpaperArrangement, WallpaperArrangement.Per); + _userSettingsClient.Setup(u => u.Settings).Returns(_settings.Object); + + _primaryMonitor.Setup(m => m.CloneWithPrimaryInfo()).Returns(_primaryMonitor.Object); + _primaryMonitor.SetupProperty(m => m.Content); + _primaryMonitor.SetupProperty(m => m.SystemIndex, 0); + _monitorManagerClient.Setup(m => m.PrimaryMonitor).Returns(_primaryMonitor.Object); + } + + private WpSettingsViewModel CreateVm(IEnumerable? monitors = null) { + var list = monitors ?? new[] { _primaryMonitor.Object }; + _monitorManagerClient.Setup(m => m.Monitors).Returns(list.ToList().AsReadOnly()); + return new WpSettingsViewModel( + _monitorManagerClient.Object, + _wpControlClient.Object, + _userSettingsClient.Object, + _storagePicker.Object); + } + + // ── FilterByTitle ───────────────────────────────────────────── + + [TestMethod] + public void FilterByTitle_WhenKeywordMatches_ShowsMatchingItems() { + var match = MakeWpData("uid-1", "Nature Wallpaper"); + var noMatch = MakeWpData("uid-2", "City Night"); + PopulateLibrary(match, noMatch); + + _vm.FilterByTitle("Nature"); + + Assert.HasCount(1, _vm.LibraryWallpapers); + Assert.AreSame(match, _vm.LibraryWallpapers[0]); + } + + [TestMethod] + public void FilterByTitle_WhenKeywordEmpty_ShowsAllItems() { + var a = MakeWpData("uid-1", "Alpha"); + var b = MakeWpData("uid-2", "Beta"); + PopulateLibrary(a, b); + + _vm.FilterByTitle(string.Empty); + + Assert.HasCount(2, _vm.LibraryWallpapers); + } + + [TestMethod] + public void FilterByTitle_IsCaseInsensitive() { + var item = MakeWpData("uid-1", "Sunset"); + PopulateLibrary(item); + + _vm.FilterByTitle("sunset"); + + Assert.HasCount(1, _vm.LibraryWallpapers); + } + + [TestMethod] + public void FilterByTitle_WhenNoMatch_LibraryWallpapersIsEmpty() { + var item = MakeWpData("uid-1", "Forest"); + PopulateLibrary(item); + + _vm.FilterByTitle("Ocean"); + + Assert.HasCount(0, _vm.LibraryWallpapers); + } + + [TestMethod] + public void FilterByTitle_WhenCalledTwice_SecondFilterOverridesFirst() { + var a = MakeWpData("uid-1", "Mountain"); + var b = MakeWpData("uid-2", "Desert"); + PopulateLibrary(a, b); + + _vm.FilterByTitle("Mountain"); + _vm.FilterByTitle("Desert"); + + Assert.HasCount(1, _vm.LibraryWallpapers); + Assert.AreSame(b, _vm.LibraryWallpapers[0]); + } + + // ── FilterKeyword ───────────────────────────────────────────── + + [TestMethod] + public void FilterKeyword_DefaultValue_IsLibraryTitle() { + Assert.AreEqual(FilterKey.LibraryTitle, _vm.FilterKeyword); + } + + // ── ApplyFilter ─────────────────────────────────────────────── + + [TestMethod] + public void ApplyFilter_DelegatesToFilterByTitle() { + var item = MakeWpData("uid-1", "Cherry Blossom"); + PopulateLibrary(item); + + _vm.ApplyFilter("Cherry"); + + Assert.HasCount(1, _vm.LibraryWallpapers); + } + + // ── HandleDelete ────────────────────────────────────────────── + + [TestMethod] + public void HandleDelete_RemovesFromLibraryWallpapers() { + var item = MakeWpData("uid-1", "Galaxy"); + PopulateLibrary(item); + + _vm.HandleDelete(item); + + Assert.HasCount(0, _vm.LibraryWallpapers); + } + + [TestMethod] + public void HandleDelete_CallsWallpaperIndexServiceRemove() { + var item = MakeWpData("uid-1", "Galaxy"); + PopulateLibrary(item); + + _vm.HandleDelete(item); + + _wallpaperIndexService.Verify(s => s.Remove(item), Times.Once); + } + + [TestMethod] + public void HandleDelete_WhenItemNotExists_DoesNotThrow() { + var item = MakeWpData("uid-99", "Ghost"); + + // 不 populate,直接删除 + _vm.HandleDelete(item); + + _wallpaperIndexService.Verify(s => s.Remove(item), Times.Once); + } + + // ── UpdateLib (TryGetValue 命中) ────────────────────────────── + + [TestMethod] + public void UpdateLib_WhenIndexExists_ReplacesItemAtIndex() { + var original = MakeWpData("uid-1", "Old Title"); + PopulateLibrary(original); + + _wallpaperIndexService + .Setup(s => s.TryGetValue("uid-1", out It.Ref.IsAny)) + .Returns((string _, ref int idx) => { idx = 0; return true; }); + + var updated = MakeWpData("uid-1", "New Title"); + _vm.UpdateLib(updated); + + Assert.AreEqual("New Title", _vm.LibraryWallpapers[0].Title); + _wallpaperIndexService.Verify(s => s.Update(updated), Times.Once); + } + + [TestMethod] + public void UpdateLib_WhenIndexNotExists_InsertsAtFront() { + _wallpaperIndexService + .Setup(s => s.TryGetValue(It.IsAny(), out It.Ref.IsAny)) + .Returns((string _, ref int idx) => { idx = -1; return false; }); + + var newItem = MakeWpData("uid-new", "Brand New"); + _vm.UpdateLib(newItem); + + Assert.HasCount(1, _vm.LibraryWallpapers); + Assert.AreSame(newItem, _vm.LibraryWallpapers[0]); + _wallpaperIndexService.Verify(s => s.Update(newItem), Times.Once); + } + + // ── IsFileInPreview ─────────────────────────────────────────── + + [TestMethod] + public void IsFileInPreview_WhenNotInPreview_ReturnsFalse() { + var data = MakeWpData("uid-1", "Test"); + + var result = _vm.IsFileInPreview(data); + + Assert.IsFalse(result); + } + + // ── IsFileInUseAsync ────────────────────────────────────────── + + [TestMethod] + public async Task IsFileInUseAsync_WhenLayoutContainsFolderPath_ReturnsTrue() { + var data = MakeWpData("uid-1", "Test", folderPath: @"C:\Wallpapers\uid-1"); + var layout = MakeWallpaperLayout(@"C:\Wallpapers\uid-1"); + + _userSettingsClient.Setup(u => u.LoadAsync>()) + .Returns(Task.CompletedTask); + _userSettingsClient.Setup(u => u.WallpaperLayouts) + .Returns(new List { layout }); + + var result = await _vm.IsFileInUseAsync(data); + + Assert.IsTrue(result); + } + + [TestMethod] + public async Task IsFileInUseAsync_WhenLayoutDoesNotContainFolderPath_ReturnsFalse() { + var data = MakeWpData("uid-1", "Test", folderPath: @"C:\Wallpapers\uid-1"); + var layout = MakeWallpaperLayout(@"C:\Wallpapers\other"); + + _userSettingsClient.Setup(u => u.LoadAsync>()) + .Returns(Task.CompletedTask); + _userSettingsClient.Setup(u => u.WallpaperLayouts) + .Returns(new List { layout }); + + var result = await _vm.IsFileInUseAsync(data); + + Assert.IsFalse(result); + } + + // ── LibLoadingStatus property ───────────────────────────────── + + [TestMethod] + public void LibLoadingStatus_WhenSet_RaisesPropertyChanged() { + bool raised = false; + _vm.PropertyChanged += (_, e) => { + if (e.PropertyName == nameof(_vm.LibLoadingStatus)) raised = true; + }; + + _vm.LibLoadingStatus = LoadingStatus.Changing; + + Assert.IsTrue(raised); + } + + // ── 辅助方法 ────────────────────────────────────────────────── + + /// 将数据同时写入 LibraryWallpapers 和内部 _libraryWallpapers + private void PopulateLibrary(params IWpBasicData[] items) { + _vm.TestPopulate(items); + } + + private static WpBasicData MakeWpData( + string uid, + string title, + string folderPath = @"C:\Wallpapers\default", + FileType ftype = FileType.FImage) { + return new WpBasicData { + WallpaperUid = uid, + Title = title, + FolderPath = folderPath, + FilePath = $"C:\\Wallpapers\\{uid}\\file.png", + ThumbnailPath = $"C:\\Wallpapers\\{uid}\\thumb.png", + AppInfo = new ApplicationInfo { AppVersion = "1.0" }, + FType = ftype, + }; + } + + private static IWallpaperLayout MakeWallpaperLayout(string folderPath) { + var mock = new Mock(); + mock.Setup(l => l.FolderPath).Returns(folderPath); + return mock.Object; + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_WpSettings/ScreenSaverViewModelTests.cs b/src/VirtualPaper.UI.Test/T_WpSettings/ScreenSaverViewModelTests.cs new file mode 100644 index 00000000..ee757c81 --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_WpSettings/ScreenSaverViewModelTests.cs @@ -0,0 +1,354 @@ +using Moq; +using VirtualPaper.Common; +using VirtualPaper.Grpc.Client.Interfaces; +using VirtualPaper.Models; +using VirtualPaper.Models.Cores.Interfaces; +using VirtualPaper.UIComponent.Utils; +using VirtualPaper.WpSettingsPanel.ViewModels; + +namespace VirtualPaper.UI.Test.T_WpSettings { + [TestClass] + public class ScreenSaverViewModelTests { + private Mock _userSettingsClient = null!; + private Mock _scrCommandsClient = null!; + private Mock _settings = null!; + private ScreenSaverViewModel _vm = null!; + + [TestInitialize] + public void Setup() { + _userSettingsClient = new Mock(); + _scrCommandsClient = new Mock(); + _settings = new Mock(); + + _settings.SetupProperty(s => s.IsScreenSaverOn, false); + _settings.SetupProperty(s => s.IsRunningLock, false); + _settings.SetupProperty(s => s.WaitingTime, 1); + _settings.SetupProperty(s => s.ScreenSaverEffect, ScrEffect.None); + _settings.Setup(s => s.WhiteListScr).Returns(new List()); + + _userSettingsClient.Setup(u => u.Settings).Returns(_settings.Object); + + _vm = new ScreenSaverViewModel( + _userSettingsClient.Object, + _scrCommandsClient.Object); + } + + [TestCleanup] + public void Cleanup() { + _vm.Dispose(); + } + + // ── IsScreenSaverOn setter ──────────────────────────────────── + + [TestMethod] + public void IsScreenSaverOn_WhenSetTrue_CallsScrCommandsStart() { + _vm.IsScreenSaverOn = true; + + _scrCommandsClient.Verify(s => s.Start(), Times.Once); + } + + [TestMethod] + public void IsScreenSaverOn_WhenSetFalse_CallsScrCommandsStop() { + // 先设为 true 再设为 false,确保触发 Stop + _settings.Object.IsScreenSaverOn = true; + _vm.IsScreenSaverOn = true; // 先同步内部状态 + _scrCommandsClient.Invocations.Clear(); + + _vm.IsScreenSaverOn = false; + + _scrCommandsClient.Verify(s => s.Stop(), Times.Once); + } + + [TestMethod] + public void IsScreenSaverOn_WhenValueUnchanged_DoesNotCallSave() { + _settings.Object.IsScreenSaverOn = false; + _vm.IsScreenSaverOn = false; // 与 Settings 相同 + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Never); + } + + [TestMethod] + public void IsScreenSaverOn_WhenValueChanged_CallsSave() { + _settings.Object.IsScreenSaverOn = false; + + _vm.IsScreenSaverOn = true; + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Once); + } + + [TestMethod] + public void IsScreenSaverOn_WhenSetTrue_ScreenSaverStatuShowsOn() { + _vm.IsScreenSaverOn = true; + + Assert.AreEqual( + LanguageUtil.GetI18n(Constants.I18n.Text_On), + _vm.ScreenSaverStatu); + } + + [TestMethod] + public void IsScreenSaverOn_WhenSetFalse_ScreenSaverStatuShowsOff() { + _vm.IsScreenSaverOn = false; + + Assert.AreEqual( + LanguageUtil.GetI18n(Constants.I18n.Text_Off), + _vm.ScreenSaverStatu); + } + + // ── IsRunningLock setter ────────────────────────────────────── + + [TestMethod] + public void IsRunningLock_WhenValueChanged_CallsChangeLockStatu() { + _settings.Object.IsRunningLock = false; + + _vm.IsRunningLock = true; + + _scrCommandsClient.Verify(s => s.ChangeLockStatu(true), Times.Once); + } + + [TestMethod] + public void IsRunningLock_WhenValueUnchanged_DoesNotCallSave() { + _settings.Object.IsRunningLock = false; + _vm.IsRunningLock = false; + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Never); + } + + [TestMethod] + public void IsRunningLock_WhenValueChanged_CallsSave() { + _settings.Object.IsRunningLock = false; + + _vm.IsRunningLock = true; + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Once); + } + + // ── WaitingTime setter ──────────────────────────────────────── + + [TestMethod] + public void WaitingTime_WhenValueUnchanged_DoesNotCallSave() { + _settings.Object.WaitingTime = 5; + _vm.WaitingTime = 5; + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Never); + } + + [TestMethod] + public void WaitingTime_WhenValueChanged_CallsSave() { + _settings.Object.WaitingTime = 1; + + _vm.WaitingTime = 10; + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Once); + } + + [TestMethod] + public void WaitingTime_WhenValueChanged_UpdatesSettings() { + _settings.Object.WaitingTime = 1; + + _vm.WaitingTime = 15; + + Assert.AreEqual(15, _settings.Object.WaitingTime); + } + + // ── SeletedEffectIndx setter ────────────────────────────────── + + [TestMethod] + public void SeletedEffectIndx_WhenValueUnchanged_DoesNotCallSave() { + _settings.Object.ScreenSaverEffect = ScrEffect.None; // index = 0 + _vm.SeletedEffectIndx = 0; + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Never); + } + + [TestMethod] + public void SeletedEffectIndx_WhenValueChanged_CallsSave() { + _settings.Object.ScreenSaverEffect = ScrEffect.None; // index = 0 + + _vm.SeletedEffectIndx = 1; + + _userSettingsClient.Verify(u => u.SaveAsync(), Times.Once); + } + + [TestMethod] + public void SeletedEffectIndx_WhenValueChanged_UpdatesSettings() { + _settings.Object.ScreenSaverEffect = ScrEffect.None; + + _vm.SeletedEffectIndx = (int)ScrEffect.Bubble; + + Assert.AreEqual(ScrEffect.Bubble, _settings.Object.ScreenSaverEffect); + } + + // ── AddToWhiteListScr ───────────────────────────────────────── + + [TestMethod] + public async Task AddToWhiteListScr_AddsToProcsFiltered() { + var proc = new ProcInfo("notepad", @"C:\Windows\notepad.exe", "icon.png"); + + await InvokeAddToWhiteListScrAsync(proc); + + Assert.HasCount(1, _vm.ProcsFiltered); + Assert.AreSame(proc, _vm.ProcsFiltered[0]); + } + + [TestMethod] + public async Task AddToWhiteListScr_AddsToInternalWhiteList() { + var proc = new ProcInfo("notepad", @"C:\Windows\notepad.exe", "icon.png"); + + await InvokeAddToWhiteListScrAsync(proc); + + Assert.Contains(proc, _vm._whiteListScr); + } + + [TestMethod] + public async Task AddToWhiteListScr_CallsScrCommandsAddToWhiteList() { + var proc = new ProcInfo("notepad", @"C:\Windows\notepad.exe", "icon.png"); + + await InvokeAddToWhiteListScrAsync(proc); + + _scrCommandsClient.Verify(s => s.AddToWhiteList("notepad"), Times.Once); + } + + [TestMethod] + public async Task AddToWhiteListScr_CallsUserSettingsSave() { + var proc = new ProcInfo("notepad", @"C:\Windows\notepad.exe", "icon.png"); + + await InvokeAddToWhiteListScrAsync(proc); + + _userSettingsClient.Verify(u => u.Save(), Times.Once); + } + + // ── RemoveFromWhiteScr ──────────────────────────────────────── + + [TestMethod] + public async Task RemoveFromWhiteScr_RemovesFromProcsFiltered() { + var proc = new ProcInfo("notepad", @"C:\Windows\notepad.exe", "icon.png"); + await InvokeAddToWhiteListScrAsync(proc); + + await InvokeRemoveFromWhiteScrAsync(proc); + + Assert.HasCount(0, _vm.ProcsFiltered); + } + + [TestMethod] + public async Task RemoveFromWhiteScr_RemovesFromInternalWhiteList() { + var proc = new ProcInfo("notepad", @"C:\Windows\notepad.exe", "icon.png"); + await InvokeAddToWhiteListScrAsync(proc); + + await InvokeRemoveFromWhiteScrAsync(proc); + + Assert.DoesNotContain(proc, _vm._whiteListScr); + } + + [TestMethod] + public async Task RemoveFromWhiteScr_CallsScrCommandsRemoveFromWhiteList() { + var proc = new ProcInfo("notepad", @"C:\Windows\notepad.exe", "icon.png"); + await InvokeAddToWhiteListScrAsync(proc); + _scrCommandsClient.Invocations.Clear(); + + await InvokeRemoveFromWhiteScrAsync(proc); + + _scrCommandsClient.Verify(s => s.RemoveFromWhiteList("notepad"), Times.Once); + } + + [TestMethod] + public async Task RemoveFromWhiteScr_CallsUserSettingsSave() { + var proc = new ProcInfo("notepad", @"C:\Windows\notepad.exe", "icon.png"); + await InvokeAddToWhiteListScrAsync(proc); + _userSettingsClient.Invocations.Clear(); + + await InvokeRemoveFromWhiteScrAsync(proc); + + _userSettingsClient.Verify(u => u.Save(), Times.Once); + } + + // ── UpdateScrSettginsAsync ──────────────────────────────────── + + [TestMethod] + public async Task UpdateScrSettginsAsync_LoadsSettingsFromClient() { + await _vm.UpdateScrSettginsAsync(); + + _userSettingsClient.Verify(u => u.LoadAsync(), Times.Once); + } + + [TestMethod] + public async Task UpdateScrSettginsAsync_SyncsIsScreenSaverOnFromSettings() { + _settings.Object.IsScreenSaverOn = true; + + await _vm.UpdateScrSettginsAsync(); + + Assert.IsTrue(_vm.IsScreenSaverOn); + } + + [TestMethod] + public async Task UpdateScrSettginsAsync_SyncsIsRunningLockFromSettings() { + _settings.Object.IsRunningLock = true; + + await _vm.UpdateScrSettginsAsync(); + + Assert.IsTrue(_vm.IsRunningLock); + } + + [TestMethod] + public async Task UpdateScrSettginsAsync_SyncsSeletedEffectIndxFromSettings() { + _settings.Object.ScreenSaverEffect = ScrEffect.Bubble; + + await _vm.UpdateScrSettginsAsync(); + + Assert.AreEqual((int)ScrEffect.Bubble, _vm.SeletedEffectIndx); + } + + // ── StopListenForClients ────────────────────────────────────── + + [TestMethod] + public void StopListenForClients_WhenCalled_DoesNotThrow() { + // 验证取消操作本身不抛出 + _vm.StopListenForClients(); + } + + // ── Dispose ─────────────────────────────────────────────────── + + [TestMethod] + public void Dispose_WhenCalledTwice_DoesNotThrow() { + _vm.Dispose(); + _vm.Dispose(); // 第二次不应抛出 + } + + [TestMethod] + public void Dispose_CancelsCtsListen() { + // ListenForClients 内部依赖 _ctsListen,Dispose 后再 Stop 不应出错 + _vm.Dispose(); + _vm.StopListenForClients(); // 不抛出即通过 + } + + // ── Effects 集合初始化 ───────────────────────────────────────── + + [TestMethod] + public void Effects_AfterConstruction_HasTwoItems() { + Assert.HasCount(2, _vm.Effects); + } + + // ── 辅助方法 ────────────────────────────────────────────────── + + /// 通过反射调用 private async void AddToWhiteListScr + private async Task InvokeAddToWhiteListScrAsync(ProcInfo proc) { + var method = typeof(ScreenSaverViewModel) + .GetMethod("AddToWhiteListScr", + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance); + Assert.IsNotNull(method, "AddToWhiteListScr 方法未找到"); + method.Invoke(_vm, new object[] { proc }); + await Task.Delay(50); // async void,等待 Task.Run 完成 + } + + /// 通过反射调用 internal async void RemoveFromWhiteScr + private async Task InvokeRemoveFromWhiteScrAsync(ProcInfo proc) { + var method = typeof(ScreenSaverViewModel) + .GetMethod("RemoveFromWhiteScr", + System.Reflection.BindingFlags.NonPublic | + System.Reflection.BindingFlags.Instance); + Assert.IsNotNull(method, "RemoveFromWhiteScr 方法未找到"); + method.Invoke(_vm, new object[] { proc }); + await Task.Delay(50); // async void,等待 Task.Run 完成 + } + } +} diff --git a/src/VirtualPaper.UI.Test/T_WpSettings/WpSettingsViewModelTests.cs b/src/VirtualPaper.UI.Test/T_WpSettings/WpSettingsViewModelTests.cs new file mode 100644 index 00000000..7cc093ca --- /dev/null +++ b/src/VirtualPaper.UI.Test/T_WpSettings/WpSettingsViewModelTests.cs @@ -0,0 +1,355 @@ +using Moq; +using VirtualPaper.Common; +using VirtualPaper.Common.Utils.Storage.Adapter; +using VirtualPaper.Grpc.Client.Interfaces; +using VirtualPaper.Models.Cores.Interfaces; +using VirtualPaper.WpSettingsPanel.Utils; +using VirtualPaper.WpSettingsPanel.ViewModels; + +namespace VirtualPaper.UI.Test.T_WpSettings { + [TestClass] + public class WpSettingsViewModelTests { + private Mock _monitorManagerClient = null!; + private Mock _wpControlClient = null!; + private Mock _userSettingsClient = null!; + private Mock _settings = null!; + private Mock _primaryMonitor = null!; + private Mock _storagePicker = null!; + private WpSettingsViewModel _vm = null!; + + [TestInitialize] + public void Setup() { + _monitorManagerClient = new Mock(); + _wpControlClient = new Mock(); + _userSettingsClient = new Mock(); + _settings = new Mock(); + _primaryMonitor = new Mock(); + _storagePicker = new Mock(); + + _settings.SetupProperty(s => s.WallpaperArrangement, WallpaperArrangement.Per); + _userSettingsClient.Setup(u => u.Settings).Returns(_settings.Object); + + _primaryMonitor.Setup(m => m.CloneWithPrimaryInfo()).Returns(_primaryMonitor.Object); + _primaryMonitor.SetupProperty(m => m.Content); + _primaryMonitor.SetupProperty(m => m.SystemIndex, 0); + _monitorManagerClient.Setup(m => m.PrimaryMonitor).Returns(_primaryMonitor.Object); + } + + private WpSettingsViewModel CreateVm(IEnumerable? monitors = null) { + var list = monitors ?? new[] { _primaryMonitor.Object }; + _monitorManagerClient.Setup(m => m.Monitors).Returns(list.ToList().AsReadOnly()); + return new WpSettingsViewModel( + _monitorManagerClient.Object, + _wpControlClient.Object, + _userSettingsClient.Object, + _storagePicker.Object); + } + + // ── SelectedMonitorIndex setter ─────────────────────────────── + + [TestMethod] + public void SelectedMonitorIndex_WhenValueUnchanged_DoesNotRaisePropertyChanged() { + _vm = CreateVm(); + bool raised = false; + _vm.PropertyChanged += (_, e) => { + if (e.PropertyName == nameof(_vm.SelectedMonitorIndex)) raised = true; + }; + + _vm.SelectedMonitorIndex = 0; // 默认值也是 0 + + Assert.IsFalse(raised); + } + + [TestMethod] + public void SelectedMonitorIndex_WhenValueChanged_RaisesPropertyChanged() { + var m1 = MakeMonitor(0); + var m2 = MakeMonitor(1); + _vm = CreateVm(new[] { m1.Object, m2.Object }); + + bool raised = false; + _vm.PropertyChanged += (_, e) => { + if (e.PropertyName == nameof(_vm.SelectedMonitorIndex)) raised = true; + }; + + _vm.SelectedMonitorIndex = 1; + + Assert.IsTrue(raised); + } + + // ── InitMonitors (Per) ──────────────────────────────────────── + + [TestMethod] + public void InitMonitors_WhenArrangementIsPer_AddsAllMonitors() { + _settings.Object.WallpaperArrangement = WallpaperArrangement.Per; + var m1 = MakeMonitor(0); + var m2 = MakeMonitor(1); + + _vm = CreateVm(new[] { m1.Object, m2.Object }); + + Assert.HasCount(2, _vm.Monitors); + } + + [TestMethod] + public void InitMonitors_WhenArrangementIsPer_OrdersBySystemIndex() { + _settings.Object.WallpaperArrangement = WallpaperArrangement.Per; + var m0 = MakeMonitor(systemIndex: 0); + var m1 = MakeMonitor(systemIndex: 1); + // 故意反序传入 + _vm = CreateVm(new[] { m1.Object, m0.Object }); + + Assert.AreEqual(0, _vm.Monitors[0].SystemIndex); + Assert.AreEqual(1, _vm.Monitors[1].SystemIndex); + } + + [TestMethod] + [DataRow(WallpaperArrangement.Duplicate)] + [DataRow(WallpaperArrangement.Expand)] + public void InitMonitors_WhenArrangementIsNotPer_AddsSinglePrimaryMonitor( + WallpaperArrangement arrangement) { + _settings.Object.WallpaperArrangement = arrangement; + _primaryMonitor.Setup(m => m.CloneWithPrimaryInfo()).Returns(_primaryMonitor.Object); + + _vm = CreateVm(); + + Assert.HasCount(1, _vm.Monitors); + } + + [TestMethod] + [DataRow(WallpaperArrangement.Duplicate)] + [DataRow(WallpaperArrangement.Expand)] + public void InitMonitors_WhenArrangementIsNotPer_SetsContentToArrangementName( + WallpaperArrangement arrangement) { + _settings.Object.WallpaperArrangement = arrangement; + _primaryMonitor.Setup(m => m.CloneWithPrimaryInfo()).Returns(_primaryMonitor.Object); + + _vm = CreateVm(); + + Assert.AreEqual(arrangement.ToString(), _vm.Monitors[0].Content); + } + + // ── InitMonitors 索引恢复 ───────────────────────────────────── + + [TestMethod] + public void InitMonitors_WhenCachedIndexValid_RestoresSelectedMonitorIndex() { + _settings.Object.WallpaperArrangement = WallpaperArrangement.Per; + var m0 = MakeMonitor(0); + var m1 = MakeMonitor(1); + _vm = CreateVm(new[] { m0.Object, m1.Object }); + + _vm.SelectedMonitorIndex = 1; + // 模拟重新调用(内部会保留 cachedIndex = 1) + _vm.InitFlyoutData(); + + Assert.AreEqual(1, _vm.SelectedMonitorIndex); + } + + [TestMethod] + public void InitMonitors_WhenCachedIndexOutOfRange_ResetsToZero() { + _settings.Object.WallpaperArrangement = WallpaperArrangement.Per; + var m0 = MakeMonitor(0); + var m1 = MakeMonitor(1); + _vm = CreateVm(new[] { m0.Object, m1.Object }); + + _vm.SelectedMonitorIndex = 1; + + // 切换到 Duplicate,只剩 1 个 monitor,index=1 越界 + _settings.Object.WallpaperArrangement = WallpaperArrangement.Duplicate; + _vm.InitFlyoutData(); + + Assert.AreEqual(0, _vm.SelectedMonitorIndex); + } + + // ── RegisterLibraryContents ─────────────────────────────────── + + [TestMethod] + public void RegisterLibraryContents_WhenNewFilterable_Registers() { + _vm = CreateVm(); + var filterable = new Mock(); + filterable.SetupProperty(f => f.FilterKeyword, FilterKey.LibraryTitle); + + _vm.RegisterLibraryContents(filterable.Object); + + // 验证注册后 OnFilterChanged 能触发它 + _vm.OnFilterChanged(FilterKey.LibraryTitle, "test"); + filterable.Verify(f => f.ApplyFilter("test"), Times.Once); + } + + [TestMethod] + public void RegisterLibraryContents_WhenSameFilterableRegisteredTwice_OnlyRegistersOnce() { + _vm = CreateVm(); + var filterable = new Mock(); + filterable.SetupProperty(f => f.FilterKeyword, FilterKey.LibraryTitle); + + _vm.RegisterLibraryContents(filterable.Object); + _vm.RegisterLibraryContents(filterable.Object); + + _vm.OnFilterChanged(FilterKey.LibraryTitle, "test"); + filterable.Verify(f => f.ApplyFilter("test"), Times.Once); + } + + // ── OnFilterChanged ─────────────────────────────────────────── + + [TestMethod] + public void OnFilterChanged_WhenKeyMatches_CallsApplyFilter() { + _vm = CreateVm(); + var filterable = new Mock(); + filterable.SetupProperty(f => f.FilterKeyword, FilterKey.LibraryTitle); + _vm.RegisterLibraryContents(filterable.Object); + + _vm.OnFilterChanged(FilterKey.LibraryTitle, "keyword"); + + filterable.Verify(f => f.ApplyFilter("keyword"), Times.Once); + } + + [TestMethod] + public void OnFilterChanged_WhenFilterableHasDifferentKey_DoesNotCallApplyFilter() { + _vm = CreateVm(); + + // 注册一个 filterable,但手动把它的 FilterKeyword 设为与触发不同的值 + // 由于 FilterKey 只有 LibraryTitle,用 default(FilterKey) 的整数偏移模拟"不匹配" + // 更稳健的做法:不注册任何 filterable,直接验证无调用 + var filterable = new Mock(); + filterable.SetupProperty(f => f.FilterKeyword, FilterKey.LibraryTitle); + // 不注册,直接触发 + _vm.OnFilterChanged(FilterKey.LibraryTitle, "keyword"); + + filterable.Verify(f => f.ApplyFilter(It.IsAny()), Times.Never); + } + + [TestMethod] + public void OnFilterChanged_WhenMultipleFilterables_AllWithSameKey_AllReceiveFilter() { + _vm = CreateVm(); + + var filterable1 = new Mock(); + filterable1.SetupProperty(f => f.FilterKeyword, FilterKey.LibraryTitle); + + var filterable2 = new Mock(); + filterable2.SetupProperty(f => f.FilterKeyword, FilterKey.LibraryTitle); + + _vm.RegisterLibraryContents(filterable1.Object); + _vm.RegisterLibraryContents(filterable2.Object); + + _vm.OnFilterChanged(FilterKey.LibraryTitle, "hello"); + + filterable1.Verify(f => f.ApplyFilter("hello"), Times.Once); + filterable2.Verify(f => f.ApplyFilter("hello"), Times.Once); + } + + // ── Detect ──────────────────────────────────────────────────── + + [TestMethod] + public async Task Detect_WhenCalled_CallsInitMonitors() { + _settings.Object.WallpaperArrangement = WallpaperArrangement.Per; + var m0 = MakeMonitor(0); + _monitorManagerClient.Setup(m => m.Monitors).Returns(new List { m0.Object }.AsReadOnly()); + _vm = new WpSettingsViewModel( + _monitorManagerClient.Object, + _wpControlClient.Object, + _userSettingsClient.Object, + _storagePicker.Object); + + _vm.Detect(); + await Task.Delay(100); // 等待 async void 完成 + + // Monitors 已被重新填充 + Assert.HasCount(1, _vm.Monitors); + } + + [TestMethod] + public async Task Detect_WhenCalledConcurrently_OnlyExecutesOnce() { + _vm = CreateVm(); + + int monitorManagerCallCount = 0; + _monitorManagerClient.Setup(m => m.Monitors) + .Callback(() => monitorManagerCallCount++) + .Returns(new List { _primaryMonitor.Object }.AsReadOnly()); + + _vm.Detect(); + _vm.Detect(); // 第二次应该被 Interlocked 拦截 + await Task.Delay(200); + + // 第二次调用在第一次完成前被拦截,只执行了一次 + Assert.AreEqual(1, monitorManagerCallCount); + } + + // ── Identify ────────────────────────────────────────────────── + + [TestMethod] + public async Task Identify_WhenCalled_CallsIdentifyMonitorsAsync() { + _vm = CreateVm(); + + _vm.Identify(); + await Task.Delay(100); + + _monitorManagerClient.Verify(m => m.IdentifyMonitorsAsync(), Times.Once); + } + + // ── Close ───────────────────────────────────────────────────── + + [TestMethod] + public async Task Close_WhenCalled_CallsCloseWallpaperAsync() { + _settings.Object.WallpaperArrangement = WallpaperArrangement.Per; + var monitor = MakeMonitor(0); + monitor.SetupProperty(m => m.ThumbnailPath, "some/path"); + _monitorManagerClient + .Setup(m => m.Monitors) + .Returns(new List { monitor.Object }.AsReadOnly()); + _vm = new WpSettingsViewModel( + _monitorManagerClient.Object, + _wpControlClient.Object, + _userSettingsClient.Object, + _storagePicker.Object); + + _vm.Close(); + await Task.Delay(100); + + _wpControlClient.Verify( + w => w.CloseWallpaperAsync(monitor.Object), + Times.Once); + } + + [TestMethod] + public async Task Close_WhenCalled_ClearsThumbnailPath() { + _settings.Object.WallpaperArrangement = WallpaperArrangement.Per; + var monitor = MakeMonitor(0); + monitor.SetupProperty(m => m.ThumbnailPath, "some/path"); + _monitorManagerClient.Setup(m => m.Monitors).Returns(new List { monitor.Object }.AsReadOnly()); + _vm = new WpSettingsViewModel( + _monitorManagerClient.Object, + _wpControlClient.Object, + _userSettingsClient.Object, + _storagePicker.Object); + + _vm.Close(); + await Task.Delay(100); + + Assert.AreEqual(string.Empty, _vm.Monitors[0].ThumbnailPath); + } + + // ── Commands IsNotNull ──────────────────────────────────────── + + [TestMethod] + [DataRow(nameof(WpSettingsViewModel.AddToLibCommand))] + [DataRow(nameof(WpSettingsViewModel.WpCloseCommand))] + [DataRow(nameof(WpSettingsViewModel.WpDetectCommand))] + [DataRow(nameof(WpSettingsViewModel.WpIdentifyCommand))] + [DataRow(nameof(WpSettingsViewModel.WpAdjustCommand))] + public void Command_IsNotNull_AfterConstruction(string commandName) { + _vm = CreateVm(); + var prop = typeof(WpSettingsViewModel).GetProperty(commandName); + Assert.IsNotNull(prop?.GetValue(_vm), $"{commandName} 为 null"); + } + + // ── 辅助方法 ───────────────────────────────────────────────── + + private static Mock MakeMonitor(int systemIndex) { + var m = new Mock(); + m.SetupProperty(x => x.SystemIndex, systemIndex); + m.SetupProperty(x => x.Content, string.Empty); + m.SetupProperty(x => x.ThumbnailPath, string.Empty); + m.SetupProperty(x => x.DeviceId, $"monitor_{systemIndex}"); + m.Setup(x => x.CloneWithPrimaryInfo()).Returns(m.Object); + return m; + } + } +} diff --git a/src/VirtualPaper.UI.Test/TestSetup.cs b/src/VirtualPaper.UI.Test/TestSetup.cs new file mode 100644 index 00000000..d4047244 --- /dev/null +++ b/src/VirtualPaper.UI.Test/TestSetup.cs @@ -0,0 +1,11 @@ +using VirtualPaper.Common; + +namespace VirtualPaper.UI.Test { + [TestClass] + public class TestSetup { + [AssemblyInitialize] + public static void AssemblyInit(TestContext context) { + Constants.IsTestMode = true; + } + } +} diff --git a/src/VirtualPaper.UI.Test/Utils/T_UiSynchronizationContext.cs b/src/VirtualPaper.UI.Test/Utils/T_UiSynchronizationContext.cs new file mode 100644 index 00000000..8ff01c16 --- /dev/null +++ b/src/VirtualPaper.UI.Test/Utils/T_UiSynchronizationContext.cs @@ -0,0 +1,10 @@ +using VirtualPaper.Common.Utils.ThreadContext; + +namespace VirtualPaper.UI.Test.Utils { + public class T_UiSynchronizationContext : IUiSynchronizationContext { + public SynchronizationContext? Current => SynchronizationContext.Current; + + public override void Post(Action action) => action(); // 直接同步执行 + public override void Send(Action action) => action(); + } +} diff --git a/src/VirtualPaper.UI.Test/VirtualPaper.UI.Test.csproj b/src/VirtualPaper.UI.Test/VirtualPaper.UI.Test.csproj new file mode 100644 index 00000000..c294000a --- /dev/null +++ b/src/VirtualPaper.UI.Test/VirtualPaper.UI.Test.csproj @@ -0,0 +1,22 @@ + + + + net8.0-windows10.0.19041.0 + latest + enable + enable + true + true + + + + + + + + + + + + + diff --git a/src/VirtualPaper.UI/App.xaml.cs b/src/VirtualPaper.UI/App.xaml.cs index 8f98e011..f389d855 100644 --- a/src/VirtualPaper.UI/App.xaml.cs +++ b/src/VirtualPaper.UI/App.xaml.cs @@ -10,13 +10,17 @@ using VirtualPaper.Common.Utils; using VirtualPaper.Common.Utils.DI; using VirtualPaper.Common.Utils.PInvoke; +using VirtualPaper.Common.Utils.Storage.Adapter; using VirtualPaper.Common.Utils.ThreadContext; using VirtualPaper.DraftPanel.ViewModels; using VirtualPaper.Grpc.Client; using VirtualPaper.Grpc.Client.Interfaces; using VirtualPaper.UIComponent.Converters; using VirtualPaper.UIComponent.Utils; +using VirtualPaper.UIComponent.Utils.Adapter; +using VirtualPaper.UIComponent.Utils.Adapter.Interfaces; using VirtualPaper.WpSettingsPanel.Utils; +using VirtualPaper.WpSettingsPanel.Utils.Interfaces; using VirtualPaper.WpSettingsPanel.ViewModels; using Windows.ApplicationModel.Core; using WinUIEx; @@ -94,14 +98,16 @@ private ServiceProvider ConfigureServices() { .AddTransient() .AddTransient() - .AddSingleton() - + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() .BuildServiceProvider(); diff --git a/src/VirtualPaper.UIComponent/Converters/ByteArrayToBrushConverter.cs b/src/VirtualPaper.UIComponent/Converters/ByteArrayToBrushConverter.cs new file mode 100644 index 00000000..b86732d9 --- /dev/null +++ b/src/VirtualPaper.UIComponent/Converters/ByteArrayToBrushConverter.cs @@ -0,0 +1,21 @@ +using System; +using Microsoft.UI; +using Microsoft.UI.Xaml.Data; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace VirtualPaper.UIComponent.Converters { + public partial class ByteArrayToBrushConverter : IValueConverter { + public object Convert(object value, Type targetType, object parameter, string language) { + if (value is byte[] argb && argb.Length == 4) { + var color = Color.FromArgb(argb[0], argb[1], argb[2], argb[3]); + return new SolidColorBrush(color); + } + return new SolidColorBrush(Colors.White); + } + + public object ConvertBack(object value, Type targetType, object parameter, string language) { + throw new NotImplementedException(); + } + } +} diff --git a/src/VirtualPaper.UIComponent/Navigation/TabView/ArcTabViewItem.cs b/src/VirtualPaper.UIComponent/Navigation/TabView/ArcTabViewItem.cs index 47c85894..d7d49a6d 100644 --- a/src/VirtualPaper.UIComponent/Navigation/TabView/ArcTabViewItem.cs +++ b/src/VirtualPaper.UIComponent/Navigation/TabView/ArcTabViewItem.cs @@ -1,6 +1,7 @@ -using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Controls; +using VirtualPaper.UIComponent.Navigation.TabView.Interfaces; namespace VirtualPaper.UIComponent.Navigation { - public partial class ArcTabViewItem : TabViewItem { + public partial class ArcTabViewItem : TabViewItem, IArcTabViewItem { } } diff --git a/src/VirtualPaper.UIComponent/Navigation/TabView/ArcTabViewItemHeader.xaml.cs b/src/VirtualPaper.UIComponent/Navigation/TabView/ArcTabViewItemHeader.xaml.cs index 8c173d16..adb8de0b 100644 --- a/src/VirtualPaper.UIComponent/Navigation/TabView/ArcTabViewItemHeader.xaml.cs +++ b/src/VirtualPaper.UIComponent/Navigation/TabView/ArcTabViewItemHeader.xaml.cs @@ -1,12 +1,12 @@ -using System; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; +using VirtualPaper.UIComponent.Navigation.TabView.Interfaces; // To learn more about WinUI, the WinUI project structure, // and more about our project templates, see: http://aka.ms/winui-project-info. namespace VirtualPaper.UIComponent.Navigation.TabView { - public sealed partial class ArcTabViewItemHeader : UserControl { + public sealed partial class ArcTabViewItemHeader : UserControl, IArcTabViewItemHeader { public object MainContent { get { return (object)GetValue(MainContentProperty); } set { SetValue(MainContentProperty, value); } diff --git a/src/VirtualPaper.UIComponent/Navigation/TabView/Interfaces/IArcTabViewItem.cs b/src/VirtualPaper.UIComponent/Navigation/TabView/Interfaces/IArcTabViewItem.cs new file mode 100644 index 00000000..ed1ffb35 --- /dev/null +++ b/src/VirtualPaper.UIComponent/Navigation/TabView/Interfaces/IArcTabViewItem.cs @@ -0,0 +1,5 @@ +namespace VirtualPaper.UIComponent.Navigation.TabView.Interfaces { + public interface IArcTabViewItem { + object? Tag { get; set; } + } +} diff --git a/src/VirtualPaper.UIComponent/Navigation/TabView/Interfaces/IArcTabViewItemHeader.cs b/src/VirtualPaper.UIComponent/Navigation/TabView/Interfaces/IArcTabViewItemHeader.cs new file mode 100644 index 00000000..ad5bc854 --- /dev/null +++ b/src/VirtualPaper.UIComponent/Navigation/TabView/Interfaces/IArcTabViewItemHeader.cs @@ -0,0 +1,6 @@ +namespace VirtualPaper.UIComponent.Navigation.TabView.Interfaces { + public interface IArcTabViewItemHeader { + object MainContent { get; set; } + bool IsSaved { get; set; } + } +} diff --git a/src/VirtualPaper.UIComponent/Resources/ResourceDictionary.xaml b/src/VirtualPaper.UIComponent/Resources/ResourceDictionary.xaml index 63e1e0d4..05980270 100644 --- a/src/VirtualPaper.UIComponent/Resources/ResourceDictionary.xaml +++ b/src/VirtualPaper.UIComponent/Resources/ResourceDictionary.xaml @@ -9,6 +9,7 @@ + diff --git a/src/VirtualPaper.UIComponent/Utils/Adapter/GlobalDialogService.cs b/src/VirtualPaper.UIComponent/Utils/Adapter/GlobalDialogService.cs new file mode 100644 index 00000000..adeb29e1 --- /dev/null +++ b/src/VirtualPaper.UIComponent/Utils/Adapter/GlobalDialogService.cs @@ -0,0 +1,48 @@ +using System.Threading.Tasks; +using Microsoft.UI.Xaml.Controls; +using VirtualPaper.Common; +using VirtualPaper.UIComponent.Utils.Adapter.Interfaces; + +namespace VirtualPaper.UIComponent.Utils.Adapter { + public class GlobalDialogService : IGlobalDialogService { + public ContentDialog? CreateDialog(object content, string title, string primaryBtnText, string secondaryBtnText, bool isDefaultPrimary = true) { + return GlobalDialogUtils.CreateDialog(content, title, primaryBtnText, secondaryBtnText, isDefaultPrimary); + } + + public ContentDialog? CreateDialog(object content, string title, string primaryBtnText, bool isDefaultPrimary = true) { + return GlobalDialogUtils.CreateDialog(content, title, primaryBtnText, isDefaultPrimary); + } + + public ContentDialog? CreateDialogWithoutTitle(object content, string primaryBtnText, string secondaryBtnText, bool isDefaultPrimary = true) { + return GlobalDialogUtils.CreateDialogWithoutTitle(content, primaryBtnText, secondaryBtnText, isDefaultPrimary); + } + + public ContentDialog? CreateDialogWithoutTitle(object content, string primaryBtnText, bool isDefaultPrimary = true) { + return GlobalDialogUtils.CreateDialogWithoutTitle(content, primaryBtnText, isDefaultPrimary); + } + + public async Task ShowDialogAsync(string message, string title, string primaryBtnText) { + await GlobalDialogUtils.ShowDialogAsync(message, title, primaryBtnText); + } + + public async Task ShowDialogAsync(object content, string title, string primaryBtnText, string secondaryBtnText, bool isDefaultPrimary = true) { + return await GlobalDialogUtils.ShowDialogAsync(content, title, primaryBtnText, secondaryBtnText, isDefaultPrimary); + } + + public async Task ShowDialogAsync(object content, string title, string primaryBtnText, bool isDefaultPrimary = true) { + return await GlobalDialogUtils.ShowDialogAsync(content, title, primaryBtnText, isDefaultPrimary); + } + + public async Task ShowDialogAsync(object content, string title, string primaryBtnText, string secondaryBtnText, string closeBtnText, bool isDefaultPrimary = true) { + return await GlobalDialogUtils.ShowDialogAsync(content, title, primaryBtnText, secondaryBtnText, closeBtnText, isDefaultPrimary); + } + + public async Task ShowDialogWithoutTitleAsync(object content, string primaryBtnText, string secondaryBtnText, bool isDefaultPrimary = true) { + return await GlobalDialogUtils.ShowDialogWithoutTitleAsync(content, primaryBtnText, secondaryBtnText, isDefaultPrimary); + } + + public async Task ShowDialogWithoutTitleAsync(object content, string primaryBtnText, bool isDefaultPrimary = true) { + return await GlobalDialogUtils.ShowDialogWithoutTitleAsync(content, primaryBtnText, isDefaultPrimary); + } + } +} diff --git a/src/VirtualPaper.UIComponent/Utils/Adapter/Interfaces/IGlobalDialogService.cs b/src/VirtualPaper.UIComponent/Utils/Adapter/Interfaces/IGlobalDialogService.cs new file mode 100644 index 00000000..26a7744f --- /dev/null +++ b/src/VirtualPaper.UIComponent/Utils/Adapter/Interfaces/IGlobalDialogService.cs @@ -0,0 +1,68 @@ +using System.Threading.Tasks; +using Microsoft.UI.Xaml.Controls; +using VirtualPaper.Common; + +namespace VirtualPaper.UIComponent.Utils.Adapter.Interfaces { + public interface IGlobalDialogService { + Task ShowDialogAsync( + string message, + string title, + string primaryBtnText); + + Task ShowDialogAsync( + object content, + string title, + string primaryBtnText, + string secondaryBtnText, + bool isDefaultPrimary = true); + + ContentDialog? CreateDialog( + object content, + string title, + string primaryBtnText, + string secondaryBtnText, + bool isDefaultPrimary = true); + + Task ShowDialogAsync( + object content, + string title, + string primaryBtnText, + bool isDefaultPrimary = true); + + Task ShowDialogAsync( + object content, + string title, + string primaryBtnText, + string secondaryBtnText, + string closeBtnText, + bool isDefaultPrimary = true); + + ContentDialog? CreateDialog( + object content, + string title, + string primaryBtnText, + bool isDefaultPrimary = true); + + Task ShowDialogWithoutTitleAsync( + object content, + string primaryBtnText, + string secondaryBtnText, + bool isDefaultPrimary = true); + + ContentDialog? CreateDialogWithoutTitle( + object content, + string primaryBtnText, + string secondaryBtnText, + bool isDefaultPrimary = true); + + Task ShowDialogWithoutTitleAsync( + object content, + string primaryBtnText, + bool isDefaultPrimary = true); + + ContentDialog? CreateDialogWithoutTitle( + object content, + string primaryBtnText, + bool isDefaultPrimary = true); + } +} diff --git a/src/VirtualPaper.UIComponent/Utils/GlobalMessageUtil.cs b/src/VirtualPaper.UIComponent/Utils/GlobalMessageUtil.cs index 7e5e5221..a881f8d9 100644 --- a/src/VirtualPaper.UIComponent/Utils/GlobalMessageUtil.cs +++ b/src/VirtualPaper.UIComponent/Utils/GlobalMessageUtil.cs @@ -12,6 +12,8 @@ namespace VirtualPaper.UIComponent.Utils { /// public static class GlobalMessageUtil { private static void AddMsg(ArcWindow? arcWindow, GlobalMsgInfo globalMsgInfo, bool isAllowDuplication = false) { + if (Constants.IsTestMode) return; + arcWindow ??= ArcWindowManager.GetArcWindow(new(ArcWindowKey.Main))!; ExecuteOnUIThread(() => { // 如果key不为null且不允许重复,检查是否已存在 @@ -33,11 +35,15 @@ private static void AddMsg(ArcWindow? arcWindow, GlobalMsgInfo globalMsgInfo, bo } }; - arcWindow.InfobarMessages.Add(globalMsgInfo); + if (!Constants.IsTestMode) { + arcWindow.InfobarMessages.Add(globalMsgInfo); + } }); } public static void CloseAndRemoveMsg(ArcWindow arcWindow, string key) { + if (Constants.IsTestMode) return; + ExecuteOnUIThread(() => { if (key == null) return; @@ -97,6 +103,8 @@ public static void ShowCanceled(ArcWindow? arcWindow = null) { /// 清除所有消息 /// public static void ClearAll(ArcWindow? arcWindow = null) { + if (Constants.IsTestMode) return; + arcWindow ??= ArcWindowManager.GetArcWindow(new(ArcWindowKey.Main))!; ExecuteOnUIThread(() => { foreach (var msg in arcWindow.InfobarMessages.ToList()) { @@ -110,7 +118,7 @@ public static void ClearAll(ArcWindow? arcWindow = null) { /// 检查是否存在指定key的消息 /// public static bool ContainsKey(ArcWindow arcWindow, string key) { - if (key == null) return false; + if (key == null || Constants.IsTestMode) return false; return GetGlobalMsg(key, arcWindow) != null; } @@ -118,6 +126,8 @@ public static bool ContainsKey(ArcWindow arcWindow, string key) { /// 显示自动关闭的消息 /// public static void ShowAutoCloseMessage(string message, InfoBarSeverity severity, int autoCloseDelay = 5000, string? key = null, ArcWindow? arcWindow = null) { + if (Constants.IsTestMode) return; + var msgInfo = new GlobalMsgInfo(key, false, message, null, severity); AddMsg(arcWindow, msgInfo); @@ -132,7 +142,7 @@ public static void ShowAutoCloseMessage(string message, InfoBarSeverity severity /// 更新现有消息内容 /// public static void UpdateMessage(string key, string newMessage, bool isNeedLocalizer = false, ArcWindow? arcWindow = null) { - if (key == null) return; + if (key == null || Constants.IsTestMode) return; ExecuteOnUIThread(() => { var msg = GetGlobalMsg(key, arcWindow); @@ -146,6 +156,8 @@ public static void UpdateMessage(string key, string newMessage, bool isNeedLocal /// 批量添加消息 /// public static void AddMessages(ArcWindow? arcWindow = null, params (string message, InfoBarSeverity severity, string key)[] messages) { + if (Constants.IsTestMode) return; + foreach (var (message, severity, key) in messages) { AddMsg(arcWindow, new GlobalMsgInfo(key, false, message, null, severity)); } @@ -155,7 +167,7 @@ public static void AddMessages(ArcWindow? arcWindow = null, params (string messa /// 获取指定key的消息(key为null时返回null) /// private static GlobalMsgInfo? GetGlobalMsg(string key, ArcWindow? arcWindow = null) { - if (key == null) return null; + if (key == null || Constants.IsTestMode) return null; arcWindow ??= ArcWindowManager.GetArcWindow(new(ArcWindowKey.Main))!; return arcWindow.InfobarMessages.FirstOrDefault(m => m.Key == key); } @@ -164,6 +176,8 @@ public static void AddMessages(ArcWindow? arcWindow = null, params (string messa /// 移除消息 /// private static void RemoveMsg(GlobalMsgInfo msg, ArcWindow? arcWindow = null) { + if (Constants.IsTestMode) return; + arcWindow ??= ArcWindowManager.GetArcWindow(new(ArcWindowKey.Main))!; ExecuteOnUIThread(() => { arcWindow.InfobarMessages.Remove(msg); diff --git a/src/VirtualPaper.UIComponent/Utils/LanguageUtil.cs b/src/VirtualPaper.UIComponent/Utils/LanguageUtil.cs index dc510fde..4545c084 100644 --- a/src/VirtualPaper.UIComponent/Utils/LanguageUtil.cs +++ b/src/VirtualPaper.UIComponent/Utils/LanguageUtil.cs @@ -72,7 +72,7 @@ private static void PromoteToHotCache(string key, string value) { } private static string LoadFromSource(string key) { - return LocalizerInstance.GetLocalizedString(key); // 实际加载逻辑 + return LocalizerInstance?.GetLocalizedString(key) ?? key; // 实际加载逻辑 } // ref: https://github.com/AndrewKeepCoding/WinUI3Localizer diff --git a/src/VirtualPaper.WpSettingsPanel/Utils/Interfaces/IWallpaperIndexService.cs b/src/VirtualPaper.WpSettingsPanel/Utils/Interfaces/IWallpaperIndexService.cs new file mode 100644 index 00000000..c175de40 --- /dev/null +++ b/src/VirtualPaper.WpSettingsPanel/Utils/Interfaces/IWallpaperIndexService.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using VirtualPaper.Models.Cores.Interfaces; + +namespace VirtualPaper.WpSettingsPanel.Utils.Interfaces { + public interface IWallpaperIndexService { + TaskCompletionSource Initialized { get; } + + void Initialize(IEnumerable wallpaperInstallDir); + + IReadOnlyList Query(int offset, int limit); + + bool TryGetValue(string wallpaperUid, out int idx); + + void Remove(IWpBasicData data); + + void Update(IWpBasicData data); + } +} diff --git a/src/VirtualPaper.WpSettingsPanel/Utils/WallpaperIndexService.cs b/src/VirtualPaper.WpSettingsPanel/Utils/WallpaperIndexService.cs index b1de70d0..d2a81476 100644 --- a/src/VirtualPaper.WpSettingsPanel/Utils/WallpaperIndexService.cs +++ b/src/VirtualPaper.WpSettingsPanel/Utils/WallpaperIndexService.cs @@ -1,13 +1,14 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using VirtualPaper.Common; using VirtualPaper.Models.Cores.Interfaces; +using VirtualPaper.WpSettingsPanel.Utils.Interfaces; namespace VirtualPaper.WpSettingsPanel.Utils { - public sealed class WallpaperIndexService { + public class WallpaperIndexService : IWallpaperIndexService { public TaskCompletionSource Initialized { get; } = new(); public async void Initialize(IEnumerable wallpaperInstallDir) { diff --git a/src/VirtualPaper.WpSettingsPanel/ViewModels/AddToLibViewModel.cs b/src/VirtualPaper.WpSettingsPanel/ViewModels/AddToLibViewModel.cs index cd202977..18a741f8 100644 --- a/src/VirtualPaper.WpSettingsPanel/ViewModels/AddToLibViewModel.cs +++ b/src/VirtualPaper.WpSettingsPanel/ViewModels/AddToLibViewModel.cs @@ -4,7 +4,7 @@ using System.Windows.Input; using VirtualPaper.Common; using VirtualPaper.Common.Utils.Files; -using VirtualPaper.Common.Utils.Storage; +using VirtualPaper.Common.Utils.Storage.Adapter; using VirtualPaper.Models.Mvvm; using VirtualPaper.UIComponent; using Windows.Storage; @@ -13,14 +13,15 @@ namespace VirtualPaper.WpSettingsPanel.ViewModels { public class AddToLibViewModel { public event EventHandler>? OnRequestAddFile; - public event EventHandler? OnRequestAddFolder; + public event EventHandler? OnRequestAddFolder; public ICommand? HandleAddFilesCommand; public ICommand? HandleAddFoldersCommand; public bool IsElevated { get; } - public AddToLibViewModel() { + public AddToLibViewModel(IStoragePicker storagePicker) { + _storagePicker = storagePicker; IsElevated = UAC.IsElevated; InitCommand(); @@ -36,7 +37,7 @@ private void InitCommand() { } private async Task FileBrowseActionAsync() { - var storage = await WindowsStoragePickers.PickFilesAsync( + var storage = await _storagePicker.PickFilesAsync( WindowConsts.WindowHandle, [.. FileFilter.FileTypeToExtension[FileType.FImage], .. FileFilter.FileTypeToExtension[FileType.FGif], .. FileFilter.FileTypeToExtension[FileType.FVideo]], true); @@ -46,13 +47,15 @@ private async Task FileBrowseActionAsync() { } private async Task FolderBrowseActionAsync() { - var storage = await WindowsStoragePickers.PickFolderAsync(WindowConsts.WindowHandle); + var storage = await _storagePicker.PickFolderAsync(WindowConsts.WindowHandle); if (storage == null) return; AddWallpaperFolder(storage); } - internal void AddWallpaperFiles(IReadOnlyList filePaths) => OnRequestAddFile?.Invoke(this, filePaths); - internal void AddWallpaperFolder(StorageFolder storageFolder) => OnRequestAddFolder?.Invoke(this, storageFolder); + public void AddWallpaperFiles(IReadOnlyList filePaths) => OnRequestAddFile?.Invoke(this, filePaths); + public void AddWallpaperFolder(IStorageFolder storageFolder) => OnRequestAddFolder?.Invoke(this, storageFolder); + + private readonly IStoragePicker _storagePicker; } } diff --git a/src/VirtualPaper.WpSettingsPanel/ViewModels/LibraryContentsViewModel.cs b/src/VirtualPaper.WpSettingsPanel/ViewModels/LibraryContentsViewModel.cs index 0efeb898..7df2eafc 100644 --- a/src/VirtualPaper.WpSettingsPanel/ViewModels/LibraryContentsViewModel.cs +++ b/src/VirtualPaper.WpSettingsPanel/ViewModels/LibraryContentsViewModel.cs @@ -26,6 +26,7 @@ using VirtualPaper.UIComponent.Utils; using VirtualPaper.UIComponent.ViewModels; using VirtualPaper.WpSettingsPanel.Utils; +using VirtualPaper.WpSettingsPanel.Utils.Interfaces; using Windows.Storage; using Windows.System.UserProfile; using WinUIEx; @@ -34,9 +35,14 @@ namespace VirtualPaper.WpSettingsPanel.ViewModels { public partial class LibraryContentsViewModel : ObservableObject, IFilterable { public ObservableCollection LibraryWallpapers { get; private set; } = null!; - private Brush _wpTitleForeground = new SolidColorBrush(Colors.White); - public Brush WpTitleForeground { - get { return _wpTitleForeground; } + //private Brush _wpTitleForeground = new SolidColorBrush(Colors.White); + //public Brush WpTitleForeground { + // get { return _wpTitleForeground; } + // set { _wpTitleForeground = value; OnPropertyChanged(); } + //} + private byte[] _wpTitleForeground = [255, 255, 255, 255]; + public byte[] WpTitleForeground { + get => _wpTitleForeground; set { _wpTitleForeground = value; OnPropertyChanged(); } } @@ -50,7 +56,7 @@ public LibraryContentsViewModel( IUserSettingsClient userSettingsClient, IWallpaperControlClient wallpaperControlClient, WpSettingsViewModel wpSettingsViewModel, - WallpaperIndexService wallpaperIndexService) { + IWallpaperIndexService wallpaperIndexService) { _userSettingsClient = userSettingsClient; _wpControlClient = wallpaperControlClient; _wpSettingsViewModel = wpSettingsViewModel; @@ -73,8 +79,9 @@ private void InitEvent() { } internal void RefreshWpTitleForeground() { - var color = ArcThemeUtil.GetFormatMainWindowTheme() == AppTheme.Light ? Colors.White : Colors.Black; - WpTitleForeground = new SolidColorBrush(color); + WpTitleForeground = ArcThemeUtil.GetFormatMainWindowTheme() == AppTheme.Light + ? [255, 255, 255, 255] // White: A=255, R=255, G=255, B=255 + : [255, 0, 0, 0]; // Black: A=255, R=0, G=0, B=0 } private void InitColletions() { @@ -355,13 +362,13 @@ internal async Task DeleteAsync(IWpBasicData data) { } } - private void HandleDelete(IWpBasicData data) { + public void HandleDelete(IWpBasicData data) { LibraryWallpapers.Remove(data); _libraryWallpapers.Remove(data); _wallpaperIndexService.Remove(data); } - private void UpdateLib(IWpBasicData data) { + public void UpdateLib(IWpBasicData data) { if (_wallpaperIndexService.TryGetValue(data.WallpaperUid, out int idx)) { LibraryWallpapers[idx] = data; _libraryWallpapers[idx] = data; @@ -492,12 +499,12 @@ private async Task CheckFileUpdateAsync(IWpBasicData data) { } } - private async Task IsFileInUseAsync(IWpBasicData data) { + public async Task IsFileInUseAsync(IWpBasicData data) { await _userSettingsClient.LoadAsync>(); return _userSettingsClient.WallpaperLayouts.Any(wl => wl.FolderPath == data.FolderPath); } - private bool IsFileInPreview(IWpBasicData data) { + public bool IsFileInPreview(IWpBasicData data) { return _previews.Keys.Any(k => k.uid == data.WallpaperUid); } @@ -537,10 +544,10 @@ public void ApplyFilter(string keyword) { FilterByTitle(keyword); } - internal void FilterByTitle(string keyword) { + public void FilterByTitle(string keyword) { var filtered = _libraryWallpapers.Where(basicData => basicData.Title != null && basicData.Title.Contains(keyword, StringComparison.InvariantCultureIgnoreCase) - ); + ).ToList(); Remove_NonMatching(filtered); AddBack_Procs(filtered); } @@ -599,13 +606,21 @@ private struct ImportValue(string filePath, FileType ftype) { private readonly IWallpaperControlClient _wpControlClient; private readonly IUserSettingsClient _userSettingsClient; private readonly WpSettingsViewModel _wpSettingsViewModel; - private readonly WallpaperIndexService _wallpaperIndexService; + private readonly IWallpaperIndexService _wallpaperIndexService; private List _wallpaperInstallFolders = []; private readonly Dictionary _details = []; private readonly Dictionary _edits = []; private readonly Dictionary<(string uid, RuntimeType rtype), ArcWindow> _previews = []; private List _libraryWallpapers = []; private bool _isInited; + + /// 仅供单元测试使用 + public void TestPopulate(IEnumerable items) { + foreach (var item in items) { + _libraryWallpapers.Add(item); + LibraryWallpapers.Add(item); + } + } } public enum LoadingStatus { diff --git a/src/VirtualPaper.WpSettingsPanel/ViewModels/ScreenSaverViewModel.cs b/src/VirtualPaper.WpSettingsPanel/ViewModels/ScreenSaverViewModel.cs index 1c492642..7187ed57 100644 --- a/src/VirtualPaper.WpSettingsPanel/ViewModels/ScreenSaverViewModel.cs +++ b/src/VirtualPaper.WpSettingsPanel/ViewModels/ScreenSaverViewModel.cs @@ -146,7 +146,7 @@ await Task.Run(async () => { } } - internal void StopListenForClients() { + public void StopListenForClients() { _ctsListen?.Cancel(); } @@ -253,7 +253,7 @@ public void Dispose() { private readonly IScrCommandsClient _scrCommandsClient; private string _effectNone = string.Empty; private string _effectBubble = string.Empty; - internal List _whiteListScr = []; + public List _whiteListScr = []; private bool _isLoading = false; } } diff --git a/src/VirtualPaper.WpSettingsPanel/ViewModels/WpSettingsViewModel.cs b/src/VirtualPaper.WpSettingsPanel/ViewModels/WpSettingsViewModel.cs index 04c5eda9..6f28cb65 100644 --- a/src/VirtualPaper.WpSettingsPanel/ViewModels/WpSettingsViewModel.cs +++ b/src/VirtualPaper.WpSettingsPanel/ViewModels/WpSettingsViewModel.cs @@ -10,6 +10,7 @@ using VirtualPaper.Common.Logging; using VirtualPaper.Common.Utils.DI; using VirtualPaper.Common.Utils.IPC; +using VirtualPaper.Common.Utils.Storage.Adapter; using VirtualPaper.Grpc.Client.Interfaces; using VirtualPaper.Models.Cores.Interfaces; using VirtualPaper.Models.Mvvm; @@ -58,17 +59,19 @@ public int SelectedMonitorIndex { public WpSettingsViewModel( IMonitorManagerClient monitorManagerClient, IWallpaperControlClient wallpaperControlClient, - IUserSettingsClient userSettingsClient) { + IUserSettingsClient userSettingsClient, + IStoragePicker storagePicker) { _monitorManagerClient = monitorManagerClient; _wpControlClient = wallpaperControlClient; _userSettingsClient = userSettingsClient; + _storagePicker = storagePicker; InitMonitors(); InitCommand(); } #region Init - internal void InitFlyoutData() { + public void InitFlyoutData() { InitWpArrangments(); InitMonitors(); // 打开该页面不会触发绑定值修改,需要手动调用更新 } @@ -147,7 +150,7 @@ public void RegisterLibraryContents(IFilterable filterable) { _filterables.Add(new WeakReference(filterable)); } - internal void OnFilterChanged(FilterKey fk, string text) { + public void OnFilterChanged(FilterKey fk, string text) { // 遍历并清理死亡引用 for (int i = _filterables.Count - 1; i >= 0; i--) { if (_filterables[i].TryGetTarget(out var target)) { @@ -164,7 +167,7 @@ internal void OnFilterChanged(FilterKey fk, string text) { private async Task ShowAddToLibDialogAsync() { IReadOnlyList files = []; - var addToLibViewModel = new AddToLibViewModel(); + var addToLibViewModel = new AddToLibViewModel(_storagePicker); var dialog = GlobalDialogUtils.CreateDialogWithoutTitle( new AddToLib(addToLibViewModel), LanguageUtil.GetI18n(Constants.I18n.Text_Confirm)); @@ -223,7 +226,7 @@ await loadingCtx.RunAsync( } #region Buttons Command - internal async void Close() { + public async void Close() { if (Interlocked.Exchange(ref _canClose, 0) != 1) return; (WpCloseCommand as RelayCommand)?.RaiseCanExecuteChanged(); @@ -234,7 +237,7 @@ internal async void Close() { (WpCloseCommand as RelayCommand)?.RaiseCanExecuteChanged(); } - internal async void Detect() { + public async void Detect() { if (Interlocked.Exchange(ref _canDetect, 0) != 1) return; (WpDetectCommand as RelayCommand)?.RaiseCanExecuteChanged(); @@ -250,7 +253,7 @@ internal async void Detect() { (WpDetectCommand as RelayCommand)?.RaiseCanExecuteChanged(); } - internal async void Identify() { + public async void Identify() { if (Interlocked.Exchange(ref _canIdentify, 0) != 1) return; (WpIdentifyCommand as RelayCommand)?.RaiseCanExecuteChanged(); @@ -309,7 +312,6 @@ await loadingCtx.RunAsync( } }, cts: ctsAdjust); - Interlocked.Exchange(ref _canAdjust, 1); (WpAdjustCommand as RelayCommand)?.RaiseCanExecuteChanged(); } @@ -319,6 +321,7 @@ await loadingCtx.RunAsync( private readonly IMonitorManagerClient _monitorManagerClient; private readonly IWallpaperControlClient _wpControlClient; private readonly IUserSettingsClient _userSettingsClient; + private readonly IStoragePicker _storagePicker; private readonly List> _filterables = []; private readonly Dictionary _adjusts = []; diff --git a/src/VirtualPaper.WpSettingsPanel/Views/LibraryContents.xaml b/src/VirtualPaper.WpSettingsPanel/Views/LibraryContents.xaml index 1a60f26e..f4f16139 100644 --- a/src/VirtualPaper.WpSettingsPanel/Views/LibraryContents.xaml +++ b/src/VirtualPaper.WpSettingsPanel/Views/LibraryContents.xaml @@ -62,7 +62,7 @@ Text="{x:Bind Title, Mode=OneWay}" MaxLines="2" FontSize="16" - Foreground="{Binding DataContext.WpTitleForeground, ElementName=rootPage}" + Foreground="{Binding DataContext.WpTitleForeground, ElementName=rootPage, Converter={StaticResource ByteArrayToBrushConverter}}" TextTrimming="CharacterEllipsis"/> diff --git a/src/VirtualPaper.sln b/src/VirtualPaper.sln index 5ce1d488..27d445b6 100644 --- a/src/VirtualPaper.sln +++ b/src/VirtualPaper.sln @@ -45,10 +45,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VirtualPaper.PlayerWeb.Core EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VirtualPaper.PlayerWeb", "VirtualPaper.PlayerWeb\VirtualPaper.PlayerWeb.csproj", "{CAAC63E7-6598-43FC-B56F-322A918B89A1}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VirtualPaper.FuntionTest", "VirtualPaper.FuntionTest\VirtualPaper.FuntionTest.csproj", "{07D724A8-FC39-EDB7-7E12-7A94AC903FA2}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Workloads.Utils", "Workloads.Utils\Workloads.Utils.csproj", "{EA7D0097-68E0-4529-B981-018C939BC060}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{C9E354C8-7B8A-43D2-82E9-4D7653F54906}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VirtualPaper.UI.Test", "VirtualPaper.UI.Test\VirtualPaper.UI.Test.csproj", "{DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VirtualPaper.Core.Test", "VirtualPaper.Core.Test\VirtualPaper.Core.Test.csproj", "{02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VirtualPaper.ML.Test", "VirtualPaper.ML.Test\VirtualPaper.ML.Test.csproj", "{68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VirtualPaper.Shader.Test", "VirtualPaper.Shader.Test\VirtualPaper.Shader.Test.csproj", "{6643885C-DF9A-43C1-8104-F7ECBF70A3C8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -409,26 +417,6 @@ Global {CAAC63E7-6598-43FC-B56F-322A918B89A1}.Release|x64.Build.0 = Release|Any CPU {CAAC63E7-6598-43FC-B56F-322A918B89A1}.Release|x86.ActiveCfg = Release|Any CPU {CAAC63E7-6598-43FC-B56F-322A918B89A1}.Release|x86.Build.0 = Release|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Debug|Any CPU.Build.0 = Debug|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Debug|ARM.ActiveCfg = Debug|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Debug|ARM.Build.0 = Debug|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Debug|ARM64.ActiveCfg = Debug|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Debug|ARM64.Build.0 = Debug|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Debug|x64.ActiveCfg = Debug|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Debug|x64.Build.0 = Debug|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Debug|x86.ActiveCfg = Debug|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Debug|x86.Build.0 = Debug|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Release|Any CPU.ActiveCfg = Release|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Release|Any CPU.Build.0 = Release|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Release|ARM.ActiveCfg = Release|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Release|ARM.Build.0 = Release|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Release|ARM64.ActiveCfg = Release|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Release|ARM64.Build.0 = Release|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Release|x64.ActiveCfg = Release|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Release|x64.Build.0 = Release|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Release|x86.ActiveCfg = Release|Any CPU - {07D724A8-FC39-EDB7-7E12-7A94AC903FA2}.Release|x86.Build.0 = Release|Any CPU {EA7D0097-68E0-4529-B981-018C939BC060}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {EA7D0097-68E0-4529-B981-018C939BC060}.Debug|Any CPU.Build.0 = Debug|Any CPU {EA7D0097-68E0-4529-B981-018C939BC060}.Debug|ARM.ActiveCfg = Debug|Any CPU @@ -449,6 +437,86 @@ Global {EA7D0097-68E0-4529-B981-018C939BC060}.Release|x64.Build.0 = Release|Any CPU {EA7D0097-68E0-4529-B981-018C939BC060}.Release|x86.ActiveCfg = Release|Any CPU {EA7D0097-68E0-4529-B981-018C939BC060}.Release|x86.Build.0 = Release|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Debug|ARM.ActiveCfg = Debug|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Debug|ARM.Build.0 = Debug|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Debug|ARM64.Build.0 = Debug|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Debug|x64.ActiveCfg = Debug|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Debug|x64.Build.0 = Debug|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Debug|x86.ActiveCfg = Debug|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Debug|x86.Build.0 = Debug|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Release|Any CPU.Build.0 = Release|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Release|ARM.ActiveCfg = Release|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Release|ARM.Build.0 = Release|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Release|ARM64.ActiveCfg = Release|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Release|ARM64.Build.0 = Release|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Release|x64.ActiveCfg = Release|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Release|x64.Build.0 = Release|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Release|x86.ActiveCfg = Release|Any CPU + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D}.Release|x86.Build.0 = Release|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Debug|ARM.ActiveCfg = Debug|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Debug|ARM.Build.0 = Debug|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Debug|ARM64.Build.0 = Debug|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Debug|x64.ActiveCfg = Debug|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Debug|x64.Build.0 = Debug|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Debug|x86.ActiveCfg = Debug|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Debug|x86.Build.0 = Debug|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Release|Any CPU.Build.0 = Release|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Release|ARM.ActiveCfg = Release|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Release|ARM.Build.0 = Release|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Release|ARM64.ActiveCfg = Release|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Release|ARM64.Build.0 = Release|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Release|x64.ActiveCfg = Release|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Release|x64.Build.0 = Release|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Release|x86.ActiveCfg = Release|Any CPU + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6}.Release|x86.Build.0 = Release|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Debug|ARM.ActiveCfg = Debug|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Debug|ARM.Build.0 = Debug|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Debug|ARM64.Build.0 = Debug|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Debug|x64.ActiveCfg = Debug|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Debug|x64.Build.0 = Debug|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Debug|x86.ActiveCfg = Debug|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Debug|x86.Build.0 = Debug|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Release|Any CPU.Build.0 = Release|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Release|ARM.ActiveCfg = Release|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Release|ARM.Build.0 = Release|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Release|ARM64.ActiveCfg = Release|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Release|ARM64.Build.0 = Release|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Release|x64.ActiveCfg = Release|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Release|x64.Build.0 = Release|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Release|x86.ActiveCfg = Release|Any CPU + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1}.Release|x86.Build.0 = Release|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Debug|ARM.ActiveCfg = Debug|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Debug|ARM.Build.0 = Debug|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Debug|ARM64.ActiveCfg = Debug|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Debug|ARM64.Build.0 = Debug|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Debug|x64.ActiveCfg = Debug|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Debug|x64.Build.0 = Debug|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Debug|x86.ActiveCfg = Debug|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Debug|x86.Build.0 = Debug|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Release|Any CPU.Build.0 = Release|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Release|ARM.ActiveCfg = Release|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Release|ARM.Build.0 = Release|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Release|ARM64.ActiveCfg = Release|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Release|ARM64.Build.0 = Release|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Release|x64.ActiveCfg = Release|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Release|x64.Build.0 = Release|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Release|x86.ActiveCfg = Release|Any CPU + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -462,9 +530,13 @@ Global {2E60E704-2F02-512D-3A0A-FE56A340ED5B} = {D252A1EE-5244-47E0-AE85-94E193E177E2} {CAAC63E7-6598-43FC-B56F-322A918B89A1} = {D252A1EE-5244-47E0-AE85-94E193E177E2} {EA7D0097-68E0-4529-B981-018C939BC060} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} + {DB3CE515-F4AC-4AEF-A855-B21F9F18B54D} = {C9E354C8-7B8A-43D2-82E9-4D7653F54906} + {02E4B24D-1CC1-4FAC-8E7F-AA6A9F2817F6} = {C9E354C8-7B8A-43D2-82E9-4D7653F54906} + {68E40740-CB2B-4F34-BAFB-25AD79BDC1D1} = {C9E354C8-7B8A-43D2-82E9-4D7653F54906} + {6643885C-DF9A-43C1-8104-F7ECBF70A3C8} = {C9E354C8-7B8A-43D2-82E9-4D7653F54906} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {C0DA4809-0B7F-4C80-8462-DE0981798196} RESX_NeutralResourcesLanguage = zh-CN + SolutionGuid = {C0DA4809-0B7F-4C80-8462-DE0981798196} EndGlobalSection EndGlobal diff --git a/src/VirtualPaper/App.xaml.cs b/src/VirtualPaper/App.xaml.cs index 6e08ae69..3a51a8ca 100644 --- a/src/VirtualPaper/App.xaml.cs +++ b/src/VirtualPaper/App.xaml.cs @@ -13,6 +13,7 @@ using VirtualPaper.Common.Utils; using VirtualPaper.Common.Utils.Files; using VirtualPaper.Common.Utils.Storage; +using VirtualPaper.Common.Utils.Storage.Adapter; using VirtualPaper.Cores.AppUpdate; using VirtualPaper.Cores.Monitor; using VirtualPaper.Cores.PlaybackControl; @@ -34,6 +35,8 @@ using VirtualPaper.Services; using VirtualPaper.Services.Download; using VirtualPaper.Services.Interfaces; +using VirtualPaper.Utils.Interfcaes; +using VirtualPaper.Utils.Services; using VirtualPaper.Utils.Theme; using VirtualPaper.ViewModels; using VirtualPaper.Views; @@ -49,7 +52,7 @@ namespace VirtualPaper { /// public partial class App : Application { internal static Logger Log => _log; - internal static JobService Jobs => Services.GetRequiredService(); + internal static IJobService Jobs => Services.GetRequiredService(); internal static IUserSettingsService UserSettings => Services.GetRequiredService(); public static IServiceProvider Services { @@ -119,9 +122,9 @@ public App() { SplashWindow? spl = null; if (UserSettings.Settings.IsUpdated || UserSettings.Settings.IsFirstRun) { spl = UserSettings.Settings.IsFirstRun ? new SplashWindow(0, 500) : null; - spl?.Show(); + spl?.Show(); UserSettings.Settings.IsFirstRun = false; - UserSettings.Save(); + UserSettings.Save(); } if (UserSettings.Settings.WallpaperDir == string.Empty @@ -140,7 +143,7 @@ public App() { // 启动针对从 Windows 发出的到该窗口的消息监听服务 Services.GetRequiredService().Show(); // 启动针对从外部设备发出的到该窗口的消息监听服务 - Services.GetRequiredService().Show(); + Services.GetRequiredService().Show(); // 启动壁纸行为/状态监听服务 Services.GetRequiredService().Start(_ctsPlayback); // 启动托盘(后台)服务 @@ -221,12 +224,21 @@ private ServiceProvider ConfigureServices() { .AddSingleton() .AddSingleton() - .AddSingleton() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() + .AddTransient() .AddSingleton() .AddSingleton() @@ -236,7 +248,6 @@ private ServiceProvider ConfigureServices() { .AddSingleton() .AddSingleton() - .AddSingleton() .AddSingleton() .AddTransient() .AddTransient() @@ -289,7 +300,7 @@ public static void ChangeTheme(AppTheme theme) { Application.Current.Dispatcher.Invoke(() => { ApplicationThemeManager.Apply(applicationTheme, updateAccent: false); - }); + }); } catch (Exception e) { Log.Error(e); diff --git a/src/VirtualPaper/Cores/AppUpdate/GithubUpdaterService.cs b/src/VirtualPaper/Cores/AppUpdate/GithubUpdaterService.cs index 2d4bb5d5..e22dca29 100644 --- a/src/VirtualPaper/Cores/AppUpdate/GithubUpdaterService.cs +++ b/src/VirtualPaper/Cores/AppUpdate/GithubUpdaterService.cs @@ -1,7 +1,6 @@ -using System.Diagnostics; using VirtualPaper.Common; using VirtualPaper.Common.Events; -using VirtualPaper.Common.Utils; +using VirtualPaper.Utils.Interfcaes; using Timer = System.Timers.Timer; namespace VirtualPaper.Cores.AppUpdate { @@ -15,7 +14,12 @@ public sealed class GithubUpdaterService : IAppUpdaterService { public Version LastCheckVersion { get; private set; } = new Version(0, 0, 0, 0); public AppUpdateStatus Status { get; private set; } = AppUpdateStatus.Notchecked; - public GithubUpdaterService() { + public GithubUpdaterService( + IGithubReleaseClient githubReleaseClient, + IVersionComparer versionComparer) { + _githubReleaseClient = githubReleaseClient; + _versionComparer = versionComparer; + _retryTimer.Elapsed += RetryTimer_Elapsed; //giving the retry delay is not reliable since it will reset if system sleeps/suspends. _retryTimer.Interval = 5 * 60 * 1000; @@ -29,8 +33,8 @@ public async Task CheckUpdate(int fetchDelay = 45000) { try { await Task.Delay(fetchDelay); - (Uri exeUri, Uri shaUri, Version verison, string changelog) = await GetLatestRelease(Constants.ApplicationType.IsTestBuild); - int verCompare = GithubUtil.CompareAssemblyVersion(verison); + (Uri exeUri, Uri shaUri, Version verison, string changelog) = await _githubReleaseClient.GetLatestRelease(Constants.ApplicationType.IsTestBuild); + int verCompare = _versionComparer.CompareAssemblyVersion(verison); if (verCompare > 0) { //update Available. Status = AppUpdateStatus.Available; @@ -58,46 +62,46 @@ public async Task CheckUpdate(int fetchDelay = 45000) { return Status; } - private async Task> GetModulesLatestRelease(bool isBeta) { - var userName = "PaperHammer"; - var repositoryName = isBeta ? "VirtualPaper-beta" : "VirtualPaper"; - var gitRelease = await GithubUtil.GetLatestRelease(repositoryName, userName, 0); - Version version = GithubUtil.GetVersion(gitRelease); - - //download asset format: virtualpaper_x64_module_YYY_vXXXX.dll, YYY - module-name, XXXX - 4 digit version no. - var gitUrls = await GithubUtil.GetAllAssetUrl( - "virtualpaper_x64_module", - gitRelease, repositoryName, userName); - List<(Uri, Version, string)> res = []; - foreach (var url in gitUrls) { - Uri uri = new(url); - string changelog = gitRelease.Body; - res.Add((uri, version, changelog)); - } - - return res; - } - - public async Task<(Uri exeUri, Uri shaUri, Version version, string changelog)> GetLatestRelease(bool isBeta) { - var userName = "PaperHammer"; - var repositoryName = isBeta ? "VirtualPaper-beta" : "VirtualPaper"; - var gitRelease = await GithubUtil.GetLatestRelease(repositoryName, userName, 0); - Version version = GithubUtil.GetVersion(gitRelease); - - //download asset format: virtualpaper_setup_x64_full_vXXXX.exe, XXXX - 4 digit version no. - var gitUrl = await GithubUtil.GetAssetUrl( - "virtualpaper_setup_x64_full", - gitRelease, repositoryName, userName); - Uri exeUri = new(gitUrl); - string changelog = gitRelease.Body; - - gitUrl = await GithubUtil.GetAssetUrl( - "SHA256", - gitRelease, repositoryName, userName); - Uri shaUri = new(gitUrl); - - return (exeUri, shaUri, version, changelog); - } + //private async Task> GetModulesLatestRelease(bool isBeta) { + // var userName = "PaperHammer"; + // var repositoryName = isBeta ? "VirtualPaper-beta" : "VirtualPaper"; + // var gitRelease = await GithubUtil.GetLatestRelease(repositoryName, userName, 0); + // Version version = GithubUtil.GetVersion(gitRelease); + + // //download asset format: virtualpaper_x64_module_YYY_vXXXX.dll, YYY - module-name, XXXX - 4 digit version no. + // var gitUrls = await GithubUtil.GetAllAssetUrl( + // "virtualpaper_x64_module", + // gitRelease, repositoryName, userName); + // List<(Uri, Version, string)> res = []; + // foreach (var url in gitUrls) { + // Uri uri = new(url); + // string changelog = gitRelease.Body; + // res.Add((uri, version, changelog)); + // } + + // return res; + //} + + //public async Task<(Uri exeUri, Uri shaUri, Version version, string changelog)> GetLatestRelease(bool isBeta) { + // var userName = "PaperHammer"; + // var repositoryName = isBeta ? "VirtualPaper-beta" : "VirtualPaper"; + // var gitRelease = await GithubUtil.GetLatestRelease(repositoryName, userName, 0); + // Version version = GithubUtil.GetVersion(gitRelease); + + // //download asset format: virtualpaper_setup_x64_full_vXXXX.exe, XXXX - 4 digit version no. + // var gitUrl = await GithubUtil.GetAssetUrl( + // "virtualpaper_setup_x64_full", + // gitRelease, repositoryName, userName); + // Uri exeUri = new(gitUrl); + // string changelog = gitRelease.Body; + + // gitUrl = await GithubUtil.GetAssetUrl( + // "SHA256", + // gitRelease, repositoryName, userName); + // Uri shaUri = new(gitUrl); + + // return (exeUri, shaUri, version, changelog); + //} /// /// Check for updates periodically. @@ -126,5 +130,7 @@ private void RetryTimer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e private readonly int _fetchDelayError = 30 * 60 * 1000; //30min private readonly int _fetchDelayRepeat = 12 * 60 * 60 * 1000; //12hr private readonly Timer _retryTimer = new(); + private readonly IGithubReleaseClient _githubReleaseClient; + private readonly IVersionComparer _versionComparer; } } diff --git a/src/VirtualPaper/Cores/AppUpdate/IAppUpdaterService.cs b/src/VirtualPaper/Cores/AppUpdate/IAppUpdaterService.cs index 75e51a82..e11b0bb5 100644 --- a/src/VirtualPaper/Cores/AppUpdate/IAppUpdaterService.cs +++ b/src/VirtualPaper/Cores/AppUpdate/IAppUpdaterService.cs @@ -12,7 +12,7 @@ public interface IAppUpdaterService { AppUpdateStatus Status { get; } Task CheckUpdate(int fetchDelay = 45000); - Task<(Uri exeUri, Uri shaUri, Version version, string changelog)> GetLatestRelease(bool isBeta); + //Task<(Uri exeUri, Uri shaUri, Version version, string changelog)> GetLatestRelease(bool isBeta); void Start(); void Stop(); } diff --git a/src/VirtualPaper/Cores/Draft/DraftControl.cs b/src/VirtualPaper/Cores/Draft/DraftControl.cs deleted file mode 100644 index d2465a50..00000000 --- a/src/VirtualPaper/Cores/Draft/DraftControl.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace VirtualPaper.Cores.Draft { - public class DraftControl : IDraftControl { - } -} diff --git a/src/VirtualPaper/Cores/Draft/IDraftControl.cs b/src/VirtualPaper/Cores/Draft/IDraftControl.cs deleted file mode 100644 index f7bd9ad6..00000000 --- a/src/VirtualPaper/Cores/Draft/IDraftControl.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace VirtualPaper.Cores.Draft { - public interface IDraftControl { - } -} diff --git a/src/VirtualPaper/Cores/PlaybackControl/Playback.cs b/src/VirtualPaper/Cores/PlaybackControl/Playback.cs index 74195361..ec82c035 100644 --- a/src/VirtualPaper/Cores/PlaybackControl/Playback.cs +++ b/src/VirtualPaper/Cores/PlaybackControl/Playback.cs @@ -1,9 +1,7 @@ using System.Diagnostics; -using System.Text; -using System.Windows.Threading; using Microsoft.Win32; -using OpenCvSharp.Internal; using VirtualPaper.Common; +using VirtualPaper.Common.Logging; using VirtualPaper.Common.Utils; using VirtualPaper.Common.Utils.Hardware; using VirtualPaper.Common.Utils.PInvoke; @@ -12,7 +10,7 @@ using VirtualPaper.Cores.WpControl; using VirtualPaper.Models.Cores.Interfaces; using VirtualPaper.Services.Interfaces; -using Windows.Devices.Display.Core; +using VirtualPaper.Utils.Interfcaes; namespace VirtualPaper.Cores.PlaybackControl { /// @@ -31,11 +29,13 @@ public Playback( IUserSettingsService userSettings, IWallpaperControl wpControl, IScrControl scrControl, - IMonitorManager monitoeManger) { + IMonitorManager monitoeManger, + IPowerService powerService) { _userSettings = userSettings; _wpControl = wpControl; _scrControl = scrControl; _monitorManger = monitoeManger; + _powerService = powerService; Initialize(); _timer = new(TimeSpan.FromMilliseconds(Math.Max(_userSettings.Settings.ProcessTimerInterval, 500))); @@ -49,10 +49,10 @@ public async void Start(CancellationTokenSource tokenSource) { } } catch (OperationCanceledException) { - App.Log.Info("Playback stoppped"); + ArcLog.GetLogger().Info("Playback stoppped"); } catch (Exception ex) { - App.Log.Error("Playback runtime Error: ", ex); + ArcLog.GetLogger().Error("Playback runtime Error: ", ex); } } @@ -65,7 +65,7 @@ private void Initialize() { _isLockScreen = IsSystemLocked(); if (_isLockScreen) { - App.Log.Info("Lockscreen Session already started!"); + ArcLog.GetLogger().Info("Lockscreen Session already started!"); } SystemEvents.SessionSwitch += SystemEvents_SessionSwitch; } @@ -74,19 +74,19 @@ private void Initialize() { private void SystemEvents_SessionSwitch(object sender, SessionSwitchEventArgs e) { if (e.Reason == SessionSwitchReason.RemoteConnect) { _isRemoteSession = true; - App.Log.Info("Remote Desktop Session started!"); + ArcLog.GetLogger().Info("Remote Desktop Session started!"); } else if (e.Reason == SessionSwitchReason.RemoteDisconnect) { _isRemoteSession = false; - App.Log.Info("Remote Desktop Session ended!"); + ArcLog.GetLogger().Info("Remote Desktop Session ended!"); } else if (e.Reason == SessionSwitchReason.SessionLock) { _isLockScreen = true; - App.Log.Info("Lockscreen Session started!"); + ArcLog.GetLogger().Info("Lockscreen Session started!"); } else if (e.Reason == SessionSwitchReason.SessionUnlock) { _isLockScreen = false; - App.Log.Info("Lockscreen Session ended!"); + ArcLog.GetLogger().Info("Lockscreen Session ended!"); } } @@ -103,19 +103,19 @@ private void RunPlayback() { (_isRemoteSession && _userSettings.Settings.RemoteDesktop == AppWpRunRulesEnum.Silence)) { ChangeWpState(AppWpRunRulesEnum.Silence); } - else if (PowerUtil.GetACPowerStatus() == PowerUtil.ACLineStatus.Offline && + else if (_powerService.GetACPowerStatus() == PowerUtil.ACLineStatus.Offline && _userSettings.Settings.BatteryPoweredn == AppWpRunRulesEnum.Pause) { ChangeWpState(AppWpRunRulesEnum.Pause); } - else if (PowerUtil.GetACPowerStatus() == PowerUtil.ACLineStatus.Offline && + else if (_powerService.GetACPowerStatus() == PowerUtil.ACLineStatus.Offline && _userSettings.Settings.BatteryPoweredn == AppWpRunRulesEnum.Silence) { ChangeWpState(AppWpRunRulesEnum.Silence); } - else if (PowerUtil.GetBatterySaverStatus() == PowerUtil.SystemStatusFlag.On && + else if (_powerService.GetBatterySaverStatus() == PowerUtil.SystemStatusFlag.On && _userSettings.Settings.PowerSaving == AppWpRunRulesEnum.Pause) { ChangeWpState(AppWpRunRulesEnum.Pause); } - else if (PowerUtil.GetBatterySaverStatus() == PowerUtil.SystemStatusFlag.On && + else if (_powerService.GetBatterySaverStatus() == PowerUtil.SystemStatusFlag.On && _userSettings.Settings.PowerSaving == AppWpRunRulesEnum.Silence) { ChangeWpState(AppWpRunRulesEnum.Silence); } @@ -169,7 +169,7 @@ private void AdjustWpBehaviourBaseOnForegroundApp() { } } catch (Exception ex) { - App.Log.Error("Playback Changes for AppRules Error: ", ex); + ArcLog.GetLogger().Error("Playback Changes for AppRules Error: ", ex); //failed to get process info.. maybe remote process; resume playback. ChangeWpState(AppWpRunRulesEnum.KeepRun); return; @@ -268,7 +268,7 @@ private void AdjustWpBehaviourBaseOnForegroundApp() { #endregion } catch (Exception ex) { - App.Log.Error("Playback Changes for Focus Error: ", ex); + ArcLog.GetLogger().Error("Playback Changes for Focus Error: ", ex); } #endregion } @@ -495,5 +495,10 @@ public void Dispose() { private readonly IWallpaperControl _wpControl; private readonly IMonitorManager _monitorManger; private readonly IScrControl _scrControl; + private readonly IPowerService _powerService; + + public void InvokeRunPlayback() => RunPlayback(); + public void SimulateLockScreen(bool value) => _isLockScreen = value; + public void SimulateRemoteSession(bool value) => _isRemoteSession = value; } } diff --git a/src/VirtualPaper/Cores/ScreenSaver/IScrControl.cs b/src/VirtualPaper/Cores/ScreenSaver/IScrControl.cs index 82d571d5..86ac1b27 100644 --- a/src/VirtualPaper/Cores/ScreenSaver/IScrControl.cs +++ b/src/VirtualPaper/Cores/ScreenSaver/IScrControl.cs @@ -1,9 +1,5 @@ -using VirtualPaper.Models.Cores.Interfaces; - -namespace VirtualPaper.Cores.ScreenSaver -{ - public interface IScrControl : IDisposable - { +namespace VirtualPaper.Cores.ScreenSaver { + public interface IScrControl : IDisposable { bool IsRunning { get; } void AddToWhiteList(string procName); void ChangeLockStatu(bool isLock); diff --git a/src/VirtualPaper/Cores/ScreenSaver/ScrControl.cs b/src/VirtualPaper/Cores/ScreenSaver/ScrControl.cs index a1bad3ff..a24a6de0 100644 --- a/src/VirtualPaper/Cores/ScreenSaver/ScrControl.cs +++ b/src/VirtualPaper/Cores/ScreenSaver/ScrControl.cs @@ -1,83 +1,84 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Diagnostics; using System.IO; using System.Text; using System.Text.Json; -using System.Windows.Threading; using VirtualPaper.Common; +using VirtualPaper.Common.Logging; using VirtualPaper.Common.Utils.IPC; using VirtualPaper.Common.Utils.PInvoke; using VirtualPaper.Cores.WpControl; using VirtualPaper.Services.Interfaces; +using VirtualPaper.Utils.Interfcaes; using VirtualPaper.Views.WindowsMsg; namespace VirtualPaper.Cores.ScreenSaver { public partial class ScrControl : IScrControl { - public Process? Proc { get; private set; } + //public Process? Proc { get; private set; } public bool IsRunning { get; private set; } = false; public ScrControl( IUserSettingsService userSettingsService, IWallpaperControl wpControl, - RawInputMsgWindow msgWindow) { + IRawInputMsg msgWindow, + IDispatcherTimer dispatcherTimer, + INativeService nativeService, + IProcessLauncher processLauncher, + IJobService jobService) { _userSettingsService = userSettingsService; _msgWindow = msgWindow; _wpControl = wpControl; + _nativeService = nativeService; + _processLauncher = processLauncher; + _jobService = jobService; _msgWindow.MouseMoveRaw += MsgWindow_MouseMoveRaw; _msgWindow.MouseDownRaw += MsgWindow_MouseDownRaw; _msgWindow.MouseUpRaw += MsgWindow_MouseUpRaw; _msgWindow.KeyboardClickRaw += MsgWindow_KeyboardClickRaw; - _dispatcherTimer = new(); + _dispatcherTimer = dispatcherTimer; foreach (var proc in userSettingsService.Settings.WhiteListScr) { _scrWhiteListProcState[proc.ProcName] = false; } } - public void ChangeLockStatu(bool isLock) { - _isRunningLock = isLock; - } - - public void Stop() { - StopTimeTask(); - } - public void Start() { - try { - if (_isTiming || IsRunning) { - return; - } + if (_isTiming || IsRunning) return; + try { _isRunningLock = _userSettingsService.Settings.IsRunningLock; - StartTimerTask(); } catch (Exception ex) { - App.Log.Error("ScreenSaver started Error..." + ex.Message); + ArcLog.GetLogger().Error("ScreenSaver started Error: " + ex.Message); } } + public void Stop() { + StopTimerTask(); + } + + public void ChangeLockStatu(bool isLock) { + _isRunningLock = isLock; + } + public void AddToWhiteList(string procName) { _scrWhiteListProcState[procName] = false; } public void RemoveFromWhiteList(string procName) { - if (_scrWhiteListProcState.ContainsKey(procName)) - _scrWhiteListProcState.Remove(procName, out _); + _scrWhiteListProcState.Remove(procName, out _); } - private void ResetTimer(string callback) { - StopTimeTask(); - if (IsRunning) { - StopProc(); - } - if (_userSettingsService.Settings.IsScreenSaverOn) { - StartTimerTask(); - } - } + // ------------------------------------------------------------------------- + // Timer + // ------------------------------------------------------------------------- + /// + /// 启动倒计时,倒计时结束后触发屏保启动 + /// private void StartTimerTask() { _dispatcherTimer.Interval = TimeSpan.FromMinutes(_userSettingsService.Settings.WaitingTime); _dispatcherTimer.Tick += DispatcherTimer_Tick; @@ -85,200 +86,267 @@ private void StartTimerTask() { _isTiming = true; } - private void StopTimeTask() { + /// + /// 停止倒计时 + /// + private void StopTimerTask() { _dispatcherTimer.Tick -= DispatcherTimer_Tick; _dispatcherTimer.Stop(); _isTiming = false; } - private void StopProc() { - lock (_objStop) { - if (_isStopping) return; - _isStopping = true; + /// + /// 重置计时器:停止当前计时(若进程在运行则一并停止),再重新开始计时。 + /// 由用户输入、白名单检测等场景触发。 + /// + private void ResetTimer(string reason) { + StopTimerTask(); - SendMessage(new VirtualPaperCloseCmd()); - App.Log.Info("ScreenSaver was stoppped."); - if (_isRunningLock) { - Native.LockWorkStation(); - } + if (IsRunning) { + StopProc(); + // 计时器重启由 Proc_Exited → RestartTimerAfterExit 完成 + return; + } + + // 进程未运行,直接重启计时器 + if (_userSettingsService.Settings.IsScreenSaverOn) { + StartTimerTask(); } } + // ------------------------------------------------------------------------- + // Process: Start + // ------------------------------------------------------------------------- + private void DispatcherTimer_Tick(object? sender, EventArgs e) { - try { - StopTimeTask(); + // 计时结束,先停掉计时器再启动屏保 + StopTimerTask(); - var tup = _wpControl.GetPrimaryWpFilePathRType(); - string? filePath = tup.Item1; - string? rtype = tup.Item2.ToString(); - if (filePath == null || rtype == null) { - ResetTimer("Primary wallpaper was none."); + try { + // 检查壁纸 + var (filePath, runtimeType) = _wpControl.GetPrimaryWpFilePathRType(); + if (filePath == null) { + ArcLog.GetLogger().Info("Primary wallpaper was none, reset timer."); + RestartTimer(); return; } - if (Native.SHQueryUserNotificationState(out Native.QUERY_USER_NOTIFICATION_STATE state) == 0) { + // 检查系统通知状态(全屏/演示/忙碌时不启动屏保) + if (_nativeService.SHQueryUserNotificationState(out var state) == 0) { switch (state) { case Native.QUERY_USER_NOTIFICATION_STATE.QUNS_NOT_PRESENT: case Native.QUERY_USER_NOTIFICATION_STATE.QUNS_BUSY: case Native.QUERY_USER_NOTIFICATION_STATE.QUNS_RUNNING_D3D_FULL_SCREEN: case Native.QUERY_USER_NOTIFICATION_STATE.QUNS_PRESENTATION_MODE: - ResetTimer("The foreground whitelist event is active"); + ArcLog.GetLogger().Info($"System notification state [{state}], reset timer."); + RestartTimer(); return; } } - var hwnd = Native.GetForegroundWindow(); - _ = Native.GetWindowThreadProcessId(hwnd, out int processId); - string procName = Process.GetProcessById(processId).ProcessName; - + // 检查白名单 + var hwnd = _nativeService.GetForegroundWindow(); + _nativeService.GetWindowThreadProcessId(hwnd, out int processId); + string procName = _nativeService.GetProcessNameById(processId); if (_scrWhiteListProcState.ContainsKey(procName)) { - ResetTimer("The foreground whitelisting program is active"); + ArcLog.GetLogger().Info($"Whitelisted process [{procName}] is active, reset timer."); + RestartTimer(); return; } - lock (_objStart) { - if (_isStarting || IsRunning) return; - _isStarting = true; + // 启动屏保进程 + LaunchProc(filePath, runtimeType.ToString()); + } + catch (Exception ex) { + ArcLog.GetLogger().Error("ScreenSaver runtime Error: " + ex.Message); + // 启动失败,清理并重新计时 + CleanupProc(); + RestartTimer(); + } + } - InitScr(filePath, rtype); + private void LaunchProc(string filePath, string rtype) { + lock (_objStart) { + if (_isStarting || IsRunning) return; + _isStarting = true; + } - if (Proc == null) { - App.Log.Error("Run ScreenSaver failed..."); - return; - } + var startInfo = BuildStartInfo(filePath, rtype); - Proc.Exited += Proc_Exited; - Proc.OutputDataReceived += Proc_OutputDataReceived; - Proc.Start(); - App.Jobs.AddProcess(Proc.Id); - Proc.BeginOutputReadLine(); + _processLauncher.Exited += Proc_Exited; + _processLauncher.OutputDataReceived += Proc_OutputDataReceived; + _processLauncher.Launch(startInfo); + _jobService.AddProcess(_processLauncher.ProcessId); + _processLauncher.BeginOutputReadLine(); - App.Log.Info("ScreenSaver is started."); - } - } - catch (Exception ex) { - App.Log.Error("ScreenSaver runtime Error..." + ex.Message); - Terminate(); - } + ArcLog.GetLogger().Info("ScreenSaver launched."); } - private void InitScr(string filePath, string ftype) { - if (filePath == null || ftype == null) return; + // ------------------------------------------------------------------------- + // Process: Stop + // ------------------------------------------------------------------------- - string workingDir = Path.Combine( - AppDomain.CurrentDomain.BaseDirectory, - Constants.WorkingDir.ScrSaver); + /// + /// 主动停止屏保进程:发送关闭消息,进程退出后由 Proc_Exited 完成清理和重启计时。 + /// + private void StopProc() { + lock (_objStop) { + if (_isStopping) return; + _isStopping = true; + } - StringBuilder cmdArgs = new(); - cmdArgs.Append($" --file-path {filePath}"); - cmdArgs.Append($" --wallpaper-type {ftype}"); - cmdArgs.Append($" --effect {_userSettingsService.Settings.ScreenSaverEffect.ToString()}"); + ArcLog.GetLogger().Info("Requesting ScreenSaver stop..."); + SendMessage(new VirtualPaperCloseCmd()); - ProcessStartInfo start = new() { - FileName = Path.Combine( - workingDir, - Constants.ModuleName.ScrSaver), - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - WorkingDirectory = workingDir, - Arguments = cmdArgs.ToString(), - }; + if (_isRunningLock) { + _nativeService.LockWorkStation(); + } + } - Process _process = new() { - EnableRaisingEvents = true, - StartInfo = start, - }; + /// + /// 进程退出事件:清理资源,并根据配置决定是否重启计时器。 + /// 无论是主动 Stop 还是意外退出,都走这里。 + /// + private void Proc_Exited(object? sender, EventArgs e) { + _processLauncher.OutputDataReceived -= Proc_OutputDataReceived; + _processLauncher.Exited -= Proc_Exited; - Proc = _process; + CleanupProc(); + RestartTimerAfterExit(); } - private void Terminate() { + /// + /// 清理进程资源,重置运行状态。 + /// + private void CleanupProc() { try { - StopTimeTask(); - if (Proc != null) { - Proc.Kill(); - Proc.Dispose(); - App.Log.Info("Proc was Killed"); - } + _processLauncher.Kill(); + _processLauncher.Dispose(); + ArcLog.GetLogger().Info("ScreenSaver process cleaned up."); + } + catch (Exception ex) { + ArcLog.GetLogger().Error("CleanupProc Error: " + ex.Message); } - catch { } finally { IsRunning = false; + _isStarting = false; _isStopping = false; } } + // ------------------------------------------------------------------------- + // Timer Restart Helpers + // ------------------------------------------------------------------------- + + /// + /// 进程退出后重启计时器(用于 Proc_Exited 场景)。 + /// + private void RestartTimerAfterExit() { + if (_userSettingsService.Settings.IsScreenSaverOn) { + StartTimerTask(); + } + } + + /// + /// 直接重启计时器(用于 Tick 内部检查未通过的场景)。 + /// + private void RestartTimer() { + if (_userSettingsService.Settings.IsScreenSaverOn) { + StartTimerTask(); + } + } + + // ------------------------------------------------------------------------- + // IPC + // ------------------------------------------------------------------------- + + private void Proc_OutputDataReceived(object? sender, ProcessOutputEventArgs e) { + if (string.IsNullOrEmpty(e.Data)) return; + + ArcLog.GetLogger().Info($"ScreenSaver: {e.Data}"); + + if (IsRunning) return; + + try { + var obj = JsonSerializer.Deserialize(e.Data, IpcMessageContext.Default.IpcMessage) + ?? throw new Exception("null msg received"); + + if (obj.Type == MessageType.msg_wploaded) { + IsRunning = true; + _isStarting = false; + } + } + catch (Exception ex) { + ArcLog.GetLogger().Error($"IpcMessage parse Error: {ex.Message}"); + } + } + private void SendMessage(IpcMessage obj) { SendMessage(JsonSerializer.Serialize(obj, IpcMessageContext.Default.IpcMessage)); } private void SendMessage(string msg) { try { - Proc?.StandardInput.WriteLine(msg); + _processLauncher.WriteStdin(msg); } catch (Exception e) { - App.Log.Error($"Stdin write fail: {e.Message}"); + ArcLog.GetLogger().Error($"Stdin write fail: {e.Message}"); } } - private void Proc_OutputDataReceived(object sender, DataReceivedEventArgs e) { - if (!string.IsNullOrEmpty(e.Data)) { - App.Log.Info($"ScreenSaver: {e.Data}"); - if (!IsRunning) { - IpcMessage obj; - try { - obj = JsonSerializer.Deserialize(e.Data, IpcMessageContext.Default.IpcMessage) ?? throw new("null msg recieved"); - } - catch (Exception ex) { - App.Log.Error($"Ipcmessage parse Error: {ex.Message}"); - return; - } + // ------------------------------------------------------------------------- + // Raw Input → ResetTimer + // ------------------------------------------------------------------------- - if (obj.Type == MessageType.msg_wploaded) { - IsRunning = true; - _isStarting = false; - } - } - } - } + private void MsgWindow_KeyboardClickRaw(object? sender, KeyboardClickRawArgs e) + => ResetTimer($"Keyboard clicked: {e.Key}"); - private void Proc_Exited(object? sender, EventArgs e) { - if (Proc != null) { - Proc.OutputDataReceived -= Proc_OutputDataReceived; - } - Terminate(); - } + private void MsgWindow_MouseUpRaw(object? sender, MouseClickRawArgs e) + => ResetTimer($"Mouse up: {e.X},{e.Y} {e.Button}"); - #region raw input - private void MsgWindow_KeyboardClickRaw(object? sender, KeyboardClickRawArgs e) { - ResetTimer($"Keyboard was Clicked at : {e.Key}"); - } + private void MsgWindow_MouseDownRaw(object? sender, MouseClickRawArgs e) + => ResetTimer($"Mouse down: {e.X},{e.Y} {e.Button}"); - private void MsgWindow_MouseUpRaw(object? sender, MouseClickRawArgs e) { - ResetTimer($"Mouse was uped at: {e.X} {e.Y} by {e.Button}"); - } + private void MsgWindow_MouseMoveRaw(object? sender, MouseRawArgs e) + => ResetTimer($"Mouse move: {e.X},{e.Y}"); - private void MsgWindow_MouseDownRaw(object? sender, MouseClickRawArgs e) { - ResetTimer($"Mouse was downed at: {e.X} {e.Y} by {e.Button}"); - } + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- - private void MsgWindow_MouseMoveRaw(object? sender, MouseRawArgs e) { - ResetTimer($"Mouse was moved at: {e.X} {e.Y}"); + private ProcessStartInfo BuildStartInfo(string filePath, string ftype) { + string workingDir = Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, + Constants.WorkingDir.ScrSaver); + + var cmdArgs = new StringBuilder() + .Append($" --file-path {filePath}") + .Append($" --wallpaper-type {ftype}") + .Append($" --effect {_userSettingsService.Settings.ScreenSaverEffect}"); + + return new ProcessStartInfo { + FileName = Path.Combine(workingDir, Constants.ModuleName.ScrSaver), + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + WorkingDirectory = workingDir, + Arguments = cmdArgs.ToString(), + }; } - #endregion #region dispose private bool _isDisposed; + protected virtual void Dispose(bool disposing) { - if (!_isDisposed) { - _msgWindow.MouseMoveRaw -= MsgWindow_MouseMoveRaw; - _msgWindow.MouseDownRaw -= MsgWindow_MouseDownRaw; - _msgWindow.MouseUpRaw -= MsgWindow_MouseUpRaw; - _msgWindow.KeyboardClickRaw -= MsgWindow_KeyboardClickRaw; - _isDisposed = true; - } + if (_isDisposed) return; + + _msgWindow.MouseMoveRaw -= MsgWindow_MouseMoveRaw; + _msgWindow.MouseDownRaw -= MsgWindow_MouseDownRaw; + _msgWindow.MouseUpRaw -= MsgWindow_MouseUpRaw; + _msgWindow.KeyboardClickRaw -= MsgWindow_KeyboardClickRaw; + + _isDisposed = true; } public void Dispose() { @@ -287,13 +355,16 @@ public void Dispose() { } #endregion + private readonly IProcessLauncher _processLauncher; + private readonly IJobService _jobService; private readonly IUserSettingsService _userSettingsService; - private readonly RawInputMsgWindow _msgWindow; + private readonly IRawInputMsg _msgWindow; private readonly IWallpaperControl _wpControl; - private readonly DispatcherTimer _dispatcherTimer; - private readonly ConcurrentDictionary _scrWhiteListProcState = []; - private readonly static object _objStop = new(); - private readonly static object _objStart = new(); + private readonly INativeService _nativeService; + private readonly IDispatcherTimer _dispatcherTimer; + private readonly ConcurrentDictionary _scrWhiteListProcState = new(StringComparer.OrdinalIgnoreCase); + private readonly object _objStop = new(); + private readonly object _objStart = new(); private bool _isRunningLock = false; private bool _isStopping = false; private bool _isStarting = false; diff --git a/src/VirtualPaper/Cores/TrayControl/TrayCommand.cs b/src/VirtualPaper/Cores/TrayControl/TrayCommand.cs index bbbc64d8..8a95c9c6 100644 --- a/src/VirtualPaper/Cores/TrayControl/TrayCommand.cs +++ b/src/VirtualPaper/Cores/TrayControl/TrayCommand.cs @@ -1,16 +1,32 @@ -using System.IO; -using System.IO.Pipes; -using System.Security.Principal; +using VirtualPaper.Utils.Interfcaes; namespace VirtualPaper.Cores.TrayControl { - public class TrayCommand() { - public async void SendMsgToUI(string msg) { + //public class TrayCommand() { + // public async void SendMsgToUI(string msg) { + // try { + // using var client = new NamedPipeClientStream("localhost", "TRAY_CMD", PipeDirection.InOut, PipeOptions.Asynchronous, TokenImpersonationLevel.None); + // await client.ConnectAsync(); + + // using var writer = new StreamWriter(client); + // writer.AutoFlush = true; + // writer.WriteLine(msg); + // client.WaitForPipeDrain(); + // } + // catch (Exception ex) { + // App.Log.Error($"[PipeClient] Exception: {ex.Message}"); + // } + // } + //} + public class TrayCommand(IPipeClientFactory pipeFactory) { + private readonly IPipeClientFactory _pipeFactory = pipeFactory; + + // async Task 替换 async void,便于测试和异常传播 + public async Task SendMsgToUIAsync(string msg, CancellationToken ct = default) { try { - using var client = new NamedPipeClientStream("localhost", "TRAY_CMD", PipeDirection.InOut, PipeOptions.Asynchronous, TokenImpersonationLevel.None); - await client.ConnectAsync(); + using var client = _pipeFactory.Create("localhost", "TRAY_CMD"); + await client.ConnectAsync(ct); - using var writer = new StreamWriter(client); - writer.AutoFlush = true; + using var writer = client.CreateWriter(); writer.WriteLine(msg); client.WaitForPipeDrain(); } diff --git a/src/VirtualPaper/Cores/WpControl/WallpaperControl.cs b/src/VirtualPaper/Cores/WpControl/WallpaperControl.cs index 1da3e284..3f1c26bf 100644 --- a/src/VirtualPaper/Cores/WpControl/WallpaperControl.cs +++ b/src/VirtualPaper/Cores/WpControl/WallpaperControl.cs @@ -21,7 +21,9 @@ using VirtualPaper.Models.Cores.Interfaces; using VirtualPaper.Services.Interfaces; using VirtualPaper.Utils; +using VirtualPaper.Utils.Interfcaes; using WinEventHook; +using static System.Windows.Forms.VisualStyles.VisualStyleElement.ToolTip; using static VirtualPaper.Common.Errors; namespace VirtualPaper.Cores.WpControl { @@ -36,10 +38,14 @@ public partial class WallpaperControl : IWallpaperControl { public WallpaperControl( IUserSettingsService userSettings, IMonitorManager monitorManager, - IWallpaperFactory wallpaperFactory) { + IWallpaperFactory wallpaperFactory, + INativeService nativeService, + IJobService jobService) { this._userSettings = userSettings; this._monitorManager = monitorManager; this._wallpaperFactory = wallpaperFactory; + this._nativeService = nativeService; + this._jobService = jobService; if (SystemParameters.HighContrast) ArcLog.GetLogger().Warn("Highcontrast mode detected, some functionalities may not work properly."); @@ -89,12 +95,18 @@ public void CloseAllWallpapers() { item.ThumbnailPath = string.Empty; } } + var tmp = _wallpapers.ToList(); if (tmp.Count > 0) { - tmp.ForEach(x => x.Close()); + tmp.ForEach(x => { + _wallpapers.RemoveAll(w => w.Monitor!.DeviceId == x.Monitor!.DeviceId); + x.Close(); + }); tmp.Clear(); + WallpaperChanged?.Invoke(this, EventArgs.Empty); + ArcLog.GetLogger().Info("Closed all wallpapers"); - } + } } public void CloseWallpaper(IMonitor? monitor) { @@ -106,10 +118,15 @@ public void CloseWallpaper(IMonitor? monitor) { int idx = _monitorManager.Monitors.FindIndex(monitor); _monitorManager.Monitors[idx].ThumbnailPath = string.Empty; - var tmp = _wallpapers.FindAll(x => x.Monitor.Equals(monitor)); - if (tmp.Count > 0) { - tmp.ForEach(x => x.Close()); - _wallpapers.RemoveAll(tmp.Contains); + var toClose = _wallpapers + .Where(x => x.Monitor!.DeviceId == monitor.DeviceId) + .ToList(); + if (toClose.Count > 0) { + toClose.ForEach(x => { + _wallpapers.RemoveAll(x => x.Monitor!.DeviceId == monitor.DeviceId); + x.Close(); + }); + WallpaperChanged?.Invoke(this, EventArgs.Empty); ArcLog.GetLogger().Info("Closed wallpaper at _monitor: " + monitor.DeviceId); } @@ -216,20 +233,21 @@ public async Task SetWallpaperAsync( IsFinished = true }; - if (monitor == null) { - response.IsFinished = false; - return response; - } - try { ArcLog.GetLogger().Info($"Setting wallpaper: {data.FilePath}"); + if (monitor == null) { + response.IsFinished = false; + return response; + } + if (data.RType == RuntimeType.RUnknown) { + response.IsFinished = false; throw new Exception("rtype error"); } #region pre-check - if (_workerW == nint.Zero) { + if (!Constants.IsTestMode && _workerW == nint.Zero) { ArcLog.GetLogger().Error("WorkerW is not found"); response.IsFinished = false; @@ -239,7 +257,6 @@ public async Task SetWallpaperAsync( if (!_monitorManager.MonitorExists(monitor)) { ArcLog.GetLogger().Info($"Skipping wallpaper, _monitor {monitor.DeviceId} not found."); WallpaperError?.Invoke(this, new ScreenNotFoundException($"Mnotir {monitor.DeviceId} not found.")); - response.IsFinished = false; return response; @@ -251,7 +268,6 @@ public async Task SetWallpaperAsync( WallpaperError?.Invoke(this, new WallpaperNotFoundException($"{LanguageManager.Instance ["WpControl_TextFileNotFound"]}\n{data.FilePath}")); WallpaperChanged?.Invoke(this, EventArgs.Empty); - response.IsFinished = false; return response; @@ -297,7 +313,7 @@ public async Task SetWallpaperAsync( } else { instance.Closing += ClosingEvent; - App.Jobs.AddProcess(instance.Proc.Id); + _jobService.AddProcess(instance.Proc.Id); _monitorManager.UpdateTargetMonitorThu(monitorIdx, data.ThumbnailPath); _wallpapers.Add(instance); } @@ -326,7 +342,7 @@ public async Task SetWallpaperAsync( } else { instance.Closing += ClosingEvent; - App.Jobs.AddProcess(instance.Proc.Id); + _jobService.AddProcess(instance.Proc.Id); _monitorManager.UpdateTargetMonitorThu(monitorIdx, data.ThumbnailPath); _wallpapers.Add(instance); } @@ -356,7 +372,7 @@ public async Task SetWallpaperAsync( } else { instance.Closing += ClosingEvent; - App.Jobs.AddProcess(instance.Proc.Id); + _jobService.AddProcess(instance.Proc.Id); _monitorManager.UpdateTargetMonitorThu(monitorIdx, data.ThumbnailPath); _wallpapers.Add(instance); } @@ -387,28 +403,28 @@ public async Task SetWallpaperAsync( return response; } - private void UpdateWallpaper(IWpPlayer instance) { - var monitorDeviceId = instance.Monitor.DeviceId; - foreach (var item in _monitorManager.Monitors) { - if (item.DeviceId == monitorDeviceId) { - continue; - } - - var targetInstance = _wallpapers.FirstOrDefault(x => x.Monitor.DeviceId == item.DeviceId); - if (targetInstance == null) { - SetWallpaperAsync(instance.Data, _monitorManager.PrimaryMonitor); - } - else { - targetInstance?.SendMessage(new VirtualPaperUpdateCmd() { - FilePath = instance.Data.FilePath, - RType = instance.Data.RType.ToString(), - WpEffectFilePathTemplate = instance.Data.WpEffectFilePathTemplate, - WpEffectFilePathTemporary = instance.Data.WpEffectFilePathTemporary, - WpEffectFilePathUsing = instance.Data.WpEffectFilePathUsing, - }); - } - } - } + //private void UpdateWallpaper(IWpPlayer instance) { + // var monitorDeviceId = instance.Monitor.DeviceId; + // foreach (var item in _monitorManager.Monitors) { + // if (item.DeviceId == monitorDeviceId) { + // continue; + // } + + // var targetInstance = _wallpapers.FirstOrDefault(x => x.Monitor.DeviceId == item.DeviceId); + // if (targetInstance == null) { + // SetWallpaperAsync(instance.Data, _monitorManager.PrimaryMonitor); + // } + // else { + // targetInstance?.SendMessage(new VirtualPaperUpdateCmd() { + // FilePath = instance.Data.FilePath, + // RType = instance.Data.RType.ToString(), + // WpEffectFilePathTemplate = instance.Data.WpEffectFilePathTemplate, + // WpEffectFilePathTemporary = instance.Data.WpEffectFilePathTemporary, + // WpEffectFilePathUsing = instance.Data.WpEffectFilePathUsing, + // }); + // } + // } + //} public void SeekWallpaper(IWpPlayerData playerData, float seek, PlaybackPosType type) { _wallpapers.ForEach(x => { @@ -805,7 +821,7 @@ private void UpdateWallpaperRect() { ArcLog.GetLogger().Info($"Updating _data rect(Expand): ({screenArea.Width}, {screenArea.Height})."); //For Play/Pause, setting the new metadata. Wallpapers[0].Monitor = _monitorManager.PrimaryMonitor; - Native.SetWindowPos(Wallpapers[0].RealPlayerWindowHandle, 1, 0, 0, screenArea.Width, screenArea.Height, (int)Native.SWP_NOACTIVATE); + _nativeService.SetWindowPos(Wallpapers[0].RealPlayerWindowHandle, 1, 0, 0, screenArea.Width, screenArea.Height, (int)Native.SWP_NOACTIVATE); } } else { @@ -818,7 +834,7 @@ private void UpdateWallpaperRect() { Wallpapers[i].Monitor = screen; var screenArea = _monitorManager.VirtualScreenBounds; - if (!Native.SetWindowPos(Wallpapers[i].RealPlayerWindowHandle, + if (!_nativeService.SetWindowPos(Wallpapers[i].RealPlayerWindowHandle, 1, screen.Bounds.X - screenArea.Location.X, screen.Bounds.Y - screenArea.Location.Y, @@ -864,8 +880,7 @@ private void ClosingEvent(object? s, EventArgs e) { instance.Closing -= ClosingEvent; instance.Closing = null; - _wallpapers.Remove(instance); - WallpaperChanged?.Invoke(this, EventArgs.Empty); + _wallpapers.RemoveAll(x => x.Monitor!.DeviceId == instance.Monitor!.DeviceId); } private void SetupDesktop_WallpaperChanged(object? sender, EventArgs e) { @@ -907,7 +922,7 @@ private void UpdateWorkerW() { ArcLog.GetLogger().Info("WorkerW initializing.."); var retries = 5; while (true) { - _workerW = CreateWorkerW(); + _workerW = _nativeService.CreateWorkerW(); if (_workerW != IntPtr.Zero) { break; } @@ -928,16 +943,16 @@ private void UpdateWorkerW() { /// /// window _handle of process to add as wallpaper /// monitorstring of _monitor to sent wp to. - private static bool TrySetWallpaperPerMonitor(nint handle, IMonitor targetMonitor) { - var success = TrySetParentWorkerW(handle); + private bool TrySetWallpaperPerMonitor(nint handle, IMonitor targetMonitor) { + var success = _nativeService.TrySetParentWorkerW(handle, _workerW); // Position the wp fullscreen to corresponding display. - if (!Native.SetWindowPos(handle, 1, targetMonitor.Bounds.X, targetMonitor.Bounds.Y, targetMonitor.Bounds.Width, targetMonitor.Bounds.Height, (int)Native.SWP_NOACTIVATE)) { + if (!_nativeService.SetWindowPos(handle, 1, targetMonitor.Bounds.X, targetMonitor.Bounds.Y, targetMonitor.Bounds.Width, targetMonitor.Bounds.Height, (int)Native.SWP_NOACTIVATE)) { ArcLog.GetLogger().Error("Failed to set perscreen wallpaper(1)}"); } var prct = new Native.RECT(); - _ = Native.MapWindowPoints(handle, _workerW, ref prct, 2); - if (!Native.SetWindowPos(handle, 1, prct.Left, prct.Top, targetMonitor.Bounds.Width, targetMonitor.Bounds.Height, (int)Native.SWP_NOACTIVATE)) { + _ = _nativeService.MapWindowPoints(handle, _workerW, ref prct, 2); + if (!_nativeService.SetWindowPos(handle, 1, prct.Left, prct.Top, targetMonitor.Bounds.Width, targetMonitor.Bounds.Height, (int)Native.SWP_NOACTIVATE)) { ArcLog.GetLogger().Error("Failed to set perscreen wallpaper(2)"); } DesktopUtil.RefreshDesktop(); @@ -948,14 +963,14 @@ private static bool TrySetWallpaperPerMonitor(nint handle, IMonitor targetMonito /// /// Spans wp across All screens. /// - private static bool TrySetWallpaperSpanMonitor(nint handle) { + private bool TrySetWallpaperSpanMonitor(nint handle) { //get spawned workerw rectangle data. _ = Native.GetWindowRect(_workerW, out Native.RECT prct); - var success = TrySetParentWorkerW(handle); + var success = _nativeService.TrySetParentWorkerW(handle, _workerW); //fill wp into the whole workerw area. ArcLog.GetLogger().Info($"Wallpaper(Span): ({prct.Left}, {prct.Top}, {prct.Right - prct.Left}, {prct.Bottom - prct.Top})."); - if (!Native.SetWindowPos(handle, 1, 0, 0, prct.Right - prct.Left, prct.Bottom - prct.Top, (int)Native.SWP_NOACTIVATE)) { + if (!_nativeService.SetWindowPos(handle, 1, 0, 0, prct.Right - prct.Left, prct.Bottom - prct.Top, (int)Native.SWP_NOACTIVATE)) { ArcLog.GetLogger().Error("Failed to set span wallpaper"); } DesktopUtil.RefreshDesktop(); @@ -978,89 +993,6 @@ public static void ConvertPopupToChildWindow(IntPtr hwnd) { // Native.SetParent(hwnd, newParentHwnd); } - private static nint CreateWorkerW() { - // Fetch the Progman window - var progman = Native.FindWindow("Progman", null); - - nint result = nint.Zero; - - // Send 0x052C to Progman. This message directs Progman to spawn a - // WorkerW behind the desktop icons. If it is already there, nothing - // happens. - Native.SendMessageTimeout(progman, - 0x052C, - new IntPtr(0xD), - new IntPtr(0x1), - Native.SendMessageTimeoutFlags.SMTO_NORMAL, - 1000, - out result); - // Spy++ output - // ..... - // 0x00010190 "" WorkerW - // ... - // 0x000100EE "" SHELLDLL_DefView - // 0x000100F0 "FolderView" SysListView32 - // 0x00100B8A "" WorkerW <-- This is the WorkerW curInstance we are after! - // 0x000100EC "Program Manager" Progman - var _workerW = IntPtr.Zero; - - // We enumerate All Windows, until we find one, that has the SHELLDLL_DefView - // as a child. - // If we found that window, we take its next sibling and assign it to _workerW. - Native.EnumWindows(new Native.EnumWindowsProc((tophandle, topparamhandle) => { - IntPtr p = Native.FindWindowEx(tophandle, - IntPtr.Zero, - "SHELLDLL_DefView", - IntPtr.Zero); - - if (p != IntPtr.Zero) { - // Gets the WorkerW Window after the current one. - _workerW = Native.FindWindowEx(IntPtr.Zero, - tophandle, - "WorkerW", - IntPtr.Zero); - } - - return true; - }), IntPtr.Zero); - - // Some Windows 11 builds have a different Progman window layout. - // If the above code failed to find WorkerW, we should try this. - // Spy++ output - // 0x000100EC "Program Manager" Progman - // 0x000100EE "" SHELLDLL_DefView - // 0x000100F0 "FolderView" SysListView32 - // 0x00100B8A "" WorkerW <-- This is the WorkerW curInstance we are after! - if (_workerW == IntPtr.Zero) { - _workerW = Native.FindWindowEx(progman, - IntPtr.Zero, - "WorkerW", - IntPtr.Zero); - } - - return _workerW; - } - - /// - /// Adds the _data as child of spawned desktop-_workerW window. - /// - /// _handle of window - private static bool TrySetParentWorkerW(IntPtr handle) { - IntPtr ret = Native.SetParent(handle, _workerW); - if (ret.Equals(IntPtr.Zero)) - return false; - - return true; - } - - private static bool TrySetParentWorkerW(nint childHandle, nint parentHandle) { - IntPtr ret = Native.SetParent(childHandle, parentHandle); - if (ret.Equals(IntPtr.Zero)) - return false; - - return true; - } - private async void WorkerWHook_EventReceived(object? sender, WinEventHookEventArgs e) { if (e.WindowHandle == _workerW && e.EventType == WindowEvent.EVENT_OBJECT_DESTROY) { ArcLog.GetLogger().Error("WorkerW destroyed."); @@ -1075,6 +1007,7 @@ protected virtual void Dispose(bool disposing) { if (!_isDisposed) { if (disposing) { WallpaperChanged -= SetupDesktop_WallpaperChanged; + _semaphoreSlimWallpaperLoadingLock.Dispose(); try { CloseAllWallpapers(); DesktopUtil.RefreshDesktop(); @@ -1094,11 +1027,13 @@ public void Dispose() { #endregion private readonly WindowEventHook? _workerWHook; - private static readonly List _wallpapers = []; - private static nint _workerW; - private static readonly SemaphoreSlim _semaphoreSlimWallpaperLoadingLock = new(1, 1); + private readonly List _wallpapers = []; + private nint _workerW; + private readonly SemaphoreSlim _semaphoreSlimWallpaperLoadingLock = new(1, 1); private readonly IUserSettingsService _userSettings; private readonly IWallpaperFactory _wallpaperFactory; private readonly IMonitorManager _monitorManager; + private readonly INativeService _nativeService; + private readonly IJobService _jobService; } } diff --git a/src/VirtualPaper/MainWindow.xaml.cs b/src/VirtualPaper/MainWindow.xaml.cs index 6d717377..95bc2835 100644 --- a/src/VirtualPaper/MainWindow.xaml.cs +++ b/src/VirtualPaper/MainWindow.xaml.cs @@ -198,7 +198,7 @@ private void ResetDe() { deBubble.Tag = "Off"; } - private void UpdateSettings() { + private async void UpdateSettings() { _userSettingsService.Settings.IsScreenSaverOn = srcsaver.Tag.ToString() == "On"; _userSettingsService.Settings.IsRunningLock = lockScr.Tag.ToString() == "On"; _userSettingsService.Settings.ScreenSaverEffect @@ -206,7 +206,7 @@ private void UpdateSettings() { _userSettingsService.Save(); var pipeClient = App.Services.GetRequiredService(); - pipeClient.SendMsgToUI("UPDATE_SCRSETTINGS"); + await pipeClient.SendMsgToUIAsync("UPDATE_SCRSETTINGS"); } private readonly IUIRunnerService _uiRunnerService; diff --git a/src/VirtualPaper/Services/Interfaces/IJobService.cs b/src/VirtualPaper/Services/Interfaces/IJobService.cs index 15ccbeee..ce364aa5 100644 --- a/src/VirtualPaper/Services/Interfaces/IJobService.cs +++ b/src/VirtualPaper/Services/Interfaces/IJobService.cs @@ -1,10 +1,8 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - namespace VirtualPaper.Services.Interfaces { - internal interface IJobService { + public interface IJobService { + bool AddProcess(IntPtr processHandle); + bool AddProcess(int processId); + void Close(); + void Dispose(); } } diff --git a/src/VirtualPaper/Services/Interfaces/IWindowService.cs b/src/VirtualPaper/Services/Interfaces/IWindowService.cs index be5b8173..bf5481c2 100644 --- a/src/VirtualPaper/Services/Interfaces/IWindowService.cs +++ b/src/VirtualPaper/Services/Interfaces/IWindowService.cs @@ -1,5 +1,3 @@ -using System.Windows; - namespace VirtualPaper.Services.Interfaces { public interface IWindowService { /// @@ -7,18 +5,23 @@ public interface IWindowService { /// /// 窗口类型 /// 传递给 ViewModel 的初始化参数 - void Show(object? parameter = null) where TWindow : Window; + void Show(object? parameter = null) where TWindow : class; /// /// 打开一个模态窗口 /// /// 窗口类型 /// 传递给 ViewModel 的初始化参数 - Task ShowDialogAsync(object? parameter = null) where TWindow : Window; + Task ShowDialogAsync(object? parameter = null) where TWindow : class; /// /// 尝试获取当前已打开的某类型窗口 /// - bool TryGet(out TWindow? window) where TWindow : Window; + bool TryGet(out TWindow? window) where TWindow : class; + + /// + /// 关闭指定类型的窗口(如果已打开) + /// + void Close() where TWindow : class; } } diff --git a/src/VirtualPaper/Services/UserSettingsService.cs b/src/VirtualPaper/Services/UserSettingsService.cs index 1e1a293b..53ad075a 100644 --- a/src/VirtualPaper/Services/UserSettingsService.cs +++ b/src/VirtualPaper/Services/UserSettingsService.cs @@ -1,6 +1,7 @@ using System.Reflection; using VirtualPaper.Common; -using VirtualPaper.Common.Utils.Storage; +using VirtualPaper.Common.Logging; +using VirtualPaper.Common.Utils.Storage.Adapter; using VirtualPaper.Cores.Monitor; using VirtualPaper.Models.Cores; using VirtualPaper.Models.Cores.Interfaces; @@ -16,7 +17,10 @@ public class UserSettingsService : IUserSettingsService { public List RecentUseds { get; private set; } = []; public UserSettingsService( - IMonitorManager moitorManager) { + IMonitorManager moitorManager, + IJsonSaver jsonSaver) { + _jsonSaver = jsonSaver; + Load(); Load>(); Load>(); @@ -42,27 +46,27 @@ public UserSettingsService( _ = WindowsAutoStart.SetAutoStart(Settings.IsAutoStart); } catch (Exception e) { - App.Log.Error(e); + ArcLog.GetLogger().Error(e); } } public void Load() { if (typeof(T) == typeof(ISettings)) { try { - Settings = JsonSaver.Load(_settingsPath, SettingsContext.Default); + Settings = _jsonSaver.Load(_settingsPath, SettingsContext.Default); } catch (Exception e) { - App.Log.Error(e); + ArcLog.GetLogger().Error(e); Settings = new Settings(); Save(); } } else if (typeof(T) == typeof(List)) { try { - AppRules = new List(JsonSaver.Load>(_appRulesPath, ApplicationRulesContext.Default)); + AppRules = new List(_jsonSaver.Load>(_appRulesPath, ApplicationRulesContext.Default)); } catch (Exception e) { - App.Log.Error(e.ToString()); + ArcLog.GetLogger().Error(e.ToString()); AppRules = [ new ApplicationRules(Constants.CoreField.AppName, AppWpRunRulesEnum.KeepRun) @@ -72,20 +76,20 @@ public void Load() { } else if (typeof(T) == typeof(List)) { try { - WallpaperLayouts = new List(JsonSaver.Load>(_wallpaperLayoutPath, WallpaperLayoutContext.Default)); + WallpaperLayouts = new List(_jsonSaver.Load>(_wallpaperLayoutPath, WallpaperLayoutContext.Default)); } catch (Exception e) { - App.Log.Error(e.ToString()); + ArcLog.GetLogger().Error(e.ToString()); WallpaperLayouts = []; Save>(); } } else if (typeof(T) == typeof(List)) { try { - RecentUseds = new List(JsonSaver.Load>(_recentUsedPath, RecentUsedContext.Default)); + RecentUseds = new List(_jsonSaver.Load>(_recentUsedPath, RecentUsedContext.Default)); } catch (Exception e) { - App.Log.Error(e.ToString()); + ArcLog.GetLogger().Error(e.ToString()); RecentUseds = []; Save>(); } @@ -97,16 +101,16 @@ public void Load() { public void Save() { if (typeof(T) == typeof(ISettings)) { - JsonSaver.Save(_settingsPath, Settings, SettingsContext.Default); + _jsonSaver.Save(_settingsPath, Settings, SettingsContext.Default); } else if (typeof(T) == typeof(List)) { - JsonSaver.Save(_appRulesPath, AppRules, ApplicationRulesContext.Default); + _jsonSaver.Save(_appRulesPath, AppRules, ApplicationRulesContext.Default); } else if (typeof(T) == typeof(List)) { - JsonSaver.Save(_wallpaperLayoutPath, WallpaperLayouts, WallpaperLayoutContext.Default); + _jsonSaver.Save(_wallpaperLayoutPath, WallpaperLayouts, WallpaperLayoutContext.Default); } else if (typeof(T) == typeof(List)) { - JsonSaver.Save(_recentUsedPath, RecentUseds, RecentUsedContext.Default); + _jsonSaver.Save(_recentUsedPath, RecentUseds, RecentUsedContext.Default); } else { throw new InvalidCastException($"ValueType not found: {typeof(T)}"); @@ -117,5 +121,6 @@ public void Save() { private readonly string _appRulesPath = Constants.CommonPaths.AppRulesPath; private readonly string _wallpaperLayoutPath = Constants.CommonPaths.WallpaperLayoutPath; private readonly string _recentUsedPath = Constants.CommonPaths.RecentUsedPath; + private readonly IJsonSaver _jsonSaver; } } diff --git a/src/VirtualPaper/Services/WindowService.cs b/src/VirtualPaper/Services/WindowService.cs index 8daf41c1..15b5846d 100644 --- a/src/VirtualPaper/Services/WindowService.cs +++ b/src/VirtualPaper/Services/WindowService.cs @@ -1,5 +1,4 @@ using System.Windows; -using Microsoft.Extensions.DependencyInjection; using VirtualPaper.Services.Interfaces; namespace VirtualPaper.Services { @@ -8,59 +7,68 @@ public WindowService(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; } - public void Show(object? parameter = null) where TWindow : Window { - var windowType = typeof(TWindow); - - // 若已存在实例,则激活 - if (_openWindows.TryGetValue(windowType, out var existingWindow)) { - if (existingWindow.WindowState == WindowState.Minimized) - existingWindow.WindowState = WindowState.Normal; - existingWindow.Activate(); + public void Show(object? parameter = null) where TWindow : class { + if (_openWindows.TryGetValue(typeof(TWindow), out var existing)) { + existing.Activate(); return; } - var window = _serviceProvider.GetRequiredService(); - - // 尝试注入参数 + var window = CreateWindow(); InjectParameter(window, parameter); - // 监听关闭事件自动清理 - window.Closed += (_, _) => _openWindows.Remove(windowType); - - _openWindows[windowType] = window; + window.Closed += (_, _) => _openWindows.Remove(typeof(TWindow)); + _openWindows[typeof(TWindow)] = window; window.Show(); - window.Activate(); } - public async Task ShowDialogAsync(object? parameter = null) where TWindow : Window { - var window = _serviceProvider.GetRequiredService(); + public Task ShowDialogAsync(object? parameter = null) where TWindow : class { + var tcs = new TaskCompletionSource(); + var window = CreateWindow(); InjectParameter(window, parameter); - return await Task.FromResult(window.ShowDialog()); + + window.Closed += (_, _) => + { + _openWindows.Remove(typeof(TWindow)); + tcs.TrySetResult(window.DialogResult); + }; + + _openWindows[typeof(TWindow)] = window; + window.ShowDialog(); + + return tcs.Task; } - public bool TryGet(out TWindow? window) where TWindow : Window { - if (_openWindows.TryGetValue(typeof(TWindow), out var existing)) { - window = existing as TWindow; + public bool TryGet(out TWindow? window) where TWindow : class { + if (_openWindows.TryGetValue(typeof(TWindow), out var w) && w is TWindow typed) { + window = typed; return true; } - window = null; return false; } - private void InjectParameter(Window window, object? parameter) { - if (parameter == null) - return; + public void Close() where TWindow : class { + if (_openWindows.TryGetValue(typeof(TWindow), out var window)) { + window.Close(); + // Closed 事件会自动从字典中移除 + } + } - if (window.DataContext is IWindowParameterReceiver receiver) { + private Window CreateWindow() where TWindow : class { + var window = _serviceProvider.GetService(typeof(TWindow)) as Window + ?? throw new InvalidOperationException( + $"Type {typeof(TWindow).Name} is not registered or is not a Window."); + return window; + } + + private static void InjectParameter(Window window, object? parameter) { + if (parameter != null && window.DataContext is IWindowParameterReceiver receiver) { receiver.ReceiveParameter(parameter); } } private readonly IServiceProvider _serviceProvider; - - // 当前打开的窗口引用 private readonly Dictionary _openWindows = []; } diff --git a/src/VirtualPaper/Utils/Interfcaes/IAppUpdate.cs b/src/VirtualPaper/Utils/Interfcaes/IAppUpdate.cs new file mode 100644 index 00000000..8231bcd7 --- /dev/null +++ b/src/VirtualPaper/Utils/Interfcaes/IAppUpdate.cs @@ -0,0 +1,10 @@ +namespace VirtualPaper.Utils.Interfcaes { + public interface IGithubReleaseClient { + Task<(Uri exeUri, Uri shaUri, Version version, string changelog)> + GetLatestRelease(bool isBeta); + } + + public interface IVersionComparer { + int CompareAssemblyVersion(Version version); + } +} diff --git a/src/VirtualPaper/Utils/Interfcaes/IDispatcherTimer.cs b/src/VirtualPaper/Utils/Interfcaes/IDispatcherTimer.cs new file mode 100644 index 00000000..daeb757e --- /dev/null +++ b/src/VirtualPaper/Utils/Interfcaes/IDispatcherTimer.cs @@ -0,0 +1,9 @@ +namespace VirtualPaper.Utils.Interfcaes { + public interface IDispatcherTimer { + TimeSpan Interval { get; set; } + bool IsEnabled { get; } + event EventHandler? Tick; + void Start(); + void Stop(); + } +} diff --git a/src/VirtualPaper/Utils/Interfcaes/INativeService.cs b/src/VirtualPaper/Utils/Interfcaes/INativeService.cs new file mode 100644 index 00000000..8358d80c --- /dev/null +++ b/src/VirtualPaper/Utils/Interfcaes/INativeService.cs @@ -0,0 +1,17 @@ +using VirtualPaper.Common.Utils.PInvoke; + +namespace VirtualPaper.Utils.Interfcaes { + public interface INativeService { + nint CreateWorkerW(); + bool TrySetParentWorkerW(nint childHandle, nint parentHandle); + bool SetWindowPos(nint handle, int hWndInsertAfter, int x, int y, int width, int height, int wFlags); + void RefreshDesktop(); + nint GetWorkerWRect(out Native.RECT rect); + bool MapWindowPoints(nint handle, nint workerW, ref Native.RECT rect, int cPoints); + int SHQueryUserNotificationState(out Native.QUERY_USER_NOTIFICATION_STATE state); + nint GetForegroundWindow(); + uint GetWindowThreadProcessId(nint hwnd, out int processId); + string GetProcessNameById(int processId); + void LockWorkStation(); + } +} diff --git a/src/VirtualPaper/Utils/Interfcaes/IPipeClient.cs b/src/VirtualPaper/Utils/Interfcaes/IPipeClient.cs new file mode 100644 index 00000000..7f9cc8e9 --- /dev/null +++ b/src/VirtualPaper/Utils/Interfcaes/IPipeClient.cs @@ -0,0 +1,13 @@ +using System.IO; + +namespace VirtualPaper.Utils.Interfcaes { + public interface IPipeClient : IDisposable { + Task ConnectAsync(CancellationToken ct = default); + void WaitForPipeDrain(); + StreamWriter CreateWriter(); + } + + public interface IPipeClientFactory { + IPipeClient Create(string serverName, string pipeName); + } +} diff --git a/src/VirtualPaper/Utils/Interfcaes/IPowerService.cs b/src/VirtualPaper/Utils/Interfcaes/IPowerService.cs new file mode 100644 index 00000000..7188ae30 --- /dev/null +++ b/src/VirtualPaper/Utils/Interfcaes/IPowerService.cs @@ -0,0 +1,8 @@ +using VirtualPaper.Common.Utils.Hardware; + +namespace VirtualPaper.Utils.Interfcaes { + public interface IPowerService { + PowerUtil.ACLineStatus GetACPowerStatus(); + PowerUtil.SystemStatusFlag GetBatterySaverStatus(); + } +} diff --git a/src/VirtualPaper/Utils/Interfcaes/IProcessLauncher.cs b/src/VirtualPaper/Utils/Interfcaes/IProcessLauncher.cs new file mode 100644 index 00000000..da7da7e6 --- /dev/null +++ b/src/VirtualPaper/Utils/Interfcaes/IProcessLauncher.cs @@ -0,0 +1,20 @@ +using System.Diagnostics; + +namespace VirtualPaper.Utils.Interfcaes { + public interface IProcessLauncher { + event EventHandler? Exited; + event EventHandler? OutputDataReceived; + + int ProcessId { get; } + bool HasExited { get; } + void Launch(ProcessStartInfo startInfo); + void BeginOutputReadLine(); + void WriteStdin(string msg); + void Kill(); + void Dispose(); + } + + public class ProcessOutputEventArgs(string? data) : EventArgs { + public string? Data { get; } = data; + } +} diff --git a/src/VirtualPaper/Utils/Interfcaes/IRawInputMsg.cs b/src/VirtualPaper/Utils/Interfcaes/IRawInputMsg.cs new file mode 100644 index 00000000..87a754ba --- /dev/null +++ b/src/VirtualPaper/Utils/Interfcaes/IRawInputMsg.cs @@ -0,0 +1,14 @@ +using VirtualPaper.Common; +using VirtualPaper.Views.WindowsMsg; + +namespace VirtualPaper.Utils.Interfcaes { + public interface IRawInputMsg { + InputForwardMode InputMode { get; } + event EventHandler? MouseMoveRaw; + event EventHandler? MouseDownRaw; + event EventHandler? MouseUpRaw; + event EventHandler? KeyboardClickRaw; + + void Show(); + } +} diff --git a/src/VirtualPaper/Utils/Services/AppUpdate.cs b/src/VirtualPaper/Utils/Services/AppUpdate.cs new file mode 100644 index 00000000..fa887b27 --- /dev/null +++ b/src/VirtualPaper/Utils/Services/AppUpdate.cs @@ -0,0 +1,32 @@ +using VirtualPaper.Common.Utils; +using VirtualPaper.Utils.Interfcaes; + +namespace VirtualPaper.Utils.Services { + public class GithubReleaseClient : IGithubReleaseClient { + public async Task<(Uri exeUri, Uri shaUri, Version version, string changelog)> GetLatestRelease(bool isBeta) { + var userName = "PaperHammer"; + var repositoryName = isBeta ? "VirtualPaper-beta" : "VirtualPaper"; + var gitRelease = await GithubUtil.GetLatestRelease(repositoryName, userName, 0); + Version version = GithubUtil.GetVersion(gitRelease); + + var gitUrl = await GithubUtil.GetAssetUrl( + "virtualpaper_setup_x64_full", + gitRelease, repositoryName, userName); + Uri exeUri = new(gitUrl); + string changelog = gitRelease.Body; + + gitUrl = await GithubUtil.GetAssetUrl( + "SHA256", + gitRelease, repositoryName, userName); + Uri shaUri = new(gitUrl); + + return (exeUri, shaUri, version, changelog); + } + } + + public class AssemblyVersionComparer : IVersionComparer { + public int CompareAssemblyVersion(Version version) { + return GithubUtil.CompareAssemblyVersion(version); + } + } +} diff --git a/src/VirtualPaper/Utils/Services/DispatcherTimerAdapter.cs b/src/VirtualPaper/Utils/Services/DispatcherTimerAdapter.cs new file mode 100644 index 00000000..a04f8f76 --- /dev/null +++ b/src/VirtualPaper/Utils/Services/DispatcherTimerAdapter.cs @@ -0,0 +1,19 @@ +using System.Windows.Threading; +using VirtualPaper.Utils.Interfcaes; + +namespace VirtualPaper.Utils.Services { + public class DispatcherTimerAdapter : IDispatcherTimer { + private readonly DispatcherTimer _inner = new(); + public TimeSpan Interval { + get => _inner.Interval; + set => _inner.Interval = value; + } + public bool IsEnabled => _inner.IsEnabled; + public event EventHandler? Tick { + add => _inner.Tick += value; + remove => _inner.Tick -= value; + } + public void Start() => _inner.Start(); + public void Stop() => _inner.Stop(); + } +} diff --git a/src/VirtualPaper/Utils/Services/NamedPipeClientAdapter.cs b/src/VirtualPaper/Utils/Services/NamedPipeClientAdapter.cs new file mode 100644 index 00000000..894bbcbe --- /dev/null +++ b/src/VirtualPaper/Utils/Services/NamedPipeClientAdapter.cs @@ -0,0 +1,33 @@ +using System.IO; +using System.IO.Pipes; +using System.Security.Principal; +using VirtualPaper.Utils.Interfcaes; + +namespace VirtualPaper.Utils.Services { + public class NamedPipeClientAdapter : IPipeClient { + public NamedPipeClientAdapter(string serverName, string pipeName) { + _inner = new NamedPipeClientStream( + serverName, pipeName, + PipeDirection.InOut, + PipeOptions.Asynchronous, + TokenImpersonationLevel.None); + } + + public Task ConnectAsync(CancellationToken ct = default) => + _inner.ConnectAsync(ct); + + public void WaitForPipeDrain() => _inner.WaitForPipeDrain(); + + public StreamWriter CreateWriter() => + new StreamWriter(_inner) { AutoFlush = true }; + + public void Dispose() => _inner.Dispose(); + + private readonly NamedPipeClientStream _inner; + } + + public class NamedPipeClientFactory : IPipeClientFactory { + public IPipeClient Create(string serverName, string pipeName) => + new NamedPipeClientAdapter(serverName, pipeName); + } +} diff --git a/src/VirtualPaper/Utils/Services/NativeService.cs b/src/VirtualPaper/Utils/Services/NativeService.cs new file mode 100644 index 00000000..cda70fb4 --- /dev/null +++ b/src/VirtualPaper/Utils/Services/NativeService.cs @@ -0,0 +1,143 @@ +using System.Diagnostics; +using VirtualPaper.Common.Utils.PInvoke; +using VirtualPaper.Utils.Interfcaes; + +namespace VirtualPaper.Utils.Services { + public class NativeService : INativeService { + /// + /// 创建 WorkerW 窗口(在 Progman 和桌面图标层之间插入) + /// 标准做法:向 Progman 发送 0x052C 消息触发 WorkerW 创建,再枚举找到它 + /// + public nint CreateWorkerW() { + // Fetch the Progman window + var progman = Native.FindWindow("Progman", null); + + nint result = nint.Zero; + + // Send 0x052C to Progman. This message directs Progman to spawn a + // WorkerW behind the desktop icons. If it is already there, nothing + // happens. + Native.SendMessageTimeout(progman, + 0x052C, + new IntPtr(0xD), + new IntPtr(0x1), + Native.SendMessageTimeoutFlags.SMTO_NORMAL, + 1000, + out result); + // Spy++ output + // ..... + // 0x00010190 "" WorkerW + // ... + // 0x000100EE "" SHELLDLL_DefView + // 0x000100F0 "FolderView" SysListView32 + // 0x00100B8A "" WorkerW <-- This is the WorkerW curInstance we are after! + // 0x000100EC "Program Manager" Progman + var _workerW = IntPtr.Zero; + + // We enumerate All Windows, until we find one, that has the SHELLDLL_DefView + // as a child. + // If we found that window, we take its next sibling and assign it to _workerW. + Native.EnumWindows(new Native.EnumWindowsProc((tophandle, topparamhandle) => { + IntPtr p = Native.FindWindowEx(tophandle, + IntPtr.Zero, + "SHELLDLL_DefView", + IntPtr.Zero); + + if (p != IntPtr.Zero) { + // Gets the WorkerW Window after the current one. + _workerW = Native.FindWindowEx(IntPtr.Zero, + tophandle, + "WorkerW", + IntPtr.Zero); + } + + return true; + }), IntPtr.Zero); + + // Some Windows 11 builds have a different Progman window layout. + // If the above code failed to find WorkerW, we should try this. + // Spy++ output + // 0x000100EC "Program Manager" Progman + // 0x000100EE "" SHELLDLL_DefView + // 0x000100F0 "FolderView" SysListView32 + // 0x00100B8A "" WorkerW <-- This is the WorkerW curInstance we are after! + if (_workerW == IntPtr.Zero) { + _workerW = Native.FindWindowEx(progman, + IntPtr.Zero, + "WorkerW", + IntPtr.Zero); + } + + return _workerW; + } + + /// + /// 将子窗口(壁纸进程窗口)设置为 WorkerW 的子窗口 + /// + public bool TrySetParentWorkerW(nint childHandle, nint parentHandle) { + IntPtr ret = Native.SetParent(childHandle, parentHandle); + if (ret.Equals(IntPtr.Zero)) + return false; + + return true; + } + + /// + /// 调整窗口位置和大小以匹配显示器区域 + /// + public bool SetWindowPos(nint handle, int hWndInsertAfter, int x, int y, int width, int height, int wFlags) { + return Native.SetWindowPos(handle, hWndInsertAfter, x, y, width, height, wFlags); + } + + /// + /// 刷新桌面(强制重绘 WorkerW 区域) + /// + public void RefreshDesktop() { + _ = Native.SystemParametersInfo(Native.SPI_SETDESKWALLPAPER, 0, null, Native.SPIF_UPDATEINIFILE); + } + + /// + /// 获取 WorkerW 的屏幕坐标矩形 + /// + public nint GetWorkerWRect(out Native.RECT rect) { + var workerW = CreateWorkerW(); + rect = default; + + if (workerW != IntPtr.Zero) { + Native.GetWindowRect(workerW, out rect); + } + + return workerW; + } + + /// + /// 将窗口坐标从屏幕坐标映射到 WorkerW 的客户端坐标 + /// 用于多显示器场景下正确定位壁纸窗口 + /// + public bool MapWindowPoints(nint handle, nint workerW, ref Native.RECT rect, int cPoints) { + int result = Native.MapWindowPoints(handle, workerW, ref rect, cPoints); + + return result != 0; + } + + public int SHQueryUserNotificationState(out Native.QUERY_USER_NOTIFICATION_STATE state) { + return Native.SHQueryUserNotificationState(out state); + } + + public nint GetForegroundWindow() { + return Native.GetForegroundWindow(); + } + + public uint GetWindowThreadProcessId(nint hwnd, out int processId) { + return Native.GetWindowThreadProcessId(hwnd, out processId); + } + + public string GetProcessNameById(int processId) { + return Process.GetProcessById(processId).ProcessName; + } + + public void LockWorkStation() { + Native.LockWorkStation(); + } + } +} diff --git a/src/VirtualPaper/Utils/Services/PowerService.cs b/src/VirtualPaper/Utils/Services/PowerService.cs new file mode 100644 index 00000000..b7772e48 --- /dev/null +++ b/src/VirtualPaper/Utils/Services/PowerService.cs @@ -0,0 +1,11 @@ +using VirtualPaper.Common.Utils.Hardware; +using VirtualPaper.Utils.Interfcaes; + +namespace VirtualPaper.Utils.Services { + public class PowerService : IPowerService { + public PowerUtil.ACLineStatus GetACPowerStatus() + => PowerUtil.GetACPowerStatus(); + public PowerUtil.SystemStatusFlag GetBatterySaverStatus() + => PowerUtil.GetBatterySaverStatus(); + } +} diff --git a/src/VirtualPaper/Utils/Services/ProcessLauncher.cs b/src/VirtualPaper/Utils/Services/ProcessLauncher.cs new file mode 100644 index 00000000..19980247 --- /dev/null +++ b/src/VirtualPaper/Utils/Services/ProcessLauncher.cs @@ -0,0 +1,102 @@ +using System.Diagnostics; +using VirtualPaper.Utils.Interfcaes; + +namespace VirtualPaper.Utils.Services { + public class ProcessLauncher : IProcessLauncher, IDisposable { + public event EventHandler? Exited; + public event EventHandler? OutputDataReceived; + + public bool HasExited => _process?.HasExited ?? true; + public int ProcessId { + get { + EnsureProcessStarted(); + return _process!.Id; + } + } + + public void Launch(ProcessStartInfo startInfo) { + ObjectDisposedException.ThrowIf(_isDisposed, this); + + _process = new Process { + EnableRaisingEvents = true, + StartInfo = startInfo, + }; + + // 桥接 Process 原生事件 → 自定义事件 + _process.Exited += OnExited; + _process.OutputDataReceived += OnOutputDataReceived; + + _process.Start(); + } + + public void BeginOutputReadLine() { + EnsureProcessStarted(); + _process!.BeginOutputReadLine(); + } + + public void WriteStdin(string msg) { + EnsureProcessStarted(); + try { + _process!.StandardInput.WriteLine(msg); + } + catch (Exception e) { + // 保持与原代码一致的日志风格,调用方可以选择捕获或忽略 + throw new InvalidOperationException($"Stdin write fail: {e.Message}", e); + } + } + + public void Kill() { + try { + if (_process != null && !_process.HasExited) { + _process.Kill(); + } + } + catch (InvalidOperationException) { + // 进程已退出,忽略 + } + } + + // 桥接 Process.Exited → IProcessLauncher.Exited + private void OnExited(object? sender, EventArgs e) { + Exited?.Invoke(this, e); + } + + // 桥接 Process.OutputDataReceived → IProcessLauncher.OutputDataReceived + private void OnOutputDataReceived(object sender, DataReceivedEventArgs e) { + if (e.Data != null) { + OutputDataReceived?.Invoke(this, new ProcessOutputEventArgs(e.Data)); + } + } + + private void EnsureProcessStarted() { + if (_process == null) + throw new InvalidOperationException("Process has not been launched yet. Call Launch() first."); + } + + #region Dispose + + protected virtual void Dispose(bool disposing) { + if (!_isDisposed) { + if (disposing) { + if (_process != null) { + _process.Exited -= OnExited; + _process.OutputDataReceived -= OnOutputDataReceived; + _process.Dispose(); + _process = null; + } + } + _isDisposed = true; + } + } + + public void Dispose() { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + #endregion + + private Process? _process; + private bool _isDisposed; + } +} diff --git a/src/VirtualPaper/Views/WindowsMsg/RawInputMsgWindow.xaml.cs b/src/VirtualPaper/Views/WindowsMsg/RawInputMsgWindow.xaml.cs index 70d1b712..280f6ad2 100644 --- a/src/VirtualPaper/Views/WindowsMsg/RawInputMsgWindow.xaml.cs +++ b/src/VirtualPaper/Views/WindowsMsg/RawInputMsgWindow.xaml.cs @@ -8,6 +8,7 @@ using VirtualPaper.Cores.WpControl; using VirtualPaper.Models.Cores.Interfaces; using VirtualPaper.Services.Interfaces; +using VirtualPaper.Utils.Interfcaes; using Point = System.Drawing.Point; namespace VirtualPaper.Views.WindowsMsg { @@ -15,7 +16,7 @@ namespace VirtualPaper.Views.WindowsMsg { /// 使用 DirectX RawInput 进行鼠标输入检索并响应到壁纸 /// ref: https://docs.microsoft.com/en-us/windows/win32/inputdev/raw-input /// - public partial class RawInputMsgWindow : Window { + public partial class RawInputMsgWindow : Window, IRawInputMsg { #region setup public InputForwardMode InputMode { get; private set; } public event EventHandler? MouseMoveRaw;