NavisworksTransport/UnitTests/Integration/NavisworksTestAutomationClient.cs
tian 44214baa00 feat: 集成测试补 T05/T06 + 动画加速与多项基础设施修复
测试新增:
- T05 Free 自由路径(FreePathAutomationTests:完整动画 + 逐帧位置=路径点)
- T06 碰撞报告内容断言(CollisionReportAssertions 接入虚拟/真实物体测试)

服务端增强:
- list-model-items 端点(枚举物体:DisplayName/PathId/包围盒中心/祖先链)
- run-virtual-collision-test 支持 frameRate/durationSeconds(测试动画加速:15fps/5s)
- 测试执行串行锁(SemaphoreSlim,防客户端超时后任务占用 UI 线程并发污染)
- 标准路径清单/XML 增加自动测试_Free

Bug 修复:
- AnimationControlViewModel.InvalidateLastGeneratedReport(跨测试碰撞报告残留)
- 真实物体身份校验 ReferenceEquals→InstanceGuid(NW ModelItem 实例不保证同引用)
- 真实物体动态解析(ModelPath 跨会话不稳定,禁止硬编码 PathId)
- PathImportValidity 测试点 Id 随机化(PathPoints.Id 全局主键,p1/p2 固定会冲突)
- start-navisworks.ps1:启动前清理 AutoSave(避免恢复弹窗拦截)、文件关联打开模型、dismiss 兜底、启动 15s→4.4s

验证:集成测试 13/13 通过,完整集 6min→59.6s(动画 15fps/5s)
2026-08-04 18:13:26 +08:00

