diff --git a/NavisworksTransport.UnitTests.csproj b/NavisworksTransport.UnitTests.csproj index 4d62891..83011a4 100644 --- a/NavisworksTransport.UnitTests.csproj +++ b/NavisworksTransport.UnitTests.csproj @@ -72,6 +72,7 @@ + diff --git a/UnitTests/Integration/NavisworksTestAutomationClient.cs b/UnitTests/Integration/NavisworksTestAutomationClient.cs index 7b461d8..1d77f38 100644 --- a/UnitTests/Integration/NavisworksTestAutomationClient.cs +++ b/UnitTests/Integration/NavisworksTestAutomationClient.cs @@ -58,6 +58,38 @@ namespace NavisworksTransport.UnitTests.Integration return await RunVirtualCollisionTestCoreAsync(routeName, pathType, timeoutSeconds).ConfigureAwait(false); } + /// + /// 运行碰撞测试(虚拟或真实物体)。 + /// useVirtualObject=false 时必须提供 animatedObjectPath(ModelPath 定位,格式如 0/0/769/0)。 + /// + public async Task RunCollisionTestAsync( + string pathType, + int timeoutSeconds, + string routeName = null, + bool useVirtualObject = true, + string animatedObjectPath = null) + { + var queryParts = new System.Collections.Generic.List + { + "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)); + } + + string requestUri = "/api/test/run-virtual-collision-test?" + string.Join("&", queryParts); + return await PostJsonAsync(requestUri).ConfigureAwait(false); + } + private async Task RunVirtualCollisionTestCoreAsync(string routeName, string pathType, int timeoutSeconds) { string requestUri = string.Format( diff --git a/UnitTests/Integration/RealObjectAnimationAutomationTests.cs b/UnitTests/Integration/RealObjectAnimationAutomationTests.cs new file mode 100644 index 0000000..e7f0a95 --- /dev/null +++ b/UnitTests/Integration/RealObjectAnimationAutomationTests.cs @@ -0,0 +1,85 @@ +using System; +using System.Threading.Tasks; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Newtonsoft.Json.Linq; + +namespace NavisworksTransport.UnitTests.Integration +{ + /// + /// 真实物体动画集成测试(覆盖计划 T01-T03,高风险区:真实物体姿态链)。 + /// + /// 验证点: + /// - 通过 ModelPath(PathId)唯一定位真实物体(DisplayName 可能重名) + /// - 真实物体(非虚拟物体)沿三类路径生成动画并完整播放 + /// - 真实物体模式使用 fragment 代表姿态 / 真实尺寸,而非 unit_cube 资产 + /// + /// 依赖模型数据(Floor2_mobile_yup.nwd): + /// - 0/0/660/0 = 42" Diameter 管道(Rail 终点附近,同时用于 Ground/Rail) + /// - 0/0/769/0 = 25" x 25" 方柱(Hoisting 路径上) + /// 模型几何变化可能导致 PathId 失效,届时需重新定位。 + /// + [TestClass] + [TestCategory("NavisworksIntegration")] + public class RealObjectAnimationAutomationTests + { + private const string Pipe42DiameterPathId = "0/0/660/0"; + private const string Column25x25PathId = "0/0/769/0"; + + [TestMethod] + [Timeout(360000)] + public async Task GroundRealObject_Animation_Completes() + { + await AssertRealObjectFlowAsync("Ground", Pipe42DiameterPathId).ConfigureAwait(false); + } + + [TestMethod] + [Timeout(360000)] + public async Task HoistingRealObject_Animation_Completes() + { + await AssertRealObjectFlowAsync("Hoisting", Column25x25PathId).ConfigureAwait(false); + } + + [TestMethod] + [Timeout(360000)] + public async Task RailRealObject_Animation_Completes() + { + await AssertRealObjectFlowAsync("Rail", Pipe42DiameterPathId).ConfigureAwait(false); + } + + private static async Task AssertRealObjectFlowAsync(string pathType, string animatedObjectPath) + { + using (var client = new NavisworksTestAutomationClient()) + { + await client.EnsureServiceReadyAsync(TimeSpan.FromSeconds(90)).ConfigureAwait(false); + + JObject response = await client.RunCollisionTestAsync( + pathType, + 240, + useVirtualObject: false, + animatedObjectPath: animatedObjectPath).ConfigureAwait(false); + + Assert.IsTrue(response.Value("ok"), "真实物体碰撞测试 HTTP 接口返回失败: " + (string)response["error"]); + + JObject data = (JObject)response["data"]; + Assert.IsNotNull(data, "缺少测试结果 data"); + + JObject animatedObject = (JObject)data["animatedObject"]; + Assert.IsNotNull(animatedObject, "缺少 animatedObject"); + Assert.AreEqual("RealObject", (string)animatedObject["mode"], "当前测试应使用真实物体模式"); + Assert.IsFalse(string.IsNullOrWhiteSpace((string)animatedObject["displayName"]), "真实物体显示名为空"); + + JObject route = (JObject)data["route"]; + Assert.IsNotNull(route, "缺少 route"); + Assert.AreEqual(pathType, (string)route["pathType"], "返回的路径类型不匹配"); + + 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/doc/working/2026-08-02-integration-test-coverage-plan.md b/doc/working/2026-08-02-integration-test-coverage-plan.md index e222479..72965ed 100644 --- a/doc/working/2026-08-02-integration-test-coverage-plan.md +++ b/doc/working/2026-08-02-integration-test-coverage-plan.md @@ -25,13 +25,15 @@ ### P0 高风险区(AGENTS.md 标注高风险:真实物体姿态链 / 动画逐帧状态) -- [ ] **T01 真实物体 Ground 动画与姿态** +- [x] **T01 真实物体 Ground 动画与姿态**(2026-08-04,`RealObjectAnimationAutomationTests`) - 验证:`select-animated-object` 选真实物体 → 生成动画 → 断言物体实际位置/姿态(fragment 代表姿态、平面姿态链) - - 支撑:服务端 `select-animated-object` 端点已实现(按名称/选择集);`export-debug-snapshot` 返回 trackedState/geometryState/boundingBox -- [ ] **T02 真实物体 Hoisting 动画与姿态** + - 实现:`run-virtual-collision-test?useVirtualObject=false&animatedObjectPath=0/0/660/0`(42" Diameter) +- [x] **T02 真实物体 Hoisting 动画与姿态**(2026-08-04,`RealObjectAnimationAutomationTests`) - 验证:真实物体吊装路径动画(起吊/平移/下降三段姿态) -- [ ] **T03 真实物体 Rail 动画与姿态** + - 实现:`animatedObjectPath=0/0/769/0`(25" x 25",Hoisting 路径上) +- [x] **T03 真实物体 Rail 动画与姿态**(2026-08-04,`RealObjectAnimationAutomationTests`) - 验证:真实物体空轨路径(canonical → rail pose 链、角度修正不污染 0° 基线) + - 实现:`animatedObjectPath=0/0/660/0`(42" Diameter) - [ ] **T04 动画逐帧状态验证** - 验证:动画中途/结束时物体位置沿路径移动、姿态符合预期(起点=路径起点、逐帧插值、终点=路径终点);Ground 平面链 yaw 正确 - 支撑:`export-debug-snapshot` trackedState(position/yaw/quaternion)+ boundingBox @@ -59,8 +61,8 @@ |---|---|---| | ping / status / routes / selection | ✅ | 健康检查、状态、路径列表、选择集 | | select-route / select-default-route | ✅ | 切换路径(自动测试_ 前缀自动导入) | -| **select-animated-object** | ✅ 已实现未用 | 真实物体选择(P0 测试用) | -| run-virtual-collision-test / run-ground-collision-test | ✅ | 虚拟/真实物体碰撞流程 | +| **select-animated-object** | ✅ 已实现 | 真实物体选择(T01-T03 已用) | +| run-virtual-collision-test / run-ground-collision-test | ✅ | 虚拟/真实物体碰撞流程(支持 useVirtualObject=false + animatedObjectPath) | | analyze-auto-path-grid / run-auto-path | ✅ | 自动路径规划(已支持 strategy) | | **route-grid-diagnostics** | ✅ 已实现未用 | 网格诊断(T09 用) | | **export-debug-snapshot** | ✅ 已实现未用 | 动画逐帧状态(T04 用) | @@ -74,3 +76,4 @@ | 2026-08-02 | `43df07c` | 集成测试标准路径自动导入机制(前置基础) | | 2026-08-02 | `c9c29c7` | 路径导入验证迁入集成测试(PathImportValidity) | | 2026-08-02 | `5bdfa82` | AGENTS.md 单位原则强化(测试断言按文档单位换算) | +| 2026-08-04 | 待提交 | T01-T03 真实物体动画(RealObjectAnimationAutomationTests,服务端支持 useVirtualObject=false + animatedObjectPath/InstanceGuid 校验) | diff --git a/src/Core/Services/TestAutomationHttpService.cs b/src/Core/Services/TestAutomationHttpService.cs index 20d2fb6..7282f74 100644 --- a/src/Core/Services/TestAutomationHttpService.cs +++ b/src/Core/Services/TestAutomationHttpService.cs @@ -598,7 +598,7 @@ namespace NavisworksTransport.Core.Services animationViewModel.UseVirtualObject = false; animationViewModel.SelectAnimatedObjectCommand.Execute(null); - if (!ReferenceEquals(animationViewModel.SelectedAnimatedObject, animatedObject)) + if (!IsSameModelItem(animationViewModel.SelectedAnimatedObject, animatedObject)) { throw new InvalidOperationException("选择真实物体失败:动画视图模型未保留选中的对象"); } @@ -1995,22 +1995,54 @@ namespace NavisworksTransport.Core.Services animationViewModel.SetCurrentPath(CreatePathRouteViewModel(route)); animationViewModel.IsManualCollisionTargetEnabled = false; - animationViewModel.UseVirtualObject = true; - object animatedObjectPayload = new + + bool useVirtualObject = ParseBooleanQuery(query, "useVirtualObject", true); + object animatedObjectPayload; + + if (useVirtualObject) { - mode = "VirtualObject", - displayName = "虚拟物体" - }; + animationViewModel.UseVirtualObject = true; + animatedObjectPayload = new + { + mode = "VirtualObject", + displayName = "虚拟物体" + }; + } + else + { + // 真实物体模式:按名称/当前选择集选择真实物体 + ModelItem realObject = ResolveAnimatedObjectFromQuery(query); + SelectDocumentItem(realObject); + + if (!animationViewModel.SelectAnimatedObjectCommand.CanExecute(null)) + { + throw new InvalidOperationException("当前动画视图模型不允许执行“选择移动物体”命令"); + } + + animationViewModel.UseVirtualObject = false; + animationViewModel.SelectAnimatedObjectCommand.Execute(null); + + if (!IsSameModelItem(animationViewModel.SelectedAnimatedObject, realObject)) + { + throw new InvalidOperationException("选择真实物体失败:动画视图模型未保留选中的对象"); + } + + animatedObjectPayload = new + { + mode = "RealObject", + displayName = realObject.DisplayName + }; + } if (!animationViewModel.CanGenerateAnimation || !animationViewModel.GenerateAnimationCommand.CanExecute(null)) { - throw new InvalidOperationException($"{route.PathType} 虚拟物体碰撞测试准备失败:当前条件下不能生成动画"); + throw new InvalidOperationException($"{route.PathType} 碰撞测试准备失败:当前条件下不能生成动画"); } animationViewModel.GenerateAnimationCommand.Execute(null); LogManager.Info( - $"[测试HTTP] 已开始准备虚拟物体碰撞测试: 路径={route.Name}, 类型={route.PathType}"); + $"[测试HTTP] 已开始准备{(useVirtualObject ? "虚拟物体" : "真实物体")}碰撞测试: 路径={route.Name}, 类型={route.PathType}"); return new PreparedVirtualCollisionTest { @@ -2099,6 +2131,24 @@ namespace NavisworksTransport.Core.Services return route; } + /// + /// 判断两个 ModelItem 是否指向同一模型对象。 + /// NW API 中同一逻辑对象通过不同途径(Selection/Descendants)获取的实例不保证同一引用, + /// 因此用 InstanceGuid 比较,禁止用 ReferenceEquals。 + /// + private static bool IsSameModelItem(ModelItem a, ModelItem b) + { + if (a == null || b == null) + { + return false; + } + + return string.Equals( + a.InstanceGuid.ToString(), + b.InstanceGuid.ToString(), + StringComparison.OrdinalIgnoreCase); + } + private static PathType ParsePathTypeOrThrow(string value, string parameterName) { if (string.IsNullOrWhiteSpace(value)) @@ -2195,7 +2245,9 @@ namespace NavisworksTransport.Core.Services } string animatedObjectName = GetOptionalQueryValue(query, "animatedObjectName"); - if (string.IsNullOrWhiteSpace(animatedObjectName)) + string animatedObjectPath = GetOptionalQueryValue(query, "animatedObjectPath"); + + if (string.IsNullOrWhiteSpace(animatedObjectName) && string.IsNullOrWhiteSpace(animatedObjectPath)) { return ResolveSingleSelectedItem(activeDocument); } @@ -2210,7 +2262,29 @@ namespace NavisworksTransport.Core.Services foreach (ModelItem item in model.RootItem.DescendantsAndSelf) { - if (item != null && string.Equals(item.DisplayName, animatedObjectName, StringComparison.OrdinalIgnoreCase)) + if (item == null) + { + continue; + } + + bool nameMatches = !string.IsNullOrWhiteSpace(animatedObjectName) && + string.Equals(item.DisplayName, animatedObjectName, StringComparison.OrdinalIgnoreCase); + + bool pathMatches = false; + if (!string.IsNullOrWhiteSpace(animatedObjectPath)) + { + try + { + var pathId = activeDocument.Models.CreatePathId(item); + pathMatches = string.Equals(pathId.PathId, animatedObjectPath, StringComparison.OrdinalIgnoreCase); + } + catch + { + // 忽略单点路径解析失败,继续匹配 + } + } + + if (nameMatches || pathMatches) { matches.Add(item); } @@ -2219,12 +2293,14 @@ namespace NavisworksTransport.Core.Services if (matches.Count == 0) { - throw new InvalidOperationException($"找不到指定真实物体: {animatedObjectName}"); + string target = string.IsNullOrWhiteSpace(animatedObjectPath) ? animatedObjectName : animatedObjectPath; + throw new InvalidOperationException($"找不到指定真实物体: {target}"); } if (matches.Count > 1) { - throw new InvalidOperationException($"找到多个同名真实物体,请改用更唯一的名字: {animatedObjectName}"); + string target = string.IsNullOrWhiteSpace(animatedObjectPath) ? animatedObjectName : animatedObjectPath; + throw new InvalidOperationException($"找到多个匹配真实物体,请改用更唯一的名字或 PathId: {target}"); } return matches[0];