c81cef39f5
Layer 4 of the condition-Quality chain (#477 -> #478). #478 gave a scripted condition the worst quality of its input tags, but only for inputs whose driver PUBLISHES a Bad data change. A poll driver whose device goes unreachable publishes nothing — it raises ConnectivityChanged(false) and goes silent — so the engine held its last Good value, WorstInputStatus stayed Good, and worse, AreInputsReady stayed true, so the predicate went on evaluating a stale value after the source had died. `DriverHostActor.FanCommsLossToDependencyMux` pushes a synthetic BadNoCommunication with a null value into the dependency mux for every value ref the disconnected driver owns. The issue asked for a driver -> all-tag-refs map to be built for this. It already exists: `_nodeIdByDriverRef`, keyed (DriverInstanceId, RawPath) and rebuilt from the composition on every apply. Alarm-bearing tags live in the separate `_alarmNodeIdByDriverRef` and are deliberately excluded — a native condition is not a mux value and takes the Part 9 quality path instead. Nothing is pushed on reconnect, deliberately: publishing Good would assert a value quality this actor has not observed. Recovery rides the driver's own republish, and the mechanism was verified rather than assumed — the Connected -> Reconnecting edge calls DetachSubscription, which nulls the subscription handle, so the reconnect's ResubscribeDesired builds a FRESH subscription whose PollGroupEngine initial push runs with forceRaise: true. Without that detach the poll engine would compare against its retained LastValues, see an unchanged value, raise nothing, and strand these refs Bad forever — the value-dedup trap that bit the v3 Batch 4 virtual-tag reassert. Closes #478 Finding 3, which the above depends on. WorstInputStatus skipped every input whose VALUE was null as "no data yet" — and Bad-with-no-value is exactly the shape a comms-lost driver produces, so the skip was hiding the case that matters most; without this half the fan-out above would be silently inert. The upstream source now stamps a never-pushed path BadWaitingForInitialData — the honest Part 4 code for that state — and the engine skips that specific code rather than every null value. Deleting the null-skip outright is the obvious fix and is wrong in the other direction: it makes every not-yet-published input drag its condition Bad at deploy, flooding the quality path with load-time annotations, which is what the skip existed to prevent. Both mistakes are pinned by a test pair that goes red under the opposite error, and both were falsified by reverting the production code: old null-skip restored -> A_pushed_Bad_carrying_no_value RED null-skip deleted outright -> An_input_that_was_never_pushed RED fan-out call removed -> the three DriverHostActor tests RED (1 of 7) The never-pushed test was vacuous on first writing and is worth recording: the quality bucket only emits on CHANGE, so the load-time evaluation is the one that flashes Bad, and subscribing to OnEvent after LoadAsync made the assertion unfalsifiable — it passed against the broken behaviour. It now subscribes first. `FakeUpstream.ReadTag` returned 0x80340000 (BadNotConnected) for an unset path, a different Bad code from production's placeholder. Harmless while any Bad behaved alike; load-bearing now, so it is aligned and pinned by a reflection guard asserting the engine's private constant agrees with what the production source stamps. Behaviour change to weigh, flagged in the issue and confirmed: virtual tags computed from a comms-lost input now go Bad too. That follows from using the shared mux and reads as correct, but it is broader than scripted alarms. Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
95 lines
4.7 KiB
C#
95 lines
4.7 KiB
C#
using System.Collections.Concurrent;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms.Tests;
|
|
|
|
/// <summary>Test fake implementation of <see cref="ITagUpstreamSource"/> for verifying subscription behavior.</summary>
|
|
public sealed class FakeUpstream : ITagUpstreamSource
|
|
{
|
|
private readonly ConcurrentDictionary<string, DataValueSnapshot> _values = new(StringComparer.Ordinal);
|
|
private readonly ConcurrentDictionary<string, List<Action<string, DataValueSnapshot>>> _subs
|
|
= new(StringComparer.Ordinal);
|
|
/// <summary>Gets the current count of active subscriptions.</summary>
|
|
public int ActiveSubscriptionCount { get; private set; }
|
|
|
|
/// <summary>Sets a tag value without notifying subscribers.</summary>
|
|
/// <param name="path">The tag path to set.</param>
|
|
/// <param name="value">The value to set for the tag.</param>
|
|
/// <param name="statusCode">The OPC UA status code for the value.</param>
|
|
public void Set(string path, object? value, uint statusCode = 0u)
|
|
{
|
|
var now = DateTime.UtcNow;
|
|
_values[path] = new DataValueSnapshot(value, statusCode, now, now);
|
|
}
|
|
|
|
/// <summary>Sets a tag value and notifies all current subscribers.</summary>
|
|
/// <param name="path">The tag path to set.</param>
|
|
/// <param name="value">The value to set for the tag.</param>
|
|
/// <param name="statusCode">The OPC UA status code for the value.</param>
|
|
public void Push(string path, object? value, uint statusCode = 0u)
|
|
{
|
|
Set(path, value, statusCode);
|
|
if (_subs.TryGetValue(path, out var list))
|
|
{
|
|
Action<string, DataValueSnapshot>[] snap;
|
|
lock (list) { snap = list.ToArray(); }
|
|
foreach (var obs in snap) obs(path, _values[path]);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// OPC UA <c>BadWaitingForInitialData</c> — the status a never-pushed path reports, matching what
|
|
/// the production <c>DependencyMuxTagUpstreamSource</c> stamps.
|
|
/// <para>This used to be <c>0x80340000</c> (<c>BadNotConnected</c>), a different Bad code from the
|
|
/// one production uses. That drift was invisible while <c>WorstInputStatus</c> skipped every
|
|
/// null-valued entry — any Bad code behaved the same. #481 made the specific code load-bearing (it
|
|
/// is how cold start is told apart from a driver reporting Bad), so a fake disagreeing with
|
|
/// production here would let every alarm with an unset input report Bad in tests and Good in the
|
|
/// real runtime, or vice versa.</para>
|
|
/// </summary>
|
|
public const uint StatusNeverPushed = 0x80320000u;
|
|
|
|
/// <summary>Reads the current value of a tag, or returns a never-pushed snapshot if not set.</summary>
|
|
/// <param name="path">The tag path to read.</param>
|
|
public DataValueSnapshot ReadTag(string path)
|
|
=> _values.TryGetValue(path, out var v) ? v
|
|
: new DataValueSnapshot(null, StatusNeverPushed, null, DateTime.UtcNow);
|
|
|
|
/// <summary>Subscribes an observer to tag changes for the given path.</summary>
|
|
/// <param name="path">The tag path to subscribe to.</param>
|
|
/// <param name="observer">The observer callback to invoke on tag changes.</param>
|
|
public IDisposable SubscribeTag(string path, Action<string, DataValueSnapshot> observer)
|
|
{
|
|
var list = _subs.GetOrAdd(path, _ => []);
|
|
lock (list) { list.Add(observer); }
|
|
ActiveSubscriptionCount++;
|
|
return new Unsub(this, path, observer);
|
|
}
|
|
|
|
/// <summary>Disposable subscription handle that unsubscribes the observer when disposed.</summary>
|
|
private sealed class Unsub : IDisposable
|
|
{
|
|
private readonly FakeUpstream _up;
|
|
private readonly string _path;
|
|
private readonly Action<string, DataValueSnapshot> _observer;
|
|
/// <summary>Initializes the unsubscription handle with references needed to clean up the subscription.</summary>
|
|
/// <param name="up">The upstream source containing the subscription list.</param>
|
|
/// <param name="path">The tag path to unsubscribe from.</param>
|
|
/// <param name="observer">The observer to remove from the subscription list.</param>
|
|
public Unsub(FakeUpstream up, string path, Action<string, DataValueSnapshot> observer)
|
|
{ _up = up; _path = path; _observer = observer; }
|
|
/// <summary>Removes the observer from the subscription list.</summary>
|
|
public void Dispose()
|
|
{
|
|
if (_up._subs.TryGetValue(_path, out var list))
|
|
{
|
|
lock (list)
|
|
{
|
|
if (list.Remove(_observer)) _up.ActiveSubscriptionCount--;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|