test: preserve server SDK 2.0 value security and history contracts
This commit is contained in:
@@ -74,7 +74,7 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
||||
var ctx = SessionContext(server, "bob", "ReadOnly"); // no AlarmAck
|
||||
var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("nope"));
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
captured.ShouldBeEmpty();
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -100,7 +100,7 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
||||
var ctx = new ServerSystemContext(server.CurrentInstance);
|
||||
var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("x"));
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
captured.ShouldBeEmpty();
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -122,7 +122,7 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
||||
condition!.OnConfirm.ShouldNotBeNull();
|
||||
|
||||
var ctx = SessionContext(server, "carol", OpcUaDataPlaneRoles.AlarmAck);
|
||||
var result = condition.OnConfirm!(ctx, condition, EventIdBytes(), comment: null);
|
||||
var result = condition.OnConfirm!(ctx, condition, EventIdBytes(), comment: default);
|
||||
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
captured.Count.ShouldBe(1);
|
||||
@@ -262,7 +262,7 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
||||
var ctx = SessionContext(server, "heidi", "ReadOnly"); // no AlarmAck
|
||||
var result = condition!.OnShelve!(ctx, condition, shelving: true, oneShot: true, shelvingTime: 0.0);
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
captured.ShouldBeEmpty();
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -383,7 +383,7 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
||||
var ctx = new ServerSystemContext(server.CurrentInstance);
|
||||
var result = condition!.OnEnableDisable!(ctx, condition, enabling: false);
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
captured.ShouldBeEmpty();
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -409,7 +409,7 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
||||
var ctx = SessionContext(server, "kara", OpcUaDataPlaneRoles.AlarmAck);
|
||||
var result = condition.OnEnableDisable!(ctx, condition, enabling: false);
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadNotSupported);
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadNotSupported.Code);
|
||||
captured.ShouldBeEmpty();
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -478,7 +478,7 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
||||
var ctx = SessionContext(server, "quinn", OpcUaDataPlaneRoles.AlarmAck);
|
||||
var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("ack"));
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
result.LocalizedText.Text.ShouldBe("no Galaxy ack account for an anonymous session");
|
||||
native.ShouldBeEmpty();
|
||||
checkedAcks.ShouldHaveSingleItem().ConditionNodeId.ShouldBe("alm-nak5");
|
||||
@@ -547,7 +547,7 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
||||
var ctx = SessionContext(server, "pete", "ReadOnly"); // non-null identity, but no AlarmAck
|
||||
var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("nope"));
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
native.ShouldBeEmpty();
|
||||
scripted.ShouldBeEmpty();
|
||||
|
||||
@@ -576,7 +576,7 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
||||
var ctx = new ServerSystemContext(server.CurrentInstance);
|
||||
var result = condition!.OnAcknowledge!(ctx, condition, EventIdBytes(), new LocalizedText("x"));
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
native.ShouldBeEmpty();
|
||||
scripted.ShouldBeEmpty();
|
||||
|
||||
@@ -727,7 +727,7 @@ public sealed class AlarmCommandRouterTests : IDisposable
|
||||
return new ServerSystemContext(server.CurrentInstance) { UserIdentity = identity };
|
||||
}
|
||||
|
||||
private static byte[] EventIdBytes() => Guid.NewGuid().ToByteArray();
|
||||
private static ByteString EventIdBytes() => ByteString.From(Guid.NewGuid().ToByteArray());
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
{
|
||||
|
||||
@@ -544,7 +544,7 @@ public sealed class DataPlaneAuthorizerTests
|
||||
private static void Veto(ServiceResult? result)
|
||||
{
|
||||
result.ShouldNotBeNull();
|
||||
result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
}
|
||||
|
||||
private static DataPlaneAuthorizer Gate(
|
||||
|
||||
@@ -32,7 +32,7 @@ public sealed class EquipmentWriteGateTests
|
||||
gatewayWired: true);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
}
|
||||
|
||||
/// <summary>(b) An identity WITHOUT the <c>WriteOperate</c> role is denied with
|
||||
@@ -46,7 +46,7 @@ public sealed class EquipmentWriteGateTests
|
||||
gatewayWired: true);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
result!.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
}
|
||||
|
||||
/// <summary>(c) An identity WITH the <c>WriteOperate</c> role and a wired gateway passes the gate
|
||||
@@ -74,8 +74,8 @@ public sealed class EquipmentWriteGateTests
|
||||
gatewayWired: false);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result!.StatusCode.Code.ShouldBe(StatusCodes.BadNotWritable);
|
||||
result.LocalizedText.Text.ShouldContain("writes unavailable");
|
||||
result!.StatusCode.Code.ShouldBe(StatusCodes.BadNotWritable.Code);
|
||||
result.LocalizedText.Text!.ShouldContain("writes unavailable");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -118,7 +118,7 @@ public sealed class EquipmentWriteGateTests
|
||||
var result = OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: null, node);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result!.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch);
|
||||
result!.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch.Code);
|
||||
}
|
||||
|
||||
/// <summary>(A2) A type-matching value (Int32 payload into an Int32 node) proceeds (returns <c>null</c>).</summary>
|
||||
@@ -149,7 +149,7 @@ public sealed class EquipmentWriteGateTests
|
||||
var result = OtOpcUaNodeManager.EvaluateEquipmentWriteStructure(value: "not-a-bool", node);
|
||||
|
||||
result.ShouldNotBeNull();
|
||||
result!.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch);
|
||||
result!.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch.Code);
|
||||
}
|
||||
|
||||
/// <summary>(A5) Confidence-gated defer: a node whose DataType is the abstract <c>BaseDataType</c>
|
||||
@@ -341,7 +341,7 @@ public sealed class EquipmentWriteGateTests
|
||||
[Fact]
|
||||
public void Range_only_whole_value_writes_proceed()
|
||||
{
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteRange(NumericRange.Empty).ShouldBeNull();
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteRange(default(NumericRange)).ShouldBeNull();
|
||||
OtOpcUaNodeManager.EvaluateEquipmentWriteRange(NumericRange.Parse("1"))!.StatusCode
|
||||
.ShouldBe((StatusCode)StatusCodes.BadWriteNotSupported);
|
||||
}
|
||||
@@ -350,7 +350,7 @@ public sealed class EquipmentWriteGateTests
|
||||
{
|
||||
var node = ValueNode(dataType);
|
||||
node.ValueRank = ValueRanks.OneDimension;
|
||||
node.ArrayDimensions = new ReadOnlyList<uint>(new UInt32Collection(new[] { length }));
|
||||
node.ArrayDimensions = (ArrayOf<uint>)[length];
|
||||
return node;
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@ public sealed class EquipmentWriteGateTests
|
||||
{
|
||||
NodeId = new NodeId("eq/x", 2),
|
||||
BrowseName = new QualifiedName("x", 2),
|
||||
DisplayName = "x",
|
||||
DisplayName = new LocalizedText("x"),
|
||||
DataType = dataType,
|
||||
ValueRank = ValueRanks.Scalar,
|
||||
};
|
||||
|
||||
@@ -57,16 +57,16 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
|
||||
var (results, errors) = Insert(nm, KeyIdentity(), nodeId, Value(T1, 42.5));
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
results[0].OperationResults.ToArray()!.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted.Code]);
|
||||
var call = updater.UpdateCalls.ShouldHaveSingleItem();
|
||||
call.Tag.ShouldBe(Tagname);
|
||||
call.Mode.ShouldBe(HistoryUpdateMode.Insert);
|
||||
var sent = call.Values.ShouldHaveSingleItem();
|
||||
sent.TimestampUtc.ShouldBe(T1);
|
||||
sent.Value.ShouldBe(HistorianValue.FromDouble(42.5));
|
||||
sent.StatusCode.ShouldBe(StatusCodes.Good);
|
||||
sent.StatusCode.ShouldBe(StatusCodes.Good.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -86,8 +86,8 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
|
||||
var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
updater.UpdateCalls.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -108,8 +108,8 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
|
||||
var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
results[0].OperationResults.ToArray()!.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted.Code]);
|
||||
updater.UpdateCalls.Count.ShouldBe(1);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -130,8 +130,8 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
|
||||
var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.WriteOperate), nodeId, Value(T1, 1d));
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
updater.UpdateCalls.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -155,12 +155,12 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
var identity = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate);
|
||||
|
||||
Insert(nm, identity, nodeId, Value(T1, 1d)).Errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
Insert(nm, identity, nodeId, Value(T1, 1d)).Errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
|
||||
clock.Advance(TimeSpan.FromMinutes(20));
|
||||
|
||||
Insert(nm, identity, nodeId, Value(T2, 2d)).Errors[0].StatusCode.Code
|
||||
.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
updater.UpdateCalls.Count.ShouldBe(1);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -184,12 +184,12 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
|
||||
var denied = Insert(nm, AnonymousIdentity(OpcUaDataPlaneRoles.WriteOperate), nodeId, Value(T1, 1d));
|
||||
denied.Errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
denied.Results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
denied.Errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
denied.Results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
updater.UpdateCalls.ShouldBeEmpty();
|
||||
|
||||
var allowed = Insert(nm, AnonymousIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T2, 2d));
|
||||
allowed.Errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
allowed.Errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
updater.UpdateCalls.Count.ShouldBe(1);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -217,7 +217,7 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
(variable.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0);
|
||||
|
||||
var (_, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -248,7 +248,7 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
.ShouldBe((byte)0, "clearing the updater retracts the bit");
|
||||
var (_, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
updater.UpdateCalls.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -279,8 +279,8 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
|
||||
var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "set by the override, not left for the master node manager");
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code, "set by the override, not left for the master node manager");
|
||||
updater.UpdateCalls.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -351,8 +351,8 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
|
||||
var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadInternalError);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadInternalError);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadInternalError.Code);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadInternalError.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -378,7 +378,7 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
{
|
||||
var (results, _) = Call(nm, identity, typeof(DeleteRawModifiedDetails),
|
||||
new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = start, EndTime = end, IsDeleteModified = false });
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidArgument);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidArgument.Code);
|
||||
}
|
||||
|
||||
updater.DeleteRawCalls.ShouldBeEmpty();
|
||||
@@ -402,30 +402,30 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
var updater = new FakeUpdater
|
||||
{
|
||||
UpdateCodes = [StatusCodes.GoodEntryInserted, StatusCodes.BadEntryExists],
|
||||
UpdateCodes = [StatusCodes.GoodEntryInserted.Code, StatusCodes.BadEntryExists.Code],
|
||||
};
|
||||
nm.HistorianDataUpdater = updater;
|
||||
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
|
||||
var bad = new DataValue(new Variant(new[] { 1d, 2d })) { SourceTimestamp = T2 };
|
||||
var bad = new DataValue(new Variant(new[] { 1d, 2d }), StatusCodes.Good, T2);
|
||||
var noTime = new DataValue(new Variant(7d)); // SourceTimestamp defaults to DateTime.MinValue
|
||||
var uncertain = new DataValue(new Variant(3)) { SourceTimestamp = T2, StatusCode = StatusCodes.UncertainLastUsableValue };
|
||||
var uncertain = new DataValue(new Variant(3), StatusCodes.UncertainLastUsableValue, T2);
|
||||
|
||||
var (results, errors) = Update(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId,
|
||||
PerformUpdateType.Insert, Value(T1, 1d), noTime, bad, uncertain);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].OperationResults.Select(c => c.Code).ShouldBe(
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
results[0].OperationResults.ToArray()!.Select(c => c.Code).ShouldBe(
|
||||
[
|
||||
StatusCodes.GoodEntryInserted, StatusCodes.BadInvalidTimestamp, StatusCodes.BadTypeMismatch,
|
||||
StatusCodes.BadEntryExists,
|
||||
StatusCodes.GoodEntryInserted.Code, StatusCodes.BadInvalidTimestamp.Code, StatusCodes.BadTypeMismatch.Code,
|
||||
StatusCodes.BadEntryExists.Code,
|
||||
]);
|
||||
var call = updater.UpdateCalls.ShouldHaveSingleItem();
|
||||
call.Values.Count.ShouldBe(2);
|
||||
call.Values[1].Value.ShouldBe(HistorianValue.FromInt64(3));
|
||||
call.Values[1].StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue);
|
||||
call.Values[1].StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -449,7 +449,7 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
var (_, removeErrors) = Update(nm, identity, nodeId, PerformUpdateType.Remove, Value(T1, 1d));
|
||||
|
||||
updater.UpdateCalls.Select(c => c.Mode).ShouldBe([HistoryUpdateMode.Replace, HistoryUpdateMode.Update]);
|
||||
removeErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
removeErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -470,7 +470,7 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
|
||||
var (_, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), unsNodeId, Value(T1, 5d));
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
updater.UpdateCalls.ShouldHaveSingleItem().Tag.ShouldBe(Tagname);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -484,7 +484,7 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var updater = new FakeUpdater { DeleteRawCode = StatusCodes.BadNoData };
|
||||
var updater = new FakeUpdater { DeleteRawCode = StatusCodes.BadNoData.Code };
|
||||
nm.HistorianDataUpdater = updater;
|
||||
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
@@ -495,10 +495,10 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
var (_, modifiedErrors) = Call(nm, identity, typeof(DeleteRawModifiedDetails),
|
||||
new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = true });
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData.Code);
|
||||
updater.DeleteRawCalls.ShouldHaveSingleItem().ShouldBe((Tagname, T1, T2));
|
||||
modifiedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
modifiedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -524,7 +524,7 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
Call(nm, identity, typeof(DeleteRawModifiedDetails),
|
||||
new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = false });
|
||||
Call(nm, identity, typeof(DeleteAtTimeDetails),
|
||||
new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1 } });
|
||||
new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new List<DateTimeUtc> { T1 } });
|
||||
|
||||
updater.Callers.Count.ShouldBe(3);
|
||||
updater.Callers.ShouldAllBe(c => c == new HistoryUpdateContext(Principal, KeyId));
|
||||
@@ -547,7 +547,7 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
|
||||
var (_, errors) = Insert(nm, AnonymousIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
updater.Callers.ShouldHaveSingleItem().ShouldBe(new HistoryUpdateContext(OpcUaPrincipals.Anonymous, null));
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -561,16 +561,16 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
try
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var updater = new FakeUpdater { DeleteAtTimeCodes = [StatusCodes.Good, StatusCodes.BadNoEntryExists] };
|
||||
var updater = new FakeUpdater { DeleteAtTimeCodes = [StatusCodes.Good.Code, StatusCodes.BadNoEntryExists.Code] };
|
||||
nm.HistorianDataUpdater = updater;
|
||||
WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate);
|
||||
var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath);
|
||||
|
||||
var (results, errors) = Call(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), typeof(DeleteAtTimeDetails),
|
||||
new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1, T2 } });
|
||||
new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new List<DateTimeUtc> { T1, T2 } });
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.Good, StatusCodes.BadNoEntryExists]);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
results[0].OperationResults.ToArray()!.Select(c => c.Code).ShouldBe([StatusCodes.Good.Code, StatusCodes.BadNoEntryExists.Code]);
|
||||
updater.DeleteAtTimeCalls.ShouldHaveSingleItem().ShouldBe([T1, T2]);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -592,7 +592,7 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
var (_, errors) = Call(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), typeof(UpdateEventDetails),
|
||||
new UpdateEventDetails { NodeId = nodeId, PerformInsertReplace = PerformUpdateType.Insert });
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
updater.UpdateCalls.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -624,11 +624,11 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
PerformUpdateType.Update, Value(T1, 1d), Value(atBound.AddTicks(1), 2d), Value(atBound, 3d),
|
||||
Value(new DateTime(2062, 1, 1, 0, 0, 0, DateTimeKind.Utc), 4d));
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].OperationResults.Select(c => c.Code).ShouldBe(
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
results[0].OperationResults.ToArray()!.Select(c => c.Code).ShouldBe(
|
||||
[
|
||||
StatusCodes.GoodEntryInserted, StatusCodes.BadOutOfRange, StatusCodes.GoodEntryInserted,
|
||||
StatusCodes.BadOutOfRange,
|
||||
StatusCodes.GoodEntryInserted.Code, StatusCodes.BadOutOfRange.Code, StatusCodes.GoodEntryInserted.Code,
|
||||
StatusCodes.BadOutOfRange.Code,
|
||||
]);
|
||||
updater.UpdateCalls.ShouldHaveSingleItem().Values.Select(v => v.TimestampUtc).ShouldBe([T1, atBound]);
|
||||
}
|
||||
@@ -656,7 +656,7 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
var (results, _) = Update(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, type,
|
||||
Value(Start.AddDays(1).AddSeconds(1), 1d));
|
||||
|
||||
results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.BadOutOfRange]);
|
||||
results[0].OperationResults.ToArray()!.Select(c => c.Code).ShouldBe([StatusCodes.BadOutOfRange.Code]);
|
||||
updater.UpdateCalls.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -680,7 +680,7 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
|
||||
var (results, _) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(farFuture, 1d));
|
||||
|
||||
results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
|
||||
results[0].OperationResults.ToArray()!.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted.Code]);
|
||||
updater.UpdateCalls.ShouldHaveSingleItem().Values.ShouldHaveSingleItem().TimestampUtc.ShouldBe(farFuture);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -711,12 +711,12 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
var (rawResults, rawErrors) = Call(nm, identity, typeof(DeleteRawModifiedDetails),
|
||||
new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = false });
|
||||
var (atResults, atErrors) = Call(nm, identity, typeof(DeleteAtTimeDetails),
|
||||
new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1 } });
|
||||
new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new List<DateTimeUtc> { T1 } });
|
||||
|
||||
foreach (var (r, e) in new[] { (results, errors), (rawResults, rawErrors), (atResults, atErrors) })
|
||||
{
|
||||
e[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState);
|
||||
r[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState);
|
||||
e[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState.Code);
|
||||
r[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState.Code);
|
||||
}
|
||||
|
||||
consulted.ShouldBe(3);
|
||||
@@ -747,8 +747,8 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
|
||||
var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d));
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
results[0].OperationResults.ToArray()!.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted.Code]);
|
||||
updater.UpdateCalls.ShouldHaveSingleItem().Tag.ShouldBe(Tagname);
|
||||
consulted.ShouldBe(1);
|
||||
}
|
||||
@@ -775,7 +775,7 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
|
||||
var (results, _) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.WriteOperate), nodeId, Value(T1, 1d));
|
||||
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
consulted.ShouldBe(0);
|
||||
updater.UpdateCalls.ShouldBeEmpty();
|
||||
}
|
||||
@@ -805,13 +805,13 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
var deleteRaw = Call(nm, identity, typeof(DeleteRawModifiedDetails),
|
||||
new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = false });
|
||||
var deleteAt = Call(nm, identity, typeof(DeleteAtTimeDetails),
|
||||
new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new DateTimeCollection { T1 } });
|
||||
new DeleteAtTimeDetails { NodeId = nodeId, ReqTimes = new List<DateTimeUtc> { T1 } });
|
||||
|
||||
foreach (var (results, errors) in new[] { update, deleteRaw, deleteAt })
|
||||
{
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
(results[0].OperationResults?.Count ?? 0).ShouldBe(0);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
(results[0].OperationResults.Count).ShouldBe(0);
|
||||
}
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -842,14 +842,14 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
var (modResults, modErrors) = Call(nm, identity, typeof(DeleteRawModifiedDetails),
|
||||
new DeleteRawModifiedDetails { NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = true });
|
||||
|
||||
removeErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
removeResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
modErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
modResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
removeErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
removeResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
modErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
modResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
consulted.ShouldBe(0);
|
||||
|
||||
var (_, servableErrors) = Insert(nm, identity, nodeId, Value(T1, 1d));
|
||||
servableErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState);
|
||||
servableErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadInvalidState.Code);
|
||||
consulted.ShouldBe(1);
|
||||
updater.UpdateCalls.ShouldBeEmpty();
|
||||
}
|
||||
@@ -883,8 +883,8 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
var (results, _) = Update(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, PerformUpdateType.Update,
|
||||
LocalValue(justPast, 1d), LocalValue(justInside, 2d));
|
||||
|
||||
results[0].OperationResults.Select(c => c.Code)
|
||||
.ShouldBe([StatusCodes.BadOutOfRange, StatusCodes.GoodEntryInserted]);
|
||||
results[0].OperationResults.ToArray()!.Select(c => c.Code)
|
||||
.ShouldBe([StatusCodes.BadOutOfRange.Code, StatusCodes.GoodEntryInserted.Code]);
|
||||
var sent = updater.UpdateCalls.ShouldHaveSingleItem().Values.ShouldHaveSingleItem();
|
||||
sent.TimestampUtc.ShouldBe(bound.AddTicks(-1));
|
||||
sent.TimestampUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
|
||||
@@ -894,15 +894,14 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
|
||||
private static DataValue LocalValue(DateTime local, double value)
|
||||
{
|
||||
var dv = new DataValue(new Variant(value)) { StatusCode = StatusCodes.Good };
|
||||
dv.SourceTimestamp = local;
|
||||
var dv = new DataValue(new Variant(value), StatusCodes.Good, local);
|
||||
return dv;
|
||||
}
|
||||
|
||||
// ───────────────────────────── helpers ─────────────────────────────
|
||||
|
||||
private static DataValue Value(DateTime timestamp, object value) =>
|
||||
new(new Variant(value)) { SourceTimestamp = timestamp, StatusCode = StatusCodes.Good };
|
||||
new(SdkValueBoundary.FromClr(value), StatusCodes.Good, timestamp);
|
||||
|
||||
private static NodeId Historized(OtOpcUaNodeManager nm, AddressSpaceRealm realm, string id)
|
||||
{
|
||||
@@ -922,7 +921,7 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
{
|
||||
NodeId = nodeId,
|
||||
PerformInsertReplace = type,
|
||||
UpdateValues = new DataValueCollection(values),
|
||||
UpdateValues = new List<DataValue>(values),
|
||||
});
|
||||
|
||||
/// <summary>Drives the SDK's PUBLIC HistoryUpdate entry point (base admission + Lock + dispatch).</summary>
|
||||
@@ -930,7 +929,7 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
OtOpcUaNodeManager nm, IUserIdentity identity, Type detailsType, HistoryUpdateDetails details)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryUpdate, identity);
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryUpdate, requestLifetime: RequestLifetime.None, identity);
|
||||
var nodesToUpdate = new List<HistoryUpdateDetails> { details };
|
||||
var results = new List<HistoryUpdateResult> { null! };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
@@ -988,7 +987,7 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
public List<(string Tag, DateTime Start, DateTime End)> DeleteRawCalls { get; } = [];
|
||||
public List<IReadOnlyList<DateTime>> DeleteAtTimeCalls { get; } = [];
|
||||
public uint[]? UpdateCodes { get; init; }
|
||||
public uint DeleteRawCode { get; init; } = StatusCodes.Good;
|
||||
public uint DeleteRawCode { get; init; } = StatusCodes.Good.Code;
|
||||
public uint[]? DeleteAtTimeCodes { get; init; }
|
||||
public Exception? Throw { get; init; }
|
||||
|
||||
@@ -999,7 +998,7 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
await Task.Delay(1, ct).ConfigureAwait(false);
|
||||
if (Throw is not null) throw Throw;
|
||||
lock (UpdateCalls) { UpdateCalls.Add((tag, mode, values.ToList())); Callers.Add(context); }
|
||||
return UpdateCodes ?? Enumerable.Repeat(StatusCodes.GoodEntryInserted, values.Count).ToArray();
|
||||
return UpdateCodes ?? Enumerable.Repeat(StatusCodes.GoodEntryInserted.Code, values.Count).ToArray();
|
||||
}
|
||||
|
||||
public async Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context,
|
||||
@@ -1017,7 +1016,7 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
await Task.Delay(1, ct).ConfigureAwait(false);
|
||||
if (Throw is not null) throw Throw;
|
||||
lock (DeleteAtTimeCalls) { DeleteAtTimeCalls.Add(timesUtc.ToList()); lock (UpdateCalls) Callers.Add(context); }
|
||||
return DeleteAtTimeCodes ?? Enumerable.Repeat(StatusCodes.Good, timesUtc.Count).ToArray();
|
||||
return DeleteAtTimeCodes ?? Enumerable.Repeat(StatusCodes.Good.Code, timesUtc.Count).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
public sealed class HistoryPagingTests
|
||||
{
|
||||
private static DataValueSnapshot Sample(double v, DateTime src) =>
|
||||
new(v, StatusCodes.Good, src, src);
|
||||
new(v, StatusCodes.Good.Code, src, src);
|
||||
|
||||
// --- IsFullPage -----------------------------------------------------------------------------
|
||||
|
||||
@@ -89,7 +89,7 @@ public sealed class HistoryPagingTests
|
||||
[Fact]
|
||||
public void ComputeResumeCursor_null_source_timestamp_falls_back_to_min_value()
|
||||
{
|
||||
var page = new[] { new DataValueSnapshot(1.0, StatusCodes.Bad, null, DateTime.UtcNow) };
|
||||
var page = new[] { new DataValueSnapshot(1.0, StatusCodes.Bad.Code, null, DateTime.UtcNow) };
|
||||
|
||||
HistoryPaging.ComputeResumeCursor(page, out var nextStart, out var skip);
|
||||
|
||||
|
||||
+3
-3
@@ -14,15 +14,15 @@ public sealed class HistoryServerCapabilitiesAdvertisementTests(BoundsServerFixt
|
||||
{
|
||||
private async Task<uint> ReadAdvertisedAsync()
|
||||
{
|
||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.Read, identity: null);
|
||||
var nodesToRead = new ReadValueIdCollection
|
||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.Read, requestLifetime: RequestLifetime.None, identity: null);
|
||||
var nodesToRead = new List<ReadValueId>
|
||||
{
|
||||
new ReadValueId { NodeId = VariableIds.HistoryServerCapabilities_MaxReturnDataValues, AttributeId = Attributes.Value },
|
||||
};
|
||||
var (values, _) = await fx.Nm.Server.NodeManager.ReadAsync(context, 0, TimestampsToReturn.Neither, nodesToRead, TestContext.Current.CancellationToken);
|
||||
values.Count.ShouldBe(1);
|
||||
StatusCode.IsGood(values[0].StatusCode).ShouldBeTrue(values[0].StatusCode.ToString());
|
||||
return (uint)values[0].Value;
|
||||
return (uint)SdkValueBoundary.ToClr(values[0].WrappedValue)!;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+18
-18
@@ -54,7 +54,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable
|
||||
|
||||
var (_, errors) = ReadRaw(nm, identity: null, nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -81,10 +81,10 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable
|
||||
|
||||
var (results, errors) = ReadRaw(nm, Identity(), nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
// Not BadHistoryOperationUnsupported: "you may not read this" and "this node has no history"
|
||||
// are different facts, and reporting the wrong one sends an operator debugging historisation.
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -106,16 +106,16 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable
|
||||
|
||||
nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.Read));
|
||||
var (deniedResults, deniedErrors) = ReadRaw(nm, Identity(), modified: true, nodeId);
|
||||
deniedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
deniedResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
deniedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
deniedResults[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
|
||||
nm.Authorizer = Authorizer(enabled: true, GrantOn(NodePermissions.HistoryRead));
|
||||
var (allowedResults, allowedErrors) = ReadRaw(nm, Identity(), modified: true, nodeId);
|
||||
// A real trail entry must come back: Null's BadHistoryOperationUnsupported is also what the SDK pre-seeds,
|
||||
// so asserting it would pass even if the grant never reached the modified branch.
|
||||
allowedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
allowedResults[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
var data = ExtensionObject.ToEncodeable(allowedResults[0].HistoryData).ShouldBeOfType<HistoryModifiedData>();
|
||||
allowedErrors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
allowedResults[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
var data = ExtensionObject.ToEncodeable(allowedResults[0].HistoryData)!.ShouldBeOfType<HistoryModifiedData>();
|
||||
data.DataValues.Count.ShouldBe(1);
|
||||
data.ModificationInfos[0].UserName.ShouldBe("alice");
|
||||
}
|
||||
@@ -136,7 +136,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable
|
||||
|
||||
var (_, errors) = ReadRaw(nm, Identity(), nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -177,8 +177,8 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable
|
||||
|
||||
var (_, errors) = ReadRaw(nm, Identity(), allowed, denied);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -235,7 +235,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable
|
||||
|
||||
var errors = Write(nm, Identity(), nodeId, 42f);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
gateway.Writes.ShouldHaveSingleItem();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -260,7 +260,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable
|
||||
|
||||
var errors = Write(nm, Identity(), nodeId, 42f);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
gateway.Writes.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -284,7 +284,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable
|
||||
|
||||
var errors = Write(nm, Identity(), nodeId, 42f);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
gateway.Writes.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -303,7 +303,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable
|
||||
OtOpcUaNodeManager nm, IUserIdentity? identity, NodeId nodeId, object value)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.Write, identity);
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.Write, requestLifetime: RequestLifetime.None, identity);
|
||||
|
||||
var writes = new List<WriteValue>
|
||||
{
|
||||
@@ -311,7 +311,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable
|
||||
{
|
||||
NodeId = nodeId,
|
||||
AttributeId = Attributes.Value,
|
||||
Value = new DataValue(new Variant(value)) { StatusCode = StatusCodes.Good },
|
||||
Value = new DataValue(SdkValueBoundary.FromClr(value), StatusCodes.Good),
|
||||
},
|
||||
};
|
||||
var errors = Enumerable.Repeat<ServiceResult>(null!, writes.Count).ToList();
|
||||
@@ -341,7 +341,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var t = DateTime.UtcNow.AddMinutes(-5);
|
||||
var prior = new DataValueSnapshot(1.5, StatusCodes.Good, t, t);
|
||||
var prior = new DataValueSnapshot(1.5, StatusCodes.Good.Code, t, t);
|
||||
return Task.FromResult(new HistoryModifiedReadResult(
|
||||
[new HistoryModifiedValue(prior, DateTime.UtcNow, HistoryModificationType.Replace, "alice", 1, t)], false));
|
||||
}
|
||||
@@ -374,7 +374,7 @@ public sealed class NodeManagerAclEnforcementTests : IDisposable
|
||||
OtOpcUaNodeManager nm, IUserIdentity? identity, bool modified, params NodeId[] nodeIds)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity);
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity);
|
||||
|
||||
var nodesToRead = nodeIds.Select(id => new HistoryReadValueId { NodeId = id }).ToList();
|
||||
var results = Enumerable.Repeat<SdkHistoryReadResult>(null!, nodeIds.Length).ToList();
|
||||
|
||||
+15
-15
@@ -62,16 +62,16 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
|
||||
// EventType — the concrete condition type, so a client reading the FIELD (not just an OfType
|
||||
// where-clause) can resolve the type.
|
||||
condition.EventType.ShouldNotBeNull();
|
||||
condition.EventType.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
|
||||
condition.EventType!.Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
|
||||
|
||||
// SourceNode — the condition's own NodeId (it IS the source: no separate value variable exists
|
||||
// for an alarm-bearing raw tag). Equal to ConditionId.
|
||||
condition.SourceNode.ShouldNotBeNull();
|
||||
condition.SourceNode.Value.ShouldBe(new NodeId(RawAlarmPath, rawNs));
|
||||
condition.SourceNode!.Value.ShouldBe(new NodeId(RawAlarmPath, rawNs));
|
||||
|
||||
// SourceName — the RawPath: unique across devices, matching ConditionId/SourceNode.
|
||||
condition.SourceName.ShouldNotBeNull();
|
||||
condition.SourceName.Value.ShouldBe(RawAlarmPath);
|
||||
condition.SourceName!.Value.ShouldBe(RawAlarmPath);
|
||||
|
||||
// The leaf name is NOT lost — it stays on ConditionName.
|
||||
condition.ConditionName!.Value.ShouldBe("HR200");
|
||||
@@ -169,11 +169,11 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
condition.ConditionClassId.ShouldNotBeNull();
|
||||
condition.ConditionClassId.Value.ShouldBe(ObjectTypeIds.BaseConditionClassType);
|
||||
condition.ConditionClassId!.Value.ShouldBe(ObjectTypeIds.BaseConditionClassType);
|
||||
|
||||
condition.ConditionClassName.ShouldNotBeNull();
|
||||
condition.ConditionClassName.Value.ShouldNotBeNull();
|
||||
condition.ConditionClassName.Value.Text.ShouldBe("BaseConditionClass");
|
||||
condition.ConditionClassName!.Value.IsNull.ShouldBeFalse();
|
||||
condition.ConditionClassName!.Value.Text.ShouldBe("BaseConditionClass");
|
||||
}
|
||||
|
||||
/// <summary>#475 — a SCRIPTED condition (UNS realm) carries the SAME classification fields: native and scripted
|
||||
@@ -213,8 +213,8 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
|
||||
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
|
||||
condition.ShouldNotBeNull();
|
||||
condition.Quality.ShouldNotBeNull();
|
||||
condition.Quality.Value.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData);
|
||||
StatusCode.IsGood(condition.Quality.Value).ShouldBeFalse();
|
||||
condition.Quality!.Value.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData);
|
||||
StatusCode.IsGood(condition.Quality!.Value).ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>#477 — a SCRIPTED condition is script-computed and always "live" in v1, so it materialises Good.
|
||||
@@ -233,7 +233,7 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
|
||||
var condition = nm.TryGetAlarmCondition("scripted-q", AddressSpaceRealm.Uns);
|
||||
condition.ShouldNotBeNull();
|
||||
condition.Quality.ShouldNotBeNull();
|
||||
StatusCode.IsGood(condition.Quality.Value).ShouldBeTrue();
|
||||
StatusCode.IsGood(condition.Quality!.Value).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>#477 — WriteAlarmCondition projects the snapshot's Quality onto the live condition node, so a
|
||||
@@ -258,12 +258,12 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
|
||||
|
||||
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
|
||||
condition.ShouldNotBeNull();
|
||||
StatusCode.IsBad(condition.Quality.Value).ShouldBeTrue();
|
||||
StatusCode.IsBad(condition.Quality!.Value).ShouldBeTrue();
|
||||
|
||||
// Recover: a Good snapshot restores Good quality.
|
||||
var goodSnapshot = badSnapshot with { StatusCode = OpcUaStatus.Good };
|
||||
nm.WriteAlarmCondition(RawAlarmPath, goodSnapshot, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
StatusCode.IsGood(condition.Quality.Value).ShouldBeTrue();
|
||||
StatusCode.IsGood(condition.Quality!.Value).ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>#477 Layer 2 — the dedicated connectivity-quality path sets ONLY the condition's Quality
|
||||
@@ -283,7 +283,7 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
|
||||
var condition = nm.TryGetAlarmCondition(RawAlarmPath, AddressSpaceRealm.Raw);
|
||||
condition.ShouldNotBeNull();
|
||||
var severityBefore = condition.Severity!.Value;
|
||||
var messageBefore = condition.Message!.Value?.Text;
|
||||
var messageBefore = condition.Message!.Value.Text;
|
||||
|
||||
// Device comes online → Good.
|
||||
nm.WriteAlarmQuality(RawAlarmPath, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
@@ -291,9 +291,9 @@ public sealed class NodeManagerAlarmSourceFieldsTests : IDisposable
|
||||
|
||||
// Device goes comms-lost → Bad, but severity / message / active-ack state are untouched.
|
||||
nm.WriteAlarmQuality(RawAlarmPath, OpcUaStatus.Bad, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
StatusCode.IsBad(condition.Quality.Value).ShouldBeTrue();
|
||||
condition.Severity.Value.ShouldBe(severityBefore);
|
||||
condition.Message.Value?.Text.ShouldBe(messageBefore);
|
||||
StatusCode.IsBad(condition.Quality!.Value).ShouldBeTrue();
|
||||
condition.Severity!.Value.ShouldBe(severityBefore);
|
||||
condition.Message!.Value.Text.ShouldBe(messageBefore);
|
||||
condition.ActiveState!.Id!.Value.ShouldBeFalse(); // unchanged: annotation only
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ public sealed class NodeManagerArrayTests : IDisposable
|
||||
var variable = nm.TryGetVariable("eq-1/arr");
|
||||
variable.ShouldNotBeNull();
|
||||
variable!.ValueRank.ShouldBe(ValueRanks.OneDimension);
|
||||
variable.ArrayDimensions.ShouldNotBeNull();
|
||||
variable.ArrayDimensions.IsNull.ShouldBeFalse();
|
||||
variable.ArrayDimensions.ShouldBe(new uint[] { 8u });
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -57,7 +57,7 @@ public sealed class NodeManagerArrayTests : IDisposable
|
||||
var variable = nm.TryGetVariable("eq-1/arr-unfixed");
|
||||
variable.ShouldNotBeNull();
|
||||
variable!.ValueRank.ShouldBe(ValueRanks.OneDimension);
|
||||
variable.ArrayDimensions.ShouldNotBeNull();
|
||||
variable.ArrayDimensions.IsNull.ShouldBeFalse();
|
||||
variable.ArrayDimensions.ShouldBe(new uint[] { 0u });
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -77,7 +77,7 @@ public sealed class NodeManagerArrayTests : IDisposable
|
||||
var variable = nm.TryGetVariable("eq-1/scalar");
|
||||
variable.ShouldNotBeNull();
|
||||
variable!.ValueRank.ShouldBe(ValueRanks.Scalar);
|
||||
variable.ArrayDimensions.ShouldBeNull();
|
||||
variable.ArrayDimensions.IsNull.ShouldBeTrue();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -98,7 +98,7 @@ public sealed class NodeManagerArrayTests : IDisposable
|
||||
|
||||
var variable = nm.TryGetVariable("eq-1/arrwrite");
|
||||
variable.ShouldNotBeNull();
|
||||
variable!.Value.ShouldBe(payload);
|
||||
SdkValueBoundary.ToClr(variable!.Value).ShouldBe(payload);
|
||||
variable.StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
|
||||
|
||||
await host.DisposeAsync();
|
||||
|
||||
@@ -66,7 +66,7 @@ public sealed class NodeManagerBatchWriteTests : IDisposable
|
||||
|
||||
var b = nm.TryGetVariable("eq-1/b");
|
||||
b.ShouldNotBeNull();
|
||||
b!.Value.ShouldBeNull();
|
||||
b!.Value.IsNull.ShouldBeTrue();
|
||||
b.StatusCode.ShouldBe((StatusCode)StatusCodes.BadNoCommunication);
|
||||
b.StatusCode.ShouldNotBe((StatusCode)StatusCodes.Bad,
|
||||
"the batched path must hand the status sub-code to the SDK verbatim (#519)");
|
||||
|
||||
+28
-28
@@ -20,7 +20,7 @@ public sealed class BoundsServerFixture : IAsyncLifetime
|
||||
public OtOpcUaNodeManager Nm { get; private set; } = null!;
|
||||
|
||||
/// <summary>The historized variable every case reads.</summary>
|
||||
public NodeId NodeId { get; private set; } = null!;
|
||||
public NodeId NodeId { get; private set; } = default;
|
||||
|
||||
/// <summary>Serialises the cases (they share the node manager's source).</summary>
|
||||
public SemaphoreSlim Gate { get; } = new(1, 1);
|
||||
@@ -79,7 +79,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
/// <summary>Part 11 Table 1's data: value = minutes past 5:00.</summary>
|
||||
private static DataValueSnapshot[] TableData() =>
|
||||
new[] { "5:00", "5:02", "5:03", "5:05", "5:06" }
|
||||
.Select(s => new DataValueSnapshot((double)(T(s) - T("5:00")).TotalMinutes, StatusCodes.Good, T(s), T(s)))
|
||||
.Select(s => new DataValueSnapshot((double)(T(s) - T("5:00")).TotalMinutes, StatusCodes.Good.Code, T(s), T(s)))
|
||||
.ToArray();
|
||||
|
||||
public enum Shape { Reverse, ForwardOnly, Microsecond }
|
||||
@@ -170,7 +170,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
var source = Source(shape, TableData());
|
||||
var (values, error, _) = await ReadAsync(source, s, e, max, bounds, cp: null);
|
||||
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
Render(values, s, e).ShouldBe(expected);
|
||||
(source as SeriesSource)?.ReverseWindowsOnForwardOnly.ShouldBe(0);
|
||||
}
|
||||
@@ -228,10 +228,10 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
public async Task A_bad_sample_is_returned_as_the_bound_as_stored(Shape shape)
|
||||
{
|
||||
var series = TableData();
|
||||
series[0] = new DataValueSnapshot(null, StatusCodes.BadNoCommunication, T("5:00"), T("5:00"));
|
||||
series[0] = new DataValueSnapshot(null, StatusCodes.BadNoCommunication.Code, T("5:00"), T("5:00"));
|
||||
var (values, _, _) = await ReadAsync(Source(shape, series), T("5:01"), T("5:04"), 0, true, cp: null);
|
||||
|
||||
values[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
|
||||
values[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code);
|
||||
values[0].SourceTimestamp.ShouldBe(T("5:00"));
|
||||
values.Count.ShouldBe(4);
|
||||
}
|
||||
@@ -248,19 +248,19 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
public async Task Tie_clusters_on_either_side_yield_one_bound_each(Shape shape)
|
||||
{
|
||||
var series = new List<DataValueSnapshot>();
|
||||
void Add(string t, double v) => series.Add(new DataValueSnapshot(v, StatusCodes.Good, T(t), T(t)));
|
||||
void Add(string t, double v) => series.Add(new DataValueSnapshot(v, StatusCodes.Good.Code, T(t), T(t)));
|
||||
Add("5:00", 1); Add("5:00", 2); Add("5:00", 3);
|
||||
Add("5:02", 4);
|
||||
Add("5:05", 5); Add("5:05", 6);
|
||||
|
||||
var (fwd, _, _) = await ReadAsync(Source(shape, series), T("5:01"), T("5:04"), 0, true, cp: null);
|
||||
fwd.Select(v => (double)v.Value).ShouldBe([3d, 4d, 5d]);
|
||||
fwd.Select(v => (double)SdkValueBoundary.ToClr(v.WrappedValue)!).ShouldBe([3d, 4d, 5d]);
|
||||
|
||||
var (rev, _, _) = await ReadAsync(Source(shape, series), T("5:04"), T("5:01"), 0, true, cp: null);
|
||||
rev.Select(v => (double)v.Value).ShouldBe([5d, 4d, 3d]);
|
||||
rev.Select(v => (double)SdkValueBoundary.ToClr(v.WrappedValue)!).ShouldBe([5d, 4d, 3d]);
|
||||
|
||||
var (onStart, _, _) = await ReadAsync(Source(shape, series), T("5:00"), T("5:03"), 0, true, cp: null);
|
||||
onStart.Select(v => (double)v.Value).ShouldBe([1d, 2d, 3d, 4d, 5d]);
|
||||
onStart.Select(v => (double)SdkValueBoundary.ToClr(v.WrappedValue)!).ShouldBe([1d, 2d, 3d, 4d, 5d]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -273,7 +273,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
var series = TableData();
|
||||
var (values, _, _) = await ReadAsync(Source(Shape.Microsecond, series), T("5:00").AddTicks(7), T("5:04"), 0, true, cp: null);
|
||||
|
||||
values.Select(v => (double)v.Value).ShouldBe([0d, 2d, 3d, 5d]);
|
||||
values.Select(v => (double)SdkValueBoundary.ToClr(v.WrappedValue)!).ShouldBe([0d, 2d, 3d, 5d]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -303,14 +303,14 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
{
|
||||
var source = new SeriesSource(TableData(), reverse: true, inclusiveForwardEnd: false, resolution: 1, ceiling: 4);
|
||||
var (first, error, cp) = await ReadAsync(source, T("5:01"), T("5:06"), max: 10, bounds: true, cp: null);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
|
||||
// Start bound 5:00 + the three data rows the ceiling allows = the page's effective cap, exactly.
|
||||
Render(first, T("5:01"), T("5:06")).ShouldBe("5:00, 5:02, 5:03, 5:05");
|
||||
cp.ShouldNotBeNull("the page is full, so the end bound is owed on the next page");
|
||||
|
||||
var (second, error2, cp2) = await ReadAsync(source, T("5:01"), T("5:06"), max: 10, bounds: true, cp);
|
||||
error2.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error2.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
Render(second, T("5:01"), T("5:06")).ShouldBe("5:06");
|
||||
cp2.ShouldBeNull();
|
||||
|
||||
@@ -353,7 +353,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
do
|
||||
{
|
||||
var (values, error, next) = await ReadAsync(capped, T(start), T(end), 0, true, cp);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"L={ceiling} ties={ties} {start}->{end}");
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code, $"L={ceiling} ties={ties} {start}->{end}");
|
||||
values.Count.ShouldBeLessThanOrEqualTo(ceiling, $"L={ceiling} ties={ties} {start}->{end} page {pages}");
|
||||
all.AddRange(values);
|
||||
cp = next;
|
||||
@@ -366,7 +366,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
}
|
||||
}
|
||||
|
||||
static DataValueSnapshot Snap(string at, double v) => new(v, StatusCodes.Good, T(at), T(at));
|
||||
static DataValueSnapshot Snap(string at, double v) => new(v, StatusCodes.Good.Code, T(at), T(at));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -391,8 +391,8 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
var (values, error, cp) = await ReadResultAsync(Source(shape, []), s, e, n);
|
||||
values.ShouldBeEmpty($"{start}->{end} by {n}");
|
||||
cp.ShouldBeNull();
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
LastNodeStatus.Code.ShouldBe(StatusCodes.GoodNoData, $"{start}->{end} by {n}");
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
LastNodeStatus.Code.ShouldBe(StatusCodes.GoodNoData.Code, $"{start}->{end} by {n}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,7 +417,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
Render(values, T("5:01"), T("5:07")).ShouldBe("5:02, 5:03, 5:05, 5:06");
|
||||
|
||||
var (_, modErr, _) = Read(T("5:00"), T("5:05"), 0, bounds: true, cp: null, modified: true);
|
||||
modErr.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "no trail on this fake — not InvalidArgument");
|
||||
modErr.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code, "no trail on this fake — not InvalidArgument");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -443,7 +443,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
{
|
||||
fx.Nm.HistorianDataSource = Source(Shape.Reverse, TableData());
|
||||
var (_, error, cp) = Read(T("5:00"), T("5:05"), 0, bounds: true, cp: null, modified: true);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.BadInvalidArgument);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.BadInvalidArgument.Code);
|
||||
cp.ShouldBeNull();
|
||||
}
|
||||
finally { fx.Gate.Release(); }
|
||||
@@ -459,19 +459,19 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
var v = values[i];
|
||||
if (v.StatusCode.Code != StatusCodes.BadBoundNotFound)
|
||||
{
|
||||
v.Value.ShouldNotBeNull();
|
||||
parts.Add($"{v.SourceTimestamp.Hour}:{v.SourceTimestamp.Minute:00}");
|
||||
SdkValueBoundary.ToClr(v.WrappedValue)!.ShouldNotBeNull();
|
||||
parts.Add($"{v.SourceTimestamp.ToDateTime().Hour}:{v.SourceTimestamp.ToDateTime().Minute:00}");
|
||||
continue;
|
||||
}
|
||||
|
||||
v.Value.ShouldBeNull("a missing bound carries a null value");
|
||||
SdkValueBoundary.ToClr(v.WrappedValue)!.ShouldBeNull("a missing bound carries a null value");
|
||||
var isFirst = i == 0 && start != Unspecified && v.SourceTimestamp == start;
|
||||
var isLast = end != Unspecified && v.SourceTimestamp == end;
|
||||
if (isFirst) { parts.Add("FIRST"); continue; }
|
||||
if (isLast) { parts.Add("LAST"); continue; }
|
||||
// Open side (footnotes a/b): one second from the previous returned value.
|
||||
i.ShouldBeGreaterThan(0);
|
||||
var prev = values[i - 1].SourceTimestamp;
|
||||
var prev = values[i - 1].SourceTimestamp.ToDateTime();
|
||||
if (v.SourceTimestamp == prev.AddSeconds(1)) parts.Add("LAST+");
|
||||
else if (v.SourceTimestamp == prev.AddSeconds(-1)) parts.Add("FIRST-");
|
||||
else parts.Add($"?{v.SourceTimestamp:O}");
|
||||
@@ -488,7 +488,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
do
|
||||
{
|
||||
var (values, error, next) = await ReadAsync(source, start, end, max, bounds, cp);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
if (max > 0) values.Count.ShouldBeLessThanOrEqualTo((int)max, "bounds count toward NumValuesPerNode");
|
||||
all.AddRange(values);
|
||||
cp = next;
|
||||
@@ -521,21 +521,21 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) :
|
||||
private (IReadOnlyList<DataValue> Values, ServiceResult Error, byte[]? Cp) Read(
|
||||
DateTime start, DateTime end, uint max, bool bounds, byte[]? cp, bool modified)
|
||||
{
|
||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null);
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = start, EndTime = end, NumValuesPerNode = max, ReturnBounds = bounds, IsReadModified = modified,
|
||||
};
|
||||
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = fx.NodeId, ContinuationPoint = cp } };
|
||||
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = fx.NodeId, ContinuationPoint = ByteString.From(cp) } };
|
||||
var results = new List<SdkHistoryReadResult> { null! };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
fx.Nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors);
|
||||
LastNodeStatus = results[0]?.StatusCode ?? StatusCodes.Bad;
|
||||
|
||||
var values = new List<DataValue>();
|
||||
if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho))
|
||||
values.AddRange(((HistoryData)ExtensionObject.ToEncodeable(ho)).DataValues);
|
||||
return (values, errors[0], results[0]?.ContinuationPoint);
|
||||
if (results[0]?.HistoryData is { } ho && !ho.IsNull)
|
||||
values.AddRange(((HistoryData)ExtensionObject.ToEncodeable(ho)!).DataValues);
|
||||
return (values, errors[0], results[0]?.ContinuationPoint.ToNullableArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+10
-10
@@ -111,7 +111,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable
|
||||
// Node index 3's tagname throws; all others return a single good sample.
|
||||
var fake = new PerTagnameFake(tagname => tagname == "WW.Tag3"
|
||||
? throw new InvalidOperationException("backend boom for Tag3")
|
||||
: new HistorianRead(new[] { new DataValueSnapshot(1.0f, StatusCodes.Good, src, srv) }, null));
|
||||
: new HistorianRead(new[] { new DataValueSnapshot(1.0f, StatusCodes.Good.Code, src, srv) }, null));
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
var nodeIds = MaterializeHistorizedNodes(nm, count: 8);
|
||||
@@ -132,12 +132,12 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable
|
||||
if (i == 3)
|
||||
{
|
||||
StatusCode.IsBad(errors[i].StatusCode).ShouldBeTrue();
|
||||
errors[i].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
errors[i].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
}
|
||||
else
|
||||
{
|
||||
errors[i].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[i].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
errors[i].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
results[i].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable
|
||||
.WaitAsync(TimeSpan.FromSeconds(10), Ct);
|
||||
|
||||
for (var i = 0; i < 4; i++)
|
||||
errors[i].StatusCode.Code.ShouldBe(StatusCodes.BadTimeout);
|
||||
errors[i].StatusCode.Code.ShouldBe(StatusCodes.BadTimeout.Code);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -212,7 +212,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable
|
||||
// Second concurrent batch: the limiter is saturated → all handles BadTooManyOperations.
|
||||
var (_, errors2) = await Task.Run(() => InvokeHistoryRead(server, nm, details, ids[1]), Ct)
|
||||
.WaitAsync(TimeSpan.FromSeconds(5), Ct);
|
||||
errors2[0].StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations);
|
||||
errors2[0].StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations.Code);
|
||||
fake.Entered.ShouldBe(1, "the rejected batch must never reach the backend");
|
||||
|
||||
// Release the parked batch; the slot frees and a later read succeeds.
|
||||
@@ -221,7 +221,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable
|
||||
|
||||
var (_, errors3) = await Task.Run(() => InvokeHistoryRead(server, nm, details, ids[1]), Ct)
|
||||
.WaitAsync(TimeSpan.FromSeconds(10), Ct);
|
||||
errors3[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
errors3[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -246,7 +246,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable
|
||||
OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, params NodeId[] nodeIds)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null);
|
||||
|
||||
var nodesToRead = nodeIds.Select(id => new HistoryReadValueId { NodeId = id }).ToList();
|
||||
var results = Enumerable.Repeat<SdkHistoryReadResult>(null!, nodeIds.Length).ToList();
|
||||
@@ -290,7 +290,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable
|
||||
}
|
||||
|
||||
return new HistorianRead(
|
||||
new[] { new DataValueSnapshot(1.0f, StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow) }, null);
|
||||
new[] { new DataValueSnapshot(1.0f, StatusCodes.Good.Code, DateTime.UtcNow, DateTime.UtcNow) }, null);
|
||||
}
|
||||
|
||||
private void UpdateMax(int observed)
|
||||
@@ -384,7 +384,7 @@ public sealed class NodeManagerHistoryReadConcurrencyTests : IDisposable
|
||||
// a saturated limiter (rather than the first batch's own deadline freeing the slot early).
|
||||
await _gate.Task.ConfigureAwait(false);
|
||||
return new HistorianRead(
|
||||
new[] { new DataValueSnapshot(1.0f, StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow) }, null);
|
||||
new[] { new DataValueSnapshot(1.0f, StatusCodes.Good.Code, DateTime.UtcNow, DateTime.UtcNow) }, null);
|
||||
}
|
||||
|
||||
public Task<HistorianRead> ReadProcessedAsync(
|
||||
|
||||
+14
-14
@@ -38,7 +38,7 @@ public sealed class NodeManagerHistoryReadEventsHorizonTests : IDisposable
|
||||
try
|
||||
{
|
||||
var r = ReadEvents(nm, node, max: 10, start: DateTime.MinValue, end: Now);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
r.Ids.ShouldBe(["r4", "r3", "r2", "r1", "r0"]);
|
||||
gw.Requests.Min(q => q.Start).ShouldBe(DateTime.UnixEpoch);
|
||||
gw.BadRequests.ShouldBe(0);
|
||||
@@ -54,7 +54,7 @@ public sealed class NodeManagerHistoryReadEventsHorizonTests : IDisposable
|
||||
{
|
||||
nm.EventReadHorizon = Horizon;
|
||||
var r = ReadEvents(nm, node, max: 10, start: DateTime.MinValue, end: Now);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
r.Ids.ShouldBe(["r4", "r3", "r2", "r1", "r0"]);
|
||||
r.Cp.ShouldBeNull();
|
||||
// Requests are rounded outward to the millisecond, so the earliest one may start up to 1 ms before the floor.
|
||||
@@ -95,12 +95,12 @@ public sealed class NodeManagerHistoryReadEventsHorizonTests : IDisposable
|
||||
{
|
||||
nm.EventReadHorizon = Horizon;
|
||||
var forward = ReadEvents(nm, node, max: 10, start: Floor.AddDays(-20), end: Floor.AddDays(-1));
|
||||
forward.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
forward.Status.Code.ShouldBe(StatusCodes.GoodNoData);
|
||||
forward.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
forward.Status.Code.ShouldBe(StatusCodes.GoodNoData.Code);
|
||||
forward.Ids.ShouldBeEmpty();
|
||||
|
||||
var reverse = ReadEvents(nm, node, max: 10, start: Floor.AddDays(-1), end: Floor.AddDays(-20));
|
||||
reverse.Status.Code.ShouldBe(StatusCodes.GoodNoData);
|
||||
reverse.Status.Code.ShouldBe(StatusCodes.GoodNoData.Code);
|
||||
|
||||
gw.Requests.ShouldBeEmpty();
|
||||
}
|
||||
@@ -122,7 +122,7 @@ public sealed class NodeManagerHistoryReadEventsHorizonTests : IDisposable
|
||||
do
|
||||
{
|
||||
var r = ReadEvents(nm, node, max: 10, start: DateTime.MinValue, end: Now, inboundCp: cp);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
all.AddRange(r.Ids);
|
||||
cp = r.Cp;
|
||||
(++pages).ShouldBeLessThan(10);
|
||||
@@ -167,23 +167,23 @@ public sealed class NodeManagerHistoryReadEventsHorizonTests : IDisposable
|
||||
private static (List<string> Ids, ServiceResult Error, StatusCode Status, byte[]? Cp) ReadEvents(
|
||||
OtOpcUaNodeManager nm, NodeId node, uint max, DateTime start, DateTime end, byte[]? inboundCp = null)
|
||||
{
|
||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null);
|
||||
var filter = new EventFilter();
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId"))
|
||||
filter.SelectClauses = [new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId"))
|
||||
{
|
||||
AttributeId = Attributes.Value,
|
||||
});
|
||||
}];
|
||||
var details = new ReadEventDetails { StartTime = start, EndTime = end, NumValuesPerNode = max, Filter = filter };
|
||||
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = node, ContinuationPoint = inboundCp } };
|
||||
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = node, ContinuationPoint = ByteString.From(inboundCp) } };
|
||||
var results = new List<SdkHistoryReadResult> { null! };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
nm.HistoryRead(context, details, TimestampsToReturn.Both, false, nodesToRead, results, errors);
|
||||
|
||||
var ids = new List<string>();
|
||||
if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho))
|
||||
foreach (var e in ((HistoryEvent)ExtensionObject.ToEncodeable(ho)).Events)
|
||||
ids.Add(System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value));
|
||||
return (ids, errors[0], results[0]?.StatusCode ?? StatusCodes.Good, results[0]?.ContinuationPoint);
|
||||
if (results[0]?.HistoryData is { } ho && !ho.IsNull)
|
||||
foreach (var e in ((HistoryEvent)ExtensionObject.ToEncodeable(ho)!).Events)
|
||||
ids.Add(System.Text.Encoding.UTF8.GetString((byte[])SdkValueBoundary.ToClr(e.EventFields[0])!));
|
||||
return (ids, errors[0], results[0]?.StatusCode ?? StatusCodes.Good, results[0]?.ContinuationPoint.ToNullableArray());
|
||||
}
|
||||
|
||||
/// <summary>The Gateway's SQL event read: forward only, <c>[start, end]</c> inclusive, time-ordered, capped + flag.</summary>
|
||||
|
||||
+30
-30
@@ -69,7 +69,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
var p1 = ReadEvents(nm, node, max: 3, inboundCp: null);
|
||||
p1.Cp.ShouldNotBeNull();
|
||||
var p2 = ReadEvents(nm, node, max: 3, inboundCp: p1.Cp);
|
||||
p2.Error.StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations);
|
||||
p2.Error.StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations.Code);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
{
|
||||
var (host, nm, node, _) = await SetupAsync(Distinct(5));
|
||||
var r = ReadEvents(nm, node, max: 2, inboundCp: Guid.NewGuid().ToByteArray());
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
p1.Cp.ShouldNotBeNull();
|
||||
InvokeRelease(nm, node, p1.Cp!);
|
||||
ReadEvents(nm, node, max: 4, inboundCp: p1.Cp).Error.StatusCode.Code
|
||||
.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
||||
.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
var (host, nm, node, _) = await SetupAsync(Distinct(10));
|
||||
var raw = nm.HistoryContinuationStore.Save(null, new HistoryContinuationState("t", Epoch, Epoch.AddDays(1), 0, 4));
|
||||
ReadEvents(nm, node, max: 4, inboundCp: raw).Error.StatusCode.Code
|
||||
.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
||||
.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
var p1 = ReadEvents(nm, node, max: 4, inboundCp: null);
|
||||
p1.Cp.ShouldNotBeNull();
|
||||
ReadEvents(nm, other, max: 4, inboundCp: p1.Cp).Error.StatusCode.Code
|
||||
.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
||||
.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -157,21 +157,21 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
|
||||
byte[] RawRead(NodeId n, byte[]? cp, out ServiceResult err)
|
||||
{
|
||||
var ctx = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
var ctx = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null);
|
||||
var res = new List<SdkHistoryReadResult> { null! };
|
||||
var errs = new List<ServiceResult> { null! };
|
||||
nm.HistoryRead(ctx, new ReadRawModifiedDetails { ReturnBounds = false, StartTime = Epoch, EndTime = Epoch.AddHours(1), NumValuesPerNode = 2, IsReadModified = false },
|
||||
TimestampsToReturn.Both, false,
|
||||
new List<HistoryReadValueId> { new() { NodeId = n, ContinuationPoint = cp } }, res, errs);
|
||||
new List<HistoryReadValueId> { new() { NodeId = n, ContinuationPoint = ByteString.From(cp) } }, res, errs);
|
||||
err = errs[0];
|
||||
return res[0]?.ContinuationPoint!;
|
||||
return res[0]?.ContinuationPoint.ToNullableArray()!;
|
||||
}
|
||||
|
||||
var cp = RawRead(a, null, out var err0);
|
||||
err0.StatusCode.Code.ShouldBe(StatusCodes.Good, err0.ToString());
|
||||
err0.StatusCode.Code.ShouldBe(StatusCodes.Good.Code, err0.ToString());
|
||||
cp.ShouldNotBeNull();
|
||||
RawRead(b, cp, out var err);
|
||||
err.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
||||
err.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -199,10 +199,10 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
good1[1].Cp.ShouldNotBeNull();
|
||||
|
||||
var mixed = ReadBatch(nm, 4, null, (node, good1[0].Cp), (other, Guid.NewGuid().ToByteArray()));
|
||||
mixed[0].Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
mixed[0].Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
// A resumed page is FULL (#675): it over-fetches by the boundary skip it then trims.
|
||||
mixed[0].Ids.ShouldBe(src.All.Skip(4).Take(4).Select(e => e.EventId));
|
||||
mixed[1].Error.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
||||
mixed[1].Error.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
// The pre-#675 shape: the cap kept the EARLIEST N, ascending, Good, and no continuation point.
|
||||
var (host, nm, node, _) = await SetupAsync(Distinct(10));
|
||||
var p1 = ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late);
|
||||
p1.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
p1.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
p1.Ids.ShouldBe(["e0009", "e0008", "e0007"]);
|
||||
p1.Cp.ShouldNotBeNull("a capped reverse read has more behind it and must page");
|
||||
await host.DisposeAsync();
|
||||
@@ -283,7 +283,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
var p1 = ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late);
|
||||
p1.Cp.ShouldNotBeNull();
|
||||
ReadEvents(nm, node, max: 3, inboundCp: p1.Cp, end: Early, start: Late).Error.StatusCode.Code
|
||||
.ShouldBe(StatusCodes.BadTooManyOperations);
|
||||
.ShouldBe(StatusCodes.BadTooManyOperations.Code);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -295,7 +295,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
var p1 = ReadEvents(nm, node, max: 4, inboundCp: null, end: Early, start: Late);
|
||||
p1.Ids.ShouldBe(["e0009", "e0008", "e0007", "e0006"]);
|
||||
var p2 = ReadEvents(nm, node, max: 4, inboundCp: p1.Cp); // default details: Early -> Late (forward)
|
||||
p2.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
p2.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
p2.Ids.ShouldBe(["e0005", "e0004", "e0003", "e0002"]);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -340,7 +340,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
var fwd = new ForwardOnlyEventSource(src);
|
||||
nm.HistorianDataSource = fwd;
|
||||
var r = ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
r.Ids.ShouldBe(["e0009", "e0008", "e0007"]);
|
||||
r.Cp.ShouldNotBeNull();
|
||||
// Forward reads on the same backend are unaffected.
|
||||
@@ -469,7 +469,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
break;
|
||||
}
|
||||
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"seed {seed}, ms max {msMax}");
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code, $"seed {seed}, ms max {msMax}");
|
||||
all.AddRange(r.Ids);
|
||||
cp = r.Cp;
|
||||
(++pages).ShouldBeLessThan(2000);
|
||||
@@ -492,7 +492,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
var (host, nm, node, _) = await SetupAsync(Distinct(10), logger: logger);
|
||||
nm.HistorianDataSource = new ThrowingReverseSource();
|
||||
ReadEvents(nm, node, max: 3, inboundCp: null, end: Early, start: Late).Error.StatusCode.Code
|
||||
.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
logger.Entries.ShouldContain(e => e.Level == LogLevel.Error && e.Exception is NotSupportedException);
|
||||
logger.Entries.ShouldNotContain(e => e.Level == LogLevel.Warning);
|
||||
await host.DisposeAsync();
|
||||
@@ -580,7 +580,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
{
|
||||
public Task<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct)
|
||||
{
|
||||
var rows = Enumerable.Range(0, 10).Select(i => new DataValueSnapshot((double)i, StatusCodes.Good,
|
||||
var rows = Enumerable.Range(0, 10).Select(i => new DataValueSnapshot((double)i, StatusCodes.Good.Code,
|
||||
Epoch.AddSeconds(i), Epoch.AddSeconds(i))).Where(v => v.SourceTimestampUtc >= s && v.SourceTimestampUtc <= e)
|
||||
.Take((int)m).ToList();
|
||||
return Task.FromResult(new HistorianRead(rows, null));
|
||||
@@ -646,7 +646,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
do
|
||||
{
|
||||
var r = ReadEvents(nm, node, max, cp, end, start);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
all.AddRange(r.Ids);
|
||||
pageSizes?.Add(r.Ids.Count);
|
||||
cp = r.Cp;
|
||||
@@ -668,12 +668,12 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
OtOpcUaNodeManager nm, uint max, DateTime? end, DateTime? start, params (NodeId Node, byte[]? Cp)[] reads)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null);
|
||||
var filter = new EventFilter();
|
||||
filter.SelectClauses.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId"))
|
||||
filter.SelectClauses = [new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("EventId"))
|
||||
{
|
||||
AttributeId = Attributes.Value,
|
||||
});
|
||||
}];
|
||||
var details = new ReadEventDetails
|
||||
{
|
||||
StartTime = start ?? Epoch.AddDays(-1),
|
||||
@@ -681,7 +681,7 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
NumValuesPerNode = max,
|
||||
Filter = filter,
|
||||
};
|
||||
var nodesToRead = reads.Select(r => new HistoryReadValueId { NodeId = r.Node, ContinuationPoint = r.Cp }).ToList();
|
||||
var nodesToRead = reads.Select(r => new HistoryReadValueId { NodeId = r.Node, ContinuationPoint = ByteString.From(r.Cp) }).ToList();
|
||||
var results = reads.Select(_ => (SdkHistoryReadResult)null!).ToList();
|
||||
var errors = reads.Select(_ => (ServiceResult)null!).ToList();
|
||||
nm.HistoryRead(context, details, TimestampsToReturn.Both, false, nodesToRead, results, errors);
|
||||
@@ -690,14 +690,14 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
for (var i = 0; i < reads.Length; i++)
|
||||
{
|
||||
var ids = new List<string>();
|
||||
if (results[i]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho))
|
||||
if (results[i]?.HistoryData is { } ho && !ho.IsNull)
|
||||
{
|
||||
var he = (HistoryEvent)ExtensionObject.ToEncodeable(ho);
|
||||
var he = (HistoryEvent)ExtensionObject.ToEncodeable(ho)!;
|
||||
foreach (var e in he.Events)
|
||||
ids.Add(System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value));
|
||||
ids.Add(System.Text.Encoding.UTF8.GetString((byte[])SdkValueBoundary.ToClr(e.EventFields[0])!));
|
||||
}
|
||||
|
||||
all.Add((ids, errors[i], results[i]?.ContinuationPoint));
|
||||
all.Add((ids, errors[i], results[i]?.ContinuationPoint.ToNullableArray()));
|
||||
}
|
||||
|
||||
return all;
|
||||
@@ -706,8 +706,8 @@ public sealed class NodeManagerHistoryReadEventsPagingTests : IDisposable
|
||||
private static void InvokeRelease(OtOpcUaNodeManager nm, NodeId node, byte[] cp)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = node, ContinuationPoint = cp } };
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null);
|
||||
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = node, ContinuationPoint = ByteString.From(cp) } };
|
||||
var results = new List<SdkHistoryReadResult> { null! };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
nm.HistoryRead(context, new ReadEventDetails { StartTime = Epoch, EndTime = Epoch.AddDays(1), NumValuesPerNode = 4 },
|
||||
|
||||
+22
-24
@@ -41,19 +41,19 @@ public sealed class NodeManagerHistoryReadEventsProjectionTests : IDisposable
|
||||
|
||||
var history = Read(server, nm, notifier, Select("EventId", "EventType", "ConditionName", "SourceNode"), where: null);
|
||||
|
||||
var rows = history.Events.ToDictionary(e => System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value));
|
||||
rows["e-limit"].EventFields[1].Value.ShouldBe(ObjectTypeIds.LimitAlarmType);
|
||||
rows["e-limit"].EventFields[2].Value.ShouldBe("HighTemp");
|
||||
rows["e-limit"].EventFields[3].Value.ShouldBe(condition, "the alarm id resolves to the materialised condition — the live events' SourceNode");
|
||||
var rows = history.Events.ToArray()!.ToDictionary(e => System.Text.Encoding.UTF8.GetString((byte[])SdkValueBoundary.ToClr(e.EventFields[0])!));
|
||||
SdkValueBoundary.ToClr(rows["e-limit"].EventFields[1])!.ShouldBe(ObjectTypeIds.LimitAlarmType);
|
||||
SdkValueBoundary.ToClr(rows["e-limit"].EventFields[2])!.ShouldBe("HighTemp");
|
||||
SdkValueBoundary.ToClr(rows["e-limit"].EventFields[3])!.ShouldBe(condition, "the alarm id resolves to the materialised condition — the live events' SourceNode");
|
||||
|
||||
rows["e-offnormal"].EventFields[1].Value.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
|
||||
rows["e-offnormal"].EventFields[3].Value.ShouldBeNull("no condition is materialised for that alarm id");
|
||||
SdkValueBoundary.ToClr(rows["e-offnormal"].EventFields[1])!.ShouldBe(ObjectTypeIds.OffNormalAlarmType);
|
||||
SdkValueBoundary.ToClr(rows["e-offnormal"].EventFields[3])!.ShouldBeNull("no condition is materialised for that alarm id");
|
||||
|
||||
// No stored type: still an alarm condition (every historical row is alarm history); an unrecognised or non-event
|
||||
// type name is not passed through as a non-event type.
|
||||
rows["e-untyped"].EventFields[1].Value.ShouldBe(ObjectTypeIds.AlarmConditionType);
|
||||
rows["e-untyped"].EventFields[2].Value.ShouldBeNull();
|
||||
rows["e-bogus"].EventFields[1].Value.ShouldBe(ObjectTypeIds.AlarmConditionType);
|
||||
SdkValueBoundary.ToClr(rows["e-untyped"].EventFields[1])!.ShouldBe(ObjectTypeIds.AlarmConditionType);
|
||||
SdkValueBoundary.ToClr(rows["e-untyped"].EventFields[2])!.ShouldBeNull();
|
||||
SdkValueBoundary.ToClr(rows["e-bogus"].EventFields[1])!.ShouldBe(ObjectTypeIds.AlarmConditionType);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -79,9 +79,7 @@ public sealed class NodeManagerHistoryReadEventsProjectionTests : IDisposable
|
||||
{
|
||||
var (host, server, nm, notifier) = await BootAsync();
|
||||
var where = new ContentFilter();
|
||||
where.Push(FilterOperator.GreaterThan,
|
||||
new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("Severity")) { AttributeId = Attributes.Value },
|
||||
new LiteralOperand(new Variant((ushort)600)));
|
||||
where.Push(FilterOperator.GreaterThan, Variant.FromStructure(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName("Severity")) { AttributeId = Attributes.Value }), Variant.FromStructure(new LiteralOperand(new Variant((ushort)600))));
|
||||
|
||||
Ids(Read(server, nm, notifier, Select("EventId"), where)).ShouldBe(["e-limit", "e-bogus"]);
|
||||
await host.DisposeAsync();
|
||||
@@ -100,13 +98,13 @@ public sealed class NodeManagerHistoryReadEventsProjectionTests : IDisposable
|
||||
switch (fault)
|
||||
{
|
||||
case "unknown operator":
|
||||
where.Push((FilterOperator)99, new LiteralOperand(new Variant(1)), new LiteralOperand(new Variant(1)));
|
||||
where.Push((FilterOperator)99, Variant.FromStructure(new LiteralOperand(new Variant(1))), Variant.FromStructure(new LiteralOperand(new Variant(1))));
|
||||
break;
|
||||
case "self-referencing element":
|
||||
where.Push(FilterOperator.Not, new ElementOperand(0));
|
||||
where.Push(FilterOperator.Not, Variant.FromStructure(new ElementOperand(0)));
|
||||
break;
|
||||
default:
|
||||
where.Push(FilterOperator.Equals, new LiteralOperand(new Variant(1)));
|
||||
where.Push(FilterOperator.Equals, Variant.FromStructure(new LiteralOperand(new Variant(1))));
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -115,39 +113,39 @@ public sealed class NodeManagerHistoryReadEventsProjectionTests : IDisposable
|
||||
|
||||
// Positive control: the same read with a valid where-clause is served.
|
||||
var valid = new ContentFilter();
|
||||
valid.Push(FilterOperator.Equals, new LiteralOperand(new Variant(1)), new LiteralOperand(new Variant(1)));
|
||||
valid.Push(FilterOperator.Equals, Variant.FromStructure(new LiteralOperand(new Variant(1))), Variant.FromStructure(new LiteralOperand(new Variant(1))));
|
||||
StatusCode.IsGood(ReadStatus(server, nm, notifier, Select("EventId"), valid).Status).ShouldBeTrue();
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
private static string[] Ids(HistoryEvent h) =>
|
||||
h.Events.Select(e => System.Text.Encoding.UTF8.GetString((byte[])e.EventFields[0].Value)).ToArray();
|
||||
h.Events.ToArray()!.Select(e => System.Text.Encoding.UTF8.GetString((byte[])SdkValueBoundary.ToClr(e.EventFields[0])!)).ToArray();
|
||||
|
||||
private static ContentFilter OfType(NodeId type)
|
||||
{
|
||||
var f = new ContentFilter();
|
||||
f.Push(FilterOperator.OfType, new LiteralOperand(new Variant(type)));
|
||||
f.Push(FilterOperator.OfType, Variant.FromStructure(new LiteralOperand(new Variant(type))));
|
||||
return f;
|
||||
}
|
||||
|
||||
private static SimpleAttributeOperandCollection Select(params string[] leaves)
|
||||
private static List<SimpleAttributeOperand> Select(params string[] leaves)
|
||||
{
|
||||
var c = new SimpleAttributeOperandCollection();
|
||||
var c = new List<SimpleAttributeOperand>();
|
||||
foreach (var leaf in leaves)
|
||||
c.Add(new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(leaf)) { AttributeId = Attributes.Value });
|
||||
return c;
|
||||
}
|
||||
|
||||
private static HistoryEvent Read(OtOpcUaSdkServer server, OtOpcUaNodeManager nm, NodeId notifier,
|
||||
SimpleAttributeOperandCollection select, ContentFilter? where)
|
||||
List<SimpleAttributeOperand> select, ContentFilter? where)
|
||||
{
|
||||
var (status, result) = ReadStatus(server, nm, notifier, select, where);
|
||||
StatusCode.IsGood(status).ShouldBeTrue($"HistoryRead failed: {status}");
|
||||
return (HistoryEvent)ExtensionObject.ToEncodeable(result.HistoryData);
|
||||
return (HistoryEvent)ExtensionObject.ToEncodeable(result.HistoryData)!;
|
||||
}
|
||||
|
||||
private static (StatusCode Status, SdkHistoryReadResult Result) ReadStatus(OtOpcUaSdkServer server, OtOpcUaNodeManager nm,
|
||||
NodeId notifier, SimpleAttributeOperandCollection select, ContentFilter? where)
|
||||
NodeId notifier, List<SimpleAttributeOperand> select, ContentFilter? where)
|
||||
{
|
||||
var details = new ReadEventDetails
|
||||
{
|
||||
@@ -156,7 +154,7 @@ public sealed class NodeManagerHistoryReadEventsProjectionTests : IDisposable
|
||||
NumValuesPerNode = 100,
|
||||
Filter = new EventFilter { SelectClauses = select, WhereClause = where ?? new ContentFilter() },
|
||||
};
|
||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null);
|
||||
var results = new List<SdkHistoryReadResult> { null! };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false,
|
||||
|
||||
+29
-27
@@ -71,26 +71,26 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||
fake.LastEnd.ShouldBe(end);
|
||||
fake.LastMaxEvents.ShouldBe(50);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
|
||||
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
||||
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData)!;
|
||||
history.Events.Count.ShouldBe(1);
|
||||
var fields = history.Events[0].EventFields;
|
||||
fields.Count.ShouldBe(6);
|
||||
|
||||
// EventId ⇒ ByteString (UTF-8 of "evt-42").
|
||||
fields[0].Value.ShouldBeOfType<byte[]>().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-42"));
|
||||
SdkValueBoundary.ToClr(fields[0])!.ShouldBeOfType<byte[]>().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-42"));
|
||||
// SourceName ⇒ string.
|
||||
fields[1].Value.ShouldBe("Pump_001");
|
||||
SdkValueBoundary.ToClr(fields[1])!.ShouldBe("Pump_001");
|
||||
// Time ⇒ DateTime (event occurrence time).
|
||||
fields[2].Value.ShouldBe(evtTime);
|
||||
SdkValueBoundary.ToClr(fields[2])!.ShouldBe(evtTime);
|
||||
// ReceiveTime ⇒ DateTime (server receipt time = ReceivedTimeUtc).
|
||||
fields[3].Value.ShouldBe(rcvTime);
|
||||
SdkValueBoundary.ToClr(fields[3])!.ShouldBe(rcvTime);
|
||||
// Message ⇒ LocalizedText.
|
||||
fields[4].Value.ShouldBeOfType<LocalizedText>().Text.ShouldBe("Pump tripped");
|
||||
SdkValueBoundary.ToClr(fields[4])!.ShouldBeOfType<LocalizedText>().Text.ShouldBe("Pump tripped");
|
||||
// Severity ⇒ UInt16.
|
||||
fields[5].Value.ShouldBe((ushort)700);
|
||||
SdkValueBoundary.ToClr(fields[5])!.ShouldBe((ushort)700);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -126,7 +126,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||
|
||||
var (_, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
// The backend saw the unbounded cap, NOT the 0/default-cap sentinel.
|
||||
fake.LastMaxEvents.ShouldBe(int.MaxValue);
|
||||
|
||||
@@ -163,14 +163,14 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||
|
||||
var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData)!;
|
||||
var fields = history.Events[0].EventFields;
|
||||
fields.Count.ShouldBe(2);
|
||||
// LocalTime ⇒ Variant.Null.
|
||||
fields[0].Value.ShouldBeNull();
|
||||
SdkValueBoundary.ToClr(fields[0])!.ShouldBeNull();
|
||||
// EventId still projects to a ByteString.
|
||||
fields[1].Value.ShouldBeOfType<byte[]>().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-1"));
|
||||
SdkValueBoundary.ToClr(fields[1])!.ShouldBeOfType<byte[]>().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-1"));
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -203,10 +203,10 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||
|
||||
var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.GoodNoData);
|
||||
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
||||
history.Events.ShouldBeEmpty();
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.GoodNoData.Code);
|
||||
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData)!;
|
||||
history.Events.Count.ShouldBe(0);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -236,14 +236,14 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||
};
|
||||
|
||||
var (_, unsupported) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
||||
unsupported[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
unsupported[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
|
||||
var fake = new RecordingHistorianDataSource();
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
var (_, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
fake.EventsCalled.ShouldBeTrue();
|
||||
fake.LastSourceName.ShouldBe(equipmentId);
|
||||
|
||||
@@ -282,8 +282,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||
};
|
||||
var (results, errors) = InvokeHistoryRead(server, nm, details, folder.NodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "set by the override's guard");
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code, "set by the override's guard");
|
||||
fake.EventsCalled.ShouldBeFalse();
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -327,7 +327,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||
|
||||
var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
fake.EventsCalled.ShouldBeFalse();
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -365,10 +365,10 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||
|
||||
// Authoritative per-node signal: errors[0] must be Bad.
|
||||
ServiceResult.IsBad(errors[0]).ShouldBeTrue();
|
||||
errors[0].StatusCode.Code.ShouldBe(communication ? StatusCodes.BadNoCommunication : StatusCodes.BadHistoryOperationUnsupported);
|
||||
errors[0].StatusCode.Code.ShouldBe(communication ? StatusCodes.BadNoCommunication.Code : StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
// The production catch now sets results[0] explicitly — assert it directly (not relying on base seeding).
|
||||
results[0].ShouldNotBeNull();
|
||||
results[0].StatusCode.Code.ShouldBe(communication ? StatusCodes.BadNoCommunication : StatusCodes.BadHistoryOperationUnsupported);
|
||||
results[0].StatusCode.Code.ShouldBe(communication ? StatusCodes.BadNoCommunication.Code : StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
// The source WAS entered (proving the override reached the bridge and the throw was swallowed).
|
||||
fake.EventsEntered.ShouldBeTrue();
|
||||
// ThrowOnRead fires before EventsCalled/LastSourceName are set — that's the throw path.
|
||||
@@ -382,15 +382,17 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||
private static EventFilter SelectFilter(params string[] leafFieldNames)
|
||||
{
|
||||
var filter = new EventFilter();
|
||||
var clauses = new List<SimpleAttributeOperand>();
|
||||
foreach (var name in leafFieldNames)
|
||||
{
|
||||
filter.SelectClauses.Add(
|
||||
clauses.Add(
|
||||
new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(name))
|
||||
{
|
||||
AttributeId = Attributes.Value,
|
||||
});
|
||||
}
|
||||
|
||||
filter.SelectClauses = clauses;
|
||||
return filter;
|
||||
}
|
||||
|
||||
@@ -400,7 +402,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||
OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, NodeId nodeId)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null);
|
||||
|
||||
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = nodeId } };
|
||||
var results = new List<SdkHistoryReadResult> { null! };
|
||||
|
||||
+34
-34
@@ -32,9 +32,9 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
try
|
||||
{
|
||||
var r = Read(nm, node, Epoch, Epoch.AddHours(1), 10, null);
|
||||
r.Data!.DataValues.Single().Value.ShouldBeOfType<float>().ShouldBe(2f);
|
||||
r.Data.ModificationInfos.Single().UserName.ShouldBe("alice");
|
||||
r.Data.ModificationInfos.Single().UpdateType.ShouldBe(HistoryUpdateType.Replace);
|
||||
SdkValueBoundary.ToClr(r.Data!.DataValues.ToArray()!.Single().WrappedValue)!.ShouldBeOfType<float>().ShouldBe(2f);
|
||||
r.Data.ModificationInfos.ToArray()!.Single().UserName.ShouldBe("alice");
|
||||
r.Data.ModificationInfos.ToArray()!.Single().UpdateType.ShouldBe(HistoryUpdateType.Replace);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -47,8 +47,8 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
{
|
||||
src.Throw = new System.IO.IOException("connection lost");
|
||||
var r = Read(nm, node, Epoch, Epoch.AddHours(1), 10, null);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
|
||||
r.Result.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code);
|
||||
r.Result.StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -67,8 +67,8 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
|
||||
var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 100, cp: null);
|
||||
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
r.Result.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
r.Result.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
r.Cp.ShouldBeNull();
|
||||
src.LastTag.ShouldBe(Tag);
|
||||
src.LastStart.ShouldBe(Epoch);
|
||||
@@ -79,16 +79,16 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
var data = r.Data!;
|
||||
data.DataValues.Count.ShouldBe(3);
|
||||
data.ModificationInfos.Count.ShouldBe(3);
|
||||
data.ModificationInfos.Select(m => m.UpdateType)
|
||||
data.ModificationInfos.ToArray()!.Select(m => m.UpdateType)
|
||||
.ShouldBe([HistoryUpdateType.Insert, HistoryUpdateType.Replace, HistoryUpdateType.Delete]);
|
||||
data.ModificationInfos.Select(m => m.UserName).ShouldBe(["alice", "bob", "carol"]);
|
||||
data.ModificationInfos.Select(m => m.ModificationTime)
|
||||
data.ModificationInfos.ToArray()!.Select(m => m.UserName).ShouldBe(["alice", "bob", "carol"]);
|
||||
data.ModificationInfos.ToArray()!.Select(m => m.ModificationTime)
|
||||
.ShouldBe([modifiedAt, modifiedAt.AddMinutes(1), modifiedAt.AddMinutes(2)]);
|
||||
data.DataValues[0].Value.ShouldBeNull();
|
||||
data.DataValues[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData);
|
||||
data.DataValues[1].Value.ShouldBe(1.5);
|
||||
SdkValueBoundary.ToClr(data.DataValues[0].WrappedValue)!.ShouldBeNull();
|
||||
data.DataValues[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoData.Code);
|
||||
SdkValueBoundary.ToClr(data.DataValues[1].WrappedValue)!.ShouldBe(1.5);
|
||||
data.DataValues[1].SourceTimestamp.ShouldBe(Epoch.AddSeconds(10));
|
||||
data.DataValues[2].Value.ShouldBe(7.0);
|
||||
SdkValueBoundary.ToClr(data.DataValues[2].WrappedValue)!.ShouldBe(7.0);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -97,9 +97,9 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
{
|
||||
var (host, nm, node, _) = await SetupAsync([]);
|
||||
var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
r.Result.StatusCode.Code.ShouldBe(StatusCodes.GoodNoData);
|
||||
r.Data!.ModificationInfos.ShouldBeEmpty();
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
r.Result.StatusCode.Code.ShouldBe(StatusCodes.GoodNoData.Code);
|
||||
r.Data!.ModificationInfos.Count.ShouldBe(0);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
{
|
||||
// Inserts have no prior value; their snapshot has no source timestamp. The cursor must not fall back to MinValue.
|
||||
var trail = Enumerable.Range(1, 6).Select(i => new HistoryModifiedValue(
|
||||
new DataValueSnapshot(null, StatusCodes.BadNoData, null, Epoch), Epoch, HistoryModificationType.Insert, "u", i,
|
||||
new DataValueSnapshot(null, StatusCodes.BadNoData.Code, null, Epoch), Epoch, HistoryModificationType.Insert, "u", i,
|
||||
Epoch.AddSeconds(i))).ToList();
|
||||
var (host, nm, node, src) = await SetupAsync(trail);
|
||||
|
||||
@@ -214,20 +214,20 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
// A RAW cursor for the same tag is not a modified cursor.
|
||||
var raw = nm.HistoryContinuationStore.Save(null, new HistoryContinuationState(Tag, Epoch, Epoch.AddDays(1), 0, 4));
|
||||
Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: raw).Error.StatusCode.Code
|
||||
.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
||||
.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code);
|
||||
|
||||
// A MODIFIED cursor presented to a raw read is invalid too.
|
||||
var p1 = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null);
|
||||
p1.Cp.ShouldNotBeNull();
|
||||
Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: p1.Cp, modified: false).Error.StatusCode.Code
|
||||
.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
||||
.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code);
|
||||
|
||||
// A modified cursor issued for one node is invalid on another node's tag.
|
||||
var p2 = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null);
|
||||
nm.EnsureVariable("eq-1/other", null, "Other", "Double", false, historianTagname: "WW.Other", realm: AddressSpaceRealm.Uns);
|
||||
var other = nm.TryGetVariable("eq-1/other")!.NodeId;
|
||||
Read(nm, other, Epoch, Epoch.AddHours(1), max: 4, cp: p2.Cp).Error.StatusCode.Code
|
||||
.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
||||
.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -245,9 +245,9 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
var modified = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null);
|
||||
var raw = Read(nm, node, Epoch, Epoch.AddHours(1), max: 10, cp: null, modified: false);
|
||||
|
||||
modified.Error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
modified.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
modified.Data!.DataValues.Count.ShouldBe(1);
|
||||
raw.Error.StatusCode.Code.ShouldNotBe(StatusCodes.BadInvalidState);
|
||||
raw.Error.StatusCode.Code.ShouldNotBe(StatusCodes.BadInvalidState.Code);
|
||||
src.Calls.ShouldBe(1);
|
||||
consulted.ShouldBe(0);
|
||||
await host.DisposeAsync();
|
||||
@@ -259,7 +259,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
var (host, nm, node, src) = await SetupAsync([Mod(1, 1, 1, HistoryModificationType.Update, "u", Epoch)]);
|
||||
src.Throw = new InvalidOperationException("boom");
|
||||
var r = Read(nm, node, Epoch, Epoch.AddHours(1), max: 4, cp: null);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -270,8 +270,8 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
{
|
||||
var t = Epoch.AddSeconds(seconds);
|
||||
var value = prior is null
|
||||
? new DataValueSnapshot(null, StatusCodes.BadNoData, t, modifiedAt)
|
||||
: new DataValueSnapshot(prior.Value, StatusCodes.Good, t, t);
|
||||
? new DataValueSnapshot(null, StatusCodes.BadNoData.Code, t, modifiedAt)
|
||||
: new DataValueSnapshot(prior.Value, StatusCodes.Good.Code, t, t);
|
||||
return new HistoryModifiedValue(value, modifiedAt, type, user, id, t);
|
||||
}
|
||||
|
||||
@@ -283,7 +283,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
do
|
||||
{
|
||||
var r = Read(nm, node, start, end, max, cp);
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good, r.Error.ToString());
|
||||
r.Error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code, r.Error.ToString());
|
||||
ids.AddRange(r.Ids);
|
||||
cp = r.Cp;
|
||||
(++pages).ShouldBeLessThan(60, "paging must terminate");
|
||||
@@ -295,25 +295,25 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
private static (ServiceResult Error, SdkHistoryReadResult Result, HistoryModifiedData? Data, byte[]? Cp, List<long> Ids) Read(
|
||||
OtOpcUaNodeManager nm, NodeId node, DateTime start, DateTime end, uint max, byte[]? cp, bool modified = true)
|
||||
{
|
||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null);
|
||||
var results = new List<SdkHistoryReadResult> { null! };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
nm.HistoryRead(context,
|
||||
new ReadRawModifiedDetails { ReturnBounds = false, StartTime = start, EndTime = end, NumValuesPerNode = max, IsReadModified = modified },
|
||||
TimestampsToReturn.Both, false,
|
||||
new List<HistoryReadValueId> { new() { NodeId = node, ContinuationPoint = cp } }, results, errors);
|
||||
new List<HistoryReadValueId> { new() { NodeId = node, ContinuationPoint = ByteString.From(cp) } }, results, errors);
|
||||
|
||||
HistoryModifiedData? data = null;
|
||||
var ids = new List<long>();
|
||||
if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho)
|
||||
&& ExtensionObject.ToEncodeable(ho) is HistoryModifiedData hm)
|
||||
if (results[0]?.HistoryData is { } ho && !ho.IsNull
|
||||
&& ExtensionObject.ToEncodeable(ho)! is HistoryModifiedData hm)
|
||||
{
|
||||
data = hm;
|
||||
// The fake stores each modification's id in its (unique) server timestamp offset — see SqlLikeTrail.
|
||||
ids.AddRange(hm.DataValues.Select(v => SqlLikeTrail.IdOf(v)));
|
||||
ids.AddRange(hm.DataValues.ToArray()!.Select(v => SqlLikeTrail.IdOf(v)));
|
||||
}
|
||||
|
||||
return (errors[0], results[0], data, results[0]?.ContinuationPoint, ids);
|
||||
return (errors[0], results[0], data, results[0]?.ContinuationPoint.ToNullableArray(), ids);
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaNodeManager Nm, NodeId Node, SqlLikeTrail Src)> SetupAsync(
|
||||
@@ -355,7 +355,7 @@ public sealed class NodeManagerHistoryReadModifiedTests : IDisposable
|
||||
public int Calls { get; private set; }
|
||||
public Exception? Throw { get; set; }
|
||||
|
||||
public static long IdOf(DataValue v) => v.ServerTimestamp.Ticks - Epoch.Ticks;
|
||||
public static long IdOf(DataValue v) => v.ServerTimestamp.ToDateTime().Ticks - Epoch.Ticks;
|
||||
|
||||
public IEnumerable<HistoryModifiedValue> Ordered(bool reverse) => reverse
|
||||
? trail.OrderByDescending(m => m.SourceTimeUtc).ThenByDescending(m => m.ModifiedId)
|
||||
|
||||
+15
-15
@@ -29,7 +29,7 @@ public sealed class NodeManagerHistoryReadPageFillTests(BoundsServerFixture fx)
|
||||
private static DataValueSnapshot[] EightValues() =>
|
||||
Enumerable.Range(1, 8).Select(i => Sample(i, T0.AddSeconds(i))).ToArray();
|
||||
|
||||
private static DataValueSnapshot Sample(double v, DateTime t) => new(v, StatusCodes.Good, t, t);
|
||||
private static DataValueSnapshot Sample(double v, DateTime t) => new(v, StatusCodes.Good.Code, t, t);
|
||||
|
||||
private static (DateTime Start, DateTime End) Window(Direction direction) => direction switch
|
||||
{
|
||||
@@ -193,7 +193,7 @@ public sealed class NodeManagerHistoryReadPageFillTests(BoundsServerFixture fx)
|
||||
{
|
||||
source.ResetCounts();
|
||||
var (values, error, next) = await ReadAsync(source, start, end, 1, bounds: false, cp);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
values.Count.ShouldBe(1);
|
||||
all.AddRange(values);
|
||||
readsPerPage.Add(source.Reads);
|
||||
@@ -202,7 +202,7 @@ public sealed class NodeManagerHistoryReadPageFillTests(BoundsServerFixture fx)
|
||||
}
|
||||
while (cp is not null);
|
||||
|
||||
all.Select(x => (double)x.Value).ShouldBe([0d, 1d, 2d, 3d, 4d, 5d, 6d]);
|
||||
all.Select(x => (double)SdkValueBoundary.ToClr(x.WrappedValue)!).ShouldBe([0d, 1d, 2d, 3d, 4d, 5d, 6d]);
|
||||
// Page 1 [a], page 2 [t1]: one read each. Page 3 [t2]: the read stalls on the cluster, then the over-fetch (2).
|
||||
// Pages 4 [t3], 5 [t4], 6 [t5]: the flagged cursor over-fetches directly (1 each). Page 7 [b]: one read.
|
||||
readsPerPage.ShouldBe([1, 1, 2, 1, 1, 1, 1]);
|
||||
@@ -227,12 +227,12 @@ public sealed class NodeManagerHistoryReadPageFillTests(BoundsServerFixture fx)
|
||||
var (start, end) = direction == Direction.Forward ? (T0.AddSeconds(5), T0.AddSeconds(7)) : (T0.AddSeconds(7), T0.AddSeconds(5));
|
||||
|
||||
var (first, _, cp) = await ReadAsync(source, start, end, 1, bounds: true, cp: null);
|
||||
first.Single().StatusCode.Code.ShouldBe(StatusCodes.BadBoundNotFound);
|
||||
first.Single().StatusCode.Code.ShouldBe(StatusCodes.BadBoundNotFound.Code);
|
||||
cp.ShouldNotBeNull();
|
||||
|
||||
source.ResetCounts();
|
||||
var (second, _, cp2) = await ReadAsync(source, start, end, 1, bounds: true, cp: cp);
|
||||
second.Single().Value.ShouldBe(direction == Direction.Forward ? 9d : 3d);
|
||||
SdkValueBoundary.ToClr(second.Single().WrappedValue)!.ShouldBe(direction == Direction.Forward ? 9d : 3d);
|
||||
cp2.ShouldBeNull();
|
||||
source.Reads.ShouldBe(0, "the end bound was found by the first page and carried");
|
||||
}
|
||||
@@ -253,7 +253,7 @@ public sealed class NodeManagerHistoryReadPageFillTests(BoundsServerFixture fx)
|
||||
fx.Nm.HistorianDataSource = source;
|
||||
// Reverse (0.1s ← 30s]: no sample at or after 30s ⇒ the start bound is a placeholder; the data is below.
|
||||
var (values, _, cp) = Read(T0.AddSeconds(30), T0.AddMilliseconds(100), 1, bounds: true, cp: null);
|
||||
values.Single().StatusCode.Code.ShouldBe(StatusCodes.BadBoundNotFound);
|
||||
values.Single().StatusCode.Code.ShouldBe(StatusCodes.BadBoundNotFound.Code);
|
||||
cp.ShouldNotBeNull();
|
||||
store.Saved.Last().WalkHint.WidthTicks.ShouldBeGreaterThan(0);
|
||||
}
|
||||
@@ -272,7 +272,7 @@ public sealed class NodeManagerHistoryReadPageFillTests(BoundsServerFixture fx)
|
||||
[Fact]
|
||||
public async Task A_stored_row_with_the_bound_not_found_status_is_data_not_a_placeholder()
|
||||
{
|
||||
var stored = new DataValueSnapshot(null, StatusCodes.BadBoundNotFound, T0.AddSeconds(6), T0.AddSeconds(6));
|
||||
var stored = new DataValueSnapshot(null, StatusCodes.BadBoundNotFound.Code, T0.AddSeconds(6), T0.AddSeconds(6));
|
||||
HistoryBounds.IsPlaceholder(stored).ShouldBeFalse();
|
||||
HistoryBounds.IsPlaceholder(HistoryBounds.NotFound(T0)).ShouldBeTrue();
|
||||
|
||||
@@ -285,12 +285,12 @@ public sealed class NodeManagerHistoryReadPageFillTests(BoundsServerFixture fx)
|
||||
// --- helpers --------------------------------------------------------------------------------
|
||||
|
||||
private static string Render(IEnumerable<DataValue> values) =>
|
||||
string.Join(" | ", values.Select(v => $"{v.SourceTimestamp:HH:mm:ss.fffffff}={v.Value ?? "null"}/{v.StatusCode.Code:X8}"));
|
||||
string.Join(" | ", values.Select(v => $"{v.SourceTimestamp:HH:mm:ss.fffffff}={SdkValueBoundary.ToClr(v.WrappedValue)! ?? "null"}/{v.StatusCode.Code:X8}"));
|
||||
|
||||
private async Task<List<DataValue>> SinglePageAsync(CountingSource source, DateTime start, DateTime end, bool bounds)
|
||||
{
|
||||
var (values, error, cp) = await ReadAsync(source, start, end, 10_000, bounds, cp: null);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
cp.ShouldBeNull();
|
||||
return values.ToList();
|
||||
}
|
||||
@@ -303,7 +303,7 @@ public sealed class NodeManagerHistoryReadPageFillTests(BoundsServerFixture fx)
|
||||
do
|
||||
{
|
||||
var (values, error, next) = await ReadAsync(source, start, end, max, bounds, cp);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
pages.Add(values);
|
||||
cp = next;
|
||||
pages.Count.ShouldBeLessThan(80, "paging must terminate");
|
||||
@@ -328,20 +328,20 @@ public sealed class NodeManagerHistoryReadPageFillTests(BoundsServerFixture fx)
|
||||
private (IReadOnlyList<DataValue> Values, ServiceResult Error, byte[]? Cp) Read(
|
||||
DateTime start, DateTime end, uint max, bool bounds, byte[]? cp)
|
||||
{
|
||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null);
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = start, EndTime = end, NumValuesPerNode = max, ReturnBounds = bounds, IsReadModified = false,
|
||||
};
|
||||
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = fx.NodeId, ContinuationPoint = cp } };
|
||||
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = fx.NodeId, ContinuationPoint = ByteString.From(cp) } };
|
||||
var results = new List<SdkHistoryReadResult> { null! };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
fx.Nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors);
|
||||
|
||||
var values = new List<DataValue>();
|
||||
if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho))
|
||||
values.AddRange(((HistoryData)ExtensionObject.ToEncodeable(ho)).DataValues);
|
||||
return (values, errors[0], results[0]?.ContinuationPoint);
|
||||
if (results[0]?.HistoryData is { } ho && !ho.IsNull)
|
||||
values.AddRange(((HistoryData)ExtensionObject.ToEncodeable(ho)!).DataValues);
|
||||
return (values, errors[0], results[0]?.ContinuationPoint.ToNullableArray());
|
||||
}
|
||||
|
||||
private static CountingSource Source(Shape shape, IReadOnlyList<DataValueSnapshot> series) => shape switch
|
||||
|
||||
+85
-48
@@ -54,7 +54,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
{
|
||||
var (values, error, nextCp) = ReadRaw(nm, nodeId, start: Epoch, end: Epoch.AddHours(1),
|
||||
max: 100, inboundCp: cp);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
collected.AddRange(values);
|
||||
cp = nextCp;
|
||||
if (cp is not null) sawCp = true;
|
||||
@@ -92,7 +92,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
do
|
||||
{
|
||||
var (values, error, next) = ReadRaw(nm, nodeId, Epoch, DateTime.MinValue, max: 100, inboundCp: cp);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
collected.AddRange(values);
|
||||
cp = next;
|
||||
(++pages).ShouldBeLessThan(20);
|
||||
@@ -121,7 +121,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
do
|
||||
{
|
||||
var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddHours(1), max: 100, inboundCp: cp);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
collected.AddRange(values);
|
||||
cp = next;
|
||||
(++pages).ShouldBeLessThan(20);
|
||||
@@ -155,7 +155,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(count: 101, stepSeconds: 1));
|
||||
var (r2, e2, cp2) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: null);
|
||||
r2.Count.ShouldBe(100);
|
||||
e2.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
e2.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
cp2.ShouldNotBeNull();
|
||||
var (r3, _, cp3) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: cp2);
|
||||
r3.ShouldBe([100d]);
|
||||
@@ -184,7 +184,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
Epoch.AddSeconds(2), // 3 │ tied at Epoch+2s
|
||||
Epoch.AddSeconds(2), // 4 ┘
|
||||
};
|
||||
var series = ts.Select((t, i) => new DataValueSnapshot((double)i, StatusCodes.Good, t, t)).ToArray();
|
||||
var series = ts.Select((t, i) => new DataValueSnapshot((double)i, StatusCodes.Good.Code, t, t)).ToArray();
|
||||
nm.HistorianDataSource = new SeriesHistorianDataSource(series);
|
||||
|
||||
nm.EnsureVariable("eq-1/tie", parentFolderNodeId: null, displayName: "Tie", dataType: "Double",
|
||||
@@ -242,7 +242,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
{
|
||||
var (values, error, nextCp) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1),
|
||||
max: 2, inboundCp: cp);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
collected.AddRange(values);
|
||||
cp = nextCp;
|
||||
pageCount++;
|
||||
@@ -273,7 +273,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
// 5 samples ALL sharing one timestamp (Epoch+2s) — a tie cluster of 5 with a page cap of 2.
|
||||
var t = Epoch.AddSeconds(2);
|
||||
var series = Enumerable.Range(0, 5)
|
||||
.Select(i => new DataValueSnapshot((double)i, StatusCodes.Good, t, t)).ToArray();
|
||||
.Select(i => new DataValueSnapshot((double)i, StatusCodes.Good.Code, t, t)).ToArray();
|
||||
nm.HistorianDataSource = new SeriesHistorianDataSource(series);
|
||||
|
||||
nm.EnsureVariable("eq-1/absurd", parentFolderNodeId: null, displayName: "Absurd", dataType: "Double",
|
||||
@@ -282,14 +282,14 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
|
||||
// Page 1: a full page of the first 2 ties, with a continuation point.
|
||||
var (r1, e1, cp1) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 2, inboundCp: null);
|
||||
e1.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
e1.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
r1.ShouldBe(new[] { 0.0, 1.0 });
|
||||
cp1.ShouldNotBeNull();
|
||||
|
||||
// Page 2: the resume read hits the cluster; over-fetch finds 5 > bound 3 ⇒ a clear error, NOT a
|
||||
// silent GoodNoData that would drop the un-emitted ties.
|
||||
var (r2, e2, cp2) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 2, inboundCp: cp1);
|
||||
e2.StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations);
|
||||
e2.StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations.Code);
|
||||
r2.ShouldBeEmpty();
|
||||
cp2.ShouldBeNull();
|
||||
|
||||
@@ -312,7 +312,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
|
||||
var (r, e, cp) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 0, inboundCp: null);
|
||||
|
||||
e.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
e.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
r.Count.ShouldBe(250);
|
||||
cp.ShouldBeNull("an unlimited (max==0) request must not page");
|
||||
|
||||
@@ -338,7 +338,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
var bogus = Guid.NewGuid().ToByteArray();
|
||||
var (_, e, cp) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: bogus);
|
||||
|
||||
e.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
||||
e.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code);
|
||||
cp.ShouldBeNull();
|
||||
fake.ReadCount.ShouldBe(0); // never reached the backend.
|
||||
|
||||
@@ -372,7 +372,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
|
||||
// Resuming the released point now misses.
|
||||
var (_, e, _) = ReadRaw(nm, nodeId, Epoch, Epoch.AddHours(1), max: 100, inboundCp: cp1);
|
||||
e.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
||||
e.StatusCode.Code.ShouldBe(StatusCodes.BadContinuationPointInvalid.Code);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -436,7 +436,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
|
||||
// Microsecond-spaced samples, so a one-tick step would land strictly between two of them.
|
||||
var series = Enumerable.Range(0, 6)
|
||||
.Select(i => { var t = Epoch.AddTicks(10L * i); return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); })
|
||||
.Select(i => { var t = Epoch.AddTicks(10L * i); return new DataValueSnapshot((double)i, StatusCodes.Good.Code, t, t); })
|
||||
.ToArray();
|
||||
nm.HistorianDataSource = new MicrosecondHistorianDataSource(series);
|
||||
|
||||
@@ -458,7 +458,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
|
||||
var series = Enumerable.Range(0, 6)
|
||||
.Select(i => { var t = Epoch.AddTicks(10L * (i + 1)); return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); })
|
||||
.Select(i => { var t = Epoch.AddTicks(10L * (i + 1)); return new DataValueSnapshot((double)i, StatusCodes.Good.Code, t, t); })
|
||||
.ToArray();
|
||||
nm.HistorianDataSource = new MicrosecondHistorianDataSource(series);
|
||||
|
||||
@@ -516,7 +516,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
|
||||
var (values, error, cp) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddSeconds(100), max: 3, inboundCp: null);
|
||||
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
values.ShouldBe([100d, 99d, 98d]);
|
||||
cp.ShouldNotBeNull();
|
||||
source.ReverseWindowCount.ShouldBe(0);
|
||||
@@ -533,7 +533,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
// (end, start] = (Epoch+9s, Epoch+200s] — both edges exercised.
|
||||
var (values, error, cp) = ReadRaw(nm, nodeId, Epoch.AddSeconds(200), Epoch.AddSeconds(9), max: 0, inboundCp: null);
|
||||
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
values.ShouldBe(Enumerable.Range(10, 191).Reverse().Select(i => (double)i));
|
||||
cp.ShouldBeNull();
|
||||
source.ReverseWindowCount.ShouldBe(0);
|
||||
@@ -583,7 +583,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
static DataValueSnapshot At(int year, double v)
|
||||
{
|
||||
var t = new DateTime(year, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
return new DataValueSnapshot(v, StatusCodes.Good, t, t);
|
||||
return new DataValueSnapshot(v, StatusCodes.Good.Code, t, t);
|
||||
}
|
||||
|
||||
var source = new ForwardOnlyHistorianDataSource([At(1990, 1), At(2000, 2)]);
|
||||
@@ -591,7 +591,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
|
||||
var (values, error, cp) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch, max: 5, inboundCp: null);
|
||||
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
values.ShouldBe([2d, 1d]);
|
||||
cp.ShouldBeNull();
|
||||
source.ReadCount.ShouldBeLessThanOrEqualTo(20);
|
||||
@@ -611,7 +611,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
|
||||
var (_, error, cp) = ReadRaw(nm, nodeId, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 2, inboundCp: null);
|
||||
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations.Code);
|
||||
cp.ShouldBeNull();
|
||||
source.ReverseWindowCount.ShouldBe(0);
|
||||
await host.DisposeAsync();
|
||||
@@ -624,7 +624,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
{
|
||||
// 2000 samples 10 ms apart: a 1-minute first segment holds all of them, far over a budget of 64.
|
||||
var series = Enumerable.Range(0, 2000)
|
||||
.Select(i => { var t = Epoch.AddMilliseconds(10L * i); return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); })
|
||||
.Select(i => { var t = Epoch.AddMilliseconds(10L * i); return new DataValueSnapshot((double)i, StatusCodes.Good.Code, t, t); })
|
||||
.ToArray();
|
||||
var source = new ForwardOnlyHistorianDataSource(series);
|
||||
var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdDensity");
|
||||
@@ -640,7 +640,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
while (cp is not null)
|
||||
{
|
||||
var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddSeconds(20), max: 20, inboundCp: cp);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
collected.AddRange(values);
|
||||
cp = next;
|
||||
(++pages).ShouldBeLessThan(200);
|
||||
@@ -665,7 +665,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
{
|
||||
// 100,000 samples 100 ms apart (~2.8 h): far more than the default tie budget would ever let a page read.
|
||||
var series = Enumerable.Range(0, 100_000)
|
||||
.Select(i => { var t = Epoch.AddMilliseconds(100L * i); return new DataValueSnapshot((double)i, StatusCodes.Good, t, t); })
|
||||
.Select(i => { var t = Epoch.AddMilliseconds(100L * i); return new DataValueSnapshot((double)i, StatusCodes.Good.Code, t, t); })
|
||||
.ToArray();
|
||||
var source = new ForwardOnlyHistorianDataSource(series);
|
||||
var (host, nm, nodeId) = await BootForwardOnlyAsync(source, "FwdDense689");
|
||||
@@ -678,7 +678,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
{
|
||||
source.ResetCounts();
|
||||
var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, end, max: 3, inboundCp: cp);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
values.ShouldNotBeEmpty();
|
||||
// #692: every page is full (N = 3) — a resumed page used to net N − 1 after trimming its boundary sample.
|
||||
values.ShouldBe([expected, expected - 1d, expected - 2d], $"page {page} continues exactly");
|
||||
@@ -726,7 +726,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
for (var k = 0; k < ties; k++)
|
||||
{
|
||||
var ts = new DateTime(t, DateTimeKind.Utc);
|
||||
series.Add(new DataValueSnapshot((double)v++, StatusCodes.Good, ts, ts));
|
||||
series.Add(new DataValueSnapshot((double)v++, StatusCodes.Good.Code, ts, ts));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -759,7 +759,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
break;
|
||||
}
|
||||
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"seed {seed}, N = {max}, ms max {msMax}");
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code, $"seed {seed}, N = {max}, ms max {msMax}");
|
||||
collected.AddRange(values);
|
||||
cp = next;
|
||||
(++pages).ShouldBeLessThan(2000, "paging must terminate, not loop");
|
||||
@@ -793,7 +793,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
{
|
||||
source.ResetCounts();
|
||||
var (values, error, next) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddHours(1), max: 10, inboundCp: cp);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
collected.AddRange(values);
|
||||
cp = next;
|
||||
page++;
|
||||
@@ -818,7 +818,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
|
||||
var (values, error, _) = ReadRaw(nm, nodeId, DateTime.MaxValue, Epoch.AddSeconds(-1), max: 3, inboundCp: null);
|
||||
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
values.ShouldBe([249d, 248d, 247d]);
|
||||
source.MaxRequestedEnd.ShouldBeLessThanOrEqualTo(now.AddDays(1).AddMilliseconds(1));
|
||||
source.ReadCount.ShouldBeLessThanOrEqualTo(10);
|
||||
@@ -836,7 +836,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
|
||||
var (values, error, _) = ReadRaw(nm, nodeId, Epoch.AddSeconds(10), Epoch.AddTicks(5_000), max: 100, inboundCp: null);
|
||||
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
values.ShouldBe(Enumerable.Range(1, 10).Reverse().Select(i => (double)i)); // (Epoch+0.5ms, Epoch+10s]: no 0
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -849,7 +849,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
DataValueSnapshot At(long ticks, double v)
|
||||
{
|
||||
var t = Epoch.AddTicks(ticks);
|
||||
return new DataValueSnapshot(v, StatusCodes.Good, t, t);
|
||||
return new DataValueSnapshot(v, StatusCodes.Good.Code, t, t);
|
||||
}
|
||||
|
||||
var t0 = TimeSpan.TicksPerSecond;
|
||||
@@ -858,7 +858,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
|
||||
var (values, error, _) = ReadRaw(nm, nodeId, Epoch.AddTicks(t0), Epoch.AddSeconds(-1), max: 10, inboundCp: null);
|
||||
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
values.ShouldBe([1d, 0d]);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -874,7 +874,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
|
||||
var (values, error, _) = ReadRaw(nm, nodeId, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 100, inboundCp: null);
|
||||
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
values.ShouldBe(Enumerable.Range(0, 8).Reverse().Select(i => (double)i));
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -889,7 +889,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
for (var c = 0; c < 3; c++)
|
||||
{
|
||||
var t = Epoch.AddSeconds(5).AddMilliseconds(c);
|
||||
for (var i = 0; i < 4; i++) series.Add(new DataValueSnapshot((double)v++, StatusCodes.Good, t, t));
|
||||
for (var i = 0; i < 4; i++) series.Add(new DataValueSnapshot((double)v++, StatusCodes.Good.Code, t, t));
|
||||
}
|
||||
|
||||
var source = new ForwardOnlyHistorianDataSource(series);
|
||||
@@ -898,7 +898,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
|
||||
var (values, error, _) = ReadRaw(nm, nodeId, Epoch.AddHours(1), Epoch.AddSeconds(-1), max: 100, inboundCp: null);
|
||||
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
values.ShouldBe(Enumerable.Range(0, 12).Reverse().Select(i => (double)i));
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -916,7 +916,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
|
||||
var (values, error, _) = ReadRaw(nm, nodeId, DateTime.MinValue, Epoch.AddSeconds(100), max: 3, inboundCp: null);
|
||||
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
values.ShouldBe([100d, 99d, 98d]);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -951,7 +951,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
do
|
||||
{
|
||||
var (values, error, nextCp) = ReadRaw(nm, nodeId, start, end, max, inboundCp: cp);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
error.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
collected.AddRange(values);
|
||||
cp = nextCp;
|
||||
pageCount++;
|
||||
@@ -965,11 +965,48 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
|
||||
/// <summary>Issue a single-node Raw HistoryRead and return that node's decoded sample values, error,
|
||||
/// and outbound continuation point.</summary>
|
||||
[Fact]
|
||||
public async Task A_cursor_cannot_cross_aliases_that_share_a_historian_tagname()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
await using var ownedHost = host;
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
|
||||
nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(10, 1));
|
||||
foreach (var realm in new[] { AddressSpaceRealm.Raw, AddressSpaceRealm.Uns })
|
||||
nm.EnsureVariable("same", null, "Same", "Double", false, realm, "WW.PV");
|
||||
var raw = nm.TryGetVariable("same", AddressSpaceRealm.Raw)!.NodeId;
|
||||
var uns = nm.TryGetVariable("same", AddressSpaceRealm.Uns)!.NodeId;
|
||||
var first = ReadRaw(nm, raw, Epoch, Epoch.AddHours(1), 2, null);
|
||||
first.Cp.ShouldNotBeNull();
|
||||
ReadRaw(nm, uns, Epoch, Epoch.AddHours(1), 2, first.Cp).Error.StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public async Task Topology_replacement_invalidates_an_existing_cursor(bool rebuild)
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
await using var ownedHost = host;
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistoryContinuationStore = new InMemoryHistoryContinuationStore();
|
||||
nm.HistorianDataSource = new SeriesHistorianDataSource(MakeSeries(10, 1));
|
||||
nm.EnsureVariable("v", null, "Value", "Double", false, AddressSpaceRealm.Raw, "WW.PV");
|
||||
var node = nm.TryGetVariable("v", AddressSpaceRealm.Raw)!.NodeId;
|
||||
var first = ReadRaw(nm, node, Epoch, Epoch.AddHours(1), 2, null);
|
||||
first.Cp.ShouldNotBeNull();
|
||||
if (rebuild) nm.RebuildAddressSpace();
|
||||
else nm.RemoveVariableNode("v", AddressSpaceRealm.Raw).ShouldBeTrue();
|
||||
nm.EnsureVariable("v", null, "Value", "Double", false, AddressSpaceRealm.Raw, "WW.PV");
|
||||
ReadRaw(nm, node, Epoch, Epoch.AddHours(1), 2, first.Cp).Error.StatusCode.ShouldBe(StatusCodes.BadContinuationPointInvalid);
|
||||
}
|
||||
|
||||
private static (IReadOnlyList<double> Values, ServiceResult Error, byte[]? Cp) ReadRaw(
|
||||
OtOpcUaNodeManager nm, NodeId nodeId, DateTime start, DateTime end, uint max, byte[]? inboundCp)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null);
|
||||
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
@@ -982,7 +1019,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
|
||||
var nodesToRead = new List<HistoryReadValueId>
|
||||
{
|
||||
new() { NodeId = nodeId, ContinuationPoint = inboundCp },
|
||||
new() { NodeId = nodeId, ContinuationPoint = ByteString.From(inboundCp) },
|
||||
};
|
||||
var results = new List<SdkHistoryReadResult> { null! };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
@@ -991,26 +1028,26 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
releaseContinuationPoints: false, nodesToRead, results, errors);
|
||||
|
||||
var values = new List<double>();
|
||||
if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho))
|
||||
if (results[0]?.HistoryData is { } ho && !ho.IsNull)
|
||||
{
|
||||
var data = (HistoryData)ExtensionObject.ToEncodeable(ho);
|
||||
foreach (var dv in data.DataValues) values.Add((double)dv.Value);
|
||||
var data = (HistoryData)ExtensionObject.ToEncodeable(ho)!;
|
||||
foreach (var dv in data.DataValues) values.Add((double)SdkValueBoundary.ToClr(dv.WrappedValue)!);
|
||||
}
|
||||
|
||||
return (values, errors[0], results[0]?.ContinuationPoint);
|
||||
return (values, errors[0], results[0]?.ContinuationPoint.ToNullableArray());
|
||||
}
|
||||
|
||||
/// <summary>Issue a release-only HistoryRead (releaseContinuationPoints=true) for the node + point.</summary>
|
||||
private static void InvokeRelease(OtOpcUaNodeManager nm, NodeId nodeId, byte[] cp)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null);
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
ReturnBounds = false,
|
||||
StartTime = Epoch, EndTime = Epoch.AddHours(1), NumValuesPerNode = 100, IsReadModified = false,
|
||||
};
|
||||
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = nodeId, ContinuationPoint = cp } };
|
||||
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = nodeId, ContinuationPoint = ByteString.From(cp) } };
|
||||
var results = new List<SdkHistoryReadResult> { null! };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
|
||||
@@ -1023,7 +1060,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
.Select(i =>
|
||||
{
|
||||
var t = Epoch.AddSeconds((long)i * stepSeconds);
|
||||
return new DataValueSnapshot((double)i, StatusCodes.Good, t, t);
|
||||
return new DataValueSnapshot((double)i, StatusCodes.Good.Code, t, t);
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
@@ -1040,17 +1077,17 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
for (var i = 0; i < distinctBefore; i++, second++)
|
||||
{
|
||||
var t = Epoch.AddSeconds(second);
|
||||
samples.Add(new DataValueSnapshot((double)value++, StatusCodes.Good, t, t));
|
||||
samples.Add(new DataValueSnapshot((double)value++, StatusCodes.Good.Code, t, t));
|
||||
}
|
||||
|
||||
var tieT = Epoch.AddSeconds(second++); // the single shared timestamp for the whole cluster
|
||||
for (var i = 0; i < tieCount; i++)
|
||||
samples.Add(new DataValueSnapshot((double)value++, StatusCodes.Good, tieT, tieT));
|
||||
samples.Add(new DataValueSnapshot((double)value++, StatusCodes.Good.Code, tieT, tieT));
|
||||
|
||||
for (var i = 0; i < distinctAfter; i++, second++)
|
||||
{
|
||||
var t = Epoch.AddSeconds(second);
|
||||
samples.Add(new DataValueSnapshot((double)value++, StatusCodes.Good, t, t));
|
||||
samples.Add(new DataValueSnapshot((double)value++, StatusCodes.Good.Code, t, t));
|
||||
}
|
||||
|
||||
return samples.ToArray();
|
||||
@@ -1176,7 +1213,7 @@ public sealed class NodeManagerHistoryReadPagingTests : IDisposable
|
||||
var rows = new List<DataValueSnapshot>();
|
||||
if (emitBoundaryRow)
|
||||
rows.Add(prior is null
|
||||
? new DataValueSnapshot(0d, StatusCodes.BadNoData, stamp, stamp)
|
||||
? new DataValueSnapshot(0d, StatusCodes.BadNoData.Code, stamp, stamp)
|
||||
: new DataValueSnapshot(prior.Value, prior.StatusCode, stamp, stamp));
|
||||
rows.AddRange(series.Where(s => Ts(s) >= startUtc && Ts(s) <= endUtc));
|
||||
var page = maxValuesPerNode == 0 ? rows : rows.Take((int)maxValuesPerNode).ToList();
|
||||
|
||||
+16
-16
@@ -102,7 +102,7 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx
|
||||
Count("oracle refused");
|
||||
continue;
|
||||
}
|
||||
truthError.StatusCode.Code.ShouldBe(StatusCodes.Good, $"oracle: {tag}");
|
||||
truthError.StatusCode.Code.ShouldBe(StatusCodes.Good.Code, $"oracle: {tag}");
|
||||
truthCp.ShouldBeNull($"oracle (no cap, N = 0) is one page: {tag}");
|
||||
if (!bounds)
|
||||
Render(truth).ShouldBe(Render(Oracle(series, start, end, direction, resolution)), $"oracle vs series: {tag}");
|
||||
@@ -124,8 +124,8 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx
|
||||
// A boundary tie cluster as large as the cap leaves a resumed page no room: the server steps past it with one
|
||||
// empty page carrying a continuation point (the documented #694 ceiling behaviour) — the one empty page allowed.
|
||||
var oversizedClusters = cap > 0
|
||||
? truth.Where(v => !(v.Value is null && v.StatusCode.Code == StatusCodes.BadBoundNotFound))
|
||||
.GroupBy(v => v.SourceTimestamp.Ticks).Count(g => g.Count() >= Math.Max(2, cap - 1))
|
||||
? truth.Where(v => !(SdkValueBoundary.ToClr(v.WrappedValue)! is null && v.StatusCode.Code == StatusCodes.BadBoundNotFound))
|
||||
.GroupBy(v => v.SourceTimestamp.ToDateTime().Ticks).Count(g => g.Count() >= Math.Max(2, cap - 1))
|
||||
: 0;
|
||||
|
||||
// The read under test.
|
||||
@@ -163,7 +163,7 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx
|
||||
continue;
|
||||
}
|
||||
|
||||
status.Code.ShouldBe(StatusCodes.Good, $"{tag}");
|
||||
status.Code.ShouldBe(StatusCodes.Good.Code, $"{tag}");
|
||||
Render(collected).ShouldBe(Render(truth), $"paged ≠ unpaged: {tag} pages {Sizes(pages)}");
|
||||
if (cap > 0)
|
||||
{
|
||||
@@ -179,8 +179,8 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx
|
||||
{
|
||||
if (pages[i].Count > 0) continue;
|
||||
// Allowed only as the step past a boundary tie cluster the cap cannot page within.
|
||||
var previousLast = i > 0 && pages[i - 1].Count > 0 ? pages[i - 1][^1].SourceTimestamp.Ticks : (long?)null;
|
||||
var clusterAtBoundary = previousLast is { } t ? truth.Count(v => v.SourceTimestamp.Ticks == t) : 0;
|
||||
var previousLast = i > 0 && pages[i - 1].Count > 0 ? pages[i - 1][^1].SourceTimestamp.ToDateTime().Ticks : (long?)null;
|
||||
var clusterAtBoundary = previousLast is { } t ? truth.Count(v => v.SourceTimestamp.ToDateTime().Ticks == t) : 0;
|
||||
(cap > 0 && clusterAtBoundary >= Math.Max(2, cap - 1)).ShouldBeTrue(
|
||||
$"an empty page with a continuation point (page {i}, boundary cluster {clusterAtBoundary}): {tag} pages {Sizes(pages)}");
|
||||
Count("empty step over an oversized tie cluster");
|
||||
@@ -259,7 +259,7 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx
|
||||
{
|
||||
var ts = Trunc(new DateTime(t, DateTimeKind.Utc), resolution);
|
||||
var status = rng.Next(12) == 0 ? StatusCodes.Bad : StatusCodes.Good;
|
||||
list.Add(new DataValueSnapshot(status == StatusCodes.Bad ? null : (double)v, status, ts, ts));
|
||||
list.Add(new DataValueSnapshot(status == StatusCodes.Bad.Code ? null : (double)v, status.Code, ts, ts));
|
||||
v++;
|
||||
}
|
||||
}
|
||||
@@ -302,13 +302,13 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx
|
||||
Direction.Reverse => series.Where(x => Ts(x) > e && Ts(x) <= s).Reverse(),
|
||||
_ => series.Where(x => Ts(x) <= e).Reverse(),
|
||||
};
|
||||
return window.Select(x => new DataValue(new Variant(x.Value), new StatusCode(x.StatusCode), Ts(x), Ts(x))).ToList();
|
||||
return window.Select(x => new DataValue(SdkValueBoundary.FromClr(x.Value), new StatusCode(x.StatusCode), Ts(x), Ts(x))).ToList();
|
||||
}
|
||||
|
||||
/// <summary>The largest tie cluster among the returned stored values (a bound placeholder is not a stored value).</summary>
|
||||
private static int LargestTieCluster(IReadOnlyList<DataValue> truth) =>
|
||||
truth.Where(v => !(v.Value is null && v.StatusCode.Code == StatusCodes.BadBoundNotFound))
|
||||
.GroupBy(v => v.SourceTimestamp.Ticks).Select(g => g.Count()).DefaultIfEmpty(0).Max();
|
||||
truth.Where(v => !(SdkValueBoundary.ToClr(v.WrappedValue)! is null && v.StatusCode.Code == StatusCodes.BadBoundNotFound))
|
||||
.GroupBy(v => v.SourceTimestamp.ToDateTime().Ticks).Select(g => g.Count()).DefaultIfEmpty(0).Max();
|
||||
|
||||
/// <summary>The most samples within one millisecond anywhere in the series (the walk-back's refusal rule).</summary>
|
||||
private static int MillisecondMax(List<DataValueSnapshot> series)
|
||||
@@ -334,24 +334,24 @@ public sealed class NodeManagerHistoryReadRawCapFuzzTests(BoundsServerFixture fx
|
||||
private static string Sizes(IEnumerable<IReadOnlyList<DataValue>> pages) => "[" + string.Join(",", pages.Select(p => p.Count)) + "]";
|
||||
|
||||
private static string Render(IEnumerable<DataValue> values) =>
|
||||
string.Join(" | ", values.Select(v => $"{v.SourceTimestamp.Ticks}={v.Value ?? "null"}/{v.StatusCode.Code:X8}"));
|
||||
string.Join(" | ", values.Select(v => $"{v.SourceTimestamp.ToDateTime().Ticks}={SdkValueBoundary.ToClr(v.WrappedValue)! ?? "null"}/{v.StatusCode.Code:X8}"));
|
||||
|
||||
private (IReadOnlyList<DataValue> Values, ServiceResult Error, byte[]? Cp) Read(DateTime start, DateTime end, uint max, bool bounds, byte[]? cp)
|
||||
{
|
||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null);
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = start, EndTime = end, NumValuesPerNode = max, ReturnBounds = bounds, IsReadModified = false,
|
||||
};
|
||||
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = fx.NodeId, ContinuationPoint = cp } };
|
||||
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = fx.NodeId, ContinuationPoint = ByteString.From(cp) } };
|
||||
var results = new List<SdkHistoryReadResult> { null! };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
fx.Nm.HistoryRead(context, details, TimestampsToReturn.Both, releaseContinuationPoints: false, nodesToRead, results, errors);
|
||||
|
||||
var values = new List<DataValue>();
|
||||
if (results[0]?.HistoryData is { } ho && !ExtensionObject.IsNull(ho))
|
||||
values.AddRange(((HistoryData)ExtensionObject.ToEncodeable(ho)).DataValues);
|
||||
return (values, errors[0], results[0]?.ContinuationPoint);
|
||||
if (results[0]?.HistoryData is { } ho && !ho.IsNull)
|
||||
values.AddRange(((HistoryData)ExtensionObject.ToEncodeable(ho)!).DataValues);
|
||||
return (values, errors[0], results[0]?.ContinuationPoint.ToNullableArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -46,7 +46,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
var src = DateTime.UtcNow.AddSeconds(-5);
|
||||
var srv = DateTime.UtcNow;
|
||||
fake.RawResult = new HistorianRead(
|
||||
new[] { new DataValueSnapshot(42.5f, StatusCodes.Good, src, srv) }, null);
|
||||
new[] { new DataValueSnapshot(42.5f, StatusCodes.Good.Code, src, srv) }, null);
|
||||
|
||||
var start = DateTime.UtcNow.AddHours(-1);
|
||||
var end = DateTime.UtcNow;
|
||||
@@ -68,13 +68,13 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
fake.LastEnd.ShouldBe(end);
|
||||
fake.LastMaxValues.ShouldBe(101u, "the page plus one lookahead row (#692) — it tells the server whether more follows");
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!;
|
||||
data.DataValues.Count.ShouldBe(1);
|
||||
var dv = data.DataValues[0];
|
||||
dv.Value.ShouldBe(42.5f);
|
||||
dv.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
SdkValueBoundary.ToClr(dv.WrappedValue)!.ShouldBe(42.5f);
|
||||
dv.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
dv.SourceTimestamp.ShouldBe(src);
|
||||
dv.ServerTimestamp.ShouldBe(srv);
|
||||
|
||||
@@ -140,10 +140,10 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
|
||||
var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.GoodNoData);
|
||||
var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
||||
data.DataValues.ShouldBeEmpty();
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.GoodNoData.Code);
|
||||
var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!;
|
||||
data.DataValues.Count.ShouldBe(0);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -174,7 +174,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
|
||||
var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
fake.LastCall.ShouldBeNull(); // source never reached
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -208,14 +208,14 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
|
||||
var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
fake.LastCall.ShouldBeNull(); // the raw read is never served in place of a modified one
|
||||
|
||||
// The Null default (no historian configured) answers the same.
|
||||
nm.HistorianDataSource = NullHistorianDataSource.Instance;
|
||||
InvokeHistoryRead(server, nm, details, nodeId).Item2[0].StatusCode.Code
|
||||
.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -239,7 +239,8 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||
EndTime = DateTime.UtcNow,
|
||||
ProcessingInterval = 10_000.0, // ms
|
||||
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Average },
|
||||
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
|
||||
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_Average },
|
||||
};
|
||||
|
||||
InvokeHistoryRead(server, nm, details, nodeId);
|
||||
@@ -272,12 +273,13 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
EndTime = DateTime.UtcNow,
|
||||
ProcessingInterval = 10_000.0,
|
||||
// StandardDeviationSample is a real OPC UA aggregate we do not serve.
|
||||
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_StandardDeviationSample },
|
||||
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
|
||||
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_StandardDeviationSample },
|
||||
};
|
||||
|
||||
var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported.Code);
|
||||
fake.LastCall.ShouldBeNull();
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -310,12 +312,13 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||
EndTime = DateTime.UtcNow,
|
||||
ProcessingInterval = 10_000.0,
|
||||
AggregateType = new NodeIdCollection { aggregate },
|
||||
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
|
||||
AggregateType = new List<NodeId> { aggregate },
|
||||
};
|
||||
|
||||
// Average is mapped by the server but not in this backend's set ⇒ refused, no call.
|
||||
InvokeHistoryRead(server, nm, Details(ObjectIds.AggregateFunction_Average), nodeId).Item2[0].StatusCode.Code
|
||||
.ShouldBe(StatusCodes.BadAggregateNotSupported);
|
||||
.ShouldBe(StatusCodes.BadAggregateNotSupported.Code);
|
||||
fake.LastCall.ShouldBeNull();
|
||||
|
||||
// Positive control: a member of the set reaches the backend.
|
||||
@@ -326,7 +329,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
// No historian: the Null default serves no aggregate.
|
||||
nm.HistorianDataSource = NullHistorianDataSource.Instance;
|
||||
InvokeHistoryRead(server, nm, Details(ObjectIds.AggregateFunction_TimeAverage), nodeId).Item2[0].StatusCode.Code
|
||||
.ShouldBe(StatusCodes.BadAggregateNotSupported);
|
||||
.ShouldBe(StatusCodes.BadAggregateNotSupported.Code);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -354,13 +357,14 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||
EndTime = DateTime.UtcNow,
|
||||
ProcessingInterval = 10_000.0,
|
||||
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_StandardDeviationPopulation },
|
||||
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
|
||||
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_StandardDeviationPopulation },
|
||||
}, nodeId);
|
||||
StatusCode.IsBad(errors[0].StatusCode).ShouldBeFalse($"got {errors[0].StatusCode}");
|
||||
fake.LastCall.ShouldBe("Processed");
|
||||
fake.LastAggregate.ShouldBe(HistoryAggregateType.StandardDeviationPopulation);
|
||||
|
||||
var diagnostics = (CustomNodeManager2)nm.Server.DiagnosticsNodeManager;
|
||||
var diagnostics = (AsyncCustomNodeManager)nm.Server.DiagnosticsNodeManager;
|
||||
var folder = diagnostics.FindPredefinedNode<NodeState>(ObjectIds.HistoryServerCapabilities_AggregateFunctions)!;
|
||||
AggregateFunctionMap.ByNodeId.Keys.Count(id => folder.ReferenceExists(ReferenceTypeIds.Organizes, false, id))
|
||||
.ShouldBe(AggregateFunctionMap.ByNodeId.Count);
|
||||
@@ -397,7 +401,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
|
||||
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_TimeAverage },
|
||||
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_TimeAverage },
|
||||
AggregateConfiguration = new AggregateConfiguration
|
||||
{
|
||||
UseServerCapabilitiesDefaults = false, TreatUncertainAsBad = treatUncertainAsBad,
|
||||
@@ -405,7 +409,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
},
|
||||
}, nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateConfigurationRejected);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateConfigurationRejected.Code);
|
||||
fake.LastCall.ShouldBeNull();
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -432,7 +436,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
|
||||
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_TimeAverage },
|
||||
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_TimeAverage },
|
||||
AggregateConfiguration = new AggregateConfiguration
|
||||
{
|
||||
UseServerCapabilitiesDefaults = useServerDefaults, TreatUncertainAsBad = treatUncertainAsBad,
|
||||
@@ -455,7 +459,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
[Fact]
|
||||
public void The_server_aggregate_defaults_deliberately_diverge_from_the_SDK_on_TreatUncertainAsBad()
|
||||
{
|
||||
var sdk = new Opc.Ua.Server.AggregateManager(null!).GetDefaultConfiguration(null);
|
||||
var sdk = new Opc.Ua.Server.AggregateManager(null!).GetDefaultConfiguration(NodeId.Null);
|
||||
var ours = ServerAggregateConfiguration.Defaults;
|
||||
ours.TreatUncertainAsBad.ShouldBeFalse();
|
||||
ours.PercentDataBad.ShouldBe((byte)100);
|
||||
@@ -476,7 +480,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
public async Task The_booted_SDK_AggregateManager_default_is_our_TreatUncertainAsBad_false()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var cfg = server.CurrentInstance.AggregateManager.GetDefaultConfiguration(null);
|
||||
var cfg = server.CurrentInstance.AggregateManager.GetDefaultConfiguration(NodeId.Null);
|
||||
cfg.TreatUncertainAsBad.ShouldBeFalse();
|
||||
cfg.PercentDataBad.ShouldBe(ServerAggregateConfiguration.PercentDataBad);
|
||||
cfg.PercentDataGood.ShouldBe(ServerAggregateConfiguration.PercentDataGood);
|
||||
@@ -500,7 +504,8 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
InvokeHistoryRead(server, nm, new ReadProcessedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
|
||||
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Interpolative },
|
||||
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
|
||||
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_Interpolative },
|
||||
}, nodeId);
|
||||
|
||||
fake.LastCall.ShouldBe("Processed");
|
||||
@@ -528,7 +533,8 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
InvokeHistoryRead(server, nm, new ReadProcessedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
|
||||
AggregateType = new NodeIdCollection { aggregateId },
|
||||
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
|
||||
AggregateType = new List<NodeId> { aggregateId },
|
||||
}, nodeId);
|
||||
|
||||
fake.LastCall.ShouldBe("Processed");
|
||||
@@ -551,10 +557,11 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
|
||||
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_PercentGood },
|
||||
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
|
||||
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_PercentGood },
|
||||
}, nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported.Code);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -573,10 +580,11 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
var (_, errors) = InvokeHistoryRead(server, nm, new ReadProcessedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, ProcessingInterval = 10_000.0,
|
||||
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Total },
|
||||
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
|
||||
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_Total },
|
||||
}, nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadTooManyOperations.Code);
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
@@ -597,7 +605,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
var t2 = DateTime.UtcNow.AddMinutes(-1);
|
||||
var details = new ReadAtTimeDetails
|
||||
{
|
||||
ReqTimes = new DateTimeCollection { t1, t2 },
|
||||
ReqTimes = new List<DateTimeUtc> { t1, t2 },
|
||||
};
|
||||
|
||||
InvokeHistoryRead(server, nm, details, nodeId);
|
||||
@@ -638,7 +646,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
var nodeIds = paths.Select((p, i) => nm.TryGetVariable(p, realms[i])!.NodeId).ToArray();
|
||||
|
||||
var (_, errors) = InvokeHistoryRead(server, nm,
|
||||
new ReadAtTimeDetails { ReqTimes = new DateTimeCollection { DateTime.UtcNow.AddMinutes(-1) } }, nodeIds);
|
||||
new ReadAtTimeDetails { ReqTimes = new List<DateTimeUtc> { DateTime.UtcNow.AddMinutes(-1) } }, nodeIds);
|
||||
|
||||
errors.ShouldAllBe(e => StatusCode.IsGood(e.StatusCode));
|
||||
fake.Hints["R.Flag"].ShouldBe(TypeCode.Boolean);
|
||||
@@ -707,7 +715,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
// Per-tagname fake: A.PV returns one sample; B.PV throws.
|
||||
var perTagFake = new PerTagnameHistorianDataSource(tagname =>
|
||||
tagname == "A.PV"
|
||||
? Task.FromResult(new HistorianRead(new[] { new DataValueSnapshot(1.0f, StatusCodes.Good, src, srv) }, null))
|
||||
? Task.FromResult(new HistorianRead(new[] { new DataValueSnapshot(1.0f, StatusCodes.Good.Code, src, srv) }, null))
|
||||
: throw new InvalidOperationException("backend boom for B.PV"));
|
||||
nm.HistorianDataSource = perTagFake;
|
||||
|
||||
@@ -725,20 +733,20 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
var (results, errors) = InvokeHistoryRead(server, nm, details, goodNodeId, badNodeId);
|
||||
|
||||
// Good node (index 0): should have a Good status + one sample.
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!;
|
||||
data.DataValues.Count.ShouldBe(1);
|
||||
data.DataValues[0].Value.ShouldBe(1.0f);
|
||||
SdkValueBoundary.ToClr(data.DataValues[0].WrappedValue)!.ShouldBe(1.0f);
|
||||
|
||||
// Bad node (index 1): errors slot must be Bad; results slot must NOT carry any HistoryData
|
||||
// (no data was projected into it). The base may pre-seed results[i] to a default-constructed
|
||||
// SdkHistoryReadResult whose StatusCode is 0 (Good) — that's fine for the result field, but the
|
||||
// distinguishing signal is the error slot AND the absence of HistoryData from the good node.
|
||||
StatusCode.IsBad(errors[1].StatusCode).ShouldBeTrue();
|
||||
errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
// HistoryData must be null/empty — the good node's sample MUST NOT have leaked into slot 1.
|
||||
(results[1].HistoryData == null || ExtensionObject.IsNull(results[1].HistoryData)).ShouldBeTrue();
|
||||
results[1].HistoryData.IsNull.ShouldBeTrue();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -770,13 +778,47 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId);
|
||||
|
||||
StatusCode.IsBad(errors[0].StatusCode).ShouldBeTrue();
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
// The result slot was never filled with a GoodNoData success.
|
||||
(results[0].StatusCode.Code == StatusCodes.GoodNoData).ShouldBeFalse();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Canceling_the_SDK_request_cancels_an_inflight_backend_read()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
await using var ownedHost = host;
|
||||
var nm = server.NodeManager!;
|
||||
nm.HistoryReadDeadline = TimeSpan.Zero;
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
nm.HistorianDataSource = new RecordingHistorianDataSource
|
||||
{
|
||||
RawRead = async ct =>
|
||||
{
|
||||
entered.SetResult();
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, ct);
|
||||
return Empty();
|
||||
},
|
||||
};
|
||||
nm.EnsureVariable("cancel", null, "Cancel", "Double", false, AddressSpaceRealm.Raw, "cancel.tag");
|
||||
var node = nm.TryGetVariable("cancel", AddressSpaceRealm.Raw)!.NodeId;
|
||||
using var lifetime = new RequestLifetime();
|
||||
using var context = new OperationContext(new RequestHeader(), null, RequestType.HistoryRead, lifetime);
|
||||
var results = new List<SdkHistoryReadResult> { null! };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
var read = Task.Run(() => nm.HistoryRead(context, new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1), EndTime = DateTime.UtcNow, NumValuesPerNode = 10,
|
||||
}, TimestampsToReturn.Both, false, [new HistoryReadValueId { NodeId = node }], results, errors), Ct);
|
||||
await entered.Task.WaitAsync(TimeSpan.FromSeconds(5), Ct);
|
||||
lifetime.TryCancel(StatusCodes.BadRequestCancelledByClient).ShouldBeTrue();
|
||||
await read.WaitAsync(TimeSpan.FromSeconds(5), Ct);
|
||||
StatusCode.IsBad(errors[0].StatusCode).ShouldBeTrue();
|
||||
results[0].ContinuationPoint.IsEmpty.ShouldBeTrue();
|
||||
}
|
||||
|
||||
/// <summary>Invoke the node manager's public HistoryRead with a single node, returning the filled
|
||||
/// results + errors. Uses a session-less <see cref="OperationContext"/> (the
|
||||
/// (RequestHeader, SecureChannelContext, RequestType, IUserIdentity) ctor) — HistoryRead's
|
||||
@@ -791,7 +833,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, params NodeId[] nodeIds)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, requestLifetime: RequestLifetime.None, identity: null);
|
||||
|
||||
var nodesToRead = nodeIds.Select(id => new HistoryReadValueId { NodeId = id }).ToList();
|
||||
var results = Enumerable.Repeat<SdkHistoryReadResult>(null!, nodeIds.Length).ToList();
|
||||
@@ -825,13 +867,14 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
HistoryReadDetails details = kind switch
|
||||
{
|
||||
"Raw" => new ReadRawModifiedDetails { IsReadModified = false, ReturnBounds = false, StartTime = t, EndTime = t.AddSeconds(10) },
|
||||
"AtTime" => new ReadAtTimeDetails { ReqTimes = new DateTimeCollection { t } },
|
||||
"AtTime" => new ReadAtTimeDetails { ReqTimes = new List<DateTimeUtc> { t } },
|
||||
_ => new ReadProcessedDetails { StartTime = t, EndTime = t.AddSeconds(10), ProcessingInterval = 1000,
|
||||
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Average } }
|
||||
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
|
||||
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_Average } }
|
||||
};
|
||||
var (results, errors) = InvokeHistoryRead(server, nm, details, nm.TryGetVariable("eq/comm")!.NodeId);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -850,20 +893,20 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
{
|
||||
var nm = server.NodeManager!;
|
||||
var t = DateTime.UtcNow.AddMinutes(-1);
|
||||
var value = new HistorianRead([new DataValueSnapshot(1d, StatusCodes.Good, t, t)], null);
|
||||
var value = new HistorianRead([new DataValueSnapshot(1d, StatusCodes.Good.Code, t, t)], null);
|
||||
nm.HistorianDataSource = new RecordingHistorianDataSource { RawResult = value, AtTimeResult = value };
|
||||
nm.EnsureVariable("eq/typed", null, "Typed", dataType, false, historianTagname: "T", realm: AddressSpaceRealm.Uns);
|
||||
HistoryReadDetails details = kind == "Raw"
|
||||
? new ReadRawModifiedDetails { IsReadModified = false, ReturnBounds = false, StartTime = t, EndTime = t.AddSeconds(10) }
|
||||
: new ReadAtTimeDetails { ReqTimes = new DateTimeCollection { t } };
|
||||
: new ReadAtTimeDetails { ReqTimes = new List<DateTimeUtc> { t } };
|
||||
var (results, errors) = InvokeHistoryRead(server, nm, details, nm.TryGetVariable("eq/typed")!.NodeId);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
var actual = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)).DataValues.Single();
|
||||
if (dataType == "Int32") actual.Value.ShouldBeOfType<int>().ShouldBe(1);
|
||||
if (dataType == "Boolean") actual.Value.ShouldBeOfType<bool>().ShouldBeTrue();
|
||||
if (dataType == "Float") actual.Value.ShouldBeOfType<float>().ShouldBe(1f);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
var actual = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!).DataValues.ToArray()!.Single();
|
||||
if (dataType == "Int32") SdkValueBoundary.ToClr(actual.WrappedValue)!.ShouldBeOfType<int>().ShouldBe(1);
|
||||
if (dataType == "Boolean") SdkValueBoundary.ToClr(actual.WrappedValue)!.ShouldBeOfType<bool>().ShouldBeTrue();
|
||||
if (dataType == "Float") SdkValueBoundary.ToClr(actual.WrappedValue)!.ShouldBeOfType<float>().ShouldBe(1f);
|
||||
actual.SourceTimestamp.ShouldBe(t);
|
||||
actual.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
actual.StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -879,12 +922,12 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
var t = DateTime.UtcNow.AddMinutes(-1);
|
||||
nm.EnsureVariable("eq/mismatch", null, "Mismatch", type, false, historianTagname: "T", realm: AddressSpaceRealm.Uns);
|
||||
nm.HistorianDataSource = new RecordingHistorianDataSource { RawResult = new HistorianRead([new(input, StatusCodes.Good, t, t)], null) };
|
||||
nm.HistorianDataSource = new RecordingHistorianDataSource { RawResult = new HistorianRead([new(input, StatusCodes.Good.Code, t, t)], null) };
|
||||
var (results, _) = InvokeHistoryRead(server, nm, new ReadRawModifiedDetails { IsReadModified = false, ReturnBounds = false,
|
||||
StartTime = t, EndTime = t.AddSeconds(10) }, nm.TryGetVariable("eq/mismatch")!.NodeId);
|
||||
var value = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)).DataValues.Single();
|
||||
value.Value.ShouldBeNull();
|
||||
value.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch);
|
||||
var value = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!).DataValues.ToArray()!.Single();
|
||||
SdkValueBoundary.ToClr(value.WrappedValue)!.ShouldBeNull();
|
||||
value.StatusCode.Code.ShouldBe(StatusCodes.BadTypeMismatch.Code);
|
||||
value.SourceTimestamp.ShouldBe(t);
|
||||
value.ServerTimestamp.ShouldBe(t);
|
||||
}
|
||||
@@ -902,17 +945,17 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
var serverTime = t.AddSeconds(1);
|
||||
nm.EnsureVariable("eq/array", null, "Array", "Int32", false, historianTagname: "T", realm: AddressSpaceRealm.Raw, isArray: true, arrayLength: 2);
|
||||
nm.HistorianDataSource = new RecordingHistorianDataSource { RawResult = new HistorianRead([
|
||||
new(new int[] { 1, 2 }, StatusCodes.Uncertain, t, serverTime),
|
||||
new(null, StatusCodes.BadNoCommunication, t.AddSeconds(1), serverTime)], null) };
|
||||
new(new int[] { 1, 2 }, StatusCodes.Uncertain.Code, t, serverTime),
|
||||
new(null, StatusCodes.BadNoCommunication.Code, t.AddSeconds(1), serverTime)], null) };
|
||||
var (results, _) = InvokeHistoryRead(server, nm, new ReadRawModifiedDetails { IsReadModified = false, ReturnBounds = false,
|
||||
StartTime = t, EndTime = t.AddSeconds(10) }, nm.TryGetVariable("eq/array", AddressSpaceRealm.Raw)!.NodeId);
|
||||
var values = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)).DataValues;
|
||||
values[0].Value.ShouldBeOfType<int[]>().ShouldBe([1, 2]);
|
||||
values[0].StatusCode.Code.ShouldBe(StatusCodes.Uncertain);
|
||||
var values = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!).DataValues;
|
||||
SdkValueBoundary.ToClr(values[0].WrappedValue)!.ShouldBeOfType<int[]>().ShouldBe([1, 2]);
|
||||
values[0].StatusCode.Code.ShouldBe(StatusCodes.Uncertain.Code);
|
||||
values[0].SourceTimestamp.ShouldBe(t);
|
||||
values[0].ServerTimestamp.ShouldBe(serverTime);
|
||||
values[1].Value.ShouldBeNull();
|
||||
values[1].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication);
|
||||
SdkValueBoundary.ToClr(values[1].WrappedValue)!.ShouldBeNull();
|
||||
values[1].StatusCode.Code.ShouldBe(StatusCodes.BadNoCommunication.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -926,11 +969,12 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
var nm = server.NodeManager!;
|
||||
var t = DateTime.UtcNow.AddMinutes(-1);
|
||||
nm.EnsureVariable("eq/count", null, "Count", "Boolean", false, historianTagname: "T", realm: AddressSpaceRealm.Uns);
|
||||
nm.HistorianDataSource = new RecordingHistorianDataSource { ProcessedResult = new HistorianRead([new(3d, StatusCodes.Good, t, t)], null) };
|
||||
nm.HistorianDataSource = new RecordingHistorianDataSource { ProcessedResult = new HistorianRead([new(3d, StatusCodes.Good.Code, t, t)], null) };
|
||||
var (results, _) = InvokeHistoryRead(server, nm, new ReadProcessedDetails { StartTime = t, EndTime = t.AddSeconds(10),
|
||||
ProcessingInterval = 1000, AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Count } }, nm.TryGetVariable("eq/count")!.NodeId);
|
||||
var value = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)).DataValues.Single();
|
||||
value.Value.ShouldBeOfType<double>().ShouldBe(3d);
|
||||
ProcessingInterval = 1000, AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
|
||||
AggregateType = new List<NodeId> { ObjectIds.AggregateFunction_Count } }, nm.TryGetVariable("eq/count")!.NodeId);
|
||||
var value = ((HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData)!).DataValues.ToArray()!.Single();
|
||||
SdkValueBoundary.ToClr(value.WrappedValue)!.ShouldBeOfType<double>().ShouldBe(3d);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -941,6 +985,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
/// a configured result (or throws when <see cref="ThrowOnRead"/> is set).</summary>
|
||||
private sealed class RecordingHistorianDataSource : IHistorianDataSource
|
||||
{
|
||||
public Func<CancellationToken, Task<HistorianRead>>? RawRead { get; init; }
|
||||
public bool ThrowOnRead { get; init; }
|
||||
public Exception? ReadException { get; init; }
|
||||
public bool AggregateNotSupported { get; init; }
|
||||
@@ -971,7 +1016,7 @@ public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
LastStart = startUtc;
|
||||
LastEnd = endUtc;
|
||||
LastMaxValues = maxValuesPerNode;
|
||||
return Task.FromResult(RawResult);
|
||||
return RawRead?.Invoke(cancellationToken) ?? Task.FromResult(RawResult);
|
||||
}
|
||||
|
||||
public Task<HistorianRead> ReadProcessedAsync(
|
||||
|
||||
+11
-11
@@ -62,11 +62,11 @@ public sealed class NodeManagerKeyUsabilityGateTests : IDisposable
|
||||
var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint");
|
||||
var identity = KeyIdentity(OpcUaDataPlaneRoles.WriteOperate);
|
||||
|
||||
Write(nm, identity, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
Write(nm, identity, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
|
||||
clock.Advance(TimeSpan.FromMinutes(20));
|
||||
|
||||
Write(nm, identity, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
Write(nm, identity, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
gateway.Writes.Count.ShouldBe(1);
|
||||
state.Current.GenerationId.ShouldBe(1); // no deployment moved anything — only the clock did
|
||||
}
|
||||
@@ -90,13 +90,13 @@ public sealed class NodeManagerKeyUsabilityGateTests : IDisposable
|
||||
var nodeId = Writable(nm, "Plant/Modbus/dev1/Setpoint");
|
||||
var identity = KeyIdentity(OpcUaDataPlaneRoles.WriteOperate);
|
||||
|
||||
Write(nm, identity, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
Write(nm, identity, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
|
||||
var disabled = Key(expiresUtc: null);
|
||||
disabled.Enabled = false;
|
||||
state.Apply(Cluster, "rev-2", [disabled], [], NoScopes);
|
||||
|
||||
Write(nm, identity, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
Write(nm, identity, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
gateway.Writes.Count.ShouldBe(1);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -124,9 +124,9 @@ public sealed class NodeManagerKeyUsabilityGateTests : IDisposable
|
||||
new AnonymousIdentityToken(), [OpcUaDataPlaneRoles.WriteOperate],
|
||||
OpcUaPrincipals.Anonymous, apiKeyId: null);
|
||||
|
||||
Write(nm, anonymous, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
Write(nm, anonymous, nodeId, 1f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
clock.Advance(TimeSpan.FromDays(400));
|
||||
Write(nm, anonymous, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
Write(nm, anonymous, nodeId, 2f)[0].StatusCode.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
|
||||
gateway.Writes.Count.ShouldBe(2);
|
||||
}
|
||||
@@ -158,13 +158,13 @@ public sealed class NodeManagerKeyUsabilityGateTests : IDisposable
|
||||
UserIdentity = KeyIdentity(OpcUaDataPlaneRoles.AlarmAck),
|
||||
};
|
||||
|
||||
condition.OnAcknowledge!(ctx, condition, Guid.NewGuid().ToByteArray(), new LocalizedText("1"))
|
||||
condition.OnAcknowledge!(ctx, condition, ByteString.From(Guid.NewGuid().ToByteArray()), new LocalizedText("1"))
|
||||
.ShouldBe(ServiceResult.Good);
|
||||
|
||||
clock.Advance(TimeSpan.FromMinutes(20));
|
||||
|
||||
condition.OnAcknowledge!(ctx, condition, Guid.NewGuid().ToByteArray(), new LocalizedText("2"))
|
||||
.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
condition.OnAcknowledge!(ctx, condition, ByteString.From(Guid.NewGuid().ToByteArray()), new LocalizedText("2"))
|
||||
.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
routed.Count.ShouldBe(1);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
@@ -217,14 +217,14 @@ public sealed class NodeManagerKeyUsabilityGateTests : IDisposable
|
||||
OtOpcUaNodeManager nm, IUserIdentity identity, NodeId nodeId, object value)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.Write, identity);
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.Write, requestLifetime: RequestLifetime.None, identity);
|
||||
var writes = new List<WriteValue>
|
||||
{
|
||||
new()
|
||||
{
|
||||
NodeId = nodeId,
|
||||
AttributeId = Attributes.Value,
|
||||
Value = new DataValue(new Variant(value)) { StatusCode = StatusCodes.Good },
|
||||
Value = new DataValue(SdkValueBoundary.FromClr(value), StatusCodes.Good),
|
||||
},
|
||||
};
|
||||
var errors = Enumerable.Repeat<ServiceResult>(null!, writes.Count).ToList();
|
||||
|
||||
+2
-2
@@ -137,7 +137,7 @@ public sealed class NodeManagerLockTimeBenchmarkTests(ITestOutputHelper output)
|
||||
|
||||
// A Server-object (all-events) subscriber, so the per-condition monitoring unit is in play.
|
||||
nm.SubscribeToAllEvents(
|
||||
new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.CreateMonitoredItems, identity: null),
|
||||
new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.CreateMonitoredItems, requestLifetime: RequestLifetime.None, identity: null),
|
||||
1, new StubEventItem(), unsubscribe: false);
|
||||
nm.ConditionEventsMonitored("alm-0", AddressSpaceRealm.Uns).ShouldBeTrue("non-vacuity: the units really were raised");
|
||||
|
||||
@@ -184,7 +184,7 @@ public sealed class NodeManagerLockTimeBenchmarkTests(ITestOutputHelper output)
|
||||
public void QueueEvent(IFilterTarget instance, bool bypassFilter) { }
|
||||
public void Dispose() { }
|
||||
|
||||
public INodeManager NodeManager => throw new NotSupportedException();
|
||||
public IAsyncNodeManager NodeManager => throw new NotSupportedException();
|
||||
public ISession Session => throw new NotSupportedException();
|
||||
public uint Id => 1;
|
||||
public uint SubscriptionId => 1;
|
||||
|
||||
+3
-3
@@ -39,15 +39,15 @@ public sealed class NodeManagerMaxReturnDataValuesSyncTests(BoundsServerFixture
|
||||
|
||||
private async Task<uint> ReadAdvertisedAsync()
|
||||
{
|
||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.Read, identity: null);
|
||||
var nodesToRead = new ReadValueIdCollection
|
||||
var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.Read, requestLifetime: RequestLifetime.None, identity: null);
|
||||
var nodesToRead = new List<ReadValueId>
|
||||
{
|
||||
new ReadValueId { NodeId = VariableIds.HistoryServerCapabilities_MaxReturnDataValues, AttributeId = Attributes.Value },
|
||||
};
|
||||
var (values, _) = await fx.Nm.Server.NodeManager.ReadAsync(context, 0, TimestampsToReturn.Neither, nodesToRead, TestContext.Current.CancellationToken);
|
||||
values.Count.ShouldBe(1);
|
||||
StatusCode.IsGood(values[0].StatusCode).ShouldBeTrue(values[0].StatusCode.ToString());
|
||||
return (uint)values[0].Value;
|
||||
return (uint)SdkValueBoundary.ToClr(values[0].WrappedValue)!;
|
||||
}
|
||||
|
||||
private sealed class CappedSource(int cap) : IHistorianDataSource
|
||||
|
||||
+7
-7
@@ -42,10 +42,10 @@ public sealed class NodeManagerModelChangeFilterTests : IDisposable
|
||||
sink.EnsureFolder(RawFolder, null, "dev1", AddressSpaceRealm.Raw);
|
||||
|
||||
var e = nm.BuildNodesAddedModelChange("EqA", AddressSpaceRealm.Uns);
|
||||
var eqA = e.Changes.Value[0];
|
||||
var raw = nm.BuildNodesAddedModelChange(RawFolder, AddressSpaceRealm.Raw).Changes.Value[0];
|
||||
e.Changes.Value = [eqA, raw];
|
||||
e.Message.Value = new LocalizedText($"Nodes added under {eqA.Affected} and {raw.Affected}");
|
||||
var eqA = e.Changes!.Value[0];
|
||||
var raw = nm.BuildNodesAddedModelChange(RawFolder, AddressSpaceRealm.Raw).Changes!.Value[0];
|
||||
e.Changes!.Value = [eqA, raw];
|
||||
e.Message!.Value = new LocalizedText($"Nodes added under {eqA.Affected} and {raw.Affected}");
|
||||
|
||||
nm.Authorizer = Authorizer(
|
||||
new NodeAcl
|
||||
@@ -62,9 +62,9 @@ public sealed class NodeManagerModelChangeFilterTests : IDisposable
|
||||
|
||||
var narrowed = nm.FilterModelChangeFor(Identity(Principal), e).ShouldNotBeNull();
|
||||
narrowed.ShouldNotBeSameAs(e);
|
||||
narrowed.Changes.Value.Select(c => c.Affected).ShouldBe([eqA.Affected]);
|
||||
narrowed.EventId.Value.ShouldBe(e.EventId.Value);
|
||||
narrowed.Message.Value.Text.ShouldNotContain(RawFolder);
|
||||
narrowed.Changes!.Value.ToArray()!.Select(c => c.Affected).ShouldBe([eqA.Affected]);
|
||||
narrowed.EventId!.Value.ToArray().ShouldBe(e.EventId!.Value.ToArray());
|
||||
narrowed.Message!.Value.Text!.ShouldNotContain(RawFolder);
|
||||
|
||||
nm.FilterModelChangeFor(Identity("everything"), e).ShouldBeSameAs(e);
|
||||
nm.FilterModelChangeFor(Identity("nobody"), e).ShouldBeNull();
|
||||
|
||||
+4
-4
@@ -51,8 +51,8 @@ public sealed class NodeManagerModelChangeOnAddTests : IDisposable
|
||||
|
||||
e.ShouldNotBeNull();
|
||||
e.Changes.ShouldNotBeNull();
|
||||
var changes = e.Changes.Value;
|
||||
changes.Length.ShouldBe(1);
|
||||
var changes = e.Changes!.Value;
|
||||
changes.Count.ShouldBe(1);
|
||||
changes[0].Affected.ShouldBe(parent.NodeId);
|
||||
changes[0].AffectedType.ShouldBe(ObjectTypeIds.FolderType);
|
||||
changes[0].Verb.ShouldBe((byte)ModelChangeStructureVerbMask.NodeAdded);
|
||||
@@ -76,8 +76,8 @@ public sealed class NodeManagerModelChangeOnAddTests : IDisposable
|
||||
|
||||
e.ShouldNotBeNull();
|
||||
e.Changes.ShouldNotBeNull();
|
||||
var changes = e.Changes.Value;
|
||||
changes.Length.ShouldBe(1);
|
||||
var changes = e.Changes!.Value;
|
||||
changes.Count.ShouldBe(1);
|
||||
changes[0].Verb.ShouldBe((byte)ModelChangeStructureVerbMask.NodeAdded);
|
||||
changes[0].AffectedType.ShouldBe(NodeId.Null);
|
||||
|
||||
|
||||
+32
-32
@@ -43,27 +43,27 @@ public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: true);
|
||||
var restored = Condition(nm);
|
||||
var idBeforeComplete = (byte[]?)restored.EventId.Value?.Clone();
|
||||
var idBeforeComplete = (byte[]?)restored.EventId!.Value.ToArray();
|
||||
|
||||
// State is back as soon as the node exists — a Read or a ConditionRefresh in between already sees it.
|
||||
restored.ActiveState.Id.Value.ShouldBeTrue();
|
||||
restored.AckedState.Id.Value.ShouldBeFalse();
|
||||
restored.Retain.Value.ShouldBeTrue();
|
||||
restored.Severity.Value.ShouldBe((ushort)800);
|
||||
restored.Message.Value.Text.ShouldBe("Xtravel FAULT 1001");
|
||||
restored.Quality.Value.Code.ShouldBe(StatusCodes.Good);
|
||||
restored.ActiveState!.Id!.Value.ShouldBeTrue();
|
||||
restored.AckedState!.Id!.Value.ShouldBeFalse();
|
||||
restored.Retain!.Value.ShouldBeTrue();
|
||||
restored.Severity!.Value.ShouldBe((ushort)800);
|
||||
restored.Message!.Value.Text.ShouldBe("Xtravel FAULT 1001");
|
||||
restored.Quality!.Value.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
// The report happens at the END of the apply (after the notifier wiring), stamped with the instant the
|
||||
// state came about, not the deploy time.
|
||||
restored.EventId.Value.ShouldNotBe(idBeforeComplete);
|
||||
restored.Time.Value.ShouldBe(raisedAt);
|
||||
restored.EventId!.Value.ToArray().ShouldNotBe(idBeforeComplete);
|
||||
restored.Time!.Value.ShouldBe(raisedAt);
|
||||
|
||||
// ...and only once: a second completion with nothing pending reports nothing.
|
||||
var idAfterComplete = (byte[]?)restored.EventId.Value?.Clone();
|
||||
var idAfterComplete = (byte[]?)restored.EventId!.Value.ToArray();
|
||||
nm.CompleteAddressSpaceApply();
|
||||
restored.EventId.Value.ShouldBe(idAfterComplete);
|
||||
restored.EventId!.Value.ToArray().ShouldBe(idAfterComplete);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -87,9 +87,9 @@ public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
var restored = Condition(nm);
|
||||
restored.ActiveState.Id.Value.ShouldBeTrue();
|
||||
restored.AckedState.Id.Value.ShouldBeTrue();
|
||||
restored.Retain.Value.ShouldBeTrue();
|
||||
restored.ActiveState!.Id!.Value.ShouldBeTrue();
|
||||
restored.AckedState!.Id!.Value.ShouldBeTrue();
|
||||
restored.Retain!.Value.ShouldBeTrue();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -108,17 +108,17 @@ public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable
|
||||
var raisedAt = new DateTime(2026, 10, 8, 9, 0, 0, DateTimeKind.Utc);
|
||||
Materialise(nm, native: true);
|
||||
nm.WriteAlarmCondition(Alarm, Fault(acked: false), raisedAt, AddressSpaceRealm.Raw);
|
||||
Condition(nm).Comment.Value = new LocalizedText("on it");
|
||||
Condition(nm).Comment!.Value = new LocalizedText("on it");
|
||||
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: true);
|
||||
var later = raisedAt.AddMinutes(5);
|
||||
nm.WriteAlarmQuality(Alarm, StatusCodes.BadNoCommunication, later, AddressSpaceRealm.Raw); // reports at `later`
|
||||
nm.WriteAlarmQuality(Alarm, StatusCodes.BadNoCommunication.Code, later, AddressSpaceRealm.Raw); // reports at `later`
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
var restored = Condition(nm);
|
||||
restored.Comment.Value.Text.ShouldBe("on it");
|
||||
restored.Time.Value.ShouldBe(later, "the restore report must not stamp the condition back to the raise time");
|
||||
restored.Comment!.Value.Text.ShouldBe("on it");
|
||||
restored.Time!.Value.ShouldBe(later, "the restore report must not stamp the condition back to the raise time");
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -137,15 +137,15 @@ public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: true);
|
||||
var fresh = Condition(nm);
|
||||
var idBefore = (byte[]?)fresh.EventId.Value?.Clone();
|
||||
var idBefore = (byte[]?)fresh.EventId!.Value.ToArray();
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
fresh.EventId.Value.ShouldBe(idBefore);
|
||||
fresh.ActiveState.Id.Value.ShouldBeFalse();
|
||||
fresh.AckedState.Id.Value.ShouldBeTrue();
|
||||
fresh.Retain.Value.ShouldBeFalse();
|
||||
fresh.Message.Value.Text.ShouldBe("Xtravel", "a normal condition keeps the materialise's message");
|
||||
fresh.Severity.Value.ShouldBe((ushort)700, "and its authored severity");
|
||||
fresh.EventId!.Value.ToArray().ShouldBe(idBefore);
|
||||
fresh.ActiveState!.Id!.Value.ShouldBeFalse();
|
||||
fresh.AckedState!.Id!.Value.ShouldBeTrue();
|
||||
fresh.Retain!.Value.ShouldBeFalse();
|
||||
fresh.Message!.Value.Text.ShouldBe("Xtravel", "a normal condition keeps the materialise's message");
|
||||
fresh.Severity!.Value.ShouldBe((ushort)700, "and its authored severity");
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -166,11 +166,11 @@ public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable
|
||||
nm.RebuildAddressSpace();
|
||||
Materialise(nm, native: true);
|
||||
var fresh = Condition(nm);
|
||||
var idBefore = (byte[]?)fresh.EventId.Value?.Clone();
|
||||
var idBefore = (byte[]?)fresh.EventId!.Value.ToArray();
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
fresh.Quality.Value.Code.ShouldBe(StatusCodes.Good);
|
||||
fresh.EventId.Value.ShouldBe(idBefore);
|
||||
fresh.Quality!.Value.Code.ShouldBe(StatusCodes.Good.Code);
|
||||
fresh.EventId!.Value.ToArray().ShouldBe(idBefore);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -192,8 +192,8 @@ public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
var readded = Condition(nm);
|
||||
readded.ActiveState.Id.Value.ShouldBeFalse();
|
||||
readded.AckedState.Id.Value.ShouldBeTrue();
|
||||
readded.ActiveState!.Id!.Value.ShouldBeFalse();
|
||||
readded.AckedState!.Id!.Value.ShouldBeTrue();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -212,7 +212,7 @@ public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable
|
||||
Materialise(nm, native: false);
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
Condition(nm).ActiveState.Id.Value.ShouldBeFalse();
|
||||
Condition(nm).ActiveState!.Id!.Value.ShouldBeFalse();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -231,7 +231,7 @@ public sealed class NodeManagerNativeAlarmRebuildCarryOverTests : IDisposable
|
||||
Materialise(nm, native: false);
|
||||
nm.CompleteAddressSpaceApply();
|
||||
|
||||
Condition(nm).ActiveState.Id.Value.ShouldBeFalse();
|
||||
Condition(nm).ActiveState!.Id!.Value.ShouldBeFalse();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
+27
-27
@@ -49,8 +49,8 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
|
||||
|
||||
var (values, errors) = Read(nm, identity: null, nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
values[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
values[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -72,8 +72,8 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
|
||||
|
||||
var (values, errors) = Read(nm, Identity(), nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
values[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
values[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -91,8 +91,8 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
|
||||
|
||||
var (values, errors) = Read(nm, Identity(), nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
values[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
values[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -144,8 +144,8 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
|
||||
|
||||
var (_, errors) = Read(nm, Identity(), granted, ungranted);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
errors[1].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -190,12 +190,12 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
|
||||
new UserNameIdentityToken { UserName = "apikey" }, [], Principal, apiKeyId: "k1");
|
||||
|
||||
// Prime the cache with an allow.
|
||||
Read(nm, identity, nodeId).Errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
Read(nm, identity, nodeId).Errors[0].StatusCode.Code.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
nm.Authorizer.Decisions.Count.ShouldBeGreaterThan(0, "the allow must actually be cached");
|
||||
|
||||
clock.Advance(TimeSpan.FromHours(2));
|
||||
|
||||
Read(nm, identity, nodeId).Errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
Read(nm, identity, nodeId).Errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -219,12 +219,12 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
|
||||
var nodeId = Variable(nm, "Plant/Modbus/dev1/Speed");
|
||||
|
||||
Read(nm, Identity(), nodeId).Errors[0].StatusCode.Code
|
||||
.ShouldNotBe(StatusCodes.BadUserAccessDenied);
|
||||
.ShouldNotBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
|
||||
// The next deployment carries no grants at all.
|
||||
state.Apply(Cluster, "rev-2", [], [], Scopes());
|
||||
|
||||
Read(nm, Identity(), nodeId).Errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
Read(nm, Identity(), nodeId).Errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -258,12 +258,12 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
|
||||
var nodeId = Variable(nm, "Plant/Modbus/dev1/Speed");
|
||||
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.CreateMonitoredItems, Identity());
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.CreateMonitoredItems, requestLifetime: RequestLifetime.None, Identity());
|
||||
|
||||
var result = nm.SubscribeToEvents(
|
||||
context, nm.GetManagerHandle(nodeId), subscriptionId: 1, monitoredItem: null!, unsubscribe: false);
|
||||
context, nm.GetManagerHandle(nodeId)!, subscriptionId: 1, monitoredItem: null!, unsubscribe: false);
|
||||
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
result.StatusCode.Code.ShouldBe(StatusCodes.BadUserAccessDenied.Code);
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -324,7 +324,7 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
|
||||
var sentinel = new ReferenceDescription
|
||||
{
|
||||
NodeId = children[1], // an explicitly DENIED node, from an earlier page
|
||||
DisplayName = "previous-page",
|
||||
DisplayName = new LocalizedText("previous-page"),
|
||||
};
|
||||
var references = Browse(nm, Identity(), deviceId, sentinel);
|
||||
|
||||
@@ -442,7 +442,7 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
|
||||
nm.Authorizer = TagBrowseAuthorizer($"{device}/T2", $"{device}/T4", $"{device}/T6");
|
||||
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.Browse, Identity());
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.Browse, requestLifetime: RequestLifetime.None, Identity());
|
||||
|
||||
// A relative path that resolves to exactly one REAL match (T1) under the SDK's own path
|
||||
// resolution — the trigger for the single Add() call FanOutTargetIdList intercepts below.
|
||||
@@ -461,7 +461,7 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
|
||||
var unresolvedTargetIds = new List<NodeId>();
|
||||
|
||||
nm.TranslateBrowsePath(
|
||||
context, nm.GetManagerHandle(deviceId), relativePath, targetIds, unresolvedTargetIds);
|
||||
context, nm.GetManagerHandle(deviceId)!, relativePath, targetIds, unresolvedTargetIds);
|
||||
|
||||
var granted = new[] { children[1], children[3], children[5] };
|
||||
var expected = new List<ExpandedNodeId> { sentinel }
|
||||
@@ -596,7 +596,7 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
|
||||
|
||||
// Non-vacuity: this really is a STRING identifier of length zero, the one case the guard used
|
||||
// to let through — not a null identifier the guard already rejected.
|
||||
emptyId.Identifier.ShouldBeOfType<string>().ShouldBeEmpty();
|
||||
emptyId.IdentifierAsString.ShouldBeEmpty();
|
||||
|
||||
var identity = Identity();
|
||||
var items = Enumerable.Range(0, 2)
|
||||
@@ -775,7 +775,7 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
|
||||
|
||||
/// <summary>Registers a stub data-change item against <paramref name="nodeId"/> as if the SDK had created it.</summary>
|
||||
private static StubSampledItem TrackItem(OtOpcUaNodeManager nm, NodeId nodeId, IUserIdentity identity) =>
|
||||
TrackItem(nm, (NodeHandle)nm.GetManagerHandle(nodeId), identity);
|
||||
TrackItem(nm, (NodeHandle)nm.GetManagerHandle(nodeId)!, identity);
|
||||
|
||||
/// <summary>
|
||||
/// Registers a stub data-change item against a hand-built <paramref name="handle"/> — the only way
|
||||
@@ -804,7 +804,7 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
|
||||
OtOpcUaNodeManager nm, IUserIdentity? identity, params (NodeId Node, uint Attribute)[] reads)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.Read, identity);
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.Read, requestLifetime: RequestLifetime.None, identity);
|
||||
|
||||
var nodesToRead = reads
|
||||
.Select(r => new ReadValueId { NodeId = r.Node, AttributeId = r.Attribute })
|
||||
@@ -827,13 +827,13 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
|
||||
params ReferenceDescription[] previousPage)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.Browse, identity);
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.Browse, requestLifetime: RequestLifetime.None, identity);
|
||||
|
||||
var continuationPoint = new ContinuationPoint
|
||||
{
|
||||
NodeToBrowse = nm.GetManagerHandle(source),
|
||||
NodeToBrowse = nm.GetManagerHandle(source)!,
|
||||
BrowseDirection = BrowseDirection.Forward,
|
||||
ReferenceTypeId = null,
|
||||
ReferenceTypeId = default,
|
||||
IncludeSubtypes = true,
|
||||
NodeClassMask = 0,
|
||||
ResultMask = BrowseResultMask.All,
|
||||
@@ -982,17 +982,17 @@ public sealed class NodeManagerStage6EnforcementTests : IDisposable
|
||||
|
||||
public NodeId NodeId => handle.NodeId;
|
||||
|
||||
public void QueueValue(DataValue value, ServiceResult error, bool ignoreFilters)
|
||||
public void QueueValue(in DataValue value, ServiceResult? error, bool ignoreFilters)
|
||||
{
|
||||
if (error?.StatusCode.Code == StatusCodes.BadUserAccessDenied) Denials++;
|
||||
}
|
||||
|
||||
public void QueueValue(DataValue value, ServiceResult error) => QueueValue(value, error, false);
|
||||
public void QueueValue(in DataValue value, ServiceResult? error) => QueueValue(value, error, false);
|
||||
|
||||
public void Dispose() { }
|
||||
|
||||
// ── everything below is untouched by revalidation ──
|
||||
public INodeManager NodeManager => throw new NotSupportedException();
|
||||
public IAsyncNodeManager NodeManager => throw new NotSupportedException();
|
||||
|
||||
public ISession Session => throw new NotSupportedException();
|
||||
|
||||
|
||||
+6
-6
@@ -109,7 +109,7 @@ public sealed class NodeManagerStaleMonitoredNodeTests : IDisposable
|
||||
nm.EnsureFolder("eq-1", null, "Equipment", AddressSpaceRealm.Uns);
|
||||
nm.MaterialiseAlarmCondition("al-1", "eq-1", "High", "OffNormalAlarm", 500, AddressSpaceRealm.Uns, isNative: false);
|
||||
var nodeId = new NodeId("al-1", nm.TryGetFolder("eq-1")!.NodeId.NamespaceIndex);
|
||||
var handle = (NodeHandle)nm.GetManagerHandle(nodeId);
|
||||
var handle = (NodeHandle)nm.GetManagerHandle(nodeId)!;
|
||||
handle.ShouldNotBeNull("non-vacuity: the condition is materialised");
|
||||
var item = new StubItem(handle, Identity());
|
||||
nm.TrackLiveMonitoredItem(handle, item);
|
||||
@@ -121,7 +121,7 @@ public sealed class NodeManagerStaleMonitoredNodeTests : IDisposable
|
||||
|
||||
item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown, "the dropped instance's item is told");
|
||||
nm.LiveItemCount.ShouldBe(0);
|
||||
((NodeHandle)nm.GetManagerHandle(nodeId)).ShouldNotBeNull("positive control: the swapped-in condition exists");
|
||||
((NodeHandle)nm.GetManagerHandle(nodeId)!).ShouldNotBeNull("positive control: the swapped-in condition exists");
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
@@ -131,7 +131,7 @@ public sealed class NodeManagerStaleMonitoredNodeTests : IDisposable
|
||||
{
|
||||
nm.EnsureVariable(rawPath, null, rawPath, "Float", writable: false, realm: AddressSpaceRealm.Raw);
|
||||
var nodeId = nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId;
|
||||
var handle = (NodeHandle)nm.GetManagerHandle(nodeId);
|
||||
var handle = (NodeHandle)nm.GetManagerHandle(nodeId)!;
|
||||
var item = new StubItem(handle, Identity());
|
||||
nm.TrackLiveMonitoredItem(handle, item);
|
||||
|
||||
@@ -188,11 +188,11 @@ public sealed class NodeManagerStaleMonitoredNodeTests : IDisposable
|
||||
public IUserIdentity EffectiveIdentity => identity;
|
||||
public object ManagerHandle => handle;
|
||||
public NodeId NodeId => handle.NodeId;
|
||||
public void QueueValue(DataValue value, ServiceResult error, bool ignoreFilters) => Statuses.Add(error?.StatusCode ?? value.StatusCode);
|
||||
public void QueueValue(DataValue value, ServiceResult error) => QueueValue(value, error, false);
|
||||
public void QueueValue(in DataValue value, ServiceResult? error, bool ignoreFilters) => Statuses.Add(error?.StatusCode ?? value.StatusCode);
|
||||
public void QueueValue(in DataValue value, ServiceResult? error) => QueueValue(value, error, false);
|
||||
public void Dispose() { }
|
||||
|
||||
public INodeManager NodeManager => throw new NotSupportedException();
|
||||
public IAsyncNodeManager NodeManager => throw new NotSupportedException();
|
||||
public ISession Session => throw new NotSupportedException();
|
||||
public uint Id => throw new NotSupportedException();
|
||||
public uint SubscriptionId => throw new NotSupportedException();
|
||||
|
||||
@@ -76,8 +76,8 @@ public sealed class NodeManagerSurgicalRemoveTests : IDisposable
|
||||
|
||||
e.ShouldNotBeNull();
|
||||
e.Changes.ShouldNotBeNull();
|
||||
var changes = e.Changes.Value;
|
||||
changes.Length.ShouldBe(1);
|
||||
var changes = e.Changes!.Value;
|
||||
changes.Count.ShouldBe(1);
|
||||
changes[0].Verb.ShouldBe((byte)ModelChangeStructureVerbMask.NodeDeleted);
|
||||
|
||||
await host.DisposeAsync();
|
||||
|
||||
+9
-9
@@ -55,7 +55,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
|
||||
applied.ShouldBeTrue();
|
||||
node.DataType.ShouldBe(DataTypeIds.Int32); // swapped in place
|
||||
node.ValueRank.ShouldBe(ValueRanks.Scalar);
|
||||
node.Value.ShouldBeNull(); // stale Float value dropped
|
||||
node.Value.IsNull.ShouldBeTrue(); // stale Float value dropped
|
||||
node.StatusCode.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData);
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -79,9 +79,9 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
|
||||
|
||||
applied.ShouldBeTrue();
|
||||
node.ValueRank.ShouldBe(ValueRanks.OneDimension);
|
||||
node.ArrayDimensions.ShouldNotBeNull();
|
||||
node.ArrayDimensions.IsNull.ShouldBeFalse();
|
||||
node.ArrayDimensions[0].ShouldBe(8u);
|
||||
node.Value.ShouldBeNull(); // stale scalar value dropped
|
||||
node.Value.IsNull.ShouldBeTrue(); // stale scalar value dropped
|
||||
node.StatusCode.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData);
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -106,7 +106,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
|
||||
|
||||
applied.ShouldBeTrue();
|
||||
node.ArrayDimensions[0].ShouldBe(8u);
|
||||
node.Value.ShouldBeNull();
|
||||
node.Value.IsNull.ShouldBeTrue();
|
||||
node.StatusCode.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData);
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -131,8 +131,8 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
|
||||
|
||||
applied.ShouldBeTrue();
|
||||
node.ValueRank.ShouldBe(ValueRanks.Scalar);
|
||||
node.ArrayDimensions.ShouldBeNull();
|
||||
node.Value.ShouldBeNull();
|
||||
node.ArrayDimensions.IsNull.ShouldBeTrue();
|
||||
node.Value.IsNull.ShouldBeTrue();
|
||||
node.StatusCode.ShouldBe((StatusCode)StatusCodes.BadWaitingForInitialData);
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -154,7 +154,7 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
|
||||
|
||||
applied.ShouldBeTrue();
|
||||
node.ValueRank.ShouldBe(ValueRanks.OneDimension);
|
||||
node.ArrayDimensions.ShouldNotBeNull();
|
||||
node.ArrayDimensions.IsNull.ShouldBeFalse();
|
||||
node.ArrayDimensions[0].ShouldBe(0u); // null length ⇒ [0], same as EnsureVariable
|
||||
|
||||
await host.DisposeAsync();
|
||||
@@ -222,8 +222,8 @@ public sealed class NodeManagerSurgicalShapeUpdateTests : IDisposable
|
||||
|
||||
e.ShouldNotBeNull();
|
||||
e.Changes.ShouldNotBeNull();
|
||||
var changes = e.Changes.Value;
|
||||
changes.Length.ShouldBe(1);
|
||||
var changes = e.Changes!.Value;
|
||||
changes.Count.ShouldBe(1);
|
||||
changes[0].Affected.ShouldBe(node.NodeId);
|
||||
changes[0].AffectedType.ShouldBe(VariableTypeIds.BaseDataVariableType);
|
||||
changes[0].Verb.ShouldBe((byte)ModelChangeStructureVerbMask.DataTypeChanged);
|
||||
|
||||
@@ -219,13 +219,13 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
|
||||
clientUserId: "op");
|
||||
|
||||
audit.ShouldNotBeNull();
|
||||
audit.SourceNode.Value.ShouldBe(node.NodeId);
|
||||
audit.AttributeId.Value.ShouldBe((uint)Attributes.Value);
|
||||
audit.OldValue.Value.ShouldBe(7);
|
||||
audit.NewValue.Value.ShouldBe(42);
|
||||
audit.ClientUserId.Value.ShouldBe("op");
|
||||
audit.Status.Value.ShouldBeFalse(); // AuditEvent Status = false ⇒ the write failed
|
||||
audit.Message.Value.Text.ShouldContain("FC06 rejected"); // device Reason carried in the Message
|
||||
audit.SourceNode!.Value.ShouldBe(node.NodeId);
|
||||
audit.AttributeId!.Value.ShouldBe((uint)Attributes.Value);
|
||||
audit.OldValue!.Value.ShouldBe(7);
|
||||
audit.NewValue!.Value.ShouldBe(42);
|
||||
audit.ClientUserId!.Value.ShouldBe("op");
|
||||
audit.Status!.Value.ShouldBeFalse(); // AuditEvent Status = false ⇒ the write failed
|
||||
audit.Message!.Value.Text!.ShouldContain("FC06 rejected"); // device Reason carried in the Message
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -247,7 +247,7 @@ public sealed class NodeManagerWriteRevertTests : IDisposable
|
||||
outcome: new NodeWriteOutcome(false, null),
|
||||
optimisticValue: 42, priorValue: 7, clientUserId: "op");
|
||||
|
||||
audit.Message.Value.Text.ShouldContain("device write rejected");
|
||||
audit.Message!.Value.Text!.ShouldContain("device write rejected");
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
+11
-11
@@ -22,7 +22,7 @@ public sealed class OpcUaApplicationHostImpersonationTests
|
||||
[Fact]
|
||||
public void HandleImpersonation_username_success_sets_identity_and_no_validation_error()
|
||||
{
|
||||
var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = Encoding.UTF8.GetBytes("secret") };
|
||||
var token = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = "alice" }) { DecryptedPassword = Encoding.UTF8.GetBytes("secret") };
|
||||
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
||||
var authenticator = new RecordingAuthenticator(
|
||||
OpcUaUserAuthResult.Allow("Alice", new[] { "ReadOnly", "WriteOperate" }));
|
||||
@@ -43,7 +43,7 @@ public sealed class OpcUaApplicationHostImpersonationTests
|
||||
public void HandleImpersonation_username_success_carries_roles_on_identity()
|
||||
{
|
||||
var roles = new[] { "ReadOnly", "WriteOperate", "AlarmAck" };
|
||||
var token = new UserNameIdentityToken { UserName = "alice", DecryptedPassword = Encoding.UTF8.GetBytes("secret") };
|
||||
var token = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = "alice" }) { DecryptedPassword = Encoding.UTF8.GetBytes("secret") };
|
||||
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
||||
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("Alice", roles));
|
||||
|
||||
@@ -57,15 +57,15 @@ public sealed class OpcUaApplicationHostImpersonationTests
|
||||
[Fact]
|
||||
public void HandleImpersonation_username_denial_sets_validation_error_and_no_identity()
|
||||
{
|
||||
var token = new UserNameIdentityToken { UserName = "mallory", DecryptedPassword = Encoding.UTF8.GetBytes("wrong") };
|
||||
var token = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = "mallory" }) { DecryptedPassword = Encoding.UTF8.GetBytes("wrong") };
|
||||
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
||||
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("Invalid credentials"));
|
||||
|
||||
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
||||
|
||||
args.Identity.ShouldBeNull();
|
||||
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
||||
args.IdentityValidationError.LocalizedText.Text.ShouldContain("Invalid credentials");
|
||||
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected.Code);
|
||||
args.IdentityValidationError.LocalizedText.Text!.ShouldContain("Invalid credentials");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -81,7 +81,7 @@ public sealed class OpcUaApplicationHostImpersonationTests
|
||||
[Fact]
|
||||
public void HandleImpersonation_anonymous_token_is_admitted_with_a_role_carrying_identity()
|
||||
{
|
||||
var args = new ImpersonateEventArgs(new AnonymousIdentityToken(), AnonPolicy, new EndpointDescription());
|
||||
var args = new ImpersonateEventArgs(new AnonymousIdentityTokenHandler(), AnonPolicy, new EndpointDescription());
|
||||
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty<string>()));
|
||||
|
||||
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
||||
@@ -98,35 +98,35 @@ public sealed class OpcUaApplicationHostImpersonationTests
|
||||
[Fact]
|
||||
public void HandleImpersonation_anonymous_token_is_rejected_when_disallowed()
|
||||
{
|
||||
var args = new ImpersonateEventArgs(new AnonymousIdentityToken(), AnonPolicy, new EndpointDescription());
|
||||
var args = new ImpersonateEventArgs(new AnonymousIdentityTokenHandler(), AnonPolicy, new EndpointDescription());
|
||||
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Allow("x", Array.Empty<string>()));
|
||||
|
||||
OpcUaApplicationHost.HandleImpersonation(
|
||||
authenticator, args, NullLogger<object>.Instance, allowAnonymous: false);
|
||||
|
||||
args.Identity.ShouldBeNull();
|
||||
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
||||
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected.Code);
|
||||
}
|
||||
|
||||
/// <summary>Verifies authenticator exceptions result in rejection with validation error.</summary>
|
||||
[Fact]
|
||||
public void HandleImpersonation_authenticator_throwing_results_in_rejection()
|
||||
{
|
||||
var token = new UserNameIdentityToken { UserName = "bob", DecryptedPassword = Encoding.UTF8.GetBytes("x") };
|
||||
var token = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = "bob" }) { DecryptedPassword = Encoding.UTF8.GetBytes("x") };
|
||||
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
||||
var authenticator = new ThrowingAuthenticator(new InvalidOperationException("LDAP unreachable"));
|
||||
|
||||
OpcUaApplicationHost.HandleImpersonation(authenticator, args, NullLogger<object>.Instance);
|
||||
|
||||
args.Identity.ShouldBeNull();
|
||||
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected);
|
||||
args.IdentityValidationError.Code.ShouldBe(StatusCodes.BadIdentityTokenRejected.Code);
|
||||
}
|
||||
|
||||
/// <summary>Verifies null username is normalized to empty string before authenticator call.</summary>
|
||||
[Fact]
|
||||
public void HandleImpersonation_null_username_treated_as_empty_string()
|
||||
{
|
||||
var token = new UserNameIdentityToken { UserName = null, DecryptedPassword = Encoding.UTF8.GetBytes("abc") };
|
||||
var token = new UserNameIdentityTokenHandler(new UserNameIdentityToken { UserName = null }) { DecryptedPassword = Encoding.UTF8.GetBytes("abc") };
|
||||
var args = new ImpersonateEventArgs(token, UserNamePolicy, new EndpointDescription());
|
||||
var authenticator = new RecordingAuthenticator(OpcUaUserAuthResult.Deny("no user"));
|
||||
|
||||
|
||||
+8
-8
@@ -127,15 +127,15 @@ public sealed class OpcUaApplicationHostSecurityTests : IDisposable
|
||||
AutoAcceptUntrustedClientCertificates = true,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance),
|
||||
() => new StandardServer(), Ct);
|
||||
() => new OtOpcUaSdkServer(), Ct);
|
||||
await using var host = started.Host;
|
||||
|
||||
var config = host.ApplicationInstance!.ApplicationConfiguration;
|
||||
config.ServerConfiguration.SecurityPolicies.Count.ShouldBe(3);
|
||||
config.ServerConfiguration.UserTokenPolicies.Count.ShouldBe(2);
|
||||
config.SecurityConfiguration.AutoAcceptUntrustedCertificates.ShouldBeTrue();
|
||||
var config = host.ApplicationInstance!.ApplicationConfiguration!;
|
||||
config.ServerConfiguration!.SecurityPolicies.Count.ShouldBe(3);
|
||||
config.ServerConfiguration!.UserTokenPolicies.Count.ShouldBe(2);
|
||||
config.SecurityConfiguration!.AutoAcceptUntrustedCertificates.ShouldBeTrue();
|
||||
|
||||
var modes = config.ServerConfiguration.SecurityPolicies
|
||||
var modes = config.ServerConfiguration!.SecurityPolicies.ToArray()!
|
||||
.Select(p => p.SecurityMode)
|
||||
.OrderBy(m => (int)m)
|
||||
.ToArray();
|
||||
@@ -162,10 +162,10 @@ public sealed class OpcUaApplicationHostSecurityTests : IDisposable
|
||||
AutoAcceptUntrustedClientCertificates = false,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance),
|
||||
() => new StandardServer(), Ct);
|
||||
() => new OtOpcUaSdkServer(), Ct);
|
||||
await using var host = started.Host;
|
||||
|
||||
var policies = host.ApplicationInstance!.ApplicationConfiguration.ServerConfiguration.SecurityPolicies;
|
||||
var policies = host.ApplicationInstance!.ApplicationConfiguration!.ServerConfiguration!.SecurityPolicies;
|
||||
policies.Count.ShouldBe(1);
|
||||
policies[0].SecurityMode.ShouldBe(MessageSecurityMode.SignAndEncrypt);
|
||||
policies[0].SecurityPolicyUri.ShouldBe(SecurityPolicies.Basic256Sha256);
|
||||
|
||||
+2
-2
@@ -38,11 +38,11 @@ public sealed class OpcUaApplicationHostServerArrayTests
|
||||
PeerApplicationUris = new[] { "urn:OtOpcUa.UnitTest.NodeB" },
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance),
|
||||
() => new StandardServer(), CancellationToken.None);
|
||||
() => new OtOpcUaSdkServer(), CancellationToken.None);
|
||||
await using var host = started.Host;
|
||||
var server = started.Server;
|
||||
|
||||
var serverArray = server.CurrentInstance.ServerObject.ServerArray.Value;
|
||||
var serverArray = server.CurrentInstance.ServerObject.ServerArray!.Value.ToArray();
|
||||
serverArray.ShouldNotBeNull();
|
||||
serverArray.ShouldContain("urn:OtOpcUa.UnitTest.NodeA");
|
||||
serverArray.ShouldContain("urn:OtOpcUa.UnitTest.NodeB");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Opc.Ua;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
@@ -34,7 +35,7 @@ public sealed class OpcUaApplicationHostTests : IDisposable
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance),
|
||||
() => new StandardServer(), Ct);
|
||||
() => new OtOpcUaSdkServer(), Ct);
|
||||
await using var host = started.Host;
|
||||
|
||||
var ownCerts = Path.Combine(_pkiRoot, "own", "certs");
|
||||
@@ -57,7 +58,7 @@ public sealed class OpcUaApplicationHostTests : IDisposable
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance),
|
||||
() => new StandardServer(), Ct);
|
||||
() => new OtOpcUaSdkServer(), Ct);
|
||||
await first.Host.DisposeAsync();
|
||||
|
||||
var ownCerts = Path.Combine(_pkiRoot, "own", "certs");
|
||||
@@ -75,7 +76,7 @@ public sealed class OpcUaApplicationHostTests : IDisposable
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance),
|
||||
() => new StandardServer(), Ct);
|
||||
() => new OtOpcUaSdkServer(), Ct);
|
||||
await second.Host.DisposeAsync();
|
||||
|
||||
var secondFiles = Directory.GetFiles(ownCerts).OrderBy(f => f).ToArray();
|
||||
|
||||
@@ -17,7 +17,7 @@ public sealed class ReverseWalkTests
|
||||
private static readonly DateTime T0 = new(2026, 10, 12, 18, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static DataValueSnapshot At(long ticks, double v) =>
|
||||
new(v, StatusCodes.Good, new DateTime(ticks, DateTimeKind.Utc), new DateTime(ticks, DateTimeKind.Utc));
|
||||
new(v, StatusCodes.Good.Code, new DateTime(ticks, DateTimeKind.Utc), new DateTime(ticks, DateTimeKind.Utc));
|
||||
|
||||
/// <summary>
|
||||
/// A randomized walk over AVEVA-shaped forward reads (inclusive end, a fabricated row at the query start — optionally
|
||||
@@ -304,7 +304,7 @@ public sealed class ReverseWalkTests
|
||||
var rows = new List<DataValueSnapshot>
|
||||
{
|
||||
prior is null
|
||||
? new DataValueSnapshot(-1d, StatusCodes.BadNoData, stamp, stamp)
|
||||
? new DataValueSnapshot(-1d, StatusCodes.BadNoData.Code, stamp, stamp)
|
||||
: new DataValueSnapshot(-2d, prior.StatusCode, stamp, stamp),
|
||||
};
|
||||
rows.AddRange(series.Where(s => s.SourceTimestampUtc >= startUtc && s.SourceTimestampUtc <= endUtc));
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
public sealed class RoleCarryingUserIdentityTests
|
||||
{
|
||||
[Fact]
|
||||
public void Mandatory_and_dynamic_SDK_roles_preserve_the_application_identity()
|
||||
{
|
||||
var namespaces = new NamespaceTable();
|
||||
var identity = new RoleCarryingUserIdentity(new UserNameIdentityToken { UserName = "apikey" },
|
||||
[OpcUaDataPlaneRoles.WriteOperate, OpcUaDataPlaneRoles.AlarmAck], "machine-a", "key-a");
|
||||
var trusted = identity.WithAdditionalRoles([Role.TrustedApplication], namespaces);
|
||||
var mapped = trusted.WithAdditionalRoles([Role.AuthenticatedUser], namespaces);
|
||||
var actual = mapped.ShouldBeOfType<RoleCarryingUserIdentity>();
|
||||
actual.Principal.ShouldBe("machine-a");
|
||||
actual.ApiKeyId.ShouldBe("key-a");
|
||||
actual.Roles.ShouldBe([OpcUaDataPlaneRoles.WriteOperate, OpcUaDataPlaneRoles.AlarmAck]);
|
||||
actual.GrantedRoleIds.Contains(ExpandedNodeId.ToNodeId(Role.TrustedApplication.RoleId, namespaces)).ShouldBeTrue();
|
||||
actual.GrantedRoleIds.Contains(ExpandedNodeId.ToNodeId(Role.AuthenticatedUser.RoleId, namespaces)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SDK_restriction_discards_application_authority_and_cannot_restore_it_by_augmentation()
|
||||
{
|
||||
var namespaces = new NamespaceTable();
|
||||
var identity = new RoleCarryingUserIdentity(new UserNameIdentityToken { UserName = "apikey" },
|
||||
[OpcUaDataPlaneRoles.WriteOperate], "machine-a", "key-a");
|
||||
var restricted = RoleBasedIdentity.CreateRestricted(identity, [Role.Anonymous], namespaces);
|
||||
(restricted is RoleCarryingUserIdentity).ShouldBeFalse();
|
||||
(restricted.WithAdditionalRoles([Role.TrustedApplication], namespaces) is RoleCarryingUserIdentity).ShouldBeFalse();
|
||||
restricted.GrantedRoleIds.Count.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using System.Net;
|
||||
using Opc.Ua;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
public sealed class Sdk2ActivationPeerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Concurrent_activations_keep_their_own_peer_across_awaits()
|
||||
{
|
||||
using var server = new OtOpcUaSdkServer();
|
||||
var peers = new IPAddress?[] { IPAddress.Parse("127.0.0.1"), IPAddress.Parse("::1"), IPAddress.Parse("::ffff:10.1.2.3"), null };
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var count = 0;
|
||||
await Task.WhenAll(peers.Select(async peer => await server.WithActivationPeerAddressAsync(peer, async () =>
|
||||
{
|
||||
server.ActivationPeerAddress.ShouldBe(peer);
|
||||
if (Interlocked.Increment(ref count) == peers.Length) entered.SetResult();
|
||||
await entered.Task;
|
||||
server.ActivationPeerAddress.ShouldBe(peer);
|
||||
return new ActivateSessionResponse();
|
||||
})));
|
||||
server.ActivationPeerAddress.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Failure_and_cancellation_restore_the_outer_peer_including_unknown()
|
||||
{
|
||||
using var server = new OtOpcUaSdkServer();
|
||||
var outer = IPAddress.Parse("10.2.3.4");
|
||||
await server.WithActivationPeerAddressAsync(outer, async () =>
|
||||
{
|
||||
await Should.ThrowAsync<InvalidOperationException>(async () =>
|
||||
await server.WithActivationPeerAddressAsync(null, () => throw new InvalidOperationException("failed activation")));
|
||||
server.ActivationPeerAddress.ShouldBe(outer);
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
||||
await server.WithActivationPeerAddressAsync(IPAddress.IPv6Loopback, () => throw new OperationCanceledException()));
|
||||
server.ActivationPeerAddress.ShouldBe(outer);
|
||||
return new ActivateSessionResponse();
|
||||
});
|
||||
server.ActivationPeerAddress.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Identity;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
public sealed class Sdk2CertificateLifecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Restart_reuses_the_application_certificate_and_its_private_key()
|
||||
{
|
||||
var pki = Path.Combine(Path.GetTempPath(), $"otua-sdk2-cert-{Guid.NewGuid():N}");
|
||||
try
|
||||
{
|
||||
string? thumbprint = null;
|
||||
for (var restart = 0; restart < 2; restart++)
|
||||
{
|
||||
var started = await OpcUaHostStart.StartAsync(port => new OpcUaApplicationHost(new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "SDK2.CertReuse", ApplicationUri = "urn:SDK2.CertReuse",
|
||||
PublicHostname = "localhost", OpcUaPort = port, PkiStoreRoot = pki, AllowAnonymous = false,
|
||||
}, NullLogger<OpcUaApplicationHost>.Instance), () => new OtOpcUaSdkServer(), TestContext.Current.CancellationToken);
|
||||
await using var host = started.Host;
|
||||
var keyFile = Directory.GetFiles(Path.Combine(pki, "own"), "*.pfx", SearchOption.AllDirectories).ShouldHaveSingleItem();
|
||||
using var certificate = X509CertificateLoader.LoadPkcs12FromFile(keyFile, null);
|
||||
certificate.HasPrivateKey.ShouldBeTrue();
|
||||
if (thumbprint is null) thumbprint = certificate.Thumbprint;
|
||||
else certificate.Thumbprint.ShouldBe(thumbprint);
|
||||
using var key = certificate.GetRSAPrivateKey();
|
||||
using var publicKey = certificate.GetRSAPublicKey();
|
||||
var signature = key!.SignData([1, 2, 3], HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
publicKey!.VerifyData(new byte[] { 1, 2, 3 }, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1).ShouldBeTrue();
|
||||
var registry = started.Server.CurrentInstance.IdentityRegistry;
|
||||
var context = new AuthenticationContext(new AnonymousIdentityTokenHandler(),
|
||||
new UserTokenPolicy(UserTokenType.Anonymous), new EndpointDescription(), started.Server.MessageContext);
|
||||
(await registry.AuthenticateAsync(context, TestContext.Current.CancellationToken)).Outcome.ShouldBe(AuthenticationOutcome.Rejected);
|
||||
var userContext = context with
|
||||
{
|
||||
TokenHandler = new UserNameIdentityTokenHandler("apikey", "invalid"u8),
|
||||
UserTokenPolicy = new UserTokenPolicy(UserTokenType.UserName),
|
||||
};
|
||||
(await registry.AuthenticateAsync(userContext, TestContext.Current.CancellationToken)).Outcome.ShouldBe(AuthenticationOutcome.Rejected);
|
||||
}
|
||||
}
|
||||
finally { if (Directory.Exists(pki)) Directory.Delete(pki, true); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_canceled_start_disposes_its_application_owner()
|
||||
{
|
||||
var pki = Path.Combine(Path.GetTempPath(), $"otua-sdk2-canceled-{Guid.NewGuid():N}");
|
||||
await using var host = new OpcUaApplicationHost(new OpcUaApplicationHostOptions { PkiStoreRoot = pki }, NullLogger<OpcUaApplicationHost>.Instance);
|
||||
using var server = new OtOpcUaSdkServer();
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
cancellation.Cancel();
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => host.StartAsync(server, cancellation.Token));
|
||||
host.ApplicationInstance.ShouldBeNull();
|
||||
if (Directory.Exists(pki)) Directory.Delete(pki, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
public sealed class Sdk2EquipmentVariableTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _pki = Path.Combine(Path.GetTempPath(), $"otua-sdk2-values-{Guid.NewGuid():N}");
|
||||
private OpcUaApplicationHost _host = null!;
|
||||
private OtOpcUaSdkServer _server = null!;
|
||||
private OtOpcUaNodeManager _manager = null!;
|
||||
private readonly RecordingGateway _gateway = new();
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
var started = await OpcUaHostStart.StartAsync(port => new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "SDK2.ValueContracts", ApplicationUri = "urn:SDK2.ValueContracts",
|
||||
PublicHostname = "localhost", OpcUaPort = port, PkiStoreRoot = _pki,
|
||||
}, NullLogger<OpcUaApplicationHost>.Instance), () => new OtOpcUaSdkServer(), Ct);
|
||||
_host = started.Host;
|
||||
_server = started.Server;
|
||||
_manager = _server.NodeManager!;
|
||||
_manager.NodeWriteGateway = _gateway;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _host.DisposeAsync();
|
||||
if (Directory.Exists(_pki)) Directory.Delete(_pki, true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Double_to_Float_retains_the_scalar_driver_contract()
|
||||
{
|
||||
var node = Node("Float");
|
||||
Write(node, 3.25d).ShouldBe(ServiceResult.Good);
|
||||
_gateway.Values.ShouldHaveSingleItem().ShouldBeOfType<double>().ShouldBe(3.25d);
|
||||
SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<double>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Checked_arrays_are_converted_before_the_async_node_write_dispatches()
|
||||
{
|
||||
var node = Node("Int16", array: true);
|
||||
var result = await node.WriteAttributeAsync(Context(), Attributes.Value, default,
|
||||
new DataValue(SdkValueBoundary.FromClr(new[] { 1, 2 })), Ct);
|
||||
result.ShouldBe(ServiceResult.Good);
|
||||
_gateway.Values.ShouldHaveSingleItem().ShouldBeOfType<short[]>().ShouldBe([1, 2]);
|
||||
SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<short[]>().ShouldBe([1, 2]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Scalar_shaped_arrays_follow_the_existing_driver_capability(bool refuse)
|
||||
{
|
||||
var node = Node("Int16", refuse: refuse);
|
||||
var result = Write(node, new short[] { 1, 2 });
|
||||
result.StatusCode.ShouldBe(refuse ? StatusCodes.BadTypeMismatch : StatusCodes.Good);
|
||||
_gateway.Values.Count.ShouldBe(refuse ? 0 : 1);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("overflow")]
|
||||
[InlineData("fraction")]
|
||||
[InlineData("null")]
|
||||
[InlineData("matrix")]
|
||||
[InlineData("length")]
|
||||
[InlineData("range")]
|
||||
public void Invalid_writes_never_dispatch_or_mutate_the_tuple(string fault)
|
||||
{
|
||||
var node = Node("Int16", array: true);
|
||||
_manager.WriteValue("v", new short[] { 7, 8 }, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
var oldValue = node.Value;
|
||||
var oldStatus = node.StatusCode;
|
||||
var oldTime = node.Timestamp;
|
||||
var payload = fault switch
|
||||
{
|
||||
"overflow" => Variant.From((ArrayOf<int>)[1, 32768]),
|
||||
"fraction" => Variant.From((ArrayOf<double>)[1d, 2.5d]),
|
||||
"null" => Variant.Null,
|
||||
"matrix" => Variant.From((MatrixOf<short>)new short[,] { { 1, 2 }, { 3, 4 } }),
|
||||
"length" => Variant.From((ArrayOf<short>)[1]),
|
||||
_ => Variant.From((ArrayOf<short>)[1, 2]),
|
||||
};
|
||||
var result = node.WriteAttribute(Context(), Attributes.Value,
|
||||
fault == "range" ? new NumericRange(0, 1) : default, new DataValue(payload));
|
||||
result.StatusCode.ShouldBe(fault == "range" ? StatusCodes.BadWriteNotSupported : StatusCodes.BadTypeMismatch);
|
||||
_gateway.Values.ShouldBeEmpty();
|
||||
node.Value.ShouldBe(oldValue);
|
||||
node.StatusCode.ShouldBe(oldStatus);
|
||||
node.Timestamp.ShouldBe(oldTime);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AddressSpaceRealm.Raw)]
|
||||
[InlineData(AddressSpaceRealm.Uns)]
|
||||
public void A_denied_identity_with_bad_shape_stays_denied(AddressSpaceRealm realm)
|
||||
{
|
||||
var node = Node("Int16", array: true, realm: realm);
|
||||
Write(node, null, authorized: false).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
_gateway.Values.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Readonly_to_writable_to_readonly_uses_the_same_adapter()
|
||||
{
|
||||
var node = Node("Float", writable: false);
|
||||
Write(node, 1d).StatusCode.ShouldBe(StatusCodes.BadNotWritable);
|
||||
_manager.UpdateTagAttributes("v", true, null, "Float", false, null, AddressSpaceRealm.Raw);
|
||||
Write(node, 2d).ShouldBe(ServiceResult.Good);
|
||||
_manager.UpdateTagAttributes("v", false, null, "Float", false, null, AddressSpaceRealm.Raw);
|
||||
Write(node, 3d).StatusCode.ShouldBe(StatusCodes.BadNotWritable);
|
||||
_gateway.Values.ShouldHaveSingleItem().ShouldBe(2d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SDK_metadata_access_checks_stay_in_force()
|
||||
{
|
||||
var node = Node("Int32");
|
||||
var infoBits = new StatusCode(0x00004000u);
|
||||
node.WriteAttribute(Context(), Attributes.Value, default, new DataValue(Variant.From(1), infoBits))
|
||||
.ShouldBe(ServiceResult.Good);
|
||||
foreach (var status in new[] { StatusCodes.GoodClamped, StatusCodes.BadNoData })
|
||||
node.WriteAttribute(Context(), Attributes.Value, default, new DataValue(Variant.From(2), status))
|
||||
.StatusCode.ShouldBe(StatusCodes.BadWriteNotSupported);
|
||||
node.WriteAttribute(Context(), Attributes.Value, default,
|
||||
new DataValue(Variant.From(3), StatusCodes.Good, DateTime.UtcNow)).StatusCode.ShouldBe(StatusCodes.BadWriteNotSupported);
|
||||
node.WriteAttribute(Context(), Attributes.Value, default,
|
||||
new DataValue(Variant.From(3), StatusCodes.Good, default, DateTime.UtcNow)).StatusCode.ShouldBe(StatusCodes.BadWriteNotSupported);
|
||||
node.AccessLevel |= AccessLevels.StatusWrite | AccessLevels.TimestampWrite;
|
||||
node.UserAccessLevel = AccessLevels.CurrentRead | AccessLevels.CurrentWrite;
|
||||
node.WriteAttribute(Context(), Attributes.Value, default, new DataValue(Variant.From(4), StatusCodes.BadNoData))
|
||||
.StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
node.WriteAttribute(Context(), Attributes.Value, default,
|
||||
new DataValue(Variant.From(4), StatusCodes.Good, DateTime.UtcNow)).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
node.OnReadUserAccessLevel = (ISystemContext _, NodeState _, ref byte access) =>
|
||||
{
|
||||
access = AccessLevels.CurrentRead;
|
||||
return ServiceResult.Good;
|
||||
};
|
||||
Write(node, 5).StatusCode.ShouldBe(StatusCodes.BadUserAccessDenied);
|
||||
_gateway.Values.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_direct_async_sampler_waits_for_the_complete_published_tuple()
|
||||
{
|
||||
var node = Node("Int32");
|
||||
var stamp = new DateTime(2026, 11, 10, 10, 0, 0, DateTimeKind.Utc);
|
||||
using var started = new ManualResetEventSlim();
|
||||
Task<(ServiceResult Result, DataValue Value)> read;
|
||||
lock (_manager.Lock)
|
||||
{
|
||||
node.Value = Variant.From(42);
|
||||
read = Task.Run(async () =>
|
||||
{
|
||||
started.Set();
|
||||
return await node.ReadAttributeAsync(Context(), Attributes.Value, default, default, default, Ct);
|
||||
}, Ct);
|
||||
started.Wait(Ct);
|
||||
read.IsCompleted.ShouldBeFalse();
|
||||
node.StatusCode = StatusCodes.UncertainLastUsableValue;
|
||||
node.Timestamp = stamp;
|
||||
}
|
||||
var result = await read;
|
||||
SdkValueBoundary.ToClr(result.Value.WrappedValue).ShouldBe(42);
|
||||
result.Value.StatusCode.ShouldBe(StatusCodes.UncertainLastUsableValue);
|
||||
result.Value.SourceTimestamp.ToDateTime().ShouldBe(stamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Published_values_retain_existing_CLR_types_and_null_semantics()
|
||||
{
|
||||
object?[] cases = [true, (sbyte)-1, (byte)1, (short)-2, (ushort)2, -3, 3u, -4L, 4UL,
|
||||
1.25f, 2.5d, "", "text", null, Guid.NewGuid(), DateTime.UtcNow,
|
||||
new byte[] { 1, 2 }, Array.Empty<byte>(), new int[] { 1, 2 }, Array.Empty<int>(), new string[] { "a", "" }];
|
||||
for (var i = 0; i < cases.Length; i++)
|
||||
{
|
||||
var id = $"payload/{i}";
|
||||
_manager.EnsureVariable(id, null, id, "BaseDataType", false, AddressSpaceRealm.Raw);
|
||||
_manager.WriteValue(id, cases[i], OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
var actual = SdkValueBoundary.ToClr(_manager.TryGetVariable(id, AddressSpaceRealm.Raw)!.Value);
|
||||
if (cases[i] is { } expected) actual!.GetType().ShouldBe(expected.GetType());
|
||||
actual.ShouldBe(cases[i]);
|
||||
}
|
||||
SdkValueBoundary.ToClr(Variant.From(default(ByteString))).ShouldBeNull();
|
||||
SdkValueBoundary.ToClr(Variant.From(ByteString.Empty)).ShouldBeOfType<byte[]>().ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Publications_detach_buffers_and_preserve_byte_array_wire_rank()
|
||||
{
|
||||
var node = Node("Byte", array: true);
|
||||
var bytes = new byte[] { 1, 2 };
|
||||
_manager.WriteValue("v", bytes, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
||||
bytes[0] = 99;
|
||||
node.Value.TypeInfo.BuiltInType.ShouldBe(BuiltInType.Byte);
|
||||
node.Value.TypeInfo.ValueRank.ShouldBe(ValueRanks.OneDimension);
|
||||
SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<byte[]>().ShouldBe([1, 2]);
|
||||
var readback = SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<byte[]>();
|
||||
readback[0] = 77;
|
||||
SdkValueBoundary.ToClr(node.Value).ShouldBeOfType<byte[]>().ShouldBe([1, 2]);
|
||||
var scalarBytes = SdkValueBoundary.FromClr(new byte[] { 1, 2 });
|
||||
scalarBytes.TypeInfo.BuiltInType.ShouldBe(BuiltInType.ByteString);
|
||||
scalarBytes.TypeInfo.ValueRank.ShouldBe(ValueRanks.Scalar);
|
||||
}
|
||||
|
||||
private BaseDataVariableState Node(string type, bool array = false, bool refuse = false,
|
||||
bool writable = true, AddressSpaceRealm realm = AddressSpaceRealm.Raw)
|
||||
{
|
||||
_manager.EnsureVariable("v", null, "Value", type, writable, realm,
|
||||
isArray: array, arrayLength: array ? 2u : null, refuseArrayOnScalar: refuse);
|
||||
return _manager.TryGetVariable("v", realm)!;
|
||||
}
|
||||
|
||||
private ServerSystemContext Context(bool authorized = true) => new(_server.CurrentInstance)
|
||||
{
|
||||
UserIdentity = new RoleCarryingUserIdentity(new AnonymousIdentityToken(),
|
||||
authorized ? [OpcUaDataPlaneRoles.WriteOperate] : [], "operator", null),
|
||||
};
|
||||
|
||||
private ServiceResult Write(BaseDataVariableState node, object? value, bool authorized = true) =>
|
||||
node.WriteAttribute(Context(authorized), Attributes.Value, default, new DataValue(SdkValueBoundary.FromClr(value)));
|
||||
|
||||
private sealed class RecordingGateway : IOpcUaNodeWriteGateway
|
||||
{
|
||||
internal List<object?> Values { get; } = [];
|
||||
public Task<NodeWriteOutcome> WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct)
|
||||
{
|
||||
Values.Add(value);
|
||||
return Task.FromResult(new NodeWriteOutcome(true, null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -323,20 +323,15 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
condition.Parent!.NodeId.ShouldBe(new NodeId("eq-1", unsNs));
|
||||
|
||||
// Initial state set by MaterialiseAlarmCondition: enabled, inactive, acked, retain=false.
|
||||
condition.EnabledState.Id.Value.ShouldBeTrue();
|
||||
condition.ActiveState.Id.Value.ShouldBeFalse();
|
||||
condition.Retain.Value.ShouldBeFalse();
|
||||
condition.EnabledState!.Id!.Value.ShouldBeTrue();
|
||||
condition.ActiveState!.Id!.Value.ShouldBeFalse();
|
||||
condition.Retain!.Value.ShouldBeFalse();
|
||||
|
||||
// --- T13 optional-children finding (RESOLVED by THIS real-server test) ---
|
||||
// AckedState is mandatory on AcknowledgeableConditionState so it is always present.
|
||||
condition.AckedState.ShouldNotBeNull();
|
||||
// FINDING (1.5.378.106): Create auto-builds the FULL optional Part 9 child set from the
|
||||
// embedded type definition WITHOUT us pre-setting any property — both ConfirmedState (Confirm
|
||||
// sub-state machine) AND ShelvingState (Shelve state machine) come back non-null. This is
|
||||
// RICHER than the SDK-notes' [SAMPLE-ONLY] caveat predicted (it suggested we'd have to
|
||||
// instantiate optional children ourselves). Net: T15/T16 can drive SetConfirmedState /
|
||||
// SetShelvingState directly — no manual child materialisation needed. Asserting both non-null
|
||||
// so a future SDK bump that changes auto-build behaviour fails loudly.
|
||||
// SDK 2.0 only builds the optional children explicitly requested by the application.
|
||||
// The existing confirm/shelve workflow remains part of the served condition contract.
|
||||
condition.ConfirmedState.ShouldNotBeNull();
|
||||
condition.ShelvingState.ShouldNotBeNull();
|
||||
|
||||
@@ -345,9 +340,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
sink.WriteAlarmCondition("alm-1", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
nm.VariableCount.ShouldBe(0); // fallback bool[2] path NOT taken
|
||||
|
||||
condition.ActiveState.Id.Value.ShouldBeTrue();
|
||||
condition.AckedState.Id.Value.ShouldBeFalse();
|
||||
condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain
|
||||
condition.ActiveState!.Id!.Value.ShouldBeTrue();
|
||||
condition.AckedState!.Id!.Value.ShouldBeFalse();
|
||||
condition.Retain!.Value.ShouldBeTrue(); // active || !acked ⇒ retain
|
||||
|
||||
// Idempotent re-materialise (e.g. redeploy): still exactly one condition node for the id.
|
||||
sink.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", severity: 700, realm: AddressSpaceRealm.Uns);
|
||||
@@ -410,17 +405,17 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
Message: "Pressure above limit"),
|
||||
DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
condition.ActiveState.Id.Value.ShouldBeTrue();
|
||||
condition.AckedState.Id.Value.ShouldBeFalse();
|
||||
condition.EnabledState.Id.Value.ShouldBeFalse(); // disabled
|
||||
condition.ConfirmedState!.Id.Value.ShouldBeFalse(); // unconfirmed
|
||||
condition.ActiveState!.Id!.Value.ShouldBeTrue();
|
||||
condition.AckedState!.Id!.Value.ShouldBeFalse();
|
||||
condition.EnabledState!.Id!.Value.ShouldBeFalse(); // disabled
|
||||
condition.ConfirmedState!.Id!.Value.ShouldBeFalse(); // unconfirmed
|
||||
// SetShelvingState(shelved:true, oneShot:false) drives the shelving sub-state machine into a
|
||||
// shelved state; CurrentState is populated (TimedShelved).
|
||||
condition.ShelvingState!.CurrentState.Id.Value.ShouldNotBeNull();
|
||||
condition.ShelvingState!.CurrentState!.Id!.Value.IsNull.ShouldBeFalse();
|
||||
// Severity 850 is carried verbatim — not re-bucketed onto EventSeverity.High (900).
|
||||
condition.Severity.Value.ShouldBe((ushort)850);
|
||||
condition.Message.Value.Text.ShouldBe("Pressure above limit");
|
||||
condition.Retain.Value.ShouldBeTrue(); // active || !acked ⇒ retain
|
||||
condition.Severity!.Value.ShouldBe((ushort)850);
|
||||
condition.Message!.Value.Text.ShouldBe("Pressure above limit");
|
||||
condition.Retain!.Value.ShouldBeTrue(); // active || !acked ⇒ retain
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -462,9 +457,9 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
DateTime.UtcNow, AddressSpaceRealm.Uns));
|
||||
|
||||
// Mandatory state still projected despite the missing optional child.
|
||||
condition.ActiveState.Id.Value.ShouldBeTrue();
|
||||
condition.AckedState.Id.Value.ShouldBeFalse();
|
||||
condition.Message.Value.Text.ShouldBe("still works");
|
||||
condition.ActiveState!.Id!.Value.ShouldBeTrue();
|
||||
condition.AckedState!.Id!.Value.ShouldBeFalse();
|
||||
condition.Message!.Value.Text.ShouldBe("still works");
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -495,12 +490,12 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
|
||||
// EventId at materialise time (Create stamps an initial one). Capture a COPY — the live byte[]
|
||||
// reference is reused, so we compare snapshots, not the same array instance.
|
||||
var materialiseEventId = (byte[]?)condition!.EventId.Value?.Clone();
|
||||
var materialiseEventId = (byte[]?)condition!.EventId!.Value.ToArray();
|
||||
|
||||
// First engine-driven transition → fires an event with a fresh EventId.
|
||||
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
var firstEventId = (byte[]?)condition.EventId.Value?.Clone();
|
||||
var firstEventId = (byte[]?)condition.EventId!.Value.ToArray();
|
||||
firstEventId.ShouldNotBeNull();
|
||||
firstEventId!.Length.ShouldBe(16); // GUID bytes
|
||||
// Changed from the materialise-time value (a real event fired, not the create-time stamp).
|
||||
@@ -512,7 +507,7 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
// Second transition → DIFFERENT EventId (fresh per event, so T17 ack-correlation is unambiguous).
|
||||
sink.WriteAlarmCondition("alm-evt", Snapshot(active: true, acknowledged: true), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
var secondEventId = (byte[]?)condition.EventId.Value?.Clone();
|
||||
var secondEventId = (byte[]?)condition.EventId!.Value.ToArray();
|
||||
secondEventId.ShouldNotBeNull();
|
||||
secondEventId!.ShouldNotBe(firstEventId);
|
||||
|
||||
@@ -551,10 +546,10 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
});
|
||||
|
||||
// Final projected state is intact after the last firing.
|
||||
condition!.ActiveState.Id.Value.ShouldBeFalse();
|
||||
condition.AckedState.Id.Value.ShouldBeTrue();
|
||||
condition.Message.Value.Text.ShouldBe("cleared");
|
||||
condition.Retain.Value.ShouldBeFalse(); // inactive && acked ⇒ no retain
|
||||
condition!.ActiveState!.Id!.Value.ShouldBeFalse();
|
||||
condition.AckedState!.Id!.Value.ShouldBeTrue();
|
||||
condition.Message!.Value.Text.ShouldBe("cleared");
|
||||
condition.Retain!.Value.ShouldBeFalse(); // inactive && acked ⇒ no retain
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -583,8 +578,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
|
||||
// Drive the alarm active+unacked through the engine path (a genuine transition → fires).
|
||||
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
condition!.ActiveState.Id.Value.ShouldBeTrue();
|
||||
condition.AckedState.Id.Value.ShouldBeFalse();
|
||||
condition!.ActiveState!.Id!.Value.ShouldBeTrue();
|
||||
condition.AckedState!.Id!.Value.ShouldBeFalse();
|
||||
|
||||
// === Simulate the SDK-applied inbound Acknowledge (E2) ===
|
||||
// After T18's gate returns Good, the SDK applies the acked state to the node and auto-fires its
|
||||
@@ -592,26 +587,26 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
lock (nm.Lock)
|
||||
{
|
||||
condition.SetAcknowledgedState(nm.SystemContext, true);
|
||||
condition.Message.Value = new LocalizedText("active");
|
||||
condition.Message!.Value = new LocalizedText("active");
|
||||
condition.ClearChangeMasks(nm.SystemContext, includeChildren: true);
|
||||
}
|
||||
condition.AckedState.Id.Value.ShouldBeTrue();
|
||||
condition.AckedState!.Id!.Value.ShouldBeTrue();
|
||||
|
||||
// Capture the EventId AFTER the SDK-applied ack but BEFORE the engine re-projection.
|
||||
var beforeReProject = (byte[]?)condition.EventId.Value?.Clone();
|
||||
var beforeReProject = (byte[]?)condition.EventId!.Value.ToArray();
|
||||
|
||||
// === Engine re-projects the SAME acked transition through WriteAlarmCondition (would-be E3) ===
|
||||
// Snapshot equals the node's current state (active, acked, message "active") ⇒ delta-gate sees
|
||||
// no change ⇒ NO event fires.
|
||||
sink.WriteAlarmCondition("alm-ack", Snapshot(active: true, acknowledged: true, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
|
||||
var afterReProject = (byte[]?)condition.EventId.Value?.Clone();
|
||||
var afterReProject = (byte[]?)condition.EventId!.Value.ToArray();
|
||||
// EventId is UNCHANGED ⇒ ReportConditionEvent did NOT run ⇒ E3 suppressed.
|
||||
afterReProject.ShouldBe(beforeReProject);
|
||||
|
||||
// The projection still applied (state is intact) — only the redundant event was suppressed.
|
||||
condition.ActiveState.Id.Value.ShouldBeTrue();
|
||||
condition.AckedState.Id.Value.ShouldBeTrue();
|
||||
condition.ActiveState!.Id!.Value.ShouldBeTrue();
|
||||
condition.AckedState!.Id!.Value.ShouldBeTrue();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
@@ -633,24 +628,24 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
|
||||
var condition = nm.TryGetAlarmCondition("alm-delta");
|
||||
condition.ShouldNotBeNull();
|
||||
|
||||
var beforeFirst = (byte[]?)condition!.EventId.Value?.Clone();
|
||||
var beforeFirst = (byte[]?)condition!.EventId!.Value.ToArray();
|
||||
|
||||
// Genuine transition: snapshot (active, unacked) differs from the materialise state
|
||||
// (inactive, acked) ⇒ delta ⇒ fires exactly one event (EventId changes).
|
||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
var afterFirst = (byte[]?)condition.EventId.Value?.Clone();
|
||||
var afterFirst = (byte[]?)condition.EventId!.Value.ToArray();
|
||||
afterFirst.ShouldNotBeNull();
|
||||
afterFirst!.ShouldNotBe(beforeFirst); // fired
|
||||
|
||||
// Identical re-projection: snapshot now EQUALS the node's current state ⇒ no delta ⇒ 0 more
|
||||
// events (EventId unchanged from the first fire).
|
||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: true, acknowledged: false, message: "active"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
var afterSecond = (byte[]?)condition.EventId.Value?.Clone();
|
||||
var afterSecond = (byte[]?)condition.EventId!.Value.ToArray();
|
||||
afterSecond.ShouldBe(afterFirst); // suppressed
|
||||
|
||||
// A FURTHER genuine transition (clear) differs again ⇒ fires once more.
|
||||
sink.WriteAlarmCondition("alm-delta", Snapshot(active: false, acknowledged: true, message: "cleared"), DateTime.UtcNow, AddressSpaceRealm.Uns);
|
||||
var afterThird = (byte[]?)condition.EventId.Value?.Clone();
|
||||
var afterThird = (byte[]?)condition.EventId!.Value.ToArray();
|
||||
afterThird.ShouldNotBe(afterSecond); // fired
|
||||
|
||||
await host.DisposeAsync();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Opc.Ua;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
@@ -34,7 +35,7 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable
|
||||
var proxy = (FaultingServer)(object)server;
|
||||
proxy.Node = new Opc.Ua.ServerObjectState(null)
|
||||
{
|
||||
ServiceLevel = new Opc.Ua.PropertyState<byte>(null),
|
||||
ServiceLevel = new Opc.Ua.PropertyState<byte>.Implementation<Opc.Ua.VariantBuilder>(null),
|
||||
};
|
||||
proxy.Fault = new InvalidOperationException("controlled diagnostics failure");
|
||||
var publisher = new SdkServiceLevelPublisher(server, NullLogger<SdkServiceLevelPublisher>.Instance);
|
||||
@@ -48,7 +49,7 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable
|
||||
protected override object? Invoke(System.Reflection.MethodInfo? method, object?[]? args)
|
||||
{
|
||||
if (method?.Name == "get_ServerObject") return Node;
|
||||
if (method?.Name == "get_DiagnosticsLock") throw Fault!;
|
||||
if (method?.Name == "UpdateServerDiagnostics") throw Fault!;
|
||||
throw new NotSupportedException(method?.Name);
|
||||
}
|
||||
}
|
||||
@@ -69,7 +70,7 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance),
|
||||
() => new StandardServer(), Ct);
|
||||
() => new OtOpcUaSdkServer(), Ct);
|
||||
await using var host = started.Host;
|
||||
var server = started.Server;
|
||||
|
||||
@@ -100,7 +101,7 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance),
|
||||
() => new StandardServer(), Ct);
|
||||
() => new OtOpcUaSdkServer(), Ct);
|
||||
await using var host = started.Host;
|
||||
var server = started.Server;
|
||||
var publisher = new SdkServiceLevelPublisher(
|
||||
@@ -111,7 +112,7 @@ public sealed class SdkServiceLevelPublisherTests : IDisposable
|
||||
publisher.Publish(150);
|
||||
publisher.Publish(240);
|
||||
|
||||
server.CurrentInstance.ServerObject.ServiceLevel.Value.ShouldBe((byte)240);
|
||||
server.CurrentInstance.ServerObject.ServiceLevel!.Value.ShouldBe((byte)240);
|
||||
}
|
||||
|
||||
/// <summary>Disposes and cleans up the test resources.</summary>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using Opc.Ua;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
internal static class SdkTestValues
|
||||
{
|
||||
internal static byte[]? ToNullableArray(this ByteString value) => value.IsEmpty ? null : value.ToArray();
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using Opc.Ua.Security.Certificates;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
public sealed class SessionHistoryContinuationStoreTests : IAsyncLifetime
|
||||
{
|
||||
private readonly string _pki = Path.Combine(Path.GetTempPath(), $"otua-sdk2-cp-{Guid.NewGuid():N}");
|
||||
private OpcUaApplicationHost _host = null!;
|
||||
private OtOpcUaSdkServer _server = null!;
|
||||
private Certificate _certificate = null!;
|
||||
private Nonce _nonce = null!;
|
||||
private readonly SessionHistoryContinuationStore _store = new();
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
var started = await OpcUaHostStart.StartAsync(port => new OpcUaApplicationHost(new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "SDK2.Continuation", ApplicationUri = "urn:SDK2.Continuation",
|
||||
PublicHostname = "localhost", OpcUaPort = port, PkiStoreRoot = _pki,
|
||||
}, NullLogger<OpcUaApplicationHost>.Instance), () => new OtOpcUaSdkServer(), TestContext.Current.CancellationToken);
|
||||
_host = started.Host;
|
||||
_server = started.Server;
|
||||
using var key = RSA.Create(2048);
|
||||
var request = new CertificateRequest("CN=SDK2.Continuation.Unit", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
_certificate = Certificate.From(request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(1)));
|
||||
_nonce = Nonce.CreateNonce(32);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_nonce.Dispose();
|
||||
_certificate.Dispose();
|
||||
await _host.DisposeAsync();
|
||||
Directory.Delete(_pki, true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Real_session_store_transfers_an_owned_single_use_wrapper()
|
||||
{
|
||||
using var session = Session();
|
||||
var state = State();
|
||||
var token = _store.Save(session, state)!;
|
||||
token.Length.ShouldBe(16);
|
||||
using var point = session.ContinuationPoints.RestoreHistory(ByteString.From(token));
|
||||
point.ShouldNotBeNull();
|
||||
point.Id.ShouldBe(new Guid(token));
|
||||
_store.TryTake(session, token).ShouldBeNull();
|
||||
var next = _store.Save(session, state)!;
|
||||
_store.TryTake(session, next).ShouldBeSameAs(state);
|
||||
_store.TryTake(session, next).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Real_sessions_isolate_points_evict_oldest_and_release_explicitly()
|
||||
{
|
||||
using var first = Session();
|
||||
using var second = Session();
|
||||
var oldest = _store.Save(first, State())!;
|
||||
var middle = _store.Save(first, State())!;
|
||||
var latest = _store.Save(first, State())!;
|
||||
_store.TryTake(first, oldest).ShouldBeNull();
|
||||
_store.TryTake(second, middle).ShouldBeNull();
|
||||
_store.TryTake(first, middle).ShouldNotBeNull();
|
||||
_store.Release(first, latest);
|
||||
_store.TryTake(first, latest).ShouldBeNull();
|
||||
_store.Release(first, latest);
|
||||
_store.TryTake(first, [1, 2, 3]).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Closing_the_real_session_drops_abandoned_points()
|
||||
{
|
||||
var session = Session();
|
||||
var token = _store.Save(session, State())!;
|
||||
session.Dispose();
|
||||
_store.TryTake(session, token).ShouldBeNull();
|
||||
}
|
||||
|
||||
private Session Session()
|
||||
{
|
||||
var endpoint = new EndpointDescription { EndpointUrl = "opc.tcp://localhost:4840", SecurityMode = MessageSecurityMode.None };
|
||||
using var context = new OperationContext(new RequestHeader(),
|
||||
new SecureChannelContext(Guid.NewGuid().ToString(), endpoint, RequestEncoding.Binary),
|
||||
RequestType.CreateSession, RequestLifetime.None);
|
||||
return new Session(context, _server.CurrentInstance, _certificate, new NodeId(Guid.NewGuid()), default,
|
||||
_nonce, "SDK2 continuation unit", new ApplicationDescription(), endpoint.EndpointUrl,
|
||||
null!, null!, 60_000, 2, 2);
|
||||
}
|
||||
|
||||
private static HistoryContinuationState State() => new("tag", DateTime.UtcNow, DateTime.UtcNow.AddHours(1), 0, 2);
|
||||
}
|
||||
Reference in New Issue
Block a user