测试新增: - 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)
229 lines
11 KiB
C#
229 lines
11 KiB
C#
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
|
||
{
|
||
}
|
||
}
|
||
|
||
}
|
||
}
|