Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/WarmRedundancyWireTests.Deferred.cs
T
Joseph Doherty 687256d68e
v2-ci / unit-tests (push) Has been cancelled
test(717): deferred Warm coverage - 2-node handover, DataEncoding, deleted/reused items, clone matrix, ACL stand-down, per-counter metrics
- T3 WarmSingletonHandoverTests: real 2-node cluster, singleton under ClusterSingletonManager on both nodes; the old
  incarnation's held-back publish (older revision, higher sequence) reaches a real Warm OpcUaPublishActor after the
  handover and never demotes the new Primary. Pin: a removed node cannot rejoin from its old ActorSystem; a new one
  rejoins younger (higher up-number).
- T6 DataEncoding: Binary/XML encoded reads of a structured tag projected on the Standby; scalar encoding error kept;
  a DefaultXml item is projected, then promoted through its own encoding (TypeId i=888).
- T7 item deleted between queue and publish; ClientHandle reused by i=2267 on a payload recycled through the SDK 2.0.1
  pool (precondition asserted); WarmStandbyCloneMatrixTests over DataChange+DiagnosticInfos/EventNotificationList/
  StatusChange, clone/cached immutability, encode round-trip, real-pool stale marker.
- T8 ACL-stood-down item receives nothing from demotion or promotion; restored grant reaches it again.
- T10 per-counter MeterListener tests (by instrument instance): warm_transition, warm_role gauge, snapshot_rejected
  (publish actor both reasons + DriverHostActor's ordering gate, first actor test of it), projected_notifications,
  suppressed_alarm_events. Process-wide state tests run in non-parallel collections.
- Every test mutation-checked (26 mutations killed); no product bug found. Plan status updated.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 04:31:11 -05:00

208 lines
13 KiB
C#

using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Gitea #717 test depth deferred from the first landing (plan "Implementation status"): the DataEncoding read (T6),
/// a monitored item deleted between queue and publish (T7; the reused-payload case is in
/// <see cref="WarmRedundancyPooledWireTests"/>), and an ACL-stood-down item across a transition
/// (T8).
/// </summary>
public sealed partial class WarmRedundancyWireTests
{
// ------------------------------------------------------------------ T6 DataEncoding
private static readonly QualifiedName DefaultBinary = new(BrowseNames.DefaultBinary);
private static readonly QualifiedName DefaultXml = new(BrowseNames.DefaultXml);
private static async Task<DataValue> ReadEncodedAsync(ISession session, NodeId node, QualifiedName encoding, CancellationToken ct)
{
var r = await ((SessionClient)session).ReadAsync(H(), 0, TimestampsToReturn.Both,
new[] { new ReadValueId { NodeId = node, AttributeId = Attributes.Value, DataEncoding = encoding } }, ct);
return r.Results[0];
}
/// <summary>
/// Spec §4.4: a Read naming a DataEncoding is projected like any other value read on the Standby — a structured
/// value never leaves through the encoding path — while an encoding the value cannot take is still reported as
/// the base read's own error (the projection runs after it). A live item created with a DataEncoding is projected
/// too, and promotion queues it the current value read through the item's own encoding.
/// </summary>
[Fact]
public async Task A_DataEncoding_read_and_item_are_projected_on_the_Standby_and_read_through_the_encoding_once_promoted()
{
var ct = Deadline();
await using var rig = await StartModeAsync("Warm717Encoding", "Warm", ct);
var projection = rig.Server.CreateStandbyProjection()!;
var structured = rig.EnsureRawVariable("eu", "Structure", writable: false);
var scalar = rig.EnsureRawVariable("dbl", "Double", writable: false);
var eu = new EUInformation { NamespaceUri = "urn:717", UnitId = 4408652, DisplayName = new LocalizedText("degC") };
rig.Sink.WriteValue(structured, new ExtensionObject(eu), OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
rig.Sink.WriteValue(scalar, 3.5, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
using var session = await rig.ConnectAsync(anonymous: true, ct);
var standbyRead = await ReadEncodedAsync(session, Raw(session, structured), DefaultBinary, ct);
standbyRead.StatusCode.ShouldBe(StatusCodes.BadOutOfService);
standbyRead.WrappedValue.IsNull.ShouldBeTrue("the encoded structure must not leave the Standby");
(await ReadEncodedAsync(session, Raw(session, structured), DefaultXml, ct)).StatusCode.ShouldBe(StatusCodes.BadOutOfService);
var scalarWithEncoding = await ReadEncodedAsync(session, Raw(session, scalar), DefaultBinary, ct);
scalarWithEncoding.StatusCode.ShouldBe(StatusCodes.BadDataEncodingUnsupported,
"an encoding a scalar cannot take is the base read's own error, not masked as BadOutOfService");
scalarWithEncoding.WrappedValue.IsNull.ShouldBeTrue();
var client = (SessionClient)session;
var sub = (await client.CreateSubscriptionAsync(H(), 50, 300, 10, 0, true, 0, ct)).SubscriptionId;
var created = await client.CreateMonitoredItemsAsync(H(), sub, TimestampsToReturn.Both, new[]
{
new MonitoredItemCreateRequest
{
ItemToMonitor = new ReadValueId { NodeId = Raw(session, structured), AttributeId = Attributes.Value, DataEncoding = DefaultXml },
MonitoringMode = MonitoringMode.Reporting,
RequestedParameters = new MonitoringParameters { ClientHandle = 5, SamplingInterval = 0, QueueSize = 10, DiscardOldest = true },
},
}, ct);
StatusCode.IsGood(created.Results[0].StatusCode).ShouldBeTrue(created.Results[0].StatusCode.ToString());
var initial = await CollectAsync(client, ct, TimeSpan.FromSeconds(1));
initial.Where(x => x.Handle == 5).ShouldNotBeEmpty();
initial.Where(x => x.Handle == 5).ShouldAllBe(x => x.Value.StatusCode == StatusCodes.BadOutOfService && x.Value.WrappedValue.IsNull,
"the encoded item's initial sample is projected on the Standby");
Promote(projection);
var promoted = await CollectAsync(client, ct, TimeSpan.FromSeconds(1));
var good = promoted.Where(x => x.Handle == 5 && x.Value.StatusCode == StatusCodes.Good).ShouldHaveSingleItem(
"promotion queues the current value to the item through its own DataEncoding");
DecodeEu(good.Value).UnitId.ShouldBe(eu.UnitId);
((ExtensionObject)good.Value.WrappedValue.AsBoxedObject()!).TypeId.ShouldBe(
new ExpandedNodeId(ObjectIds.EUInformation_Encoding_DefaultXml), "the item's own (XML) encoding, not the default binary one");
var activeRead = await ReadEncodedAsync(session, Raw(session, structured), DefaultBinary, ct);
activeRead.StatusCode.ShouldBe(StatusCodes.Good);
DecodeEu(activeRead).DisplayName.Text.ShouldBe("degC");
((ExtensionObject)activeRead.WrappedValue.AsBoxedObject()!).TypeId.ShouldBe(new ExpandedNodeId(ObjectIds.EUInformation_Encoding_DefaultBinary));
}
private static EUInformation DecodeEu(DataValue value)
{
var body = value.WrappedValue.AsBoxedObject().ShouldBeOfType<ExtensionObject>();
body.TryGetValue<EUInformation>(out var eu).ShouldBeTrue();
return eu!;
}
// ------------------------------------------------------------------ T7 item deleted / reused between queue and publish
/// <summary>
/// An ordinary-tag item deleted after its Good samples were queued but before the Publish that would carry
/// them: the deleted item's notifications never surface, a sibling item queued in the same window is still
/// first-delivered projected (D4), and Republish returns the same recorded choice.
/// </summary>
[Fact]
public async Task An_item_deleted_between_queue_and_publish_leaves_nothing_behind_and_its_sibling_is_still_projected()
{
var ct = Deadline();
await using var rig = await StartModeAsync("Warm717Delete", "Warm", ct);
var projection = rig.Server.CreateStandbyProjection()!;
var gone = rig.EnsureRawVariable("gone", "Double", writable: false);
var kept = rig.EnsureRawVariable("kept", "Double", writable: false);
rig.Sink.WriteValue(gone, 1.0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
rig.Sink.WriteValue(kept, 1.0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
Promote(projection);
using var session = await rig.ConnectAsync(anonymous: true, ct);
var client = (SessionClient)session;
var sub = (await client.CreateSubscriptionAsync(H(), 50, 300, 10, 0, true, 0, ct)).SubscriptionId;
var goneItem = await AddItemAsync(client, sub, Raw(session, gone), 1, 10, ct);
await AddItemAsync(client, sub, Raw(session, kept), 2, 10, ct);
(await CollectAsync(client, ct, TimeSpan.FromSeconds(1))).Select(x => x.Handle).Distinct().Order().ShouldBe(new uint[] { 1, 2 });
await client.SetPublishingModeAsync(H(), false, new[] { sub }, ct);
rig.Sink.WriteValue(gone, 10.0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
rig.Sink.WriteValue(kept, 20.0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
rig.Sink.WriteValue(gone, 11.0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
var deleted = await client.DeleteMonitoredItemsAsync(H(), sub, new[] { goneItem }, ct);
StatusCode.IsGood(deleted.Results[0]).ShouldBeTrue();
DemoteDeliveryOnly(projection);
await client.SetPublishingModeAsync(H(), true, new[] { sub }, ct);
var msg = await PublishDataAsync(client, ct);
var changes = DataChanges(msg.NotificationMessage);
changes.ShouldNotContain(x => x.Handle == 1, "a deleted item's queued samples are never delivered");
changes.Where(x => x.Handle == 2).ShouldNotBeEmpty();
changes.Where(x => x.Handle == 2).ShouldAllBe(x => x.Value.StatusCode == StatusCodes.BadOutOfService && x.Value.WrappedValue.IsNull);
var again = await client.RepublishAsync(H(), sub, msg.NotificationMessage.SequenceNumber, ct);
DataChanges(again.NotificationMessage).Select(x => (x.Handle, x.Value.StatusCode))
.ShouldBe(changes.Select(x => (x.Handle, x.Value.StatusCode)), "Republish applies the recorded choice to the same payload");
}
// ------------------------------------------------------------------ T8 ACL-stood-down item on transition
/// <summary>
/// Plan T8: an item the ACL revalidation has stood down (#562) receives nothing from either transition — no
/// BadOutOfService on demotion and no current value on promotion — while a permitted sibling receives both. A
/// restored grant re-attaches the item and the next transition reaches it again (the skip is the stand-down,
/// not the node).
/// </summary>
[Fact]
public async Task An_ACL_stood_down_item_receives_nothing_from_a_demotion_or_a_promotion()
{
var ct = Deadline();
await using var rig = await StartModeAsync("Warm717AclLive", "Warm", ct);
var projection = rig.Server.CreateStandbyProjection()!;
rig.EnsureEquipment();
rig.Sink.EnsureVariable("EqA/a", EqA, "a", "Double", writable: false, AddressSpaceRealm.Uns);
rig.Sink.EnsureVariable("EqB/b", EqB, "b", "Double", writable: false, AddressSpaceRealm.Uns);
rig.Sink.WriteValue("EqA/a", 1.0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
rig.Sink.WriteValue("EqB/b", 1.0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
const NodePermissions DataRead = NodePermissions.Browse | NodePermissions.Read | NodePermissions.Subscribe;
rig.Enforce(true, [Key()], EquipmentGrant(EqA, DataRead), EquipmentGrant(EqB, DataRead));
Promote(projection);
using var session = await rig.ConnectAsync(anonymous: true, ct);
var client = (SessionClient)session;
var sub = (await client.CreateSubscriptionAsync(H(), 50, 300, 10, 0, true, 0, ct)).SubscriptionId;
await AddItemAsync(client, sub, UnsNodeId(session, "EqA/a"), 1, 10, ct);
await AddItemAsync(client, sub, UnsNodeId(session, "EqB/b"), 2, 10, ct);
(await CollectAsync(client, ct, TimeSpan.FromSeconds(1))).Select(x => x.Handle).Distinct().Order().ShouldBe(new uint[] { 1, 2 });
rig.Enforce(true, [Key()], EquipmentGrant(EqA, DataRead)); // EqB revoked
rig.Nm.RevalidateMonitoredItems();
var revoked = await CollectAsync(client, ct, TimeSpan.FromSeconds(1));
revoked.ShouldContain(x => x.Handle == 2 && x.Value.StatusCode == StatusCodes.BadUserAccessDenied, "the item is told, then stood down");
// Demotion, in the spec §4.7 order, counting what step 5 queued.
projection.SetStandbyDelivery(true);
projection.SetAlarmSuppression(true);
projection.SetDataProjection(true);
projection.QueueCurrentToLiveItems(true).ShouldBe(1, "only the permitted item is queued to");
rig.Sink.WriteValue("EqB/b", 5.0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
var demoted = await CollectAsync(client, ct, TimeSpan.FromSeconds(1));
demoted.Where(x => x.Handle == 1).ShouldHaveSingleItem().Value.StatusCode.ShouldBe(StatusCodes.BadOutOfService);
demoted.ShouldNotContain(x => x.Handle == 2, "a stood-down item receives no demotion value");
rig.Sink.WriteValue("EqA/a", 7.0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
rig.Sink.WriteValue("EqB/b", 8.0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns);
projection.SetDataProjection(false);
projection.QueueCurrentToLiveItems(false).ShouldBe(1);
projection.SetStandbyDelivery(false);
projection.SetAlarmSuppression(false);
projection.RefreshAllEventItems();
var promoted = await CollectAsync(client, ct, TimeSpan.FromSeconds(1));
promoted.ShouldContain(x => x.Handle == 1 && x.Value.StatusCode == StatusCodes.Good && Equals(x.Value.ClrValue(), 7.0));
promoted.ShouldNotContain(x => x.Handle == 2, "a stood-down item receives no promotion value");
// Positive control: the restored grant re-attaches the item and the next demotion reaches it.
rig.Enforce(true, [Key()], EquipmentGrant(EqA, DataRead), EquipmentGrant(EqB, DataRead));
rig.Nm.RevalidateMonitoredItems();
await CollectAsync(client, ct, TimeSpan.FromMilliseconds(500));
Demote(projection);
var restored = await CollectAsync(client, ct, TimeSpan.FromSeconds(1));
restored.ShouldContain(x => x.Handle == 2 && x.Value.StatusCode == StatusCodes.BadOutOfService);
}
}