6c4d29902e
v2-ci / unit-tests (push) Has been cancelled
#899 — EnsureTags is an upsert, so binding a node to an existing AVEVA tag via historianTagname re-typed/re-described it on every add and host start. - TagConfig `historianReadOnly: true` (TagConfigIntent -> RawTagPlan.HistorianReadOnly): HistoryRead from an existing historian tag that OtOpcUa never provisions and never records into (AddressSpaceApplier.IsOwnedHistorizedValue gates both the EnsureTags hook and the recorder feed). Node-inert: surgical, not mirrored on UNS refs, in the RawTagPlan equality/hash. A non-bool value is the deploy error HistorianReadOnlyInvalid. AdminUI: CSV column HistorianReadOnly (strict), TagHistorizeConfig reads via the runtime parse. - ServerHistorian:ProvisionTags = CreateOrUpdate (default, unchanged) | CreateOnly (Gateway only: TagExistsAsync first, existing tags left alone, failed lookup not sent) | Off. Unknown value / CreateOnly on a SQL backend fail host start. - Default for an explicit historianTagname stays OWNED: overrides pin owned history across renames, a read-only default would silently stop provisioning + recording. #898 — the hook parsed Tag.DataType (an OPC UA name) as DriverDataType, so historized Float/Double/Byte/SByte tags were never provisioned. New shared HistorianDataTypeNames.TryResolve: Float->Float32, Double->Float64, Byte/SByte->Int16 (lossless, provisioned as Int2); unmapped names logged at Information. Gateway now skips Int64/UInt64 (HistorianGateway cannot create Int8/UInt8: lossless-cast-or-reject). UPGRADE: the first host start after this provisions (upserts) every existing historized Float/Double/Byte/SByte tag. Mark foreign-tag bindings historianReadOnly, or set ServerHistorian:ProvisionTags=CreateOnly/Off, before upgrading (docs/Historian.md). Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
162 lines
8.4 KiB
C#
162 lines
8.4 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",
|
|
// lmxopcua#899: node-inert — it only steers the raw-tag provisioning hook and recorder feed; a UNS mirror
|
|
// serves the same HistoryRead either way and neither provisions nor records.
|
|
[nameof(RawTagPlan.HistorianReadOnly)] = "provisioning/recorder only; node-inert and never applied to a UNS mirror",
|
|
};
|
|
|
|
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), nameof(RawTagPlan.HistorianReadOnly),
|
|
},
|
|
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;
|
|
}
|
|
}
|