18f8eb5d2f
Array nodes were forced read-only for every driver. A new single authority, Core.Abstractions DriverArrayWriteSupport, says which (driver, element type) pairs encode a whole-array write: Modbus (Coils / numeric HoldingRegisters), S7 (Byte/Int16/UInt16/Int32/UInt32/Float32), OpcUaClient (pass-through) and Galaxy (Boolean/Int32/Int64/Float/Double/String/DateTime). Everything else stays read-only, so the server never advertises a write the driver refuses. - Composer derives RawTagPlan.ArrayWritable + UnsReferenceVariable .BackingArrayWritable at compose time (both seams, byte-parity); the applier's one writability rule is Writable && (!IsArray || ArrayWritable) for materialise and surgical paths, raw and UNS alike. - ArrayWritable is on the classifier's surgical raw-tag whitelist and mirrored on the UNS record (UnsReferenceBackingParityTests updated). - Node manager refuses a wrong-length / non-array value on an array node (BadTypeMismatch) and any IndexRange write (BadWriteNotSupported) inline, before dispatch; WriteOperate/ACL gates and the revert are unchanged. - Per-driver parity tests pin the table against the real Modbus write path, S7 EncodeArrayBlock and Galaxy MxValueEncoder (UInt32/UInt64 excluded: array covariance reinterprets their bits). - Over-the-wire proof in OpcUaServer.IntegrationTests (ArrayWriteWireTests). - ValueRank > 1 documented as out of scope by decision (docs/Uns.md). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
159 lines
8.0 KiB
C#
159 lines
8.0 KiB
C#
using System.Reflection;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// Gitea #599 / archreview 03/U11 — a mechanical guard on the <c>Backing*</c>-field parity invariant.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A non-alarm raw-tag delta over the classifier's surgical whitelist is applied IN PLACE on the raw
|
|
/// node, and every referencing UNS mirror is repaired only because the same edit makes its
|
|
/// <see cref="UnsReferenceVariable"/> record unequal — the planner then emits a coincident
|
|
/// <c>UnsReferenceDelta</c>. That works only while every whitelisted raw-tag field the mirror inherits
|
|
/// has a counterpart on <see cref="UnsReferenceVariable"/> (same name, or <c>Backing</c> + name).
|
|
/// Omitting one is silent: the raw node updates, the mirror stays stale, and nothing fails. Until this
|
|
/// test the invariant was enforced by comments alone.
|
|
/// </para>
|
|
/// <para>
|
|
/// The whitelist is DISCOVERED, not restated: each <see cref="RawTagPlan"/> property is perturbed in
|
|
/// isolation and asked of <see cref="AddressSpaceChangeClassifier.RawTagDeltaIsSurgicalEligible"/>. So
|
|
/// widening the classifier whitelist without mirroring the field fails here, and so does adding a
|
|
/// <c>Backing*</c> field that names no raw-tag field.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class UnsReferenceBackingParityTests
|
|
{
|
|
/// <summary>
|
|
/// Surgically-eligible raw-tag fields that a UNS mirror deliberately does NOT inherit, each with the
|
|
/// reason. Anything else the classifier lets through surgically must be mirrored.
|
|
/// </summary>
|
|
private static readonly IReadOnlyDictionary<string, string> NotInheritedByMirror = new Dictionary<string, string>
|
|
{
|
|
// Node-inert for a value tag: it only drives native-alarm notifier wiring, and an alarm-bearing delta
|
|
// never takes the surgical path. The mirror is the referencing side, so it has nothing to inherit.
|
|
[nameof(RawTagPlan.ReferencingEquipmentPaths)] = "notifier wiring only; the mirror is the referencing side",
|
|
};
|
|
|
|
private static RawTagPlan Baseline() => new(
|
|
TagId: "tag-1",
|
|
NodeId: "MAIN/modbus/dev/grp/temp",
|
|
ParentNodeId: "MAIN/modbus/dev/grp",
|
|
DriverInstanceId: "drv-1",
|
|
Name: "temp",
|
|
DataType: "Float",
|
|
Writable: false,
|
|
Alarm: null,
|
|
ReferencingEquipmentPaths: new[] { "Area/Line/Eq" },
|
|
IsHistorized: false,
|
|
HistorianTagname: null,
|
|
IsArray: false,
|
|
ArrayLength: null,
|
|
Realm: AddressSpaceRealm.Raw);
|
|
|
|
/// <summary>The raw-tag fields the classifier lets through surgically, found by perturbing each one.</summary>
|
|
private static IReadOnlyList<PropertyInfo> SurgicallyEligibleRawTagFields()
|
|
{
|
|
var baseline = Baseline();
|
|
var eligible = new List<PropertyInfo>();
|
|
foreach (var p in RecordProperties(typeof(RawTagPlan)))
|
|
{
|
|
var current = Perturb(baseline, p);
|
|
if (AddressSpaceChangeClassifier.RawTagDeltaIsSurgicalEligible(new AddressSpacePlan.RawTagDelta(baseline, current)))
|
|
eligible.Add(p);
|
|
}
|
|
return eligible;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Every raw-tag field the classifier applies surgically is mirrored on the UNS reference record (or is
|
|
/// explicitly listed as not inherited) — with the same type, so the planner's record equality sees the
|
|
/// edit.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
[Fact]
|
|
public void Every_surgically_eligible_raw_tag_field_is_mirrored_on_the_UNS_reference_record()
|
|
{
|
|
var eligible = SurgicallyEligibleRawTagFields();
|
|
|
|
// Positive control: the perturbation machinery really does find the whitelist. If it found nothing,
|
|
// the loop below would pass vacuously.
|
|
eligible.Select(p => p.Name).ShouldBe(
|
|
new[]
|
|
{
|
|
nameof(RawTagPlan.DataType), nameof(RawTagPlan.Writable), nameof(RawTagPlan.ReferencingEquipmentPaths),
|
|
nameof(RawTagPlan.IsHistorized), nameof(RawTagPlan.HistorianTagname), nameof(RawTagPlan.IsArray),
|
|
nameof(RawTagPlan.ArrayLength), nameof(RawTagPlan.ArrayWritable),
|
|
},
|
|
ignoreOrder: true);
|
|
|
|
var mirror = RecordProperties(typeof(UnsReferenceVariable)).ToDictionary(p => p.Name, StringComparer.Ordinal);
|
|
foreach (var p in eligible)
|
|
{
|
|
if (NotInheritedByMirror.ContainsKey(p.Name)) continue;
|
|
|
|
var counterpart = mirror.GetValueOrDefault(p.Name) ?? mirror.GetValueOrDefault("Backing" + p.Name);
|
|
counterpart.ShouldNotBeNull(
|
|
$"RawTagPlan.{p.Name} is applied surgically on the raw node, but UnsReferenceVariable has neither " +
|
|
$"'{p.Name}' nor 'Backing{p.Name}' — a raw-side edit of it would leave every referencing UNS mirror stale.");
|
|
counterpart.PropertyType.ShouldBe(p.PropertyType, $"UnsReferenceVariable.{counterpart.Name} vs RawTagPlan.{p.Name}");
|
|
}
|
|
}
|
|
|
|
/// <summary>Every <c>Backing*</c> field on the UNS reference record names a real raw-tag field of the same
|
|
/// type — a misspelt or orphaned mirror field repairs nothing.</summary>
|
|
[Trait("Category", "Unit")]
|
|
[Fact]
|
|
public void Every_Backing_field_names_a_raw_tag_field_of_the_same_type()
|
|
{
|
|
var raw = RecordProperties(typeof(RawTagPlan)).ToDictionary(p => p.Name, StringComparer.Ordinal);
|
|
var backing = RecordProperties(typeof(UnsReferenceVariable))
|
|
.Where(p => p.Name.StartsWith("Backing", StringComparison.Ordinal) && p.Name != nameof(UnsReferenceVariable.BackingRawPath))
|
|
.ToList();
|
|
|
|
backing.ShouldNotBeEmpty();
|
|
foreach (var b in backing)
|
|
{
|
|
var source = raw.GetValueOrDefault(b.Name["Backing".Length..]);
|
|
source.ShouldNotBeNull($"UnsReferenceVariable.{b.Name} names no RawTagPlan field");
|
|
source.PropertyType.ShouldBe(b.PropertyType, $"UnsReferenceVariable.{b.Name} vs RawTagPlan.{source.Name}");
|
|
}
|
|
}
|
|
|
|
/// <summary>The allow-list stays honest: every entry must still be a surgically-eligible raw-tag field.</summary>
|
|
[Trait("Category", "Unit")]
|
|
[Fact]
|
|
public void Not_inherited_allow_list_names_only_surgically_eligible_fields()
|
|
{
|
|
var eligible = SurgicallyEligibleRawTagFields().Select(p => p.Name).ToHashSet(StringComparer.Ordinal);
|
|
foreach (var name in NotInheritedByMirror.Keys) eligible.ShouldContain(name);
|
|
}
|
|
|
|
private static IEnumerable<PropertyInfo> RecordProperties(Type t) =>
|
|
t.GetProperties(BindingFlags.Public | BindingFlags.Instance)
|
|
.Where(p => p.CanWrite && p.Name != "EqualityContract");
|
|
|
|
/// <summary>A copy of <paramref name="source"/> differing ONLY in <paramref name="property"/>.</summary>
|
|
private static RawTagPlan Perturb(RawTagPlan source, PropertyInfo property)
|
|
{
|
|
var clone = (RawTagPlan)typeof(RawTagPlan).GetMethod("<Clone>$")!.Invoke(source, null)!;
|
|
var value = property.GetValue(source);
|
|
object? changed = property.PropertyType switch
|
|
{
|
|
var t when t == typeof(bool) => !(bool)value!,
|
|
var t when t == typeof(string) => (string?)value + "-changed",
|
|
var t when t == typeof(uint?) => ((uint?)value ?? 0u) + 1u,
|
|
var t when t == typeof(IReadOnlyList<string>) => ((IReadOnlyList<string>)value!).Append("Other/Eq").ToArray(),
|
|
var t when t == typeof(AddressSpaceRealm) => (AddressSpaceRealm)value! == AddressSpaceRealm.Raw ? AddressSpaceRealm.Uns : AddressSpaceRealm.Raw,
|
|
var t when t == typeof(EquipmentTagAlarmInfo) => new EquipmentTagAlarmInfo("OffNormalAlarm", 500),
|
|
var t => throw new InvalidOperationException(
|
|
$"RawTagPlan.{property.Name} has type {t.Name}, which this guard cannot perturb — add a case so the field is checked."),
|
|
};
|
|
property.SetValue(clone, changed);
|
|
return clone;
|
|
}
|
|
}
|