From b849bd9aedf4fcdb7e295e69d5596a4c93a52464 Mon Sep 17 00:00:00 2001
From: tian <11429339@qq.com>
Date: Mon, 13 Apr 2026 02:33:29 +0800
Subject: [PATCH] Add Navisworks automation integration tests
---
AGENTS.md | 24 +
NavisworksTransport.UnitTests.csproj | 5 +
TransportPlugin.csproj | 1 +
.../NavisworksTestAutomationClient.cs | 85 ++
.../VirtualCollisionAutomationTests.cs | 68 +
run-ground-virtual-collision-test.bat | 7 +
run-unit-tests.bat | 2 +-
scripts/run-ground-virtual-collision-test.ps1 | 154 ++
scripts/start-navisworks.ps1 | 167 +++
scripts/test-automation.defaults.json | 4 +
src/Core/MainPlugin.cs | 7 +
.../Services/TestAutomationHttpService.cs | 1296 +++++++++++++++++
.../ViewModels/AnimationControlViewModel.cs | 74 +-
.../ViewModels/SystemManagementViewModel.cs | 6 +-
.../WPF/Views/CollisionAnalysisDialog.xaml.cs | 24 +
src/Utils/DialogHelper.cs | 162 +++
start-navisworks.bat | 7 +
17 files changed, 2067 insertions(+), 26 deletions(-)
create mode 100644 UnitTests/Integration/NavisworksTestAutomationClient.cs
create mode 100644 UnitTests/Integration/VirtualCollisionAutomationTests.cs
create mode 100644 run-ground-virtual-collision-test.bat
create mode 100644 scripts/run-ground-virtual-collision-test.ps1
create mode 100644 scripts/start-navisworks.ps1
create mode 100644 scripts/test-automation.defaults.json
create mode 100644 src/Core/Services/TestAutomationHttpService.cs
create mode 100644 start-navisworks.bat
diff --git a/AGENTS.md b/AGENTS.md
index 745e39b..b8719a7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -67,6 +67,30 @@
不要并行执行编译和部署。否则很容易把旧 DLL 部署到插件目录。
+### 并行执行边界
+
+后续会话中的 AI 助手必须把命令分成两类:
+
+- **允许并行的纯读取操作**
+ - `rg`
+ - `Get-Content`
+ - `ls / Get-ChildItem`
+ - 读取日志
+ - 查询状态
+- **禁止并行的产出型/宿主相关操作**
+ - `run-unit-tests.bat`
+ - `compile.bat`
+ - `deploy-plugin.bat`
+ - 启动/关闭 Navisworks
+ - 会占用 DLL、修改 `bin/obj`、写插件部署目录、依赖上一步产物的任何命令
+
+硬约束:
+
+- 只有“纯读取、无副作用、且互不依赖”的命令才允许并行
+- 只要命令会生成、覆盖、部署、锁定文件、启动宿主进程,必须串行执行
+- 对本仓库,默认把 `run-unit-tests -> compile -> deploy -> start Navisworks` 视为**单通道流水线**
+- 不允许把这条流水线放进任何并行工具里,即使只是为了节省时间
+
### 插件部署目录
- `C:\ProgramData\Autodesk\Navisworks Manage 2026\plugins\TransportPlugin\`
diff --git a/NavisworksTransport.UnitTests.csproj b/NavisworksTransport.UnitTests.csproj
index 959e016..113b76a 100644
--- a/NavisworksTransport.UnitTests.csproj
+++ b/NavisworksTransport.UnitTests.csproj
@@ -42,6 +42,9 @@
packages\MSTest.TestFramework.3.0.4\lib\net462\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll
+
+ packages\Newtonsoft.Json.13.0.3\lib\net45\Newtonsoft.Json.dll
+
@@ -57,6 +60,8 @@
+
+
diff --git a/TransportPlugin.csproj b/TransportPlugin.csproj
index a41f577..6cf663a 100644
--- a/TransportPlugin.csproj
+++ b/TransportPlugin.csproj
@@ -150,6 +150,7 @@
+
diff --git a/UnitTests/Integration/NavisworksTestAutomationClient.cs b/UnitTests/Integration/NavisworksTestAutomationClient.cs
new file mode 100644
index 0000000..e5bfab5
--- /dev/null
+++ b/UnitTests/Integration/NavisworksTestAutomationClient.cs
@@ -0,0 +1,85 @@
+using System;
+using System.Net.Http;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json.Linq;
+
+namespace NavisworksTransport.UnitTests.Integration
+{
+ internal sealed class NavisworksTestAutomationClient : IDisposable
+ {
+ private readonly HttpClient _httpClient;
+
+ public NavisworksTestAutomationClient()
+ {
+ _httpClient = new HttpClient
+ {
+ BaseAddress = new Uri("http://127.0.0.1:18777"),
+ Timeout = TimeSpan.FromSeconds(20)
+ };
+ }
+
+ public async Task EnsureServiceReadyAsync(TimeSpan timeout)
+ {
+ DateTime deadlineUtc = DateTime.UtcNow.Add(timeout);
+ Exception lastError = null;
+
+ while (DateTime.UtcNow < deadlineUtc)
+ {
+ try
+ {
+ JObject pingResponse = await GetJsonAsync("/api/test/ping").ConfigureAwait(false);
+ if (pingResponse.Value("ok") == true)
+ {
+ return;
+ }
+ }
+ catch (Exception ex)
+ {
+ lastError = ex;
+ }
+
+ await Task.Delay(TimeSpan.FromSeconds(1)).ConfigureAwait(false);
+ }
+
+ Assert.Fail(
+ "Navisworks 测试服务未就绪。请先用 start-navisworks.bat 启动 Navisworks,并确保插件面板已加载。最后错误: {0}",
+ lastError?.Message ?? "unknown");
+ }
+
+ public async Task RunVirtualCollisionTestAsync(string pathType, int timeoutSeconds)
+ {
+ string requestUri = string.Format(
+ "/api/test/run-virtual-collision-test?pathType={0}&timeoutSeconds={1}",
+ Uri.EscapeDataString(pathType),
+ timeoutSeconds);
+
+ return await PostJsonAsync(requestUri).ConfigureAwait(false);
+ }
+
+ private async Task GetJsonAsync(string requestUri)
+ {
+ using (HttpResponseMessage response = await _httpClient.GetAsync(requestUri).ConfigureAwait(false))
+ {
+ string content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+ return JObject.Parse(content);
+ }
+ }
+
+ private async Task PostJsonAsync(string requestUri)
+ {
+ using (HttpResponseMessage response = await _httpClient.PostAsync(requestUri, new StringContent(string.Empty)).ConfigureAwait(false))
+ {
+ string content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+ return JObject.Parse(content);
+ }
+ }
+
+ public void Dispose()
+ {
+ _httpClient.Dispose();
+ }
+ }
+}
diff --git a/UnitTests/Integration/VirtualCollisionAutomationTests.cs b/UnitTests/Integration/VirtualCollisionAutomationTests.cs
new file mode 100644
index 0000000..1791734
--- /dev/null
+++ b/UnitTests/Integration/VirtualCollisionAutomationTests.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Newtonsoft.Json.Linq;
+
+namespace NavisworksTransport.UnitTests.Integration
+{
+ [TestClass]
+ [TestCategory("NavisworksIntegration")]
+ public class VirtualCollisionAutomationTests
+ {
+ private const int DefaultCollisionTimeoutSeconds = 240;
+
+ [TestMethod]
+ [Timeout(360000)]
+ public async Task GroundVirtualCollision_AutoTestRoute_Completes()
+ {
+ await AssertVirtualCollisionTestAsync("Ground").ConfigureAwait(false);
+ }
+
+ [TestMethod]
+ [Timeout(360000)]
+ public async Task HoistingVirtualCollision_AutoTestRoute_Completes()
+ {
+ await AssertVirtualCollisionTestAsync("Hoisting").ConfigureAwait(false);
+ }
+
+ [TestMethod]
+ [Timeout(360000)]
+ public async Task RailVirtualCollision_AutoTestRoute_Completes()
+ {
+ await AssertVirtualCollisionTestAsync("Rail").ConfigureAwait(false);
+ }
+
+ private static async Task AssertVirtualCollisionTestAsync(string pathType)
+ {
+ using (var client = new NavisworksTestAutomationClient())
+ {
+ await client.EnsureServiceReadyAsync(TimeSpan.FromSeconds(90)).ConfigureAwait(false);
+
+ JObject response = await client.RunVirtualCollisionTestAsync(pathType, DefaultCollisionTimeoutSeconds).ConfigureAwait(false);
+ Assert.IsTrue(response.Value("ok"), "测试 HTTP 接口返回失败: " + (string)response["error"]);
+
+ JObject data = (JObject)response["data"];
+ Assert.IsNotNull(data, "缺少测试结果 data");
+
+ Assert.AreEqual(pathType, (string)data["requestedPathType"], "请求的路径类型不匹配");
+
+ JObject route = (JObject)data["route"];
+ Assert.IsNotNull(route, "缺少 route");
+ Assert.AreEqual(pathType, (string)route["pathType"], "返回的路径类型不匹配");
+ Assert.IsFalse(string.IsNullOrWhiteSpace((string)route["name"]), "路径名为空");
+
+ JObject animatedObject = (JObject)data["animatedObject"];
+ Assert.IsNotNull(animatedObject, "缺少 animatedObject");
+ Assert.AreEqual("VirtualObject", (string)animatedObject["mode"], "当前集成测试应使用虚拟物体");
+
+ JObject animation = (JObject)data["animation"];
+ Assert.IsNotNull(animation, "缺少 animation");
+ Assert.AreEqual("Finished", (string)animation["currentState"], "动画未完成");
+ Assert.IsTrue((int)animation["totalFrames"] > 0, "动画总帧数应大于 0");
+ Assert.IsTrue(animation["detectionRecordId"] != null && (int)animation["detectionRecordId"] > 0, "检测记录 ID 无效");
+
+ Assert.IsNotNull(data["report"], "缺少碰撞报告");
+ }
+ }
+ }
+}
diff --git a/run-ground-virtual-collision-test.bat b/run-ground-virtual-collision-test.bat
new file mode 100644
index 0000000..e38e421
--- /dev/null
+++ b/run-ground-virtual-collision-test.bat
@@ -0,0 +1,7 @@
+@echo off
+setlocal
+
+powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\run-ground-virtual-collision-test.ps1" %*
+
+if errorlevel 1 exit /b 1
+exit /b 0
diff --git a/run-unit-tests.bat b/run-unit-tests.bat
index b5df301..64220ca 100644
--- a/run-unit-tests.bat
+++ b/run-unit-tests.bat
@@ -41,7 +41,7 @@ if errorlevel 1 (
exit /b 1
)
-%VSTEST_PATH% "bin\x64\Release\NavisworksTransport.UnitTests.dll" /Platform:x64 /TestAdapterPath:%TEST_ADAPTER_PATH%
+%VSTEST_PATH% "bin\x64\Release\NavisworksTransport.UnitTests.dll" /Platform:x64 /TestAdapterPath:%TEST_ADAPTER_PATH% /TestCaseFilter:"TestCategory!=NavisworksIntegration"
if errorlevel 1 (
echo Unit tests failed!
exit /b 1
diff --git a/scripts/run-ground-virtual-collision-test.ps1 b/scripts/run-ground-virtual-collision-test.ps1
new file mode 100644
index 0000000..d4a48c9
--- /dev/null
+++ b/scripts/run-ground-virtual-collision-test.ps1
@@ -0,0 +1,154 @@
+[CmdletBinding()]
+param(
+ [string]$ModelPath,
+ [int]$ServiceStartupTimeoutSeconds = 90,
+ [int]$TestTimeoutSeconds = 240
+)
+
+$ErrorActionPreference = 'Stop'
+
+$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
+$repoRoot = Split-Path -Parent $scriptDirectory
+$startScriptPath = Join-Path $scriptDirectory 'start-navisworks.ps1'
+$defaultsFilePath = Join-Path $scriptDirectory 'test-automation.defaults.json'
+$testApiBaseUrl = 'http://127.0.0.1:18777'
+
+function Resolve-DefaultModelPath {
+ if (-not (Test-Path $defaultsFilePath)) {
+ return $null
+ }
+
+ $defaults = Get-Content $defaultsFilePath -Raw | ConvertFrom-Json
+ $defaultModelPath = [string]$defaults.defaultModelPath
+ if ([string]::IsNullOrWhiteSpace($defaultModelPath)) {
+ return $null
+ }
+
+ return [System.IO.Path]::GetFullPath($defaultModelPath)
+}
+
+function Wait-TestApiReady {
+ param(
+ [string]$BaseUrl,
+ [int]$TimeoutSeconds
+ )
+
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ $lastError = $null
+
+ while ((Get-Date) -lt $deadline) {
+ try {
+ $pingResponse = Invoke-RestMethod -Uri ($BaseUrl + '/api/test/ping') -TimeoutSec 10
+ if ($pingResponse.ok) {
+ return
+ }
+ }
+ catch {
+ $lastError = $_.Exception.Message
+ }
+
+ Start-Sleep -Seconds 1
+ }
+
+ if ([string]::IsNullOrWhiteSpace($lastError)) {
+ throw "测试 HTTP 服务未在 $TimeoutSeconds 秒内就绪。"
+ }
+
+ throw "测试 HTTP 服务未在 $TimeoutSeconds 秒内就绪。最后错误: $lastError"
+}
+
+function Wait-GroundRouteReady {
+ param(
+ [string]$BaseUrl,
+ [int]$TimeoutSeconds
+ )
+
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ $lastError = $null
+
+ while ((Get-Date) -lt $deadline) {
+ try {
+ $routesResponse = Invoke-RestMethod -Uri ($BaseUrl + '/api/test/routes') -TimeoutSec 10
+ if ($routesResponse.ok -and $routesResponse.data -and $routesResponse.data.totalRouteCount -gt 0) {
+ $groundRoute = $routesResponse.data.suggestedAutoTestRoutes.ground
+ if (-not [string]::IsNullOrWhiteSpace([string]$groundRoute)) {
+ return [string]$groundRoute
+ }
+ }
+ }
+ catch {
+ $lastError = $_.Exception.Message
+ }
+
+ Start-Sleep -Seconds 1
+ }
+
+ if ([string]::IsNullOrWhiteSpace($lastError)) {
+ throw "默认地面测试路径未在 $TimeoutSeconds 秒内就绪。"
+ }
+
+ throw "默认地面测试路径未在 $TimeoutSeconds 秒内就绪。最后错误: $lastError"
+}
+
+function Save-JsonResult {
+ param(
+ [object]$Payload
+ )
+
+ $logRoot = Join-Path $env:ProgramData 'Autodesk\Navisworks Manage 2026\plugins\TransportPlugin\logs\test-automation'
+ New-Item -ItemType Directory -Path $logRoot -Force | Out-Null
+
+ $fileName = 'ground-virtual-collision-test-' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '.json'
+ $outputPath = Join-Path $logRoot $fileName
+ $json = $Payload | ConvertTo-Json -Depth 16
+ [System.IO.File]::WriteAllText($outputPath, $json, [System.Text.UTF8Encoding]::new($false))
+ return $outputPath
+}
+
+if ([string]::IsNullOrWhiteSpace($ModelPath)) {
+ $ModelPath = Resolve-DefaultModelPath
+}
+
+if (-not [string]::IsNullOrWhiteSpace($ModelPath)) {
+ $ModelPath = [System.IO.Path]::GetFullPath($ModelPath)
+ if (-not (Test-Path $ModelPath)) {
+ throw "模型文件不存在: $ModelPath"
+ }
+}
+
+Write-Host '启动 Navisworks...'
+if ([string]::IsNullOrWhiteSpace($ModelPath)) {
+ & $startScriptPath
+}
+else {
+ & $startScriptPath $ModelPath
+}
+
+Write-Host '等待测试 HTTP 服务就绪...'
+Wait-TestApiReady -BaseUrl $testApiBaseUrl -TimeoutSeconds $ServiceStartupTimeoutSeconds
+
+Write-Host '等待默认地面测试路径就绪...'
+$groundRouteName = Wait-GroundRouteReady -BaseUrl $testApiBaseUrl -TimeoutSeconds $ServiceStartupTimeoutSeconds
+
+Write-Host ('切换到默认地面测试路径: {0}' -f $groundRouteName)
+$selectRouteResponse = Invoke-RestMethod -Method Post -Uri ($testApiBaseUrl + '/api/test/select-default-route?pathType=Ground') -TimeoutSec 15
+if (-not $selectRouteResponse.ok) {
+ throw '切换默认地面测试路径失败。'
+}
+
+Write-Host '执行地面虚拟物体碰撞测试...'
+$requestUrl = $testApiBaseUrl + "/api/test/run-ground-collision-test?useVirtualObject=true&timeoutSeconds=$TestTimeoutSeconds"
+$testResult = Invoke-RestMethod -Method Post -Uri $requestUrl -TimeoutSec ($TestTimeoutSeconds + 30)
+
+$outputFilePath = Save-JsonResult -Payload $testResult
+
+Write-Host ('结果已保存: {0}' -f $outputFilePath)
+if ($testResult.ok -and $testResult.data) {
+ Write-Host ('路径: {0}' -f $testResult.data.route.name)
+ Write-Host ('动画状态: {0}' -f $testResult.data.animation.currentState)
+ Write-Host ('检测记录ID: {0}' -f $testResult.data.animation.detectionRecordId)
+ if ($testResult.data.report) {
+ Write-Host ('碰撞数: {0}' -f $testResult.data.report.totalCollisions)
+ Write-Host ('截图数: {0}' -f $testResult.data.report.screenshotCount)
+ }
+}
diff --git a/scripts/start-navisworks.ps1 b/scripts/start-navisworks.ps1
new file mode 100644
index 0000000..6a150b5
--- /dev/null
+++ b/scripts/start-navisworks.ps1
@@ -0,0 +1,167 @@
+[CmdletBinding()]
+param(
+ [Parameter(Position = 0)]
+ [string]$ModelPath
+)
+
+$ErrorActionPreference = 'Stop'
+$scriptDirectory = Split-Path -Parent $MyInvocation.MyCommand.Path
+$repoRoot = Split-Path -Parent $scriptDirectory
+$defaultsFilePath = Join-Path $scriptDirectory 'test-automation.defaults.json'
+
+function Resolve-RoamerPath {
+ $candidates = New-Object System.Collections.Generic.List[string]
+ $appPathKeys = @(
+ 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\Roamer.exe',
+ 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\Roamer.exe'
+ )
+
+ foreach ($key in $appPathKeys) {
+ try {
+ $value = (Get-ItemProperty -Path $key -ErrorAction Stop).'(default)'
+ if (-not [string]::IsNullOrWhiteSpace($value)) {
+ $candidates.Add($value)
+ }
+ }
+ catch {
+ }
+ }
+
+ $wellKnownPaths = @(
+ (Join-Path ${env:ProgramFiles} 'Autodesk\Navisworks Manage 2026\Roamer.exe'),
+ (Join-Path ${env:ProgramFiles(x86)} 'Autodesk\Navisworks Manage 2026\Roamer.exe'),
+ (Join-Path ${env:ProgramFiles} 'Autodesk\Navisworks Simulate 2026\Roamer.exe'),
+ (Join-Path ${env:ProgramFiles(x86)} 'Autodesk\Navisworks Simulate 2026\Roamer.exe')
+ ) | Where-Object { $_ }
+
+ foreach ($path in $wellKnownPaths) {
+ $candidates.Add($path)
+ }
+
+ foreach ($candidate in $candidates | Select-Object -Unique) {
+ if (-not [string]::IsNullOrWhiteSpace($candidate) -and (Test-Path $candidate)) {
+ return [System.IO.Path]::GetFullPath($candidate)
+ }
+ }
+
+ throw '找不到 Roamer.exe。请确认 Navisworks Manage 2026 已安装。'
+}
+
+function Resolve-DefaultModelPath {
+ if (-not (Test-Path $defaultsFilePath)) {
+ return $null
+ }
+
+ try {
+ $defaults = Get-Content $defaultsFilePath -Raw | ConvertFrom-Json
+ $defaultModelPath = [string]$defaults.defaultModelPath
+ if ([string]::IsNullOrWhiteSpace($defaultModelPath)) {
+ return $null
+ }
+
+ $resolvedPath = [System.IO.Path]::GetFullPath($defaultModelPath)
+ if (-not (Test-Path $resolvedPath)) {
+ throw "默认模型文件不存在: $resolvedPath"
+ }
+
+ return $resolvedPath
+ }
+ catch {
+ throw "读取默认测试配置失败: $($_.Exception.Message)"
+ }
+}
+
+function Dismiss-RecoveryPromptIfPresent {
+ param(
+ [int]$TimeoutSeconds = 20
+ )
+
+ Add-Type -AssemblyName System.Windows.Forms | Out-Null
+ $shell = New-Object -ComObject WScript.Shell
+ $windowTitles = @(
+ '是否从自动保存重新载入?',
+ '是否从自动保存重新载入?'
+ )
+
+ $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
+ while ((Get-Date) -lt $deadline) {
+ foreach ($windowTitle in $windowTitles) {
+ try {
+ if ($shell.AppActivate($windowTitle)) {
+ Start-Sleep -Milliseconds 300
+ [System.Windows.Forms.SendKeys]::SendWait('%N')
+ Write-Host '已自动选择“否”以跳过自动保存恢复窗口。'
+ return
+ }
+ }
+ catch {
+ }
+ }
+
+ Start-Sleep -Milliseconds 500
+ }
+}
+
+if ([string]::IsNullOrWhiteSpace($ModelPath)) {
+ $ModelPath = Resolve-DefaultModelPath
+}
+
+if (-not [string]::IsNullOrWhiteSpace($ModelPath)) {
+ $ModelPath = [System.IO.Path]::GetFullPath($ModelPath)
+ if (-not (Test-Path $ModelPath)) {
+ throw "模型文件不存在: $ModelPath"
+ }
+}
+
+$roamerPath = Resolve-RoamerPath
+$existingProcess = Get-Process Roamer -ErrorAction SilentlyContinue | Select-Object -First 1
+$env:TRANSPORTPLUGIN_TEST_AUTOMATION = '1'
+
+if ($existingProcess) {
+ Write-Host ("Navisworks 已在运行,PID={0}" -f $existingProcess.Id)
+ if (-not [string]::IsNullOrWhiteSpace($ModelPath)) {
+ Start-Process -FilePath $roamerPath -ArgumentList @($ModelPath) | Out-Null
+ Write-Host ("已请求 Navisworks 打开模型: {0}" -f $ModelPath)
+ }
+
+ Dismiss-RecoveryPromptIfPresent
+
+ Write-Host '提示: 当前测试 HTTP 服务在插件 DockPane OnLoaded 中启动,首次测试前请确保插件面板已打开。'
+ exit 0
+}
+
+$arguments = @()
+if (-not [string]::IsNullOrWhiteSpace($ModelPath)) {
+ $arguments += $ModelPath
+}
+
+$startProcessParams = @{
+ FilePath = $roamerPath
+ PassThru = $true
+}
+
+if ($arguments.Count -gt 0) {
+ $startProcessParams.ArgumentList = $arguments
+}
+
+$process = Start-Process @startProcessParams
+Write-Host ("Navisworks 启动中,PID={0}" -f $process.Id)
+
+$deadline = (Get-Date).AddSeconds(30)
+do {
+ Start-Sleep -Milliseconds 500
+ $running = Get-Process -Id $process.Id -ErrorAction SilentlyContinue
+} while (-not $running -and (Get-Date) -lt $deadline)
+
+if (-not $running) {
+ throw 'Navisworks 启动超时,30 秒内未检测到 Roamer.exe 进程。'
+}
+
+Dismiss-RecoveryPromptIfPresent
+
+Write-Host ("Navisworks 已启动: {0}" -f $roamerPath)
+if (-not [string]::IsNullOrWhiteSpace($ModelPath)) {
+ Write-Host ("已打开模型参数: {0}" -f $ModelPath)
+}
+
+Write-Host '提示: 当前测试 HTTP 服务在插件 DockPane OnLoaded 中启动,首次测试前请确保插件面板已打开。'
diff --git a/scripts/test-automation.defaults.json b/scripts/test-automation.defaults.json
new file mode 100644
index 0000000..b5d5620
--- /dev/null
+++ b/scripts/test-automation.defaults.json
@@ -0,0 +1,4 @@
+{
+ "defaultModelPath": "C:\\Users\\Tellme\\Documents\\NavisworksTransport\\模型\\Floor2_mobile_yup.nwd",
+ "defaultRouteNames": []
+}
diff --git a/src/Core/MainPlugin.cs b/src/Core/MainPlugin.cs
index 32a3607..eb5e92d 100644
--- a/src/Core/MainPlugin.cs
+++ b/src/Core/MainPlugin.cs
@@ -7,6 +7,7 @@ using System.Windows.Forms.Integration;
using NavisworksTransport.Core;
using NavisworksTransport.Core.Animation;
using NavisworksTransport.Core.Config;
+using NavisworksTransport.Core.Services;
using NavisworksTransport.Utils;
using NavisworksTransport.Utils.CoordinateSystem;
using NavisApplication = Autodesk.Navisworks.Api.Application;
@@ -381,6 +382,9 @@ namespace NavisworksTransport
// 订阅文档事件
SubscribeToDocumentEvents();
+
+ // 启动本地测试自动化 HTTP 控制面
+ TestAutomationHttpService.Instance.Start();
// 检查是否已有活动文档且包含模型
var activeDoc = NavisApplication.ActiveDocument;
@@ -412,6 +416,9 @@ namespace NavisworksTransport
// 清理管理器
CleanupManagers();
+
+ // 停止本地测试自动化 HTTP 控制面
+ TestAutomationHttpService.Instance.Stop();
base.OnUnloading();
}
diff --git a/src/Core/Services/TestAutomationHttpService.cs b/src/Core/Services/TestAutomationHttpService.cs
new file mode 100644
index 0000000..ece2ce5
--- /dev/null
+++ b/src/Core/Services/TestAutomationHttpService.cs
@@ -0,0 +1,1296 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Autodesk.Navisworks.Api;
+using NavisworksTransport.Commands;
+using NavisworksTransport.Core.Animation;
+using NavisworksTransport.UI.WPF.ViewModels;
+using NavisworksTransport.Utils;
+using Newtonsoft.Json;
+
+namespace NavisworksTransport.Core.Services
+{
+ ///
+ /// 本地测试自动化 HTTP 控制面。
+ /// 当前暴露最小只读/导出接口,后续可在保持协议稳定的前提下追加命令端点。
+ ///
+ public sealed class TestAutomationHttpService : IDisposable
+ {
+ private const int DefaultPort = 18777;
+ private const string DefaultHost = "127.0.0.1";
+ private const string DefaultAutoTestRoutePrefix = "自动测试_";
+
+ private static readonly Lazy _instance =
+ new Lazy(() => new TestAutomationHttpService());
+
+ private readonly object _syncRoot = new object();
+ private static int _autoConfirmCollisionAnalysisDialogRequestCount;
+ private static int _autoChooseCreateNewDetectionRecordRequestCount;
+
+ private TcpListener _listener;
+ private CancellationTokenSource _cts;
+ private Task _acceptLoopTask;
+ private DateTime _startedAtUtc;
+ private string _lastStartError;
+
+ private TestAutomationHttpService()
+ {
+ }
+
+ public static TestAutomationHttpService Instance => _instance.Value;
+
+ public bool IsRunning
+ {
+ get
+ {
+ lock (_syncRoot)
+ {
+ return _listener != null;
+ }
+ }
+ }
+
+ public int Port => DefaultPort;
+
+ public string BaseUrl => $"http://{DefaultHost}:{DefaultPort}";
+
+ public static bool ShouldAutoConfirmCollisionAnalysisDialogs =>
+ Interlocked.CompareExchange(ref _autoConfirmCollisionAnalysisDialogRequestCount, 0, 0) > 0;
+
+ public static bool ShouldAutoChooseCreateNewDetectionRecord =>
+ Interlocked.CompareExchange(ref _autoChooseCreateNewDetectionRecordRequestCount, 0, 0) > 0;
+
+ public void Start()
+ {
+ lock (_syncRoot)
+ {
+ if (_listener != null)
+ {
+ return;
+ }
+
+ try
+ {
+ _cts = new CancellationTokenSource();
+ _listener = new TcpListener(IPAddress.Loopback, DefaultPort);
+ _listener.Start();
+ _startedAtUtc = DateTime.UtcNow;
+ _lastStartError = null;
+ _acceptLoopTask = Task.Run(() => AcceptLoopAsync(_cts.Token));
+
+ LogManager.Info($"[测试HTTP] 服务已启动: {BaseUrl}");
+ }
+ catch (Exception ex)
+ {
+ _lastStartError = ex.Message;
+ _listener = null;
+ _cts?.Dispose();
+ _cts = null;
+ _acceptLoopTask = null;
+ LogManager.Error($"[测试HTTP] 服务启动失败: {ex.Message}", ex);
+ }
+ }
+ }
+
+ public void Stop()
+ {
+ Task acceptLoopTask = null;
+
+ lock (_syncRoot)
+ {
+ if (_listener == null)
+ {
+ return;
+ }
+
+ try
+ {
+ _cts?.Cancel();
+ _listener.Stop();
+ acceptLoopTask = _acceptLoopTask;
+ }
+ catch (Exception ex)
+ {
+ LogManager.Warning($"[测试HTTP] 服务停止时出现警告: {ex.Message}");
+ }
+ finally
+ {
+ _listener = null;
+ _acceptLoopTask = null;
+ _cts?.Dispose();
+ _cts = null;
+ }
+ }
+
+ try
+ {
+ acceptLoopTask?.Wait(1000);
+ }
+ catch
+ {
+ // 忽略退出等待异常
+ }
+
+ LogManager.Info("[测试HTTP] 服务已停止");
+ }
+
+ private async Task AcceptLoopAsync(CancellationToken cancellationToken)
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ TcpClient client = null;
+ try
+ {
+ client = await _listener.AcceptTcpClientAsync().ConfigureAwait(false);
+ _ = Task.Run(() => HandleClientAsync(client, cancellationToken), cancellationToken);
+ }
+ catch (ObjectDisposedException)
+ {
+ break;
+ }
+ catch (SocketException) when (cancellationToken.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ LogManager.Error($"[测试HTTP] 接收请求失败: {ex.Message}", ex);
+ client?.Dispose();
+ }
+ }
+ }
+
+ private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken)
+ {
+ using (client)
+ using (var stream = client.GetStream())
+ using (var reader = new StreamReader(stream, Encoding.UTF8, false, 4096, true))
+ using (var writer = new StreamWriter(stream, new UTF8Encoding(false), 4096, true) { NewLine = "\r\n", AutoFlush = true })
+ {
+ try
+ {
+ string requestLine = await reader.ReadLineAsync().ConfigureAwait(false);
+ if (string.IsNullOrWhiteSpace(requestLine))
+ {
+ return;
+ }
+
+ string[] requestLineParts = requestLine.Split(' ');
+ if (requestLineParts.Length < 2)
+ {
+ await WriteJsonResponseAsync(writer, 400, BuildEnvelope(false, error: "Invalid request line")).ConfigureAwait(false);
+ return;
+ }
+
+ string method = requestLineParts[0].Trim().ToUpperInvariant();
+ string requestTarget = requestLineParts[1].Trim();
+ var request = ParseRequestTarget(requestTarget);
+
+ string headerLine;
+ while (!string.IsNullOrEmpty(headerLine = await reader.ReadLineAsync().ConfigureAwait(false)))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+ }
+
+ if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(request.Path, "/api/test/ping", StringComparison.OrdinalIgnoreCase))
+ {
+ var payload = new
+ {
+ ok = true,
+ service = "NavisworksTransport.TestAutomation",
+ protocol = "http",
+ version = 1,
+ baseUrl = BaseUrl,
+ serverTimeUtc = DateTime.UtcNow.ToString("o")
+ };
+ await WriteJsonResponseAsync(writer, 200, payload).ConfigureAwait(false);
+ return;
+ }
+
+ if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(request.Path, "/api/test/status", StringComparison.OrdinalIgnoreCase))
+ {
+ object payload = InvokeOnUiThread(BuildStatusPayload);
+ await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false);
+ return;
+ }
+
+ if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(request.Path, "/api/test/routes", StringComparison.OrdinalIgnoreCase))
+ {
+ object payload = InvokeOnUiThread(BuildRoutesPayload);
+ await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false);
+ return;
+ }
+
+ if (string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(request.Path, "/api/test/selection", StringComparison.OrdinalIgnoreCase))
+ {
+ object payload = InvokeOnUiThread(BuildSelectionPayload);
+ await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false);
+ return;
+ }
+
+ if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(request.Path, "/api/test/export-debug-snapshot", StringComparison.OrdinalIgnoreCase))
+ {
+ object snapshotPayload = InvokeOnUiThread(BuildDebugSnapshotPayload);
+ string snapshotFilePath = WriteSnapshotToDisk(snapshotPayload);
+ var payload = new
+ {
+ snapshotFilePath,
+ snapshot = snapshotPayload
+ };
+
+ await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false);
+ return;
+ }
+
+ if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(request.Path, "/api/test/select-route", StringComparison.OrdinalIgnoreCase))
+ {
+ object payload = InvokeOnUiThread(() => SelectRoutePayload(request.Query));
+ await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false);
+ return;
+ }
+
+ if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(request.Path, "/api/test/select-default-route", StringComparison.OrdinalIgnoreCase))
+ {
+ object payload = InvokeOnUiThread(() => SelectDefaultRoutePayload(request.Query));
+ await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false);
+ return;
+ }
+
+ if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(request.Path, "/api/test/select-animated-object", StringComparison.OrdinalIgnoreCase))
+ {
+ object payload = InvokeOnUiThread(() => SelectAnimatedObjectPayload(request.Query));
+ await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false);
+ return;
+ }
+
+ if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(request.Path, "/api/test/run-ground-collision-test", StringComparison.OrdinalIgnoreCase))
+ {
+ object payload = await RunVirtualCollisionTestAsync(request.Query, PathType.Ground).ConfigureAwait(false);
+ await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false);
+ return;
+ }
+
+ if (string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(request.Path, "/api/test/run-virtual-collision-test", StringComparison.OrdinalIgnoreCase))
+ {
+ object payload = await RunVirtualCollisionTestAsync(request.Query, null).ConfigureAwait(false);
+ await WriteJsonResponseAsync(writer, 200, BuildEnvelope(true, payload)).ConfigureAwait(false);
+ return;
+ }
+
+ if (!string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(method, "POST", StringComparison.OrdinalIgnoreCase))
+ {
+ await WriteJsonResponseAsync(writer, 405, BuildEnvelope(false, error: "Only GET and POST are supported in the current test API")).ConfigureAwait(false);
+ return;
+ }
+
+ await WriteJsonResponseAsync(writer, 404, BuildEnvelope(false, error: $"Unknown endpoint: {request.Path}")).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ LogManager.Error($"[测试HTTP] 处理请求失败: {ex.Message}", ex);
+ await WriteJsonResponseAsync(writer, 500, BuildEnvelope(false, error: ex.Message)).ConfigureAwait(false);
+ }
+ }
+ }
+
+ private object BuildStatusPayload()
+ {
+ Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument;
+ PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager();
+ PathAnimationManager animationManager = PathAnimationManager.GetInstance();
+
+ PathRoute currentRoute = pathManager?.CurrentRoute;
+ List allRoutes = pathManager?.GetAllRoutes();
+
+ return new
+ {
+ service = new
+ {
+ name = "NavisworksTransport.TestAutomation",
+ protocol = "http",
+ version = 1,
+ isRunning = IsRunning,
+ baseUrl = BaseUrl,
+ port = Port,
+ startedAtUtc = _startedAtUtc == default(DateTime) ? null : _startedAtUtc.ToString("o"),
+ lastStartError = _lastStartError
+ },
+ environment = new
+ {
+ processId = System.Diagnostics.Process.GetCurrentProcess().Id,
+ machineName = Environment.MachineName,
+ logFilePath = LogManager.LogFilePath
+ },
+ document = new
+ {
+ hasActiveDocument = activeDocument != null,
+ fileName = activeDocument?.FileName,
+ title = string.IsNullOrEmpty(activeDocument?.FileName) ? null : Path.GetFileName(activeDocument.FileName),
+ modelCount = activeDocument?.Models?.Count ?? 0
+ },
+ pathManager = new
+ {
+ isAvailable = pathManager != null,
+ routeCount = allRoutes?.Count ?? 0,
+ currentRouteId = currentRoute?.Id,
+ currentRouteName = currentRoute?.Name,
+ currentRoutePathType = currentRoute?.PathType.ToString(),
+ currentRoutePointCount = currentRoute?.Points?.Count ?? 0
+ },
+ animation = new
+ {
+ isAvailable = animationManager != null,
+ currentState = animationManager?.CurrentState.ToString(),
+ isAnimating = animationManager?.IsAnimating ?? false,
+ currentFrame = animationManager?.CurrentFrame ?? 0,
+ totalFrames = animationManager?.TotalFrames ?? 0,
+ hasTrackedRotation = animationManager?.HasTrackedRotation ?? false,
+ currentYawDegrees = animationManager == null ? 0.0 : animationManager.CurrentYaw * 180.0 / Math.PI
+ }
+ };
+ }
+
+ private object BuildDebugSnapshotPayload()
+ {
+ Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument;
+ PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager();
+ PathAnimationManager animationManager = PathAnimationManager.GetInstance();
+
+ PathRoute currentRoute = pathManager?.CurrentRoute;
+ ModelItem controlledObject = ResolveControlledObject(animationManager);
+ var trackedState = ResolveTrackedState(animationManager, controlledObject);
+ var geometryState = ResolveGeometryState(controlledObject);
+ var boundingBoxState = ResolveBoundingBoxState(controlledObject);
+
+ return new
+ {
+ snapshotVersion = 1,
+ capturedAtUtc = DateTime.UtcNow.ToString("o"),
+ document = new
+ {
+ hasActiveDocument = activeDocument != null,
+ fileName = activeDocument?.FileName,
+ title = string.IsNullOrEmpty(activeDocument?.FileName) ? null : Path.GetFileName(activeDocument.FileName),
+ modelCount = activeDocument?.Models?.Count ?? 0
+ },
+ route = new
+ {
+ isAvailable = currentRoute != null,
+ id = currentRoute?.Id,
+ name = currentRoute?.Name,
+ pathType = currentRoute?.PathType.ToString(),
+ pointCount = currentRoute?.Points?.Count ?? 0
+ },
+ animation = new
+ {
+ isAvailable = animationManager != null,
+ currentState = animationManager?.CurrentState.ToString(),
+ isAnimating = animationManager?.IsAnimating ?? false,
+ currentFrame = animationManager?.CurrentFrame ?? 0,
+ totalFrames = animationManager?.TotalFrames ?? 0,
+ currentYawDegrees = animationManager == null ? 0.0 : animationManager.CurrentYaw * 180.0 / Math.PI,
+ hasTrackedRotation = animationManager?.HasTrackedRotation ?? false
+ },
+ controlledObject = new
+ {
+ exists = controlledObject != null,
+ displayName = controlledObject?.DisplayName,
+ instanceGuid = controlledObject == null ? null : controlledObject.InstanceGuid.ToString(),
+ isVirtualObject = controlledObject != null &&
+ VirtualObjectManager.Instance.IsVirtualObjectActive &&
+ ReferenceEquals(VirtualObjectManager.Instance.CurrentVirtualObject, controlledObject),
+ trackedState,
+ geometryState,
+ boundingBox = boundingBoxState
+ },
+ environment = new
+ {
+ processId = System.Diagnostics.Process.GetCurrentProcess().Id,
+ machineName = Environment.MachineName,
+ logFilePath = LogManager.LogFilePath
+ }
+ };
+ }
+
+ private object BuildRoutesPayload()
+ {
+ PathPlanningManager pathManager = PathPlanningManager.GetActivePathManager();
+ List routes = pathManager?.GetAllRoutes() ?? new List();
+ PathRoute currentRoute = pathManager?.CurrentRoute;
+
+ return new
+ {
+ isAvailable = pathManager != null,
+ currentRouteId = currentRoute?.Id,
+ currentRouteName = currentRoute?.Name,
+ currentRoutePathType = currentRoute?.PathType.ToString(),
+ totalRouteCount = routes.Count,
+ routes = routes.Select(route => SerializeRoute(route, currentRoute)).ToList(),
+ suggestedAutoTestRoutes = new
+ {
+ ground = TryFindAutoTestRoute(routes, PathType.Ground)?.Name,
+ hoisting = TryFindAutoTestRoute(routes, PathType.Hoisting)?.Name,
+ rail = TryFindAutoTestRoute(routes, PathType.Rail)?.Name
+ }
+ };
+ }
+
+ private object BuildSelectionPayload()
+ {
+ Document activeDocument = Autodesk.Navisworks.Api.Application.ActiveDocument;
+ var selectedItems = activeDocument?.CurrentSelection?.SelectedItems?.Cast().Where(item => item != null).ToList()
+ ?? new List();
+
+ return new
+ {
+ hasActiveDocument = activeDocument != null,
+ selectionCount = selectedItems.Count,
+ selectedItems = selectedItems.Select(SerializeModelItem).ToList()
+ };
+ }
+
+ private object SelectRoutePayload(Dictionary query)
+ {
+ PathPlanningManager pathManager = RequirePathManager();
+ string routeName = GetRequiredQueryValue(query, "name");
+ List routes = pathManager.GetAllRoutes() ?? new List();
+
+ PathRoute selectedRoute = routes.FirstOrDefault(route =>
+ string.Equals(route.Name, routeName, StringComparison.OrdinalIgnoreCase));
+ if (selectedRoute == null)
+ {
+ throw new InvalidOperationException($"找不到指定路径: {routeName}");
+ }
+
+ pathManager.SetCurrentRoute(selectedRoute);
+ LogManager.Info($"[测试HTTP] 已切换当前路径: {selectedRoute.Name} ({selectedRoute.PathType})");
+
+ return new
+ {
+ selected = SerializeRoute(selectedRoute, selectedRoute),
+ totalRouteCount = routes.Count
+ };
+ }
+
+ private object SelectDefaultRoutePayload(Dictionary query)
+ {
+ PathPlanningManager pathManager = RequirePathManager();
+ string pathTypeValue = GetRequiredQueryValue(query, "pathType");
+ string prefix = GetOptionalQueryValue(query, "prefix") ?? DefaultAutoTestRoutePrefix;
+
+ if (!Enum.TryParse(pathTypeValue, true, out PathType pathType))
+ {
+ throw new InvalidOperationException($"无法解析路径类型: {pathTypeValue}");
+ }
+
+ List routes = pathManager.GetAllRoutes() ?? new List();
+ PathRoute selectedRoute = routes
+ .FirstOrDefault(route =>
+ route.PathType == pathType &&
+ !string.IsNullOrWhiteSpace(route.Name) &&
+ route.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
+
+ if (selectedRoute == null)
+ {
+ throw new InvalidOperationException(
+ $"找不到默认测试路径: prefix={prefix}, pathType={pathType}");
+ }
+
+ pathManager.SetCurrentRoute(selectedRoute);
+ LogManager.Info($"[测试HTTP] 已切换默认测试路径: {selectedRoute.Name} ({selectedRoute.PathType})");
+
+ return new
+ {
+ selectionRule = new
+ {
+ prefix,
+ pathType = pathType.ToString()
+ },
+ selected = SerializeRoute(selectedRoute, selectedRoute)
+ };
+ }
+
+ private object SelectAnimatedObjectPayload(Dictionary query)
+ {
+ AnimationControlViewModel animationViewModel = RequireAnimationControlViewModel();
+ PathPlanningManager pathManager = RequirePathManager();
+ animationViewModel.SetPathPlanningManager(pathManager);
+
+ ModelItem animatedObject = ResolveAnimatedObjectFromQuery(query);
+ SelectDocumentItem(animatedObject);
+
+ if (!animationViewModel.SelectAnimatedObjectCommand.CanExecute(null))
+ {
+ throw new InvalidOperationException("当前动画视图模型不允许执行“选择移动物体”命令");
+ }
+
+ animationViewModel.UseVirtualObject = false;
+ animationViewModel.SelectAnimatedObjectCommand.Execute(null);
+
+ if (!ReferenceEquals(animationViewModel.SelectedAnimatedObject, animatedObject))
+ {
+ throw new InvalidOperationException("选择真实物体失败:动画视图模型未保留选中的对象");
+ }
+
+ return new
+ {
+ selectedAnimatedObject = SerializeModelItem(animatedObject),
+ canGenerateAnimation = animationViewModel.CanGenerateAnimation,
+ isManualCollisionTargetEnabled = animationViewModel.IsManualCollisionTargetEnabled
+ };
+ }
+
+ private async Task