using Shouldly; using ZB.MOM.WW.OtOpcUa.Commons.Browsing; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using Xunit; namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing; public class CapturedTreeBrowseSessionTests { private static DriverAttributeInfo Attr(string fullName, bool isAlarm = false) => new(fullName, DriverDataType.Float64, false, null, SecurityClassification.Operate, false, IsAlarm: isAlarm); // A small captured graph: a folder Plc1 with a Speed leaf and an alarm leaf, plus a root-level leaf. // truncated=true builds with a tiny cap so recording stops short and Truncated flips. private static CapturedTree SampleTree(bool truncated = false) { var b = new CapturingAddressSpaceBuilder(nodeCap: truncated ? 2 : 100); var plc = b.Folder("Plc1", "PLC 1"); plc.Variable("Speed", "Speed", Attr("Plc1.Speed")); plc.Variable("Alarm", "Alarm", Attr("Plc1.Alarm")).MarkAsAlarmCondition( new AlarmConditionInfo("Alarm", AlarmSeverity.High, null)); b.Variable("Direct", "Direct", Attr("Root.Direct")); return b.Build(); } [Fact] public async Task RootReturnsTopLevelNodes() { await using var s = new CapturedTreeBrowseSession(SampleTree()); var root = await s.RootAsync(CancellationToken.None); var folder = root.Single(n => n.NodeId == "/Plc1"); folder.Kind.ShouldBe(BrowseNodeKind.Folder); folder.HasChildrenHint.ShouldBeTrue(); var leaf = root.Single(n => n.NodeId == "Root.Direct"); leaf.Kind.ShouldBe(BrowseNodeKind.Leaf); leaf.NodeId.ShouldBe("Root.Direct"); // leaf NodeId IS the driver FullName } [Fact] public async Task ExpandReturnsCapturedChildren() { await using var s = new CapturedTreeBrowseSession(SampleTree()); var kids = await s.ExpandAsync("/Plc1", CancellationToken.None); kids.Select(n => n.NodeId).ShouldBe(new[] { "Plc1.Speed", "Plc1.Alarm" }); } [Fact] public async Task ExpandUnknownNodeReturnsEmpty() { await using var s = new CapturedTreeBrowseSession(SampleTree()); (await s.ExpandAsync("/nope", CancellationToken.None)).ShouldBeEmpty(); } [Fact] public async Task AttributesForLeafMapsDriverAttributeInfo() { await using var s = new CapturedTreeBrowseSession(SampleTree()); var attrs = await s.AttributesAsync("Plc1.Speed", CancellationToken.None); var a = attrs.Single(); a.Name.ShouldBe("Speed"); a.DriverDataType.ShouldBe(DriverDataType.Float64.ToString()); a.IsArray.ShouldBeFalse(); a.SecurityClass.ShouldBe(SecurityClassification.Operate.ToString()); a.IsAlarm.ShouldBeFalse(); // Alarm marked during discovery surfaces IsAlarm=true even though attr.IsAlarm is false. var alarmAttrs = await s.AttributesAsync("Plc1.Alarm", CancellationToken.None); alarmAttrs.Single().IsAlarm.ShouldBeTrue(); } [Fact] public async Task TruncatedTreeAppendsMarkerFolderAtRoot() { await using var s = new CapturedTreeBrowseSession(SampleTree(truncated: true)); var root = await s.RootAsync(CancellationToken.None); var marker = root[^1]; marker.Kind.ShouldBe(BrowseNodeKind.Folder); marker.HasChildrenHint.ShouldBeFalse(); marker.DisplayName.ToLowerInvariant().ShouldContain("truncated"); } [Fact] public async Task CallsRefreshLastUsedUtc() { await using var s = new CapturedTreeBrowseSession(SampleTree()); var before = s.LastUsedUtc; await Task.Delay(5, TestContext.Current.CancellationToken); await s.RootAsync(CancellationToken.None); s.LastUsedUtc.ShouldBeGreaterThan(before); } [Fact] public async Task DisposedSessionThrowsObjectDisposed() { var s = new CapturedTreeBrowseSession(SampleTree()); await s.DisposeAsync(); await Should.ThrowAsync(() => s.RootAsync(CancellationToken.None)); } [Fact] public async Task TwoSessionsOverSameTreeAreIndependent() { var tree = SampleTree(); var s1 = new CapturedTreeBrowseSession(tree); var s2 = new CapturedTreeBrowseSession(tree); s1.Token.ShouldNotBe(s2.Token); await s1.DisposeAsync(); // Disposing s1 must not affect s2 — the tree is immutable and shared. (await s2.RootAsync(CancellationToken.None)).ShouldNotBeEmpty(); await s2.DisposeAsync(); } }