244 lines
9.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Globalization;
using System.Net.Http;
using System.Threading;
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"),
// 全局不设超时:每个请求用 CancellationToken 单独控制(动画/碰撞类请求可能远大于 20s
Timeout = Timeout.InfiniteTimeSpan
};
}
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<bool?>("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<JObject> RunVirtualCollisionTestAsync(string pathType, int timeoutSeconds)
{
return await RunVirtualCollisionTestCoreAsync(null, pathType, timeoutSeconds).ConfigureAwait(false);
}
public async Task<JObject> RunVirtualCollisionTestAsync(string routeName, string pathType, int timeoutSeconds)
{
return await RunVirtualCollisionTestCoreAsync(routeName, pathType, timeoutSeconds).ConfigureAwait(false);
}
/// <summary>
/// 运行碰撞测试(虚拟或真实物体)。
/// useVirtualObject=false 时必须提供 animatedObjectPathModelPath 定位,格式如 0/0/769/0
/// frameRate/durationSeconds 可覆盖动画参数加速测试(如 15 FPS / 5 秒)。
/// </summary>
public async Task<JObject> RunCollisionTestAsync(
string pathType,
int timeoutSeconds,
string routeName = null,
bool useVirtualObject = true,
string animatedObjectPath = null,
int? frameRate = null,
double? durationSeconds = null)
{
var queryParts = new System.Collections.Generic.List<string>
{
"pathType=" + Uri.EscapeDataString(pathType),
"timeoutSeconds=" + timeoutSeconds.ToString(CultureInfo.InvariantCulture),
"useVirtualObject=" + (useVirtualObject ? "true" : "false")
};
if (!string.IsNullOrWhiteSpace(routeName))
{
queryParts.Add("routeName=" + Uri.EscapeDataString(routeName));
}
if (!string.IsNullOrWhiteSpace(animatedObjectPath))
{
queryParts.Add("animatedObjectPath=" + Uri.EscapeDataString(animatedObjectPath));
}
if (frameRate.HasValue)
{
queryParts.Add("frameRate=" + frameRate.Value.ToString(CultureInfo.InvariantCulture));
}
if (durationSeconds.HasValue)
{
queryParts.Add("durationSeconds=" + durationSeconds.Value.ToString(CultureInfo.InvariantCulture));
}
string requestUri = "/api/test/run-virtual-collision-test?" + string.Join("&", queryParts);
return await PostJsonAsync(requestUri, timeoutSeconds + 30).ConfigureAwait(false);
}
private async Task<JObject> RunVirtualCollisionTestCoreAsync(string routeName, string pathType, int timeoutSeconds)
{
string requestUri = string.Format(
CultureInfo.InvariantCulture,
"/api/test/run-virtual-collision-test?routeName={0}&pathType={1}&timeoutSeconds={2}",
Uri.EscapeDataString(routeName ?? string.Empty),
Uri.EscapeDataString(pathType),
timeoutSeconds);
return await PostJsonAsync(requestUri).ConfigureAwait(false);
}
public async Task<JObject> ImportRouteFileAsync(string filePath, bool overwrite = false)
{
string requestUri = string.Format(
"/api/test/import-route-file?path={0}&overwrite={1}",
Uri.EscapeDataString(filePath),
overwrite ? "true" : "false");
return await PostJsonAsync(requestUri).ConfigureAwait(false);
}
public async Task<JObject> SelectRouteAsync(string routeName)
{
string requestUri = string.Format(
"/api/test/select-route?name={0}",
Uri.EscapeDataString(routeName));
return await PostJsonAsync(requestUri).ConfigureAwait(false);
}
public async Task<JObject> AnalyzeAutoPathGridAsync(string pathType)
{
string requestUri = string.Format(
"/api/test/analyze-auto-path-grid?pathType={0}",
Uri.EscapeDataString(pathType));
return await PostJsonAsync(requestUri).ConfigureAwait(false);
}
public async Task<JObject> RunAutoPathAsync(
string pathType,
string routeName,
double objectLengthInMeters,
double objectWidthInMeters,
double safetyMarginInMeters,
double gridSizeInMeters)
{
string requestUri = string.Format(
CultureInfo.InvariantCulture,
"/api/test/run-auto-path?pathType={0}&routeName={1}&objectLengthInMeters={2}&objectWidthInMeters={3}&safetyMarginInMeters={4}&gridSizeInMeters={5}",
Uri.EscapeDataString(pathType),
Uri.EscapeDataString(routeName),
objectLengthInMeters,
objectWidthInMeters,
safetyMarginInMeters,
gridSizeInMeters);
return await PostJsonAsync(requestUri).ConfigureAwait(false);
}
private async Task<JObject> GetJsonAsync(string requestUri, int timeoutSeconds = 30)
{
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)))
using (HttpResponseMessage response = await _httpClient.GetAsync(requestUri, cts.Token).ConfigureAwait(false))
{
string content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return JObject.Parse(content);
}
}
private async Task<JObject> PostJsonAsync(string requestUri, int timeoutSeconds = 60)
{
using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)))
using (HttpResponseMessage response = await _httpClient
.PostAsync(requestUri, new StringContent(string.Empty), cts.Token)
.ConfigureAwait(false))
{
string content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return JObject.Parse(content);
}
}
/// <summary>
/// 动画逐帧状态探针:生成动画后 seek 到指定进度点,返回各点物体跟踪位置/yaw。
/// frameRate/durationSeconds 可覆盖动画参数加速测试。
/// </summary>
public async Task<JObject> ProbeAnimationFramesAsync(
string pathType,
int timeoutSeconds,
string probeProgress = "0,0.25,0.5,0.75,1.0",
int? frameRate = null,
double? durationSeconds = null)
{
string requestUri = string.Format(
CultureInfo.InvariantCulture,
"/api/test/probe-animation-frames?pathType={0}&probeProgress={1}&timeoutSeconds={2}",
Uri.EscapeDataString(pathType),
Uri.EscapeDataString(probeProgress),
timeoutSeconds);
if (frameRate.HasValue)
{
requestUri += "&frameRate=" + frameRate.Value.ToString(CultureInfo.InvariantCulture);
}
if (durationSeconds.HasValue)
{
requestUri += "&durationSeconds=" + durationSeconds.Value.ToString(CultureInfo.InvariantCulture);
}
return await PostJsonAsync(requestUri, timeoutSeconds + 30).ConfigureAwait(false);
}
/// <summary>
/// 枚举模型物体(按名称过滤),返回 DisplayName/PathId/包围盒中心/祖先链。
/// </summary>
public async Task<JObject> ListModelItemsAsync(string name = null, int maxResults = 200)
{
string requestUri = "/api/test/list-model-items?maxResults=" + maxResults.ToString(CultureInfo.InvariantCulture);
if (!string.IsNullOrWhiteSpace(name))
{
requestUri += "&name=" + Uri.EscapeDataString(name);
}
return await GetJsonAsync(requestUri).ConfigureAwait(false);
}
public void Dispose()
{
_httpClient.Dispose();
}
}
}