refactor: AliasNodeIdentity dual-field + reuse ModelItemAnalysisHelper
- 新增 IndexPath(COM 树索引路径),复用 ModelItemAnalysisHelper.GetModelItemIndexPaths - 保留 DisplayPath(父链 DisplayName 拼接)作为降级匹配 - 序列化格式:"IndexPath||DisplayPath",向后兼容纯 DisplayPath - FindModelItemByNodeKey: IndexPath 优先定位,DisplayPath 降级 - BuildNode: 新增旧格式兼容查找 - 单元测试全面更新覆盖新/旧格式、双字段解析、前缀匹配
This commit is contained in:
parent
56d18c696c
commit
4feb12f6b9
@ -9,141 +9,187 @@ namespace NavisworksTransport.UnitTests.Core
|
||||
[TestClass]
|
||||
public class AliasNodeIdentityTests
|
||||
{
|
||||
// ── 构造与序列化 ──
|
||||
// ── 构造 ──
|
||||
|
||||
[TestMethod]
|
||||
public void Constructor_WithValidArgs_ShouldSetProperties()
|
||||
public void Constructor_ShouldSetBothFields()
|
||||
{
|
||||
var id = new AliasNodeIdentity("building/floor", 2);
|
||||
Assert.AreEqual("building/floor", id.HierarchicalPath);
|
||||
Assert.AreEqual(2, id.SiblingIndex);
|
||||
var id = new AliasNodeIdentity("0,2,5", "building/floor/wall#1");
|
||||
Assert.AreEqual("0,2,5", id.IndexPath);
|
||||
Assert.AreEqual("building/floor/wall#1", id.DisplayPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Constructor_WithEmptyIndexPath_IsValid()
|
||||
{
|
||||
var id = new AliasNodeIdentity("", "building/floor");
|
||||
Assert.AreEqual("", id.IndexPath);
|
||||
Assert.AreEqual("building/floor", id.DisplayPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(ArgumentNullException))]
|
||||
public void Constructor_NullDisplayPath_ShouldThrow()
|
||||
{
|
||||
new AliasNodeIdentity("0,0", null);
|
||||
}
|
||||
|
||||
// ── 序列化 ──
|
||||
|
||||
[TestMethod]
|
||||
public void ToKey_WithBothFields_ShouldFormatCorrectly()
|
||||
{
|
||||
var id = new AliasNodeIdentity("0,2,5", "模型/楼层/墙#0");
|
||||
Assert.AreEqual("0,2,5||模型/楼层/墙#0", id.ToKey());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ToKey_WithEmptyIndexPath_ShouldReturnDisplayPathOnly()
|
||||
{
|
||||
var id = new AliasNodeIdentity("", "模型/楼层/墙#0");
|
||||
Assert.AreEqual("模型/楼层/墙#0", id.ToKey());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FromKey_NewFormat_ShouldParseCorrectly()
|
||||
{
|
||||
var id = AliasNodeIdentity.FromKey("0,2,5||模型/楼层/墙#0");
|
||||
Assert.AreEqual("0,2,5", id.IndexPath);
|
||||
Assert.AreEqual("模型/楼层/墙#0", id.DisplayPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FromKey_OldFormat_ShouldRetroCompatible()
|
||||
{
|
||||
var id = AliasNodeIdentity.FromKey("building/floor/wall#2");
|
||||
Assert.AreEqual("", id.IndexPath);
|
||||
Assert.AreEqual("building/floor/wall#2", id.DisplayPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FromKey_OldFormatNoIndex_ShouldParse()
|
||||
{
|
||||
var id = AliasNodeIdentity.FromKey("building/floor");
|
||||
Assert.AreEqual("", id.IndexPath);
|
||||
Assert.AreEqual("building/floor", id.DisplayPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(ArgumentException))]
|
||||
public void Constructor_WithEmptyPath_ShouldThrow()
|
||||
public void FromKey_EmptyString_ShouldThrow()
|
||||
{
|
||||
new AliasNodeIdentity("", 0);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(ArgumentOutOfRangeException))]
|
||||
public void Constructor_WithNegativeIndex_ShouldThrow()
|
||||
{
|
||||
new AliasNodeIdentity("building/floor", -1);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ToKey_ShouldReturnCorrectFormat()
|
||||
{
|
||||
var id = new AliasNodeIdentity("building/floor/wall", 3);
|
||||
Assert.AreEqual("building/floor/wall#3", id.ToKey());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FromKey_ShouldRestoreIdentity()
|
||||
{
|
||||
var restored = AliasNodeIdentity.FromKey("building/floor/wall#3");
|
||||
Assert.AreEqual("building/floor/wall", restored.HierarchicalPath);
|
||||
Assert.AreEqual(3, restored.SiblingIndex);
|
||||
AliasNodeIdentity.FromKey("");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(FormatException))]
|
||||
public void FromKey_WithoutSeparator_ShouldThrow()
|
||||
public void FromKey_EmptyDisplayAfterSeparator_ShouldThrow()
|
||||
{
|
||||
AliasNodeIdentity.FromKey("building/floor/wall");
|
||||
AliasNodeIdentity.FromKey("0,0||");
|
||||
}
|
||||
|
||||
// ── TryParse ──
|
||||
|
||||
[TestMethod]
|
||||
public void TryParseKey_NewFormat_ShouldSucceed()
|
||||
{
|
||||
Assert.IsTrue(AliasNodeIdentity.TryParseKey("0,0||path#0", out var id));
|
||||
Assert.AreEqual("0,0", id.IndexPath);
|
||||
Assert.AreEqual("path#0", id.DisplayPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(FormatException))]
|
||||
public void FromKey_EmptyIndex_ShouldThrow()
|
||||
public void TryParseKey_OldFormat_ShouldSucceed()
|
||||
{
|
||||
AliasNodeIdentity.FromKey("building/floor/wall#");
|
||||
Assert.IsTrue(AliasNodeIdentity.TryParseKey("path#0", out var id));
|
||||
Assert.AreEqual("", id.IndexPath);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(FormatException))]
|
||||
public void FromKey_NonNumericIndex_ShouldThrow()
|
||||
{
|
||||
AliasNodeIdentity.FromKey("building/floor/wall#abc");
|
||||
}
|
||||
|
||||
// ── TryParseKey ──
|
||||
|
||||
[TestMethod]
|
||||
public void TryParseKey_ValidKey_ShouldReturnTrue()
|
||||
{
|
||||
bool ok = AliasNodeIdentity.TryParseKey("building/floor#0", out var id);
|
||||
Assert.IsTrue(ok);
|
||||
Assert.AreEqual("building/floor", id.HierarchicalPath);
|
||||
Assert.AreEqual(0, id.SiblingIndex);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryParseKey_InvalidKey_ShouldReturnFalse()
|
||||
public void TryParseKey_Empty_ShouldReturnFalse()
|
||||
{
|
||||
Assert.IsFalse(AliasNodeIdentity.TryParseKey("", out _));
|
||||
Assert.IsFalse(AliasNodeIdentity.TryParseKey("nohash", out _));
|
||||
Assert.IsFalse(AliasNodeIdentity.TryParseKey("hashonly#", out _));
|
||||
Assert.IsFalse(AliasNodeIdentity.TryParseKey("path#-1", out _));
|
||||
Assert.IsFalse(AliasNodeIdentity.TryParseKey("path#abc", out _));
|
||||
}
|
||||
|
||||
// ── 值语义 ──
|
||||
// ── 查询辅助 ──
|
||||
|
||||
[TestMethod]
|
||||
public void Equals_IdenticalIdentities_ShouldBeEqual()
|
||||
public void GetHierarchicalPath_WithIndex_ShouldStripSuffix()
|
||||
{
|
||||
var a = new AliasNodeIdentity("root/a", 0);
|
||||
var b = new AliasNodeIdentity("root/a", 0);
|
||||
Assert.AreEqual(a, b);
|
||||
Assert.IsTrue(a == b);
|
||||
Assert.IsFalse(a != b);
|
||||
Assert.AreEqual(a.GetHashCode(), b.GetHashCode());
|
||||
var id = new AliasNodeIdentity("0,0", "a/b/c#2");
|
||||
Assert.AreEqual("a/b/c", id.GetHierarchicalPath());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Equals_DifferentPath_ShouldNotBeEqual()
|
||||
public void GetHierarchicalPath_WithoutIndex_ShouldReturnFull()
|
||||
{
|
||||
var a = new AliasNodeIdentity("root/a", 0);
|
||||
var b = new AliasNodeIdentity("root/b", 0);
|
||||
Assert.AreNotEqual(a, b);
|
||||
var id = new AliasNodeIdentity("0,0", "a/b/c");
|
||||
Assert.AreEqual("a/b/c", id.GetHierarchicalPath());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Equals_DifferentIndex_ShouldNotBeEqual()
|
||||
public void GetSiblingIndex_WithIndex_ShouldReturn()
|
||||
{
|
||||
var a = new AliasNodeIdentity("root/a", 0);
|
||||
var b = new AliasNodeIdentity("root/a", 1);
|
||||
Assert.AreNotEqual(a, b);
|
||||
var id = new AliasNodeIdentity("0,0", "a/b#3");
|
||||
Assert.AreEqual(3, id.GetSiblingIndex());
|
||||
}
|
||||
|
||||
// ── HasPathPrefix ──
|
||||
[TestMethod]
|
||||
public void GetSiblingIndex_WithoutIndex_ShouldReturnZero()
|
||||
{
|
||||
var id = new AliasNodeIdentity("0,0", "a/b");
|
||||
Assert.AreEqual(0, id.GetSiblingIndex());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void HasPathPrefix_MatchingPrefix_ShouldReturnTrue()
|
||||
public void HasPathPrefix_Matching_ShouldReturnTrue()
|
||||
{
|
||||
var id = new AliasNodeIdentity("building/floor/wall", 0);
|
||||
var id = new AliasNodeIdentity("0,0", "building/floor/wall#0");
|
||||
Assert.IsTrue(id.HasPathPrefix("building/floor"));
|
||||
Assert.IsTrue(id.HasPathPrefix("building"));
|
||||
Assert.IsTrue(id.HasPathPrefix("building/floor/wall"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void HasPathPrefix_NonMatchingPrefix_ShouldReturnFalse()
|
||||
public void HasPathPrefix_NonMatching_ShouldReturnFalse()
|
||||
{
|
||||
var id = new AliasNodeIdentity("building/floor/wall", 0);
|
||||
var id = new AliasNodeIdentity("0,0", "building/floor/wall#0");
|
||||
Assert.IsFalse(id.HasPathPrefix("building/roof"));
|
||||
Assert.IsFalse(id.HasPathPrefix("Build"));
|
||||
}
|
||||
|
||||
// ── ToString ──
|
||||
// ── 值语义 ──
|
||||
|
||||
[TestMethod]
|
||||
public void Equals_Identical_ShouldMatch()
|
||||
{
|
||||
var a = new AliasNodeIdentity("0,1", "path#0");
|
||||
var b = new AliasNodeIdentity("0,1", "path#0");
|
||||
Assert.AreEqual(a, b);
|
||||
Assert.IsTrue(a == b);
|
||||
Assert.AreEqual(a.GetHashCode(), b.GetHashCode());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Equals_DifferentIndexPath_ShouldNotMatch()
|
||||
{
|
||||
var a = new AliasNodeIdentity("0,1", "path");
|
||||
var b = new AliasNodeIdentity("0,2", "path");
|
||||
Assert.AreNotEqual(a, b);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Equals_DifferentDisplayPath_ShouldNotMatch()
|
||||
{
|
||||
var a = new AliasNodeIdentity("0,1", "path/a");
|
||||
var b = new AliasNodeIdentity("0,1", "path/b");
|
||||
Assert.AreNotEqual(a, b);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ToString_ShouldMatchToKey()
|
||||
{
|
||||
var id = new AliasNodeIdentity("a/b/c", 1);
|
||||
var id = new AliasNodeIdentity("0,1", "path#0");
|
||||
Assert.AreEqual(id.ToKey(), id.ToString());
|
||||
}
|
||||
}
|
||||
@ -157,7 +203,6 @@ namespace NavisworksTransport.UnitTests.Core
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
// 使用临时文件创建独立的 SQLite 数据库
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"AliasDataStoreTest_{Guid.NewGuid():N}.db");
|
||||
_connection = new SQLiteConnection($"Data Source={_dbPath};Version=3;");
|
||||
_connection.Open();
|
||||
@ -168,8 +213,7 @@ namespace NavisworksTransport.UnitTests.Core
|
||||
{
|
||||
_connection?.Close();
|
||||
_connection?.Dispose();
|
||||
try { File.Delete(_dbPath); }
|
||||
catch { /* 清理临时文件 */ }
|
||||
try { File.Delete(_dbPath); } catch { }
|
||||
}
|
||||
|
||||
private AliasDataStore CreateStore()
|
||||
@ -179,8 +223,8 @@ namespace NavisworksTransport.UnitTests.Core
|
||||
return store;
|
||||
}
|
||||
|
||||
private AliasNodeIdentity Id(string path, int index = 0)
|
||||
=> new AliasNodeIdentity(path, index);
|
||||
private AliasNodeIdentity Id(string displayPath, string indexPath = "")
|
||||
=> new AliasNodeIdentity(indexPath, displayPath);
|
||||
|
||||
// ── CRUD ──
|
||||
|
||||
@ -188,36 +232,34 @@ namespace NavisworksTransport.UnitTests.Core
|
||||
public void SetAndGetAlias_ShouldReturnSavedValue()
|
||||
{
|
||||
var store = CreateStore();
|
||||
store.SetAlias(Id("building/floor", 0), "1F-办公层");
|
||||
string result = store.GetAlias(Id("building/floor", 0));
|
||||
Assert.AreEqual("1F-办公层", result);
|
||||
store.SetAlias(Id("building/floor", "0,1"), "1F-办公层");
|
||||
Assert.AreEqual("1F-办公层", store.GetAlias(Id("building/floor", "0,1")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetAlias_Nonexistent_ShouldReturnNull()
|
||||
{
|
||||
var store = CreateStore();
|
||||
string result = store.GetAlias(Id("some/nonexistent", 0));
|
||||
Assert.IsNull(result);
|
||||
Assert.IsNull(store.GetAlias(Id("nonexistent")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SetAlias_Overwrite_ShouldUpdateValue()
|
||||
{
|
||||
var store = CreateStore();
|
||||
store.SetAlias(Id("building/floor", 0), "旧名称");
|
||||
store.SetAlias(Id("building/floor", 0), "新名称");
|
||||
Assert.AreEqual("新名称", store.GetAlias(Id("building/floor", 0)));
|
||||
store.SetAlias(Id("wall", "0,0"), "旧名称");
|
||||
store.SetAlias(Id("wall", "0,0"), "新名称");
|
||||
Assert.AreEqual("新名称", store.GetAlias(Id("wall", "0,0")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SetAlias_EmptyString_ShouldDelete()
|
||||
{
|
||||
var store = CreateStore();
|
||||
store.SetAlias(Id("building/floor", 0), "原本");
|
||||
store.SetAlias(Id("x", "0,0"), "原本");
|
||||
Assert.AreEqual(1, store.Count);
|
||||
store.SetAlias(Id("building/floor", 0), " ");
|
||||
Assert.IsNull(store.GetAlias(Id("building/floor", 0)));
|
||||
store.SetAlias(Id("x", "0,0"), " ");
|
||||
Assert.IsNull(store.GetAlias(Id("x", "0,0")));
|
||||
Assert.AreEqual(0, store.Count);
|
||||
}
|
||||
|
||||
@ -225,129 +267,95 @@ namespace NavisworksTransport.UnitTests.Core
|
||||
public void DeleteAlias_ShouldRemoveEntry()
|
||||
{
|
||||
var store = CreateStore();
|
||||
store.SetAlias(Id("building/floor", 0), "别名");
|
||||
store.SetAlias(Id("x"), "别名");
|
||||
Assert.AreEqual(1, store.Count);
|
||||
store.DeleteAlias(Id("building/floor", 0));
|
||||
store.DeleteAlias(Id("x"));
|
||||
Assert.AreEqual(0, store.Count);
|
||||
Assert.IsNull(store.GetAlias(Id("building/floor", 0)));
|
||||
}
|
||||
|
||||
// ── 批量操作 ──
|
||||
// ── 批量 ──
|
||||
|
||||
[TestMethod]
|
||||
public void DeleteByPathPrefix_ShouldRemoveAllUnderPath()
|
||||
{
|
||||
var store = CreateStore();
|
||||
store.SetAlias(Id("building/floor/a", 0), "别名1");
|
||||
store.SetAlias(Id("building/floor/b", 0), "别名2");
|
||||
store.SetAlias(Id("building/roof/c", 0), "别名3");
|
||||
store.SetAlias(Id("building/floor/1"));
|
||||
store.SetAlias(Id("building/floor/2"));
|
||||
store.SetAlias(Id("building/roof/3"));
|
||||
|
||||
store.DeleteAliasesByPathPrefix("building/floor");
|
||||
|
||||
Assert.IsNull(store.GetAlias(Id("building/floor/a", 0)));
|
||||
Assert.IsNull(store.GetAlias(Id("building/floor/b", 0)));
|
||||
Assert.AreEqual("别名3", store.GetAlias(Id("building/roof/c", 0)));
|
||||
Assert.IsNull(store.GetAlias(Id("building/floor/1")));
|
||||
Assert.IsNull(store.GetAlias(Id("building/floor/2")));
|
||||
Assert.IsNotNull(store.GetAlias(Id("building/roof/3")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LoadAll_ShouldReturnAllEntries()
|
||||
public void LoadAll_ShouldReturnAll()
|
||||
{
|
||||
var store = CreateStore();
|
||||
store.SetAlias(Id("a", 0), "别名A");
|
||||
store.SetAlias(Id("b", 1), "别名B");
|
||||
store.SetAlias(Id("c", 2), "别名C");
|
||||
store.SetAlias(Id("a", "0,0"), "别名A");
|
||||
store.SetAlias(Id("b", "1,0"), "别名B");
|
||||
|
||||
var all = store.LoadAll();
|
||||
Assert.AreEqual(3, all.Count);
|
||||
Assert.AreEqual("别名A", all[Id("a", 0).ToKey()]);
|
||||
Assert.AreEqual("别名B", all[Id("b", 1).ToKey()]);
|
||||
Assert.AreEqual("别名C", all[Id("c", 2).ToKey()]);
|
||||
Assert.AreEqual(2, all.Count);
|
||||
Assert.AreEqual("别名A", all[Id("a", "0,0").ToKey()]);
|
||||
Assert.AreEqual("别名B", all[Id("b", "1,0").ToKey()]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Count_EmptyStore_ShouldBeZero()
|
||||
public void Count_ShouldReflectInserts()
|
||||
{
|
||||
var store = CreateStore();
|
||||
Assert.AreEqual(0, store.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Count_AfterInsert_ShouldIncrease()
|
||||
{
|
||||
var store = CreateStore();
|
||||
store.SetAlias(Id("a", 0), "alias1");
|
||||
store.SetAlias(Id("b", 0), "alias2");
|
||||
store.SetAlias(Id("a"), "alias1");
|
||||
store.SetAlias(Id("b"), "alias2");
|
||||
Assert.AreEqual(2, store.Count);
|
||||
}
|
||||
|
||||
// ── 批量导入 ──
|
||||
// ── ImportBatch ──
|
||||
|
||||
[TestMethod]
|
||||
public void ImportBatch_ShouldMergeEntries()
|
||||
public void ImportBatch_ShouldMergeAndOverwrite()
|
||||
{
|
||||
var store = CreateStore();
|
||||
store.SetAlias(Id("existing", 0), "保留值");
|
||||
store.SetAlias(Id("existing", "0,0"), "保留值");
|
||||
|
||||
var entries = new System.Collections.Generic.Dictionary<string, string>
|
||||
{
|
||||
{ Id("existing", 0).ToKey(), "覆盖值" },
|
||||
{ Id("new", 0).ToKey(), "新值" }
|
||||
{ Id("existing", "0,0").ToKey(), "覆盖值" },
|
||||
{ Id("new", "1,0").ToKey(), "新值" }
|
||||
};
|
||||
store.ImportBatch(entries);
|
||||
|
||||
Assert.AreEqual("覆盖值", store.GetAlias(Id("existing", 0)));
|
||||
Assert.AreEqual("新值", store.GetAlias(Id("new", 0)));
|
||||
Assert.AreEqual("覆盖值", store.GetAlias(Id("existing", "0,0")));
|
||||
Assert.AreEqual("新值", store.GetAlias(Id("new", "1,0")));
|
||||
Assert.AreEqual(2, store.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ImportBatch_EmptyDict_ShouldNotAffectStore()
|
||||
{
|
||||
var store = CreateStore();
|
||||
store.SetAlias(Id("a", 0), "值");
|
||||
store.ImportBatch(new System.Collections.Generic.Dictionary<string, string>());
|
||||
Assert.AreEqual(1, store.Count);
|
||||
}
|
||||
|
||||
// ── 清除全部 ──
|
||||
|
||||
[TestMethod]
|
||||
public void ClearAll_ShouldRemoveEverything()
|
||||
{
|
||||
var store = CreateStore();
|
||||
store.SetAlias(Id("a", 0), "别名1");
|
||||
store.SetAlias(Id("b", 1), "别名2");
|
||||
store.SetAlias(Id("a"), "别名1");
|
||||
store.SetAlias(Id("b"), "别名2");
|
||||
store.ClearAll();
|
||||
Assert.AreEqual(0, store.Count);
|
||||
}
|
||||
|
||||
// ── 同名兄弟节点去重 ──
|
||||
// ── 新格式 → 旧格式 兼容 ──
|
||||
|
||||
[TestMethod]
|
||||
public void SiblingNodes_WithDifferentIndex_ShouldStoreSeparately()
|
||||
public void OldFormatKey_StillWorks()
|
||||
{
|
||||
var store = CreateStore();
|
||||
store.SetAlias(Id("floor/wall", 0), "墙-1");
|
||||
store.SetAlias(Id("floor/wall", 1), "墙-2");
|
||||
store.SetAlias(Id("floor/wall", 2), "墙-3");
|
||||
string oldKey = "building/floor/wall#0";
|
||||
store.SetAlias(AliasNodeIdentity.FromKey(oldKey), "别名");
|
||||
Assert.AreEqual("别名", store.GetAlias(AliasNodeIdentity.FromKey(oldKey)));
|
||||
|
||||
Assert.AreEqual("墙-1", store.GetAlias(Id("floor/wall", 0)));
|
||||
Assert.AreEqual("墙-2", store.GetAlias(Id("floor/wall", 1)));
|
||||
Assert.AreEqual("墙-3", store.GetAlias(Id("floor/wall", 2)));
|
||||
Assert.AreEqual(3, store.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LoadAll_SiblingNodes_ShouldReturnAll()
|
||||
{
|
||||
var store = CreateStore();
|
||||
store.SetAlias(Id("same/path", 0), "第一个");
|
||||
store.SetAlias(Id("same/path", 1), "第二个");
|
||||
|
||||
var all = store.LoadAll();
|
||||
Assert.AreEqual(2, all.Count);
|
||||
Assert.AreEqual("第一个", all["same/path#0"]);
|
||||
Assert.AreEqual("第二个", all["same/path#1"]);
|
||||
// 同一个节点用新格式读取
|
||||
var newId = new AliasNodeIdentity("", "building/floor/wall#0");
|
||||
Assert.AreEqual("别名", store.GetAlias(newId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,55 +1,59 @@
|
||||
using Autodesk.Navisworks.Api;
|
||||
using NavisworksTransport.Utils;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Linq;
|
||||
|
||||
namespace NavisworksTransport.Core.AliasTree
|
||||
{
|
||||
/// <summary>
|
||||
/// 稳定的复合节点标识 — 替代 InstanceGuid 用于持久化。
|
||||
///
|
||||
/// 基于父链 DisplayName 拼接 + 同级同名节点去重索引 构成唯一 key。
|
||||
/// 即使 Revit 重新导出 NWC/append 进 NWD,只要 DisplayName 层级结构不变,
|
||||
/// 别名就能正确关联。
|
||||
///
|
||||
/// 设计原则:
|
||||
/// - 序列化格式: "path/to/node#index",便于 SQLite 存储和 JSON 导出
|
||||
/// - 不可变结构体(值语义),可用作 Dictionary key
|
||||
/// - 从 ModelItem 构造时在主线程调用(Navisworks API)
|
||||
/// - 反序列化/比较时纯字符串操作(可在后台线程安全使用)
|
||||
///
|
||||
/// 双字段设计:
|
||||
/// - <see cref="IndexPath"/>:COM API 树索引路径(如 "0,2,5,1"),
|
||||
/// 复用 <see cref="ModelItemAnalysisHelper.GetModelItemIndexPaths"/> 生成。
|
||||
/// 定位时优先使用,精度高、速度快。
|
||||
/// - <see cref="DisplayPath"/>:父链 DisplayName 拼接路径(如 "模型/楼层/墙#0"),
|
||||
/// 可读可调试,IndexPath 失效时作为降级定位手段。
|
||||
///
|
||||
/// 序列化格式(ToKey): "IndexPath||DisplayPath"
|
||||
/// 示例: "0,2,5,1||模型/楼层/墙#0"
|
||||
///
|
||||
/// 向后兼容:不含 "||" 的 key 按旧格式(纯 DisplayPath)解析。
|
||||
/// </summary>
|
||||
public readonly struct AliasNodeIdentity : IEquatable<AliasNodeIdentity>
|
||||
{
|
||||
/// <summary>父链 DisplayName 拼接路径</summary>
|
||||
public string HierarchicalPath { get; }
|
||||
/// <summary>COM 树索引路径(如 "0,2,5,1"),优先用于定位</summary>
|
||||
public string IndexPath { get; }
|
||||
|
||||
/// <summary>同级同名节点中的去重索引(0-based)</summary>
|
||||
public int SiblingIndex { get; }
|
||||
/// <summary>父链 DisplayName 拼接路径 + 同级索引(如 "模型/楼层/墙#0")</summary>
|
||||
public string DisplayPath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 构造复合标识
|
||||
/// 从全量信息构造
|
||||
/// </summary>
|
||||
public AliasNodeIdentity(string hierarchicalPath, int siblingIndex)
|
||||
public AliasNodeIdentity(string indexPath, string displayPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(hierarchicalPath))
|
||||
throw new ArgumentException("路径不能为空", nameof(hierarchicalPath));
|
||||
if (siblingIndex < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(siblingIndex), "索引不能为负");
|
||||
|
||||
HierarchicalPath = hierarchicalPath;
|
||||
SiblingIndex = siblingIndex;
|
||||
IndexPath = indexPath ?? string.Empty;
|
||||
DisplayPath = displayPath ?? throw new ArgumentNullException(nameof(displayPath));
|
||||
}
|
||||
|
||||
// ── 工厂方法 ──
|
||||
|
||||
/// <summary>
|
||||
/// 从 ModelItem 构造复合标识。
|
||||
/// 注意:必须在主线程中调用,因为涉及 Navisworks API 访问。
|
||||
/// 必须在主线程调用(涉及 Navisworks API + COM API)。
|
||||
/// </summary>
|
||||
public static AliasNodeIdentity FromModelItem(ModelItem item)
|
||||
{
|
||||
if (item == null)
|
||||
throw new ArgumentNullException(nameof(item));
|
||||
|
||||
// 1. 沿 Parent 链收集 DisplayName
|
||||
// 1. IndexPath — 复用 ModelItemAnalysisHelper
|
||||
var indexPaths = ModelItemAnalysisHelper.GetModelItemIndexPaths(item);
|
||||
string indexPath = indexPaths.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
// 2. DisplayPath — 父链 DisplayName + 同级索引
|
||||
var parts = new List<string>();
|
||||
var current = item;
|
||||
while (current != null)
|
||||
@ -57,134 +61,151 @@ namespace NavisworksTransport.Core.AliasTree
|
||||
parts.Insert(0, current.DisplayName ?? "?");
|
||||
current = current.Parent;
|
||||
}
|
||||
string displayPath = string.Join("/", parts);
|
||||
|
||||
var path = string.Join("/", parts);
|
||||
// 3. 同级同名节点去重索引
|
||||
int siblingIndex = ComputeSiblingIndex(item);
|
||||
|
||||
// 2. 计算同级同名节点中的索引
|
||||
int siblingIndex = 0;
|
||||
int matchIndex = 0;
|
||||
bool foundSelf = false;
|
||||
string displayPathWithIndex = siblingIndex > 0
|
||||
? $"{displayPath}#{siblingIndex}"
|
||||
: displayPath;
|
||||
|
||||
var parent = item.Parent;
|
||||
if (parent != null)
|
||||
{
|
||||
foreach (var child in parent.Children)
|
||||
{
|
||||
if (string.Equals(child.DisplayName, item.DisplayName, StringComparison.Ordinal))
|
||||
{
|
||||
if (child.Equals(item))
|
||||
{
|
||||
foundSelf = true;
|
||||
siblingIndex = matchIndex;
|
||||
break;
|
||||
}
|
||||
matchIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundSelf)
|
||||
{
|
||||
// 若未在父节点中找到自身(理论上不应发生),使用 0
|
||||
siblingIndex = 0;
|
||||
}
|
||||
|
||||
return new AliasNodeIdentity(path, siblingIndex);
|
||||
return new AliasNodeIdentity(indexPath, displayPathWithIndex);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 序列化为数据库主键字符串
|
||||
/// 计算同名兄弟节点中的位置
|
||||
/// </summary>
|
||||
private static int ComputeSiblingIndex(ModelItem item)
|
||||
{
|
||||
var parent = item.Parent;
|
||||
if (parent == null) return 0;
|
||||
|
||||
string targetName = item.DisplayName;
|
||||
int matchIndex = 0;
|
||||
|
||||
foreach (var child in parent.Children)
|
||||
{
|
||||
if (child.Equals(item))
|
||||
return matchIndex;
|
||||
if (string.Equals(child.DisplayName, targetName, StringComparison.Ordinal))
|
||||
matchIndex++;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ── 序列化 ──
|
||||
|
||||
/// <summary>
|
||||
/// 序列化为 DB 主键 / JSON key
|
||||
/// 格式: "IndexPath||DisplayPath"
|
||||
/// </summary>
|
||||
public string ToKey()
|
||||
{
|
||||
return $"{HierarchicalPath}#{SiblingIndex}";
|
||||
if (string.IsNullOrEmpty(IndexPath))
|
||||
return DisplayPath;
|
||||
return $"{IndexPath}||{DisplayPath}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 从 key 字符串反序列化
|
||||
/// 从 key 反序列化
|
||||
/// </summary>
|
||||
public static AliasNodeIdentity FromKey(string key)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key))
|
||||
throw new ArgumentException("key 不能为空", nameof(key));
|
||||
|
||||
int separatorIndex = key.LastIndexOf('#');
|
||||
if (separatorIndex < 0 || separatorIndex >= key.Length - 1)
|
||||
throw new FormatException($"无法从 key 解析 AliasNodeIdentity: {key}");
|
||||
// 检查是否有双字段分隔符
|
||||
int separatorIndex = key.IndexOf("||", StringComparison.Ordinal);
|
||||
if (separatorIndex >= 0)
|
||||
{
|
||||
string indexPath = key.Substring(0, separatorIndex);
|
||||
string displayPath = key.Substring(separatorIndex + 2);
|
||||
if (string.IsNullOrEmpty(displayPath))
|
||||
throw new FormatException($"DisplayPath 为空: {key}");
|
||||
return new AliasNodeIdentity(indexPath, displayPath);
|
||||
}
|
||||
|
||||
string path = key.Substring(0, separatorIndex);
|
||||
string indexStr = key.Substring(separatorIndex + 1);
|
||||
|
||||
if (!int.TryParse(indexStr, out int siblingIndex))
|
||||
throw new FormatException($"无法解析 sibling index: {indexStr}");
|
||||
|
||||
return new AliasNodeIdentity(path, siblingIndex);
|
||||
// 向后兼容:纯 DisplayPath 格式(旧数据)
|
||||
return new AliasNodeIdentity(string.Empty, key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提前验证 key 字符串格式是否合法(不抛出异常)
|
||||
/// 提前验证 key 字符串格式是否合法
|
||||
/// </summary>
|
||||
public static bool TryParseKey(string key, out AliasNodeIdentity identity)
|
||||
{
|
||||
identity = default;
|
||||
if (string.IsNullOrEmpty(key)) return false;
|
||||
|
||||
int separatorIndex = key.LastIndexOf('#');
|
||||
if (separatorIndex < 0 || separatorIndex >= key.Length - 1) return false;
|
||||
try
|
||||
{
|
||||
identity = FromKey(key);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
string path = key.Substring(0, separatorIndex);
|
||||
string indexStr = key.Substring(separatorIndex + 1);
|
||||
// ── 查询辅助 ──
|
||||
|
||||
if (!int.TryParse(indexStr, out int siblingIndex)) return false;
|
||||
if (string.IsNullOrEmpty(path)) return false;
|
||||
if (siblingIndex < 0) return false;
|
||||
|
||||
identity = new AliasNodeIdentity(path, siblingIndex);
|
||||
return true;
|
||||
/// <summary>
|
||||
/// 获取 DisplayPath 部分中的 HierarchicalPath(去掉尾部 #索引)
|
||||
/// </summary>
|
||||
public string GetHierarchicalPath()
|
||||
{
|
||||
int hashIdx = DisplayPath.LastIndexOf('#');
|
||||
return hashIdx > 0 ? DisplayPath.Substring(0, hashIdx) : DisplayPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 仅检查 HierarchicalPath 前缀匹配 — 用于"批量删除该分支下所有别名"
|
||||
/// 获取 DisplayPath 部分中的 SiblingIndex
|
||||
/// </summary>
|
||||
public int GetSiblingIndex()
|
||||
{
|
||||
int hashIdx = DisplayPath.LastIndexOf('#');
|
||||
if (hashIdx < 0 || hashIdx >= DisplayPath.Length - 1) return 0;
|
||||
string indexStr = DisplayPath.Substring(hashIdx + 1);
|
||||
int.TryParse(indexStr, out int idx);
|
||||
return idx;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查 DisplayPath 是否以指定前缀开头(用于"清除某分支下全部别名")
|
||||
/// </summary>
|
||||
public bool HasPathPrefix(string prefix)
|
||||
{
|
||||
return HierarchicalPath.StartsWith(prefix, StringComparison.Ordinal);
|
||||
return GetHierarchicalPath().StartsWith(prefix, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
// ── 值语义 ──
|
||||
|
||||
public bool Equals(AliasNodeIdentity other)
|
||||
{
|
||||
return string.Equals(HierarchicalPath, other.HierarchicalPath, StringComparison.Ordinal)
|
||||
&& SiblingIndex == other.SiblingIndex;
|
||||
return string.Equals(IndexPath, other.IndexPath, StringComparison.Ordinal)
|
||||
&& string.Equals(DisplayPath, other.DisplayPath, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public override bool Equals(object obj)
|
||||
{
|
||||
return obj is AliasNodeIdentity other && Equals(other);
|
||||
}
|
||||
=> obj is AliasNodeIdentity other && Equals(other);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
return (HierarchicalPath.GetHashCode() * 397) ^ SiblingIndex;
|
||||
return (IndexPath?.GetHashCode() ?? 0 * 397) ^ (DisplayPath?.GetHashCode() ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool operator ==(AliasNodeIdentity left, AliasNodeIdentity right)
|
||||
{
|
||||
return left.Equals(right);
|
||||
}
|
||||
=> left.Equals(right);
|
||||
|
||||
public static bool operator !=(AliasNodeIdentity left, AliasNodeIdentity right)
|
||||
{
|
||||
return !left.Equals(right);
|
||||
}
|
||||
=> !left.Equals(right);
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return ToKey();
|
||||
}
|
||||
public override string ToString() => ToKey();
|
||||
}
|
||||
}
|
||||
|
||||
@ -237,6 +237,12 @@ namespace NavisworksTransport.UI.WPF.Views
|
||||
string alias = null;
|
||||
aliasMap?.TryGetValue(key, out alias);
|
||||
|
||||
// 旧格式兼容:也尝试用纯 DisplayPath 查找
|
||||
if (alias == null && !string.IsNullOrEmpty(identity.IndexPath))
|
||||
{
|
||||
aliasMap?.TryGetValue(identity.DisplayPath, out alias);
|
||||
}
|
||||
|
||||
var node = new AliasNodeViewModel
|
||||
{
|
||||
NodeKey = key,
|
||||
@ -247,8 +253,7 @@ namespace NavisworksTransport.UI.WPF.Views
|
||||
|
||||
_nodeMap[key] = node;
|
||||
|
||||
// 子节点(第一层不懒加载,展开时在 Expanded 事件中再填充)
|
||||
// 但先加一个占位节点让 TreeView 显示展开箭头
|
||||
// 第一层放占位节点以显示展开箭头
|
||||
if (node.ChildCount > 0)
|
||||
{
|
||||
node.Children.Add(new AliasNodeViewModel
|
||||
@ -299,6 +304,7 @@ namespace NavisworksTransport.UI.WPF.Views
|
||||
|
||||
// ============================================================
|
||||
// 从 NodeKey 回溯定位 ModelItem
|
||||
// 策略:IndexPath(COM 树索引路径)优先,DisplayPath 降级
|
||||
// ============================================================
|
||||
|
||||
private Autodesk.Navisworks.Api.ModelItem FindModelItemByNodeKey(string key)
|
||||
@ -309,57 +315,112 @@ namespace NavisworksTransport.UI.WPF.Views
|
||||
try
|
||||
{
|
||||
var doc = NavisApplication.ActiveDocument;
|
||||
if (doc == null) return null;
|
||||
if (doc == null || doc.Models == null) return null;
|
||||
|
||||
// 沿 HierarchicalPath 匹配 DisplayName
|
||||
string[] parts = identity.HierarchicalPath.Split('/');
|
||||
// parts[0] 是模型名/根节点名
|
||||
// 遍历 Models 找匹配的根
|
||||
foreach (var model in doc.Models)
|
||||
// 策略 1:从 IndexPath 定位(最快、最精确)
|
||||
if (!string.IsNullOrEmpty(identity.IndexPath))
|
||||
{
|
||||
var current = model.RootItem;
|
||||
if (current == null) continue;
|
||||
|
||||
// 检查根节点
|
||||
if (parts.Length > 0 && current.DisplayName != parts[0])
|
||||
continue;
|
||||
|
||||
for (int i = 1; i < parts.Length; i++)
|
||||
{
|
||||
string targetName = parts[i];
|
||||
Autodesk.Navisworks.Api.ModelItem matchedChild = null;
|
||||
foreach (var child in current.Children)
|
||||
{
|
||||
if (child.DisplayName == targetName)
|
||||
{
|
||||
matchedChild = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matchedChild == null) return null;
|
||||
current = matchedChild;
|
||||
}
|
||||
|
||||
// 到了叶子层级,用 SiblingIndex 在同名兄弟中定位
|
||||
if (identity.SiblingIndex > 0)
|
||||
{
|
||||
int idx = 0;
|
||||
foreach (var child in current.Children)
|
||||
{
|
||||
if (child.DisplayName == parts.Last())
|
||||
{
|
||||
if (idx == identity.SiblingIndex)
|
||||
return child;
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return current;
|
||||
var item = LocateByIndexPath(doc, identity.IndexPath);
|
||||
if (item != null && ModelItemAnalysisHelper.IsModelItemValid(item))
|
||||
return item;
|
||||
}
|
||||
|
||||
// 策略 2:从 DisplayPath 降级定位
|
||||
return LocateByDisplayPath(doc, identity);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按 COM 索引路径定位 ModelItem
|
||||
/// 路径格式:"模型索引,一级子索引,二级子索引,..."
|
||||
/// </summary>
|
||||
private static Autodesk.Navisworks.Api.ModelItem LocateByIndexPath(
|
||||
Autodesk.Navisworks.Api.Document doc, string indexPath)
|
||||
{
|
||||
string[] parts = indexPath.Split(',');
|
||||
if (parts.Length == 0) return null;
|
||||
|
||||
if (!int.TryParse(parts[0], out int modelIndex))
|
||||
return null;
|
||||
|
||||
// 取模型
|
||||
if (modelIndex < 0 || modelIndex >= doc.Models.Count)
|
||||
return null;
|
||||
|
||||
var model = doc.Models[modelIndex];
|
||||
if (model?.RootItem == null) return null;
|
||||
|
||||
Autodesk.Navisworks.Api.ModelItem current = model.RootItem;
|
||||
|
||||
for (int i = 1; i < parts.Length; i++)
|
||||
{
|
||||
if (!int.TryParse(parts[i], out int childIndex))
|
||||
return null;
|
||||
|
||||
var childrenList = current.Children.ToList();
|
||||
if (childIndex < 0 || childIndex >= childrenList.Count)
|
||||
return null;
|
||||
|
||||
current = childrenList[childIndex];
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按 DisplayPath 降级定位
|
||||
/// </summary>
|
||||
private static Autodesk.Navisworks.Api.ModelItem LocateByDisplayPath(
|
||||
Autodesk.Navisworks.Api.Document doc, AliasNodeIdentity identity)
|
||||
{
|
||||
string[] parts = identity.GetHierarchicalPath().Split('/');
|
||||
if (parts.Length == 0) return null;
|
||||
|
||||
// parts[0] 是模型名/根节点名,匹配模型
|
||||
foreach (var model in doc.Models)
|
||||
{
|
||||
var current = model.RootItem;
|
||||
if (current == null) continue;
|
||||
if (current.DisplayName != parts[0]) continue;
|
||||
|
||||
for (int i = 1; i < parts.Length; i++)
|
||||
{
|
||||
string targetName = parts[i];
|
||||
Autodesk.Navisworks.Api.ModelItem matched = null;
|
||||
foreach (var child in current.Children)
|
||||
{
|
||||
if (child.DisplayName == targetName)
|
||||
{
|
||||
matched = child;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched == null) return null;
|
||||
current = matched;
|
||||
}
|
||||
|
||||
// 同级同名去重索引
|
||||
int siblingIndex = identity.GetSiblingIndex();
|
||||
if (siblingIndex > 0)
|
||||
{
|
||||
int idx = 0;
|
||||
foreach (var child in current.Children)
|
||||
{
|
||||
if (child.DisplayName == parts.Last())
|
||||
{
|
||||
if (idx == siblingIndex) return child;
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
catch { }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user