ddd7678269
A full RebuildAddressSpace recreated every native Part 9 condition inactive + acknowledged, and when the alarm-tag set was unchanged (e.g. one UnsTagReference added to an alarm-bearing raw tag) nothing re-told the new node its state: the host does not re-subscribe for an unchanged set, and Galaxy / OpcUaClient have no snapshot to replay anyway. Active alarms read normal until their next device transition. OtOpcUaNodeManager now captures each NATIVE condition's live state (from the node, so a client-applied ack is kept) before the rebuild clears, restores it when the same id is re-materialised native in the same apply, and reports ONE condition event per restored off-normal condition at the new IOpcUaAddressSpaceSink.CompleteAddressSpaceApply (after notifier wiring). Unclaimed captures are discarded there, so a later re-add is fresh. Quality is carried silently for normal conditions too (it is only re-annotated on a connectivity edge). Scripted conditions are untouched (#487 re-asserts them). OpcUaPublishActor calls CompleteAddressSpaceApply at the end of every apply. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
192 lines
9.9 KiB
C#
192 lines
9.9 KiB
C#
using System.Reflection;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.OpcUa;
|
|
|
|
/// <summary>
|
|
/// Arch-review <b>03/U2</b> — a mechanical, reflection-driven guard that <b>every</b> method of
|
|
/// <b>every</b> forwarding interface the deferred wrappers implement actually reaches the inner
|
|
/// sink/publisher. The per-member hand-written tests in <c>DeferredAddressSpaceSinkTests</c> cover
|
|
/// the members that exist today; this test closes the class of bug where a <i>newly-added</i> sink
|
|
/// capability is implemented on <c>DeferredAddressSpaceSink</c> as a silent no-op (never forwarded),
|
|
/// so the optimization ships <b>inert on every driver-role host</b> — the F10b / PR#423 failure mode,
|
|
/// because actors inject the deferred <i>wrapper</i>, not the inner <c>SdkAddressSpaceSink</c>.
|
|
///
|
|
/// A <see cref="DispatchProxy"/> recording inner auto-implements any future interface member with
|
|
/// zero maintenance, so adding a method to <see cref="IOpcUaAddressSpaceSink"/> or
|
|
/// <see cref="ISurgicalAddressSpaceSink"/> is covered automatically — if the wrapper forgets to
|
|
/// forward it, <see cref="Every_forwarding_interface_method_reaches_the_inner_sink"/> fails. Adding a
|
|
/// brand-new capability <i>interface</i> to the wrapper is caught by
|
|
/// <see cref="DeferredAddressSpaceSink_implements_exactly_the_known_forwarding_interfaces"/>.
|
|
/// </summary>
|
|
public class DeferredSinkForwardingReflectionTests
|
|
{
|
|
/// <summary>The interfaces whose members <see cref="DeferredAddressSpaceSink"/> is contractually
|
|
/// required to forward to its inner sink. Kept in sync with reality by
|
|
/// <see cref="DeferredAddressSpaceSink_implements_exactly_the_known_forwarding_interfaces"/>.</summary>
|
|
private static readonly Type[] ForwardingInterfaces =
|
|
{
|
|
typeof(IOpcUaAddressSpaceSink),
|
|
typeof(ISurgicalAddressSpaceSink),
|
|
};
|
|
|
|
[Fact]
|
|
public void DeferredAddressSpaceSink_implements_exactly_the_known_forwarding_interfaces()
|
|
{
|
|
// Guard-of-the-guard: if a new capability interface is added to (or removed from)
|
|
// DeferredAddressSpaceSink, this trips — forcing whoever added it to (1) list it in
|
|
// ForwardingInterfaces and (2) extend IRecordingSink so the exhaustive forwarding test
|
|
// below actually covers its members. Without this, a new interface could ship with an
|
|
// un-forwarded member and no test would notice (the F10b / PR#423 trap class).
|
|
var actual = typeof(DeferredAddressSpaceSink).GetInterfaces()
|
|
.Where(i => i.Namespace == typeof(IOpcUaAddressSpaceSink).Namespace)
|
|
.ToHashSet();
|
|
|
|
actual.ShouldBe(
|
|
ForwardingInterfaces.ToHashSet(),
|
|
ignoreOrder: true,
|
|
customMessage:
|
|
"A forwarding interface was added to / removed from DeferredAddressSpaceSink without updating " +
|
|
"this exhaustive forwarding guard. Update ForwardingInterfaces + IRecordingSink, then re-run.");
|
|
}
|
|
|
|
[Fact]
|
|
public void Every_forwarding_interface_method_reaches_the_inner_sink()
|
|
{
|
|
foreach (var method in ForwardingInterfaces.SelectMany(i => i.GetMethods()))
|
|
{
|
|
// Fresh wrapper + recorder per member so Invoked reflects exactly this call.
|
|
var proxy = DispatchProxy.Create<IRecordingSink, RecordingProxy>();
|
|
var recorder = (RecordingProxy)(object)proxy;
|
|
|
|
var deferred = new DeferredAddressSpaceSink();
|
|
deferred.SetSink(proxy);
|
|
|
|
var args = method.GetParameters().Select(p => DummyArg(p.ParameterType)).ToArray();
|
|
method.Invoke(deferred, args);
|
|
|
|
recorder.Invoked.ShouldContain(
|
|
method.Name,
|
|
$"DeferredAddressSpaceSink.{method.Name} did NOT forward to its inner sink — it ships inert " +
|
|
"on every driver-role host (F10b / PR#423 class). Forward it in DeferredAddressSpaceSink.cs.");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Every_node_naming_sink_method_carries_a_realm_discriminator()
|
|
{
|
|
// v3 B4-WP2 dual-namespace invariant: a node id alone is no longer globally unique across the two
|
|
// namespaces (Raw vs UNS), so every sink method that NAMES a node must carry an AddressSpaceRealm
|
|
// discriminator — the sink resolves the namespace from the realm rather than parsing the id string.
|
|
// RebuildAddressSpace (it clears BOTH realms) and CompleteAddressSpaceApply (it closes an apply pass,
|
|
// Gitea #641) are the only node-naming-free methods. This guard locks in the realm surface so a future
|
|
// method can't be added that names a node without a realm.
|
|
foreach (var method in ForwardingInterfaces.SelectMany(i => i.GetMethods()))
|
|
{
|
|
if (method.Name is nameof(IOpcUaAddressSpaceSink.RebuildAddressSpace)
|
|
or nameof(IOpcUaAddressSpaceSink.CompleteAddressSpaceApply)) continue;
|
|
|
|
method.GetParameters().Any(p => CarriesRealm(p.ParameterType)).ShouldBeTrue(
|
|
$"{method.DeclaringType!.Name}.{method.Name} names a node but has no AddressSpaceRealm parameter — " +
|
|
"a bare node id is ambiguous across the Raw and UNS namespaces (B4-WP2). Add the realm discriminator.");
|
|
}
|
|
}
|
|
|
|
/// <summary>Whether a parameter type supplies the B4-WP2 realm discriminator — either directly, or
|
|
/// carried per element by a batch parameter (<c>WriteValues(IReadOnlyList<ValueWrite>)</c> is
|
|
/// realm-qualified per item, since one batch legitimately spans both namespaces). Looking THROUGH the
|
|
/// element type rather than exempting the method by name keeps the guard mechanical: a future batch
|
|
/// item struct that forgets its realm still trips it.</summary>
|
|
private static bool CarriesRealm(Type t)
|
|
{
|
|
if (t == typeof(AddressSpaceRealm)) return true;
|
|
|
|
if (t.IsGenericType && t.GetGenericArguments().Any(CarriesRealm)) return true;
|
|
|
|
return t.Namespace == typeof(IOpcUaAddressSpaceSink).Namespace
|
|
&& (t.GetProperties().Any(p => p.PropertyType == typeof(AddressSpaceRealm))
|
|
|| t.GetFields().Any(f => f.FieldType == typeof(AddressSpaceRealm)));
|
|
}
|
|
|
|
[Fact]
|
|
public void Every_IServiceLevelPublisher_method_reaches_the_inner_publisher()
|
|
{
|
|
// DeferredServiceLevelPublisher is the sibling late-binding adapter with the same trap;
|
|
// the plan (03/U2) calls for the same treatment.
|
|
foreach (var method in typeof(IServiceLevelPublisher).GetMethods())
|
|
{
|
|
var proxy = DispatchProxy.Create<IServiceLevelPublisher, RecordingProxy>();
|
|
var recorder = (RecordingProxy)(object)proxy;
|
|
|
|
var deferred = new DeferredServiceLevelPublisher();
|
|
deferred.SetInner(proxy);
|
|
|
|
var args = method.GetParameters().Select(p => DummyArg(p.ParameterType)).ToArray();
|
|
method.Invoke(deferred, args);
|
|
|
|
recorder.Invoked.ShouldContain(
|
|
method.Name,
|
|
$"DeferredServiceLevelPublisher.{method.Name} did NOT forward to its inner publisher.");
|
|
}
|
|
}
|
|
|
|
/// <summary>Combined interface so a single <see cref="DispatchProxy"/> recorder implements every
|
|
/// forwarding member of <see cref="IOpcUaAddressSpaceSink"/> + <see cref="ISurgicalAddressSpaceSink"/>
|
|
/// (the wrapper requires an inner that is BOTH for surgical forwarding to engage). New members on
|
|
/// either base interface are implemented by the proxy automatically.</summary>
|
|
public interface IRecordingSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink;
|
|
|
|
/// <summary>Records the name of every interface member the wrapper forwards to it. Returns
|
|
/// <c>true</c> for <see cref="bool"/>-returning members so the wrapper's capability-sniffing
|
|
/// <c>_inner is ISurgicalAddressSpaceSink surgical && surgical.X(...)</c> short-circuit
|
|
/// still invokes (and thus records) the member.</summary>
|
|
public class RecordingProxy : DispatchProxy
|
|
{
|
|
/// <summary>Gets the names of the interface members invoked on this proxy.</summary>
|
|
public List<string> Invoked { get; } = new();
|
|
|
|
/// <inheritdoc />
|
|
protected override object? Invoke(MethodInfo? targetMethod, object?[]? args)
|
|
{
|
|
Invoked.Add(targetMethod!.Name);
|
|
var rt = targetMethod.ReturnType;
|
|
if (rt == typeof(void)) return null;
|
|
if (rt == typeof(bool)) return true;
|
|
return rt.IsValueType ? Activator.CreateInstance(rt) : null;
|
|
}
|
|
}
|
|
|
|
/// <summary>Builds a non-null dummy argument for any parameter type in the sink interfaces so the
|
|
/// reflective invoke can't throw on a null value type. Fidelity of the forwarded args is asserted
|
|
/// by the hand-written per-member tests; here we only need the call to arrive.</summary>
|
|
private static object? DummyArg(Type t)
|
|
{
|
|
if (t == typeof(string)) return "n";
|
|
if (t == typeof(object)) return 0;
|
|
|
|
var underlying = Nullable.GetUnderlyingType(t);
|
|
if (underlying != null) return DummyArg(underlying);
|
|
|
|
if (t.IsEnum) return Enum.GetValues(t).GetValue(0);
|
|
if (t == typeof(DateTime)) return DateTime.UtcNow;
|
|
|
|
if (t.IsValueType)
|
|
{
|
|
if (t.IsPrimitive || t == typeof(decimal)) return Activator.CreateInstance(t);
|
|
var vctor = GreediestCtor(t);
|
|
return vctor is null
|
|
? Activator.CreateInstance(t)
|
|
: vctor.Invoke(vctor.GetParameters().Select(p => DummyArg(p.ParameterType)).ToArray());
|
|
}
|
|
|
|
// Reference type with a greedy constructor (e.g. the AlarmConditionSnapshot record).
|
|
var ctor = GreediestCtor(t);
|
|
return ctor?.Invoke(ctor.GetParameters().Select(p => DummyArg(p.ParameterType)).ToArray());
|
|
}
|
|
|
|
private static ConstructorInfo? GreediestCtor(Type t) =>
|
|
t.GetConstructors().OrderByDescending(c => c.GetParameters().Length).FirstOrDefault();
|
|
}
|