Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/CapturedTreeBrowseSessionTests.cs
T
Joseph Doherty 6b3da93151
v2-ci / build (push) Successful in 4m17s
v2-ci / unit-tests (push) Failing after 16m47s
fix(browse): commit the browsed data type, and fix the probe classification the fixture was hiding (#536)
A browse-committed AB CIP / TwinCAT / FOCAS tag bound with its model's DEFAULT
data type, not the one the device reported. A browsed REAL committed as
`{"tagPath":"Pump_1_Speed","dataType":"DInt"}` — and those three drivers decode
the wire bytes by that field, not by the raw row's OPC UA DataType — so the tag
read four bytes of a float through an integer decoder and published a plausible
wrong number with no error raised anywhere. FOCAS was missed when #536 was filed.

The type is CARRIED, not derived. Reverse-mapping the leaf's driver-agnostic
DriverDataType looks like the smaller fix and is wrong: SInt/Int/DInt/USInt/UInt
all collapse into Int32 on the way out of the driver, so that version would bind
a 16-bit INT as a 32-bit DINT while appearing to fix the REAL. Drivers now state
their own type name on DriverAttributeInfo.NativeDataType, which reaches the
picker as AttributeInfo.AddressFields["dataType"] and the commit mapper from
there. A leaf that states nothing keeps the model default — the pre-#536
behaviour, kept deliberately as the fallback rather than a guess.

Numeric strings are refused before parsing: Enum.TryParse accepts "9" for any
enum and Enum.IsDefined then confirms it, so an unguarded parse would bind a tag
to a type nobody named. A test caught this in the first version of the guard.

--- and the other half, which the fixture was hiding ---

Test Connect on a device with NO authored tags — every AB CIP device at the
moment an operator first presses the button — reported "CIP handshake failed"
against a healthy controller. That was written down as a fixture limitation
("ab_server doesn't implement @raw_cpu_type"). Both halves of that were wrong:

  * @raw_cpu_type is not a libplctag special tag at all (only @raw, @tags and
    @udt/ are), so it goes out as an ordinary symbolic name that no controller
    defines, and the controller's "tag does not exist" IS the successful answer.
  * ab_server answered an unresolvable tag path with CIP 0x03 where a Logix
    controller answers 0x04 — ErrorBadParam vs ErrorNotFound, which the probe
    classifies oppositely. Fixed in vendored patch 0002.
  * With the fixture then telling the truth, the real defect surfaced:
    AbCipDriverProbe.IsReachableException matched the native C status spellings
    ("STATUS_NOT_FOUND"), but libplctag.NET throws with the MANAGED enum name
    ("ErrorNotFound"). Those substrings can never match, so the reachable
    classification could not fire on the exception path — which is the path every
    no-authored-tags probe takes. This was failing on real hardware too, not just
    against the sim. It now recovers the Status and defers to IsReachableStatus,
    so there is one classification rather than two that can drift.

Rejected: switching the fallback to @tags. libplctag builds a tag-listing tag
with NO network I/O, so it reports OK against any TCP listener — measured green
against a Mosquitto broker.

Verification: full solution suite green. Falsification runs confirm exactly the
intended tests go red — 9 for the type carry, 3 (the reachable statuses only) for
the probe classification. Live on docker-dev: browse-committed TestString/
Motor_Run/TestSINT now store String/Bool/SInt, alongside last session's
Pump_1_Speed still holding the wrong DInt. TestSINT is the falsifier — its row
DataType is Int32, identical to TestDINT's, so no reverse map could have produced
"SInt".

Known consequence: pre-existing browse-committed tags keep their wrong dataType;
the fix applies to new commits only.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-08-01 15:20:07 -04:00

153 lines
6.2 KiB
C#

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();
}
/// <summary>
/// A driver's OWN type name reaches the picker as the <c>dataType</c> 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
/// <see cref="AttributeInfo.DriverDataType"/>: that is the driver-agnostic type, and several
/// native types share one agnostic value.
/// </summary>
[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
}
/// <summary>
/// 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.
/// </summary>
[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<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();
}
}