feat(browse): Wave-0 Batch C — CapturingAddressSpaceBuilder + CapturedTreeBrowseSession
Task 6: CapturedTree/CapturedNode model + CapturingAddressSpaceBuilder — an in-memory
IAddressSpaceBuilder that records a driver's DiscoverAsync stream (folder nesting,
leaf FullName as node id, alarm mark, node-cap truncation, no-op alarm sink).
Task 7: CapturedTreeBrowseSession — serves the captured snapshot through IBrowseSession
(Root/Expand/Attributes from memory, truncation marker, shared-immutable-tree dispose).
13 unit tests green.
This commit is contained in:
+115
@@ -0,0 +1,115 @@
|
||||
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<ObjectDisposedException>(() => 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();
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
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 CapturingAddressSpaceBuilderTests
|
||||
{
|
||||
private static DriverAttributeInfo Attr(string fullName, bool isAlarm = false) =>
|
||||
new(fullName, DriverDataType.Float64, false, null, SecurityClassification.Operate, false, IsAlarm: isAlarm);
|
||||
|
||||
[Fact]
|
||||
public void CapturesNestedFoldersAndLeaves()
|
||||
{
|
||||
var b = new CapturingAddressSpaceBuilder(nodeCap: 100);
|
||||
var plc = b.Folder("Plc1", "PLC 1");
|
||||
plc.Variable("Speed", "Speed", Attr("Program:Main.Speed"));
|
||||
var udt = plc.Folder("Motor", "Motor");
|
||||
udt.Variable("Amps", "Amps", Attr("Program:Main.Motor.Amps"));
|
||||
|
||||
var tree = b.Build();
|
||||
tree.Truncated.ShouldBeFalse();
|
||||
tree.Root.Children.Count.ShouldBe(1);
|
||||
var plcNode = tree.Root.Children[0];
|
||||
plcNode.Id.ShouldBe("/Plc1");
|
||||
plcNode.IsLeaf.ShouldBeFalse();
|
||||
plcNode.Children.Select(c => c.Id).ShouldBe(new[] { "Program:Main.Speed", "/Plc1/Motor" });
|
||||
tree.ById["Program:Main.Motor.Amps"].Attribute!.FullName.ShouldBe("Program:Main.Motor.Amps");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LeafNodeIdIsTheDriverFullName()
|
||||
{
|
||||
var b = new CapturingAddressSpaceBuilder(100);
|
||||
var handle = b.Variable("T", "T", Attr("Device.Tag1"));
|
||||
handle.FullReference.ShouldBe("Device.Tag1");
|
||||
b.Build().ById["Device.Tag1"].IsLeaf.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NodeCapTruncatesInsteadOfGrowing()
|
||||
{
|
||||
var b = new CapturingAddressSpaceBuilder(nodeCap: 3);
|
||||
for (var i = 0; i < 10; i++) b.Variable($"T{i}", $"T{i}", Attr($"Dev.T{i}"));
|
||||
var tree = b.Build();
|
||||
tree.Truncated.ShouldBeTrue();
|
||||
tree.Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MarkAsAlarmConditionRecordsAlarmAndReturnsNoopSink()
|
||||
{
|
||||
var b = new CapturingAddressSpaceBuilder(100);
|
||||
var h = b.Variable("A", "A", Attr("Dev.Alarm1"));
|
||||
var sink = h.MarkAsAlarmCondition(new AlarmConditionInfo("A", AlarmSeverity.High, null));
|
||||
sink.ShouldNotBeNull();
|
||||
Should.NotThrow(() => sink.OnTransition(null!)); // no-op — browse never delivers transitions
|
||||
b.Build().ById["Dev.Alarm1"].IsAlarmMarked.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddPropertyAttachesToCurrentScope()
|
||||
{
|
||||
var b = new CapturingAddressSpaceBuilder(100);
|
||||
var f = b.Folder("Dev", "Dev");
|
||||
f.AddProperty("Model", DriverDataType.String, "1756-L83E");
|
||||
b.Build().ById["/Dev"].Properties.ShouldContain(p => p.Name == "Model" && (string?)p.Value == "1756-L83E");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user