NavisworksTransport/UnitTests/Integration/PathImportValidityAutomationTests.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

229 lines
11 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.IO;
using System.Text;
using System.Threading.Tasks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Newtonsoft.Json.Linq;
namespace NavisworksTransport.UnitTests.Integration
{
/// <summary>
/// 路径导入与有效性集成测试。
/// 验证完整流程JSON/XML 导入 → Rail 属性映射 → 长度计算 → 切换路径 → 完整动画/碰撞流程。
///
/// 背景:原单测 PathPersistenceTests 验证 Rail 属性映射时,导入必经 RecalculateRoute
/// 触发 Rail 长度计算(读取 Navisworks Point3D要求 API 初始化),而单元测试进程不是
/// Navisworks 宿主无法初始化 API导致长度计算必然失败异常被容错吞掉测试“假通过”
/// 按“依赖 Navisworks 的验证归入集成测试”原则,改为在本测试中于 Navisworks 进程内
/// 完整验证导入链路(含单测无法覆盖的长度计算正确性),单测中的对应测试已删除。
/// </summary>
[TestClass]
[TestCategory("NavisworksIntegration")]
public class PathImportValidityAutomationTests
{
private const int TestFrameRate = 15;
private const double TestDurationSeconds = 5.0;
// 模型内已验证可用的 Rail 坐标(与 resources/auto-test-routes.xml 自动测试_Rail 同源)
private const double StartX = -207.591;
private const double StartY = 29.482;
private const double StartZ = 3.504;
private const double EndX = -195.556;
private const double EndY = 27.776;
private const double EndZ = 3.308;
private const double RailNormalOffset = 0.25;
// 输入两点坐标的模型单位直线距离(服务端按文档单位换算为米)
private static readonly double ExpectedLengthModelUnits = Math.Sqrt(
(EndX - StartX) * (EndX - StartX) +
(EndY - StartY) * (EndY - StartY) +
(EndZ - StartZ) * (EndZ - StartZ));
[TestMethod]
[Timeout(360000)]
public async Task ImportRailRoute_FromJson_PropertiesLengthAndAnimation_AreValid()
{
await RunImportValidityFlowAsync("集成测试_导入_Rail_JSON", "rail-import.json", BuildRailJson).ConfigureAwait(false);
}
[TestMethod]
[Timeout(360000)]
public async Task ImportRailRoute_FromXml_PropertiesLengthAndAnimation_AreValid()
{
await RunImportValidityFlowAsync("集成测试_导入_Rail_XML", "rail-import.xml", BuildRailXml).ConfigureAwait(false);
}
private static async Task RunImportValidityFlowAsync(
string routeName,
string fileName,
Func<string, string, string, string, string> buildContent)
{
using (var client = new NavisworksTestAutomationClient())
{
await client.EnsureServiceReadyAsync(TimeSpan.FromSeconds(90)).ConfigureAwait(false);
string tempDir = CreateTempDir();
try
{
string filePath = Path.Combine(tempDir, fileName);
// PathPoints.Id 是全局主键:路径点 Id 必须随机,避免与历史残留/其他测试文件冲突p1/p2 固定会主键冲突)
string routeId = "import-" + Guid.NewGuid().ToString("N");
string point1Id = Guid.NewGuid().ToString("N");
string point2Id = Guid.NewGuid().ToString("N");
File.WriteAllText(filePath, buildContent(routeName, routeId, point1Id, point2Id), new UTF8Encoding(false));
// 1. 导入(在 Navisworks 进程内执行 PathDataManager 导入,覆盖模式保证可重复执行)
JObject importResponse = await client.ImportRouteFileAsync(filePath, overwrite: true).ConfigureAwait(false);
AssertImportResult(importResponse, routeName);
// 2. 切换导入路径为当前路径
JObject selectResponse = await client.SelectRouteAsync(routeName).ConfigureAwait(false);
Assert.IsTrue(selectResponse.Value<bool>("ok"), "切换导入路径失败: " + (string)selectResponse["error"]);
Assert.AreEqual(routeName, (string)((JObject)selectResponse["data"])["selected"]["name"], "切换的路径不匹配");
// 3. 完整动画/碰撞流程验证路径有效性
JObject animationResponse = await client.RunCollisionTestAsync(
"Rail",
240,
routeName: routeName,
frameRate: TestFrameRate,
durationSeconds: TestDurationSeconds).ConfigureAwait(false);
Assert.IsTrue(animationResponse.Value<bool>("ok"), "导入路径动画流程失败: " + (string)animationResponse["error"]);
JObject data = (JObject)animationResponse["data"];
JObject route = (JObject)data["route"];
Assert.AreEqual(routeName, (string)route["name"], "动画流程使用的路径不匹配");
JObject animation = (JObject)data["animation"];
Assert.AreEqual("Finished", (string)animation["currentState"], "动画未完成");
Assert.IsTrue((int)animation["totalFrames"] > 0, "动画总帧数应大于 0");
}
finally
{
SafeDelete(tempDir);
}
}
}
private static void AssertImportResult(JObject response, string expectedRouteName)
{
Assert.IsTrue(response.Value<bool>("ok"), "导入失败: " + (string)response["error"]);
JObject data = (JObject)response["data"];
Assert.IsNotNull(data, "缺少导入结果 data");
// 服务端返回的米→模型单位因子,用于把输入坐标距离换算为期望的米长度(不硬编码文档单位)
double metersToModelUnits = data.Value<double?>("metersToModelUnits") ?? 1.0;
double expectedLengthMeters = ExpectedLengthModelUnits / metersToModelUnits;
JArray routes = (JArray)data["routes"];
Assert.IsNotNull(routes, "缺少 routes");
Assert.AreEqual(1, routes.Count, "应导入 1 条路径");
JObject route = (JObject)routes[0];
Assert.AreEqual(expectedRouteName, (string)route["name"], "导入路径名称不匹配");
Assert.AreEqual("Rail", (string)route["pathType"], "导入路径类型不匹配");
Assert.AreEqual(2, (int)route["pointCount"], "导入路径点数不匹配");
Assert.AreEqual("UnderRail", (string)route["railMountMode"], "railMountMode 映射不匹配");
Assert.AreEqual("RailCenterLine", (string)route["railPathDefinitionMode"], "railPathDefinitionMode 映射不匹配");
Assert.AreEqual(RailNormalOffset, (double)route["railNormalOffset"], 1e-6, "railNormalOffset 映射不匹配");
Assert.AreEqual(expectedLengthMeters, (double)route["totalLengthInMeters"], 1e-3, "导入路径长度计算不匹配");
JObject normal = (JObject)route["railPreferredNormal"];
Assert.IsNotNull(normal, "缺少 railPreferredNormal");
Assert.AreEqual(-0.2, (double)normal["x"], 1e-6, "railPreferredNormal.x 映射不匹配");
Assert.AreEqual(0.5, (double)normal["y"], 1e-6, "railPreferredNormal.y 映射不匹配");
Assert.AreEqual(0.8, (double)normal["z"], 1e-6, "railPreferredNormal.z 映射不匹配");
}
private static string BuildRailJson(string routeName, string routeId, string point1Id, string point2Id)
{
return string.Format(
System.Globalization.CultureInfo.InvariantCulture,
@"{{
""PathPlanningData"": {{
""version"": ""1.0"",
""generator"": ""integration-test"",
""timestamp"": ""2026-08-02T21:30:00"",
""ProjectInfo"": {{ ""name"": ""test"", ""description"": ""test"", ""units"": ""meters"", ""coordinateSystem"": ""Global"" }},
""Routes"": [
{{
""id"": ""{0}"",
""name"": ""{1}"",
""description"": """",
""pathType"": ""Rail"",
""railMountMode"": ""UnderRail"",
""railPathDefinitionMode"": ""RailCenterLine"",
""railNormalOffset"": 0.25,
""railPreferredNormal"": {{ ""x"": -0.2, ""y"": 0.5, ""z"": 0.8 }},
""totalLength"": 10.0,
""objectLimits"": {{ ""maxLength"": 0, ""maxWidth"": 0, ""maxHeight"": 0, ""safetyMargin"": 0 }},
""gridSize"": 1.0,
""liftHeight"": 0.0,
""created"": ""2026-08-02T21:30:00"",
""points"": [
{{ ""id"": ""{2}"", ""name"": ""start"", ""type"": ""StartPoint"", ""index"": 0, ""x"": {4}, ""y"": {5}, ""z"": {6}, ""created"": ""2026-08-02T21:30:00"" }},
{{ ""id"": ""{3}"", ""name"": ""end"", ""type"": ""EndPoint"", ""index"": 1, ""x"": {7}, ""y"": {8}, ""z"": {9}, ""created"": ""2026-08-02T21:30:00"" }}
]
}}
]
}}
}}",
routeId,
routeName,
point1Id,
point2Id,
StartX, StartY, StartZ,
EndX, EndY, EndZ);
}
private static string BuildRailXml(string routeName, string routeId, string point1Id, string point2Id)
{
return string.Format(
System.Globalization.CultureInfo.InvariantCulture,
@"<?xml version=""1.0"" encoding=""UTF-8""?>
<PathPlanningData xmlns=""http://www.3ds.com/delmia/pathplanning"" version=""1.0"" generator=""integration-test"" timestamp=""2026-08-02T21:30:00"">
<ProjectInfo name=""test"" description=""test"" units=""meters"" coordinateSystem=""Global"" />
<Routes>
<Route id=""{0}"" name=""{1}"" description="""" pathType=""Rail"" railMountMode=""UnderRail"" railPathDefinitionMode=""RailCenterLine"" railNormalOffset=""0.25"" railPreferredNormalX=""-0.2"" railPreferredNormalY=""0.5"" railPreferredNormalZ=""0.8"" totalLength=""10.0"" maxObjectLength=""0.0"" maxObjectWidth=""0.0"" maxObjectHeight=""0.0"" safetyMargin=""0.0"" gridSize=""1.0"" liftHeight=""0.0"" created=""2026-08-02T21:30:00"">
<Points>
<Point id=""{2}"" name=""start"" type=""StartPoint"" index=""0"" x=""{4}"" y=""{5}"" z=""{6}"" created=""2026-08-02T21:30:00"" />
<Point id=""{3}"" name=""end"" type=""EndPoint"" index=""1"" x=""{7}"" y=""{8}"" z=""{9}"" created=""2026-08-02T21:30:00"" />
</Points>
</Route>
</Routes>
</PathPlanningData>",
routeId,
routeName,
point1Id,
point2Id,
StartX, StartY, StartZ,
EndX, EndY, EndZ);
}
private static string CreateTempDir()
{
string tempDir = Path.Combine(Path.GetTempPath(), "NavisworksTransportIntegrationTests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(tempDir);
return tempDir;
}
private static void SafeDelete(string directory)
{
if (!Directory.Exists(directory))
{
return;
}
try
{
Directory.Delete(directory, true);
}
catch
{
}
}
}
}