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();
}
///
/// A driver's OWN type name reaches the picker as the dataType address field, so the
/// browse-commit can stamp the driver-typed TagConfig with the type the device reported rather
/// than the tag model's default (Gitea #536). It cannot ride
/// : that is the driver-agnostic type, and several
/// native types share one agnostic value.
///
[Fact]
public async Task AttributesCarryTheDriverNativeTypeForTheCommitPath()
{
var b = new CapturingAddressSpaceBuilder();
b.Variable("Speed", "Speed", new DriverAttributeInfo(
"Pump_1_Speed", DriverDataType.Float32, false, null,
SecurityClassification.Operate, false, NativeDataType: "Real"));
await using var s = new CapturedTreeBrowseSession(b.Build());
var a = (await s.AttributesAsync("Pump_1_Speed", CancellationToken.None)).Single();
a.AddressFields.ShouldNotBeNull()["dataType"].ShouldBe("Real");
a.DriverDataType.ShouldBe(DriverDataType.Float32.ToString()); // the agnostic type is unchanged
}
///
/// A driver that states no native type states NOTHING — the commit then keeps its model default
/// instead of a guess. An empty-but-present dictionary would be indistinguishable from "the
/// driver stamped a blank type", so the absence has to be represented as absence.
///
[Fact]
public async Task AttributesCarryNoAddressFieldsWhenTheDriverStatedNoNativeType()
{
await using var s = new CapturedTreeBrowseSession(SampleTree());
var a = (await s.AttributesAsync("Plc1.Speed", CancellationToken.None)).Single();
a.AddressFields.ShouldBeNull();
}
[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();
}
}