687256d68e
v2-ci / unit-tests (push) Has been cancelled
- 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
208 lines
13 KiB
C#
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);
|
|
}
|
|
}
|