mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-17 05:11:13 +08:00
Close the SupportsFilteredRetain gaps in the server implementation (#4377)
Fixes #4370. `SupportsFilteredRetain` worked for the exact shape the unit tests exercised, but was unreachable for other filter targets, wrong for branches, and kept state that outlived the filter it was derived from. This addresses all seven gaps from the issue. ### 1 — reachable only via `InstanceStateSnapshot` `CanSendFilteredAlarm` now resolves the condition through a helper that accepts either a snapshot's `Handle` or a `ConditionState` passed directly. Anything else still degrades to plain `WhereClause.Evaluate`, which is now stated in the XML docs instead of left implicit. ### 2 — the commented-out `Retain` guard Behaviour is unchanged — that is a spec call — but the dead `if` is gone and the open question is written up in place with a link to the issue, so it is tracked somewhere more durable than a bare source comment. ### 3 — branches collide on the key The key is now composed of ConditionId **and** BranchId, so a condition and its branches no longer contend for one entry. ### 4 — branches never inherit the flag `CreateBranch` copies `SupportsFilteredRetain` into the branch explicitly. ### 5 — address-space wiring I checked the spec before touching this. Part 9 §5.5.2 says the Property *"is only provided on the ConditionType"*, and `i=32060` in the standard nodeset accordingly carries no `HasModellingRule` — so the generator and nodeset are right, and making it an instance child would be the deviation. Confirmed experimentally: flipping `StandardTypes.xml` to `ModellingRule="Optional"` does generate the full property / `GetChildren` / `FindChild` / `CopyTo` wiring, and its **only** address-space effect is stamping `ModellingRule_Optional` onto `i=32060` — which the standard nodeset does not have. That change is therefore not made here. The two halves are reconciled the other way instead: the C# property is documented as the server-side per-condition switch that is deliberately *not* an address-space child (clients read the flag from the type node, which the generated address space already builds), with a test locking that in. Since nothing walks it as a child, #4 is fixed by the explicit copy rather than by `Initialize`. ### 6 — `ConditionRefresh` bypassed the bookkeeping Fixed for free by 1. Refresh queues the raw `ConditionState`, which now resolves, so refreshed conditions are evaluated against the where clause and re-prime their entries. No new API was needed — the concern in the issue about refresh mutating the set turns out to be benign: an entry means "passed the where clause at the last evaluation", so a refresh of an in-scope condition removes and re-adds the same key. ### 7 — never cleared, never persisted `ModifyAttributes` discards the state when the where clause actually changes (select clauses are ignored — they only shape the fields of an event that is being sent anyway). Persistence added via `IStoredMonitoredItem.FilteredRetainConditionIds` → `StoredMonitoredItem` → `ToStorableMonitoredItem` / restore constructor, plus encode/decode in `SharedKeyValueSubscriptionStore` behind a new definition format version 3. ## Judgment calls worth a look - **`SetMonitoringMode` deliberately keeps the state.** A disabled item never reaches `CanSendFilteredAlarm` at all, so the entries still describe what the client was last told, and the trailing event is owed to it once reporting resumes. Documented rather than changed. - **`IStoredMonitoredItem` gained a member**, which is source-breaking for anyone with a custom subscription store. `TestStoredMonitoredItem` in the test suite needed the same one-line addition. ## Tests `tests/Opc.Ua.Server.Tests/FilterRetainTests.cs` grows from 16 to 26 tests, covering branches, `ConditionState` targets, filter modification (both changed and unchanged where clause) and a durable round trip. Each new test was verified to fail against the unfixed code by reverting the corresponding change individually. The suite also drops the reflection hazard the issue flagged: a `TestableMonitoredItem` subclass exposes the protected method, so a signature change now fails the build instead of the run. Its queue factory is owned by the fixture rather than disposed inside the call that hands it to the item. ## Verification Full solution builds clean. `Opc.Ua.Server.Tests` (4801), `Opc.Ua.Core.Tests` (4371), `Opc.Ua.Redundancy.Server.Tests` (573) and `Opc.Ua.History.Tests` (506) all pass on net10.0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,7 @@ For the formal model, see
|
||||
| Latched + Reset | `SetLatchedState` + auto from `SetActiveState` | `AlarmClient.ResetAsync` |
|
||||
| Shelve / Unshelve | `SetShelvingState` | `AlarmClient.TimedShelveAsync` / `OneShotShelveAsync` / `UnshelveAsync` |
|
||||
| Re-alarm | `ProcessReAlarm` | n/a (server timer) |
|
||||
| Filtered retain | `ConditionState.SupportsFilteredRetain` | n/a (per-subscription) |
|
||||
| Alarm groups | `AlarmGroup`, `AlarmSuppressionEngine` | `AlarmClient.GetGroupMembershipsAsync` |
|
||||
| Alarm rate | `AlarmRateTracker` | read `AlarmMetricsType` attributes |
|
||||
| Refresh state | `Server.ConditionRefresh` (server-driven) | `AlarmClient.ConditionRefreshAsync` / `ConditionRefresh2Async` |
|
||||
@@ -199,6 +200,41 @@ alarm.SetSuppressedState(context, false); // SuppressedOrShelved stays true
|
||||
alarm.SetOutOfServiceState(context, false); // SuppressedOrShelved = false
|
||||
```
|
||||
|
||||
### Filtered retain
|
||||
|
||||
Part 9 B.1.4 lets a server give each client a Retain flag that takes
|
||||
that client's event filter into account. With filtered retain on, a
|
||||
condition that drops out of the scope of a client's where clause
|
||||
reports one final event — so that client sees `Retain = false` even
|
||||
though nothing changed on the server, and the alarm stays active for
|
||||
everyone else.
|
||||
|
||||
Opt a condition in by setting `SupportsFilteredRetain`:
|
||||
|
||||
```csharp
|
||||
alarm.SupportsFilteredRetain =
|
||||
PropertyState<bool>.With<VariantBuilder>(alarm, value: true);
|
||||
```
|
||||
|
||||
Notes on the shape of that property:
|
||||
|
||||
* Part 9 provides `SupportsFilteredRetain` **on the ConditionType
|
||||
only** — the standard nodeset declares no modelling rule for it, so
|
||||
condition instances do not carry it as a child. The C# property is
|
||||
therefore the server side switch, not an address space node:
|
||||
`FindChild(BrowseNames.SupportsFilteredRetain)` on an instance
|
||||
returns null by design. Clients read the flag from the type node,
|
||||
which the generated address space already builds.
|
||||
* Branches inherit the parent's setting — `CreateBranch` copies it,
|
||||
since nothing that walks the instance children would.
|
||||
* Each condition and branch is tracked separately, so one branch
|
||||
leaving filter scope does not affect its siblings or its parent.
|
||||
* `ConditionRefresh` participates: refreshed conditions are evaluated
|
||||
against the where clause and re-prime the tracking.
|
||||
* Changing a monitored item's where clause with `ModifyMonitoredItems`
|
||||
discards the tracking, because it describes the *previous* filter.
|
||||
Durable subscriptions carry it across a restart.
|
||||
|
||||
### Alarm groups and first-in-group
|
||||
|
||||
`src/Opc.Ua.Server/Alarms/AlarmGroup.cs` wraps a generated
|
||||
|
||||
@@ -50,7 +50,28 @@ namespace Opc.Ua
|
||||
AddComment?.OnCall = OnAddCommentCalled;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <summary>
|
||||
/// Opts this condition into filtered retain (see OPC UA Part 9, B.1.4): when the
|
||||
/// value is true, a monitored item reports one final event as the condition leaves
|
||||
/// the scope of that client's where clause, so the client sees Retain go false even
|
||||
/// though the condition is unchanged on the server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Part 9 states that the SupportsFilteredRetain Property "is only provided on the
|
||||
/// ConditionType", and the standard nodeset accordingly declares no modelling rule
|
||||
/// for it, so condition <em>instances</em> do not carry it as an address space child.
|
||||
/// This property is therefore deliberately not part of the instance child hierarchy:
|
||||
/// it is not returned by <see cref="NodeState.GetChildren"/> and cannot be reached
|
||||
/// through <c>FindChild(BrowseNames.SupportsFilteredRetain)</c>. It is the server
|
||||
/// side switch that mirrors, per condition, what the type node advertises. A server
|
||||
/// that wants clients to browse the flag exposes it on its ConditionType node, which
|
||||
/// the generated address space already builds.
|
||||
/// <para>
|
||||
/// Because the property is not a child, nothing that copies a condition by walking
|
||||
/// its children carries it. <see cref="CreateBranch"/> copies it explicitly, so
|
||||
/// branches inherit the parent's setting.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public PropertyState<bool>? SupportsFilteredRetain
|
||||
{
|
||||
get => m_supportsFilteredRetain;
|
||||
@@ -177,6 +198,10 @@ namespace Opc.Ua
|
||||
branchedAlarm.Initialize(context, this);
|
||||
branchedAlarm.BranchId!.Value = branchId; // ConditionState.Initialize creates BranchId
|
||||
branchedAlarm.AutoReportStateChanges = AutoReportStateChanges;
|
||||
// SupportsFilteredRetain is not an instance child, so Initialize does not
|
||||
// carry it over with the rest of the condition. Copy it here, otherwise
|
||||
// filtered retain would be silently off for every branch.
|
||||
CopySupportsFilteredRetain(context, branchedAlarm);
|
||||
branchedAlarm.ReportStateChange(context, false);
|
||||
|
||||
string postEventId = branchedAlarm.EventId!.Value.ToHexString(); // ConditionState.Initialize creates EventId
|
||||
@@ -192,6 +217,31 @@ namespace Opc.Ua
|
||||
return state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gives the branch its own copy of <see cref="SupportsFilteredRetain"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The flag is not part of the instance child hierarchy - see the remarks on
|
||||
/// <see cref="SupportsFilteredRetain"/> - so <c>NodeState.Initialize</c>
|
||||
/// does not carry it across when the branch is built from its parent. The branch
|
||||
/// gets a copy rather than the parent's instance so the two nodes stay separate
|
||||
/// address space objects.
|
||||
/// </remarks>
|
||||
private void CopySupportsFilteredRetain(ISystemContext context, ConditionState branch)
|
||||
{
|
||||
PropertyState<bool>? source = SupportsFilteredRetain;
|
||||
|
||||
if (source == null)
|
||||
{
|
||||
branch.SupportsFilteredRetain = null;
|
||||
return;
|
||||
}
|
||||
|
||||
PropertyState<bool> copy = PropertyState<bool>.With<VariantBuilder>(branch);
|
||||
copy.Create(context, source);
|
||||
branch.SupportsFilteredRetain = copy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new instance of the current condition type for branching.
|
||||
/// Override in derived types to avoid reflection-based instantiation.
|
||||
|
||||
@@ -984,7 +984,8 @@ namespace Opc.Ua.Redundancy.Server
|
||||
SourceSamplingInterval = item.SourceSamplingInterval,
|
||||
SubscriptionId = item.SubscriptionId,
|
||||
TimestampsToReturn = item.TimestampsToReturn,
|
||||
TypeMask = item.TypeMask
|
||||
TypeMask = item.TypeMask,
|
||||
FilteredRetainConditionIds = item.FilteredRetainConditionIds
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1268,6 +1269,10 @@ namespace Opc.Ua.Redundancy.Server
|
||||
encoder.WriteBoolean(null, item.IsDeleted);
|
||||
encoder.WriteBoolean(null, item.IsDetached);
|
||||
}
|
||||
if (version >= FilteredRetainDefinitionFormatVersion)
|
||||
{
|
||||
encoder.WriteStringArray(null, item.FilteredRetainConditionIds);
|
||||
}
|
||||
}
|
||||
|
||||
private static StoredMonitoredItem DecodeMonitoredItem(BinaryDecoder decoder, int version)
|
||||
@@ -1309,6 +1314,11 @@ namespace Opc.Ua.Redundancy.Server
|
||||
item.IsDeleted = decoder.ReadBoolean(null);
|
||||
item.IsDetached = decoder.ReadBoolean(null);
|
||||
}
|
||||
if (version >= FilteredRetainDefinitionFormatVersion)
|
||||
{
|
||||
// the keys are written by the monitored item and never null.
|
||||
item.FilteredRetainConditionIds = decoder.ReadStringArray(null)!;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -1391,7 +1401,8 @@ namespace Opc.Ua.Redundancy.Server
|
||||
|
||||
private const int LegacyDefinitionFormatVersion = 1;
|
||||
private const int LifecycleStateDefinitionFormatVersion = 2;
|
||||
private const int DefinitionFormatVersion = LifecycleStateDefinitionFormatVersion;
|
||||
private const int FilteredRetainDefinitionFormatVersion = 3;
|
||||
private const int DefinitionFormatVersion = FilteredRetainDefinitionFormatVersion;
|
||||
private const int DefinitionSnapshotManifestFormatVersion = 1;
|
||||
private const int ContinuationPointFormatVersion = 1;
|
||||
private const int LegacyRetransmissionStateFormatVersion = 1;
|
||||
|
||||
@@ -320,6 +320,17 @@ namespace Opc.Ua.Server
|
||||
m_lastValue = storedMonitoredItem.LastValue;
|
||||
MonitoredItemType = storedMonitoredItem.TypeMask;
|
||||
|
||||
// without this the first transition out of filter scope after a restart is
|
||||
// dropped, because the item would not know the client had been told about the
|
||||
// condition.
|
||||
ArrayOf<string> filteredRetainConditionIds =
|
||||
storedMonitoredItem.FilteredRetainConditionIds;
|
||||
|
||||
if (!filteredRetainConditionIds.IsEmpty)
|
||||
{
|
||||
m_filteredRetainConditionIds = [.. filteredRetainConditionIds];
|
||||
}
|
||||
|
||||
// create aggregate calculator.
|
||||
if (storedMonitoredItem.FilterToUse is ServerAggregateFilter aggregateFilter)
|
||||
{
|
||||
@@ -924,10 +935,14 @@ namespace Opc.Ua.Server
|
||||
ClientHandle = clientHandle;
|
||||
m_discardOldest = discardOldest;
|
||||
|
||||
MonitoringFilter? previousFilterToUse = m_filterToUse;
|
||||
|
||||
Filter = originalFilter;
|
||||
m_filterToUse = filterToUse;
|
||||
m_cachedDataChangeFilter = filterToUse as DataChangeFilter;
|
||||
|
||||
DiscardFilteredRetainStateOnWhereClauseChange(previousFilterToUse, filterToUse);
|
||||
|
||||
if (range != null)
|
||||
{
|
||||
m_range = range.High - range.Low;
|
||||
@@ -1348,6 +1363,22 @@ namespace Opc.Ua.Server
|
||||
/// <summary>
|
||||
/// Determines whether an event can be sent with SupportsFilteredRetain in consideration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Filtered retain (OPC UA Part 9, B.1.4) lets a condition report one final event as it
|
||||
/// leaves the scope of this client's where clause, so the client sees Retain go false
|
||||
/// even though the condition itself is unchanged on the server. The item therefore
|
||||
/// remembers which conditions currently pass its where clause; a condition that drops
|
||||
/// out of that set is delivered once more and then forgotten.
|
||||
/// <para>
|
||||
/// The condition behind <paramref name="instance"/> is resolved from either an
|
||||
/// <see cref="InstanceStateSnapshot"/> - the shape <c>ReportEvent</c> produces - or a
|
||||
/// <see cref="ConditionState"/> handed over directly, which is what
|
||||
/// <c>ConditionState.ConditionRefresh</c> puts into the refresh event list. Any other
|
||||
/// <see cref="IFilterTarget"/> cannot be mapped onto a condition, so a server that
|
||||
/// queues its own filter targets falls back to plain where clause evaluation and
|
||||
/// filtered retain has no effect for those events.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
protected bool CanSendFilteredAlarm(
|
||||
IFilterContext context,
|
||||
EventFilter filter,
|
||||
@@ -1355,53 +1386,69 @@ namespace Opc.Ua.Server
|
||||
{
|
||||
bool passedFilter = filter.WhereClause.Evaluate(context, instance);
|
||||
|
||||
ConditionState? alarmCondition = null;
|
||||
NodeId conditionId = default;
|
||||
if (instance is InstanceStateSnapshot instanceStateSnapshot)
|
||||
{
|
||||
alarmCondition = instanceStateSnapshot.Handle as ConditionState;
|
||||
ConditionState? alarmCondition = GetFilteredRetainCondition(instance);
|
||||
|
||||
if (alarmCondition != null &&
|
||||
alarmCondition.SupportsFilteredRetain != null &&
|
||||
alarmCondition.SupportsFilteredRetain.Value &&
|
||||
!filter.SelectClauses.IsNull)
|
||||
{
|
||||
conditionId = alarmCondition.NodeId;
|
||||
}
|
||||
if (alarmCondition == null || filter.SelectClauses.IsNull)
|
||||
{
|
||||
return passedFilter;
|
||||
}
|
||||
|
||||
bool canSend = passedFilter;
|
||||
HashSet<string> conditionIds = GetFilteredRetainConditionIds();
|
||||
string key = GetFilteredRetainKey(alarmCondition);
|
||||
|
||||
// ConditionId is valid only if FilteredRetain is set for the alarm condition
|
||||
if (!conditionId.IsNull && alarmCondition != null)
|
||||
// the key is present exactly while the condition passed the where clause the
|
||||
// last time it was evaluated, so removing it here both consumes the entry for
|
||||
// the transition out of scope and clears the way for re-priming it below.
|
||||
bool wasInFilterScope = conditionIds.Remove(key);
|
||||
|
||||
if (passedFilter)
|
||||
{
|
||||
HashSet<string> conditionIds = GetFilteredRetainConditionIds();
|
||||
|
||||
string key = conditionId.ToString();
|
||||
|
||||
bool saved = conditionIds.Contains(key);
|
||||
|
||||
if (saved)
|
||||
{
|
||||
conditionIds.Remove(key);
|
||||
}
|
||||
|
||||
if (passedFilter)
|
||||
{
|
||||
// Archie - December 17 2024
|
||||
// Requires discussion with Part 9 Editor
|
||||
// if (alarmCondition.Retain.Value)
|
||||
{
|
||||
conditionIds.Add(key);
|
||||
}
|
||||
}
|
||||
else if (saved)
|
||||
{
|
||||
canSend = true;
|
||||
}
|
||||
// Archie - December 17 2024
|
||||
// Whether the condition should only be tracked while Retain is true is an
|
||||
// open question with the Part 9 editor; it is tracked in
|
||||
// https://github.com/OPCFoundation/UA-.NETStandard/issues/4370. Until that
|
||||
// is settled a condition is tracked whenever it passes the where clause,
|
||||
// which means it always produces one trailing event on the way out - even
|
||||
// if Retain was already false when it passed.
|
||||
conditionIds.Add(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
return canSend;
|
||||
// out of scope now: send the trailing event if it was in scope before.
|
||||
return wasInFilterScope;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the condition that <paramref name="instance"/> reports on when that
|
||||
/// condition opted into filtered retain, otherwise <c>null</c>.
|
||||
/// </summary>
|
||||
private static ConditionState? GetFilteredRetainCondition(IFilterTarget instance)
|
||||
{
|
||||
ConditionState? condition = instance switch
|
||||
{
|
||||
InstanceStateSnapshot snapshot => snapshot.Handle as ConditionState,
|
||||
ConditionState state => state,
|
||||
_ => null
|
||||
};
|
||||
|
||||
return condition?.SupportsFilteredRetain?.Value == true ? condition : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the key a condition is tracked under.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A branch shares its parent's NodeId - branches are told apart by BranchId - so the
|
||||
/// NodeId alone would make a condition and all of its branches contend for a single
|
||||
/// entry, and one branch leaving filter scope would consume the entry another branch
|
||||
/// relies on.
|
||||
/// </remarks>
|
||||
private static string GetFilteredRetainKey(ConditionState condition)
|
||||
{
|
||||
return Utils.Format(
|
||||
"{0}|{1}",
|
||||
condition.NodeId,
|
||||
condition.BranchId?.Value ?? default);
|
||||
}
|
||||
|
||||
private HashSet<string> GetFilteredRetainConditionIds()
|
||||
@@ -1409,6 +1456,41 @@ namespace Opc.Ua.Server
|
||||
return m_filteredRetainConditionIds ??= [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops the filtered retain bookkeeping when the where clause it was derived from
|
||||
/// is replaced.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every entry means "this condition passed the previous where clause". Keeping
|
||||
/// those across a ModifyMonitoredItems that changes the where clause would produce
|
||||
/// a trailing event against a filter that never saw the condition pass. The select
|
||||
/// clauses do not take part in the decision, since they only shape the fields of an
|
||||
/// event that is being sent anyway.
|
||||
/// <para>
|
||||
/// A monitoring mode change deliberately keeps the state. A disabled item does not
|
||||
/// evaluate events at all, so the entries still describe what the client was last
|
||||
/// told, and the trailing event is owed to it once reporting resumes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void DiscardFilteredRetainStateOnWhereClauseChange(
|
||||
MonitoringFilter? previousFilter,
|
||||
MonitoringFilter? newFilter)
|
||||
{
|
||||
if (m_filteredRetainConditionIds == null ||
|
||||
m_filteredRetainConditionIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ContentFilter? previousWhereClause = (previousFilter as EventFilter)?.WhereClause;
|
||||
ContentFilter? newWhereClause = (newFilter as EventFilter)?.WhereClause;
|
||||
|
||||
if (!Utils.IsEqual(previousWhereClause, newWhereClause))
|
||||
{
|
||||
m_filteredRetainConditionIds.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Used to check whether the item is ready to sample.
|
||||
/// </summary>
|
||||
@@ -1847,7 +1929,10 @@ namespace Opc.Ua.Server
|
||||
Range = m_range,
|
||||
TimestampsToReturn = m_timestampsToReturn,
|
||||
TypeMask = MonitoredItemType,
|
||||
ParsedIndexRange = m_parsedIndexRange
|
||||
ParsedIndexRange = m_parsedIndexRange,
|
||||
FilteredRetainConditionIds = m_filteredRetainConditionIds?.Count > 0
|
||||
? [.. m_filteredRetainConditionIds]
|
||||
: ArrayOf<string>.Null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +166,19 @@ namespace Opc.Ua.Server
|
||||
/// </summary>
|
||||
int TypeMask { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The conditions that currently pass the item's event filter and are tracked for
|
||||
/// filtered retain (OPC UA Part 9, B.1.4).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Without this state a durable subscription drops the first transition out of
|
||||
/// filter scope after a restart, because the item no longer knows that the client
|
||||
/// had ever been told about the condition. The entries are opaque keys built by the
|
||||
/// monitored item; a store only has to round-trip them. A null or empty array both
|
||||
/// mean nothing is being tracked.
|
||||
/// </remarks>
|
||||
ArrayOf<string> FilteredRetainConditionIds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// An optional data-change queue pre-hydrated by an asynchronous
|
||||
/// <see cref="ISubscriptionStore"/> during restore. Runtime-only; never persisted.
|
||||
|
||||
@@ -110,6 +110,9 @@ namespace Opc.Ua.Server
|
||||
/// <inheritdoc/>
|
||||
public NumericRange ParsedIndexRange { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ArrayOf<string> FilteredRetainConditionIds { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IDataChangeMonitoredItemQueue? RestoredDataChangeQueue { get; set; }
|
||||
|
||||
|
||||
+54
@@ -64,6 +64,9 @@ namespace Opc.Ua.Server.Tests.Redundancy
|
||||
{
|
||||
private const ushort NamespaceIndex = 2;
|
||||
|
||||
private static readonly string[] s_filteredRetainConditionIds =
|
||||
["ns=2;s=Alarm|", "ns=2;s=Alarm|i=7"];
|
||||
|
||||
[Test]
|
||||
public async Task StoreAndRestoreRoundTripsDefinitionAsync()
|
||||
{
|
||||
@@ -109,6 +112,57 @@ namespace Opc.Ua.Server.Tests.Redundancy
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task StoreAndRestoreRoundTripsFilteredRetainConditionIdsAsync()
|
||||
{
|
||||
using var kv = new InMemorySharedKeyValueStore();
|
||||
SharedKeyValueSubscriptionStore active = CreateStore(kv);
|
||||
SharedKeyValueSubscriptionStore backup = CreateStore(kv);
|
||||
StoredSubscription expected = NewSubscription(103, 13);
|
||||
var expectedItem = (StoredMonitoredItem)expected.MonitoredItems.Single();
|
||||
expectedItem.FilteredRetainConditionIds = [.. s_filteredRetainConditionIds];
|
||||
|
||||
await active.StoreSubscriptionsAsync([expected]).ConfigureAwait(false);
|
||||
RestoreSubscriptionResult result = await backup.RestoreSubscriptionsAsync().ConfigureAwait(false);
|
||||
|
||||
Assert.That(result.Success, Is.True);
|
||||
var actual = (StoredMonitoredItem)result.Subscriptions!.Single().MonitoredItems.Single();
|
||||
Assert.That(
|
||||
actual.FilteredRetainConditionIds.Memory.ToArray(),
|
||||
Is.EqualTo(s_filteredRetainConditionIds));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task StoreAndRestoreRoundTripsAbsentFilteredRetainConditionIdsAsync()
|
||||
{
|
||||
using var kv = new InMemorySharedKeyValueStore();
|
||||
SharedKeyValueSubscriptionStore active = CreateStore(kv);
|
||||
SharedKeyValueSubscriptionStore backup = CreateStore(kv);
|
||||
|
||||
await active.StoreSubscriptionsAsync([NewSubscription(104, 14)]).ConfigureAwait(false);
|
||||
RestoreSubscriptionResult result = await backup.RestoreSubscriptionsAsync().ConfigureAwait(false);
|
||||
|
||||
Assert.That(result.Success, Is.True);
|
||||
var actual = (StoredMonitoredItem)result.Subscriptions!.Single().MonitoredItems.Single();
|
||||
Assert.That(actual.FilteredRetainConditionIds.IsEmpty, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CloneMonitoredItemPreservesFilteredRetainConditionIds()
|
||||
{
|
||||
StoredMonitoredItem item = NewItem(105, 15);
|
||||
item.FilteredRetainConditionIds = [.. s_filteredRetainConditionIds];
|
||||
MethodInfo clone = GetPrivateMethod(
|
||||
"CloneMonitoredItem",
|
||||
typeof(IStoredMonitoredItem));
|
||||
|
||||
var cloned = (StoredMonitoredItem)clone.Invoke(null, [item])!;
|
||||
|
||||
Assert.That(
|
||||
cloned.FilteredRetainConditionIds.Memory.ToArray(),
|
||||
Is.EqualTo(s_filteredRetainConditionIds));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CloneMonitoredItemPreservesLifecycleState()
|
||||
{
|
||||
|
||||
@@ -5188,6 +5188,8 @@ namespace Opc.Ua.Server.Tests
|
||||
|
||||
public NumericRange ParsedIndexRange { get; set; }
|
||||
|
||||
public ArrayOf<string> FilteredRetainConditionIds { get; set; }
|
||||
|
||||
public bool IsDurable { get; set; }
|
||||
|
||||
public ServiceResult LastError { get; set; }
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
#pragma warning disable CA2000
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using System.Linq;
|
||||
using BenchmarkDotNet.Attributes;
|
||||
using Moq;
|
||||
using NUnit.Framework;
|
||||
@@ -28,6 +28,14 @@ namespace Opc.Ua.Server.Tests
|
||||
{
|
||||
private SystemContext m_systemContext;
|
||||
private IFilterContext m_filterContext;
|
||||
private MonitoredItemQueueFactory m_queueFactory;
|
||||
|
||||
[OneTimeTearDown]
|
||||
public void OneTimeTearDown()
|
||||
{
|
||||
m_queueFactory?.Dispose();
|
||||
m_queueFactory = null;
|
||||
}
|
||||
|
||||
internal static readonly LocalizedText InService = new("en", "In Service");
|
||||
internal static readonly LocalizedText OutOfService = new("en", "Out of Service");
|
||||
@@ -55,7 +63,7 @@ namespace Opc.Ua.Server.Tests
|
||||
alarm.SetLimitState(systemContext, desiredState);
|
||||
|
||||
EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry);
|
||||
using MonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
CanSendFilteredAlarm(monitoredItem, GetFilterContext(telemetry), filter, alarm, pass, telemetry);
|
||||
}
|
||||
|
||||
@@ -81,7 +89,7 @@ namespace Opc.Ua.Server.Tests
|
||||
|
||||
EventFilter filter = GetHighOnlyEventFilter(addClauses: !pass, telemetry);
|
||||
|
||||
using MonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
CanSendFilteredAlarm(monitoredItem, context, filter, alarm, pass, telemetry);
|
||||
}
|
||||
|
||||
@@ -97,7 +105,7 @@ namespace Opc.Ua.Server.Tests
|
||||
IFilterContext context = GetFilterContext(telemetry);
|
||||
|
||||
EventFilter filter = GetHighOnlyEventFilter(addClauses: !pass, telemetry);
|
||||
using MonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
CanSendFilteredAlarm(monitoredItem, context, filter, certificateType, pass, telemetry);
|
||||
}
|
||||
|
||||
@@ -116,7 +124,7 @@ namespace Opc.Ua.Server.Tests
|
||||
alarm.SetLimitState(GetSystemContext(telemetry), LimitAlarmStates.Inactive);
|
||||
|
||||
EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry);
|
||||
using MonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
|
||||
CanSendFilteredAlarm(monitoredItem, GetFilterContext(telemetry), filter, alarm, expected: false, telemetry);
|
||||
}
|
||||
@@ -136,7 +144,7 @@ namespace Opc.Ua.Server.Tests
|
||||
IFilterContext filterContext = GetFilterContext(telemetry);
|
||||
|
||||
EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry);
|
||||
using MonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
|
||||
SystemContext systemContext = GetSystemContext(telemetry);
|
||||
alarm.SetLimitState(systemContext, LimitAlarmStates.Inactive);
|
||||
@@ -175,7 +183,7 @@ namespace Opc.Ua.Server.Tests
|
||||
telemetry: telemetry);
|
||||
|
||||
EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry);
|
||||
using MonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
|
||||
SystemContext systemContext = GetSystemContext(telemetry);
|
||||
IFilterContext filterContext = GetFilterContext(telemetry);
|
||||
@@ -211,7 +219,7 @@ namespace Opc.Ua.Server.Tests
|
||||
telemetry: telemetry);
|
||||
|
||||
EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry);
|
||||
using MonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
|
||||
SystemContext systemContext = GetSystemContext(telemetry);
|
||||
IFilterContext filterContext = GetFilterContext(telemetry);
|
||||
@@ -279,7 +287,7 @@ namespace Opc.Ua.Server.Tests
|
||||
};
|
||||
_ = filter.Validate(filterContext);
|
||||
|
||||
using MonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
|
||||
// 16 States in Table B.3
|
||||
|
||||
@@ -384,8 +392,335 @@ namespace Opc.Ua.Server.Tests
|
||||
CanSendFilteredAlarm(monitoredItem, filterContext, filter, alarm, expected, telemetry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ConditionRefresh puts the ConditionState itself into the event list rather than
|
||||
/// an InstanceStateSnapshot, so filtered retain has to recognise that shape too.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ConditionStateTargetParticipatesInFilteredRetain()
|
||||
{
|
||||
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
||||
|
||||
ExclusiveLevelAlarmState alarm = GetExclusiveLevelAlarm(
|
||||
addFilterRetain: true,
|
||||
filterRetainValue: true,
|
||||
telemetry: telemetry);
|
||||
|
||||
SystemContext systemContext = GetSystemContext(telemetry);
|
||||
IFilterContext filterContext = GetFilterContext(telemetry);
|
||||
EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry);
|
||||
using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
|
||||
alarm.SetLimitState(systemContext, LimitAlarmStates.High);
|
||||
alarm.Retain.Value = true;
|
||||
Assert.That(
|
||||
monitoredItem.CanSendFilteredAlarmForTest(filterContext, filter, alarm),
|
||||
Is.True,
|
||||
"the alarm passes the where clause");
|
||||
|
||||
alarm.SetLimitState(systemContext, LimitAlarmStates.Inactive);
|
||||
alarm.Retain.Value = false;
|
||||
Assert.That(
|
||||
monitoredItem.CanSendFilteredAlarmForTest(filterContext, filter, alarm),
|
||||
Is.True,
|
||||
"leaving filter scope produces the trailing event");
|
||||
|
||||
Assert.That(
|
||||
monitoredItem.CanSendFilteredAlarmForTest(filterContext, filter, alarm),
|
||||
Is.False,
|
||||
"the trailing event is sent once");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A branch shares its parent's NodeId, so tracking by NodeId alone would let one of
|
||||
/// them consume the entry the other relies on.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void BranchesDoNotShareFilteredRetainState()
|
||||
{
|
||||
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
||||
|
||||
ExclusiveLevelAlarmState alarm = GetExclusiveLevelAlarm(
|
||||
addFilterRetain: true,
|
||||
filterRetainValue: true,
|
||||
telemetry: telemetry);
|
||||
|
||||
SystemContext systemContext = GetSystemContext(telemetry);
|
||||
IFilterContext filterContext = GetFilterContext(telemetry);
|
||||
EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry);
|
||||
using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
|
||||
alarm.SetLimitState(systemContext, LimitAlarmStates.High);
|
||||
alarm.Retain.Value = true;
|
||||
|
||||
var branch = (ExclusiveLevelAlarmState)alarm.CreateBranch(systemContext, new NodeId(1, 1));
|
||||
Assert.That(branch, Is.Not.Null);
|
||||
Assert.That(branch.NodeId, Is.EqualTo(alarm.NodeId), "a branch keeps the parent NodeId");
|
||||
|
||||
// both are in scope; each has to claim its own entry.
|
||||
Assert.That(
|
||||
monitoredItem.CanSendFilteredAlarmForTest(filterContext, filter, alarm),
|
||||
Is.True);
|
||||
Assert.That(
|
||||
monitoredItem.CanSendFilteredAlarmForTest(filterContext, filter, branch),
|
||||
Is.True);
|
||||
|
||||
// the branch leaves scope and consumes its own entry only.
|
||||
branch.SetLimitState(systemContext, LimitAlarmStates.Inactive);
|
||||
Assert.That(
|
||||
monitoredItem.CanSendFilteredAlarmForTest(filterContext, filter, branch),
|
||||
Is.True,
|
||||
"the branch gets its trailing event");
|
||||
Assert.That(
|
||||
monitoredItem.CanSendFilteredAlarmForTest(filterContext, filter, branch),
|
||||
Is.False);
|
||||
|
||||
// the parent is untouched by that and still gets its own trailing event.
|
||||
alarm.SetLimitState(systemContext, LimitAlarmStates.Inactive);
|
||||
Assert.That(
|
||||
monitoredItem.CanSendFilteredAlarmForTest(filterContext, filter, alarm),
|
||||
Is.True,
|
||||
"the parent entry survived the branch transition");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SupportsFilteredRetain is not an instance child, so nothing that copies a
|
||||
/// condition by walking its children carries it. CreateBranch copies it explicitly.
|
||||
/// </summary>
|
||||
[Test]
|
||||
[TestCase(false)]
|
||||
[TestCase(true)]
|
||||
public void BranchInheritsSupportsFilteredRetain(bool supportsFilteredRetain)
|
||||
{
|
||||
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
||||
|
||||
ExclusiveLevelAlarmState alarm = GetExclusiveLevelAlarm(
|
||||
addFilterRetain: true,
|
||||
filterRetainValue: supportsFilteredRetain,
|
||||
telemetry: telemetry);
|
||||
|
||||
var branch = (ConditionState)alarm.CreateBranch(
|
||||
GetSystemContext(telemetry),
|
||||
new NodeId(1, 1));
|
||||
|
||||
Assert.That(branch, Is.Not.Null);
|
||||
Assert.That(branch.SupportsFilteredRetain, Is.Not.Null);
|
||||
Assert.That(branch.SupportsFilteredRetain.Value, Is.EqualTo(supportsFilteredRetain));
|
||||
Assert.That(
|
||||
branch.SupportsFilteredRetain,
|
||||
Is.Not.SameAs(alarm.SupportsFilteredRetain),
|
||||
"the branch owns its copy");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A condition that never opted in must not get a property out of thin air.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void BranchWithoutSupportsFilteredRetainStaysUnset()
|
||||
{
|
||||
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
||||
|
||||
ExclusiveLevelAlarmState alarm = GetExclusiveLevelAlarm(
|
||||
addFilterRetain: false,
|
||||
filterRetainValue: false,
|
||||
telemetry: telemetry);
|
||||
|
||||
var branch = (ConditionState)alarm.CreateBranch(
|
||||
GetSystemContext(telemetry),
|
||||
new NodeId(1, 1));
|
||||
|
||||
Assert.That(branch, Is.Not.Null);
|
||||
Assert.That(branch.SupportsFilteredRetain, Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Part 9 provides SupportsFilteredRetain on the ConditionType only, and the standard
|
||||
/// nodeset declares no modelling rule for it, so condition instances must not expose
|
||||
/// it as an address space child.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void SupportsFilteredRetainIsNotAnInstanceChild()
|
||||
{
|
||||
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
||||
|
||||
SystemContext systemContext = GetSystemContext(telemetry);
|
||||
ExclusiveLevelAlarmState alarm = GetExclusiveLevelAlarm(
|
||||
addFilterRetain: true,
|
||||
filterRetainValue: true,
|
||||
telemetry: telemetry);
|
||||
|
||||
var children = new List<BaseInstanceState>();
|
||||
alarm.GetChildren(systemContext, children);
|
||||
|
||||
Assert.That(
|
||||
children.Any(child =>
|
||||
child.BrowseName.Name == BrowseNames.SupportsFilteredRetain),
|
||||
Is.False);
|
||||
Assert.That(
|
||||
alarm.FindChild(
|
||||
systemContext,
|
||||
QualifiedName.From(BrowseNames.SupportsFilteredRetain)),
|
||||
Is.Null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every entry means "this condition passed the previous where clause", so keeping
|
||||
/// them across a where clause change would produce a trailing event against a filter
|
||||
/// that never saw the condition pass.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ModifyAttributesDiscardsFilteredRetainWhenWhereClauseChanges()
|
||||
{
|
||||
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
||||
|
||||
ExclusiveLevelAlarmState alarm = GetExclusiveLevelAlarm(
|
||||
addFilterRetain: true,
|
||||
filterRetainValue: true,
|
||||
telemetry: telemetry);
|
||||
|
||||
SystemContext systemContext = GetSystemContext(telemetry);
|
||||
IFilterContext filterContext = GetFilterContext(telemetry);
|
||||
EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry);
|
||||
using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
|
||||
alarm.SetLimitState(systemContext, LimitAlarmStates.High);
|
||||
alarm.Retain.Value = true;
|
||||
CanSendFilteredAlarm(monitoredItem, filterContext, filter, alarm, expected: true, telemetry);
|
||||
|
||||
// a where clause the condition has never been evaluated against.
|
||||
var replacement = new EventFilter
|
||||
{
|
||||
SelectClauses = GetSelectFields(),
|
||||
WhereClause = GetStateFilter()
|
||||
};
|
||||
_ = replacement.Validate(filterContext);
|
||||
|
||||
monitoredItem.ModifyAttributes(
|
||||
DiagnosticsMasks.All,
|
||||
TimestampsToReturn.Server,
|
||||
3,
|
||||
replacement,
|
||||
replacement,
|
||||
null,
|
||||
1000.0,
|
||||
10,
|
||||
false);
|
||||
|
||||
alarm.SetLimitState(systemContext, LimitAlarmStates.Inactive);
|
||||
alarm.Retain.Value = false;
|
||||
CanSendFilteredAlarm(
|
||||
monitoredItem,
|
||||
filterContext,
|
||||
replacement,
|
||||
alarm,
|
||||
expected: false,
|
||||
telemetry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A modification that leaves the where clause alone - a new queue size, say - has to
|
||||
/// keep the bookkeeping, otherwise the trailing event is lost.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ModifyAttributesKeepsFilteredRetainWhenWhereClauseIsUnchanged()
|
||||
{
|
||||
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
||||
|
||||
ExclusiveLevelAlarmState alarm = GetExclusiveLevelAlarm(
|
||||
addFilterRetain: true,
|
||||
filterRetainValue: true,
|
||||
telemetry: telemetry);
|
||||
|
||||
SystemContext systemContext = GetSystemContext(telemetry);
|
||||
IFilterContext filterContext = GetFilterContext(telemetry);
|
||||
EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry);
|
||||
using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
|
||||
alarm.SetLimitState(systemContext, LimitAlarmStates.High);
|
||||
alarm.Retain.Value = true;
|
||||
CanSendFilteredAlarm(monitoredItem, filterContext, filter, alarm, expected: true, telemetry);
|
||||
|
||||
EventFilter sameFilter = GetHighOnlyEventFilter(addClauses: true, telemetry);
|
||||
|
||||
monitoredItem.ModifyAttributes(
|
||||
DiagnosticsMasks.All,
|
||||
TimestampsToReturn.Server,
|
||||
3,
|
||||
sameFilter,
|
||||
sameFilter,
|
||||
null,
|
||||
1000.0,
|
||||
20,
|
||||
false);
|
||||
|
||||
alarm.SetLimitState(systemContext, LimitAlarmStates.Inactive);
|
||||
alarm.Retain.Value = false;
|
||||
CanSendFilteredAlarm(
|
||||
monitoredItem,
|
||||
filterContext,
|
||||
sameFilter,
|
||||
alarm,
|
||||
expected: true,
|
||||
telemetry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A durable subscription has to carry the bookkeeping across a restart, otherwise
|
||||
/// the first transition out of filter scope after the restart is dropped.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void FilteredRetainSurvivesADurableRoundTrip()
|
||||
{
|
||||
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
||||
|
||||
ExclusiveLevelAlarmState alarm = GetExclusiveLevelAlarm(
|
||||
addFilterRetain: true,
|
||||
filterRetainValue: true,
|
||||
telemetry: telemetry);
|
||||
|
||||
SystemContext systemContext = GetSystemContext(telemetry);
|
||||
IFilterContext filterContext = GetFilterContext(telemetry);
|
||||
EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry);
|
||||
|
||||
IStoredMonitoredItem stored;
|
||||
using (TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry))
|
||||
{
|
||||
alarm.SetLimitState(systemContext, LimitAlarmStates.High);
|
||||
alarm.Retain.Value = true;
|
||||
CanSendFilteredAlarm(monitoredItem, filterContext, filter, alarm, expected: true, telemetry);
|
||||
|
||||
stored = monitoredItem.ToStorableMonitoredItem();
|
||||
}
|
||||
|
||||
Assert.That(stored.FilteredRetainConditionIds.IsNull, Is.False);
|
||||
Assert.That(stored.FilteredRetainConditionIds.Count, Is.EqualTo(1));
|
||||
|
||||
using TestableMonitoredItem restored = RestoreMonitoredItem(stored, telemetry);
|
||||
|
||||
alarm.SetLimitState(systemContext, LimitAlarmStates.Inactive);
|
||||
alarm.Retain.Value = false;
|
||||
CanSendFilteredAlarm(restored, filterContext, filter, alarm, expected: true, telemetry);
|
||||
CanSendFilteredAlarm(restored, filterContext, filter, alarm, expected: false, telemetry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An item with nothing to track stores nothing.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void NothingIsStoredWhenNoConditionIsTracked()
|
||||
{
|
||||
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
||||
|
||||
EventFilter filter = GetHighOnlyEventFilter(addClauses: true, telemetry);
|
||||
using TestableMonitoredItem monitoredItem = CreateMonitoredItem(filter, telemetry);
|
||||
|
||||
Assert.That(
|
||||
monitoredItem.ToStorableMonitoredItem().FilteredRetainConditionIds.IsNull,
|
||||
Is.True);
|
||||
}
|
||||
|
||||
private void CanSendFilteredAlarm(
|
||||
MonitoredItem monitoredItem,
|
||||
TestableMonitoredItem monitoredItem,
|
||||
IFilterContext context,
|
||||
EventFilter filter,
|
||||
BaseObjectState alarm,
|
||||
@@ -397,14 +732,11 @@ namespace Opc.Ua.Server.Tests
|
||||
var eventSnapshot = new InstanceStateSnapshot();
|
||||
eventSnapshot.Initialize(systemContext, alarm);
|
||||
|
||||
const BindingFlags eFlags = BindingFlags.Instance | BindingFlags.NonPublic;
|
||||
MethodInfo methodInfo = typeof(MonitoredItem).GetMethod("CanSendFilteredAlarm", eFlags);
|
||||
Debug.WriteLine("Expecting " + expected.ToString());
|
||||
object result = methodInfo.Invoke(monitoredItem, [context, filter, eventSnapshot]);
|
||||
|
||||
Assert.That(result, Is.Not.Null);
|
||||
Assert.That(result.GetType().Name, Is.EqualTo("Boolean"));
|
||||
Assert.That((bool)result, Is.EqualTo(expected));
|
||||
Assert.That(
|
||||
monitoredItem.CanSendFilteredAlarmForTest(context, filter, eventSnapshot),
|
||||
Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
private ExclusiveLevelAlarmState GetExclusiveLevelAlarm(
|
||||
@@ -640,23 +972,13 @@ namespace Opc.Ua.Server.Tests
|
||||
return m_filterContext;
|
||||
}
|
||||
|
||||
private MonitoredItem CreateMonitoredItem(MonitoringFilter filter, ITelemetryContext telemetry)
|
||||
private TestableMonitoredItem CreateMonitoredItem(
|
||||
MonitoringFilter filter,
|
||||
ITelemetryContext telemetry)
|
||||
{
|
||||
var serverMock = new Mock<IServerInternal>();
|
||||
|
||||
SystemContext systemContext = GetSystemContext(telemetry);
|
||||
serverMock.Setup(s => s.Telemetry).Returns(telemetry);
|
||||
serverMock.Setup(s => s.NamespaceUris).Returns(systemContext.NamespaceUris);
|
||||
serverMock.Setup(s => s.TypeTree).Returns((TypeTable)systemContext.TypeTable);
|
||||
using var queueFactory = new MonitoredItemQueueFactory(telemetry);
|
||||
serverMock.Setup(s => s.MonitoredItemQueueFactory)
|
||||
.Returns(queueFactory);
|
||||
|
||||
var nodeMangerMock = new Mock<IAsyncNodeManager>();
|
||||
|
||||
return new MonitoredItem(
|
||||
serverMock.Object,
|
||||
nodeMangerMock.Object,
|
||||
return new TestableMonitoredItem(
|
||||
CreateServer(telemetry),
|
||||
new Mock<IAsyncNodeManager>().Object,
|
||||
null,
|
||||
1,
|
||||
2,
|
||||
@@ -673,5 +995,97 @@ namespace Opc.Ua.Server.Tests
|
||||
false,
|
||||
1000);
|
||||
}
|
||||
|
||||
private TestableMonitoredItem RestoreMonitoredItem(
|
||||
IStoredMonitoredItem storedMonitoredItem,
|
||||
ITelemetryContext telemetry)
|
||||
{
|
||||
return new TestableMonitoredItem(
|
||||
CreateServer(telemetry),
|
||||
new Mock<IAsyncNodeManager>().Object,
|
||||
null,
|
||||
storedMonitoredItem);
|
||||
}
|
||||
|
||||
private IServerInternal CreateServer(ITelemetryContext telemetry)
|
||||
{
|
||||
var serverMock = new Mock<IServerInternal>();
|
||||
|
||||
SystemContext systemContext = GetSystemContext(telemetry);
|
||||
serverMock.Setup(s => s.Telemetry).Returns(telemetry);
|
||||
serverMock.Setup(s => s.NamespaceUris).Returns(systemContext.NamespaceUris);
|
||||
serverMock.Setup(s => s.TypeTree).Returns((TypeTable)systemContext.TypeTable);
|
||||
|
||||
// the factory has to outlive every item the fixture builds, so it is owned by
|
||||
// the fixture rather than by the call that hands it to a monitored item.
|
||||
m_queueFactory ??= new MonitoredItemQueueFactory(telemetry);
|
||||
serverMock.Setup(s => s.MonitoredItemQueueFactory).Returns(m_queueFactory);
|
||||
|
||||
return serverMock.Object;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exposes the protected filtered retain evaluation so the suite does not have to
|
||||
/// reach for it by reflection, where a signature change would compile cleanly and
|
||||
/// only fail at run time.
|
||||
/// </summary>
|
||||
private sealed class TestableMonitoredItem : MonitoredItem
|
||||
{
|
||||
public TestableMonitoredItem(
|
||||
IServerInternal server,
|
||||
IAsyncNodeManager nodeManager,
|
||||
object managerHandle,
|
||||
uint subscriptionId,
|
||||
uint id,
|
||||
ReadValueId itemToMonitor,
|
||||
DiagnosticsMasks diagnosticsMasks,
|
||||
TimestampsToReturn timestampsToReturn,
|
||||
MonitoringMode monitoringMode,
|
||||
uint clientHandle,
|
||||
MonitoringFilter originalFilter,
|
||||
MonitoringFilter filterToUse,
|
||||
Range range,
|
||||
double samplingInterval,
|
||||
uint queueSize,
|
||||
bool discardOldest,
|
||||
double sourceSamplingInterval)
|
||||
: base(
|
||||
server,
|
||||
nodeManager,
|
||||
managerHandle,
|
||||
subscriptionId,
|
||||
id,
|
||||
itemToMonitor,
|
||||
diagnosticsMasks,
|
||||
timestampsToReturn,
|
||||
monitoringMode,
|
||||
clientHandle,
|
||||
originalFilter,
|
||||
filterToUse,
|
||||
range,
|
||||
samplingInterval,
|
||||
queueSize,
|
||||
discardOldest,
|
||||
sourceSamplingInterval)
|
||||
{
|
||||
}
|
||||
|
||||
public TestableMonitoredItem(
|
||||
IServerInternal server,
|
||||
IAsyncNodeManager nodeManager,
|
||||
object managerHandle,
|
||||
IStoredMonitoredItem storedMonitoredItem)
|
||||
: base(server, nodeManager, managerHandle, storedMonitoredItem)
|
||||
{
|
||||
}
|
||||
|
||||
public bool CanSendFilteredAlarmForTest(
|
||||
IFilterContext context,
|
||||
EventFilter filter,
|
||||
IFilterTarget instance)
|
||||
{
|
||||
return CanSendFilteredAlarm(context, filter, instance);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user