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
263 lines
13 KiB
C#
263 lines
13 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// v3 Batch 4 (B4-WP1) — the classifier folds the Raw + UNS subtree diff sets into its routing policy:
|
|
/// raw/uns adds count as adds (PureAdd), removes as removes (PureRemove).
|
|
/// <para>Surgical deploys (2026-08-15 Task 4) narrowed the Changed sets: a NON-alarm raw-tag delta whose
|
|
/// only differences are the in-place-updatable attributes and/or its <c>ReferencingEquipmentPaths</c> is
|
|
/// surgical (NOT a rebuild), and every UNS-reference delta is applied remove+recreate. Alarm-bearing and
|
|
/// identity-field raw-tag deltas still route to Rebuild — the default-closed WP4 notifier guard.</para>
|
|
/// </summary>
|
|
public sealed class AddressSpaceChangeClassifierDualNamespaceTests
|
|
{
|
|
private static readonly RawContainerNode Folder = new("Plant", null, "Plant", RawNodeKind.Folder);
|
|
|
|
private static RawTagPlan RawTag(string nodeId) =>
|
|
new("t-1", nodeId, "Plant/Modbus1/PLC-A", "drv-1", "Speed", "Float",
|
|
Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty<string>());
|
|
|
|
private static UnsReferenceVariable UnsRef(string nodeId) =>
|
|
new("ref-1", "eq-1", nodeId, "Speed", "Plant/Modbus1/PLC-A/Speed", "Float", Writable: false);
|
|
|
|
private static AddressSpacePlan Empty() => new(
|
|
Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<AddressSpacePlan.EquipmentDelta>(),
|
|
Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<AddressSpacePlan.DriverDelta>(),
|
|
Array.Empty<ScriptedAlarmPlan>(), Array.Empty<ScriptedAlarmPlan>(), Array.Empty<AddressSpacePlan.AlarmDelta>());
|
|
|
|
[Fact]
|
|
public void Added_raw_tag_only_is_PureAdd()
|
|
{
|
|
var plan = Empty() with { AddedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Speed") } };
|
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
|
|
}
|
|
|
|
[Fact]
|
|
public void Added_raw_container_only_is_PureAdd()
|
|
{
|
|
var plan = Empty() with { AddedRawContainers = new[] { Folder } };
|
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
|
|
}
|
|
|
|
[Fact]
|
|
public void Added_uns_reference_only_is_PureAdd()
|
|
{
|
|
var plan = Empty() with { AddedUnsReferenceVariables = new[] { UnsRef("filling/line-1/station-1/Speed") } };
|
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
|
|
}
|
|
|
|
[Fact]
|
|
public void Removed_raw_tag_only_is_PureRemove()
|
|
{
|
|
var plan = Empty() with { RemovedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Speed") } };
|
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove);
|
|
}
|
|
|
|
[Fact]
|
|
public void Removed_uns_reference_only_is_PureRemove()
|
|
{
|
|
var plan = Empty() with { RemovedUnsReferenceVariables = new[] { UnsRef("filling/line-1/station-1/Speed") } };
|
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove);
|
|
}
|
|
|
|
[Fact]
|
|
public void Raw_rename_add_and_remove_is_AddRemoveMix()
|
|
{
|
|
var plan = Empty() with
|
|
{
|
|
RemovedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Speed") },
|
|
AddedRawTags = new[] { RawTag("Plant/Modbus1/PLC-A/Velocity") },
|
|
};
|
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AddRemoveMix);
|
|
}
|
|
|
|
/// <summary>The headline surgical case: adding/dropping a <c>UnsTagReference</c> flips the BACKING raw tag
|
|
/// to Changed (ReferencingEquipmentPaths is part of RawTagPlan's equality). For a non-alarm VALUE tag that
|
|
/// set is node-inert — it only drives native-alarm notifier wiring — so it must NOT tear down the world.
|
|
/// (Was <c>Changed_raw_tag_is_Rebuild</c>; deliberately re-pointed by 2026-08-15 Task 4.)</summary>
|
|
[Fact]
|
|
public void Reference_only_raw_tag_delta_is_not_a_rebuild()
|
|
{
|
|
var plan = Empty() with
|
|
{
|
|
ChangedRawTags = new[]
|
|
{
|
|
new AddressSpacePlan.RawTagDelta(
|
|
RawTag("Plant/Modbus1/PLC-A/Speed"),
|
|
RawTag("Plant/Modbus1/PLC-A/Speed") with
|
|
{
|
|
ReferencingEquipmentPaths = new[] { "filling/line-1/station-1" },
|
|
}),
|
|
},
|
|
};
|
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly);
|
|
}
|
|
|
|
/// <summary>The in-place-updatable attribute whitelist (Writable / IsHistorized / HistorianTagname /
|
|
/// DataType / IsArray / ArrayLength / ArrayWritable) on a non-alarm raw tag is surgical, not a rebuild.</summary>
|
|
[Theory]
|
|
[InlineData("writable")]
|
|
[InlineData("historized")]
|
|
[InlineData("historianTagname")]
|
|
[InlineData("dataType")]
|
|
[InlineData("array")]
|
|
[InlineData("arrayLength")]
|
|
[InlineData("arrayWritable")]
|
|
public void Attribute_raw_tag_delta_is_not_a_rebuild(string field)
|
|
{
|
|
var baseline = RawTag("Plant/Modbus1/PLC-A/Speed");
|
|
// ArrayLength moving ALONE (a resize of an already-array tag) — the whitelist must carry ArrayLength
|
|
// in its own right, not only as a passenger of an IsArray flip. Its "previous" is already an array.
|
|
// ArrayWritable (#610) likewise moves on an already-array tag (a driver-type or element-type edit).
|
|
var prev = field is "arrayLength" or "arrayWritable" ? baseline with { IsArray = true, ArrayLength = 8 } : baseline;
|
|
var cur = field switch
|
|
{
|
|
"writable" => prev with { Writable = true },
|
|
"historized" => prev with { IsHistorized = true },
|
|
"historianTagname" => prev with { IsHistorized = true, HistorianTagname = "Plant.PLC_A.Speed" },
|
|
"dataType" => prev with { DataType = "Double" },
|
|
"array" => prev with { IsArray = true, ArrayLength = 8 },
|
|
"arrayLength" => prev with { ArrayLength = 16 },
|
|
"arrayWritable" => prev with { ArrayWritable = true },
|
|
_ => throw new ArgumentOutOfRangeException(nameof(field)),
|
|
};
|
|
|
|
var plan = Empty() with { ChangedRawTags = new[] { new AddressSpacePlan.RawTagDelta(prev, cur) } };
|
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly);
|
|
}
|
|
|
|
/// <summary>WP4 binding guard: a raw tag carrying a native alarm on EITHER side still rebuilds — the
|
|
/// rebuild is what clears + re-wires the per-equipment alarm notifiers.</summary>
|
|
[Theory]
|
|
[InlineData(true, false)]
|
|
[InlineData(false, true)]
|
|
[InlineData(true, true)]
|
|
public void Alarm_bearing_raw_tag_delta_still_rebuilds(bool alarmBefore, bool alarmAfter)
|
|
{
|
|
var alarm = new EquipmentTagAlarmInfo("OffNormalAlarm", 700);
|
|
var prev = RawTag("Plant/Modbus1/PLC-A/Speed") with { Alarm = alarmBefore ? alarm : null };
|
|
var cur = RawTag("Plant/Modbus1/PLC-A/Speed") with
|
|
{
|
|
Alarm = alarmAfter ? alarm : null,
|
|
ReferencingEquipmentPaths = new[] { "filling/line-1/station-1" },
|
|
};
|
|
|
|
var plan = Empty() with { ChangedRawTags = new[] { new AddressSpacePlan.RawTagDelta(prev, cur) } };
|
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
|
|
}
|
|
|
|
/// <summary>WP4 binding guard, CONTENT edition: an alarm present on BOTH sides whose severity (or type)
|
|
/// moved still rebuilds. The null-check pair alone does not decide it — the with-equality whitelist has to
|
|
/// leave <c>Alarm</c> out, so the two <see cref="EquipmentTagAlarmInfo"/> values compare unequal.</summary>
|
|
[Fact]
|
|
public void Alarm_content_only_raw_tag_delta_still_rebuilds()
|
|
{
|
|
var prev = RawTag("Plant/Modbus1/PLC-A/Speed") with { Alarm = new EquipmentTagAlarmInfo("OffNormalAlarm", 500) };
|
|
var cur = prev with { Alarm = new EquipmentTagAlarmInfo("OffNormalAlarm", 900) };
|
|
|
|
var plan = Empty() with { ChangedRawTags = new[] { new AddressSpacePlan.RawTagDelta(prev, cur) } };
|
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
|
|
}
|
|
|
|
/// <summary>The QUANTIFIER: the rebuild gate is <c>.Any(d => !eligible(d))</c>, so ONE ineligible raw-tag
|
|
/// delta must rebuild even when every other delta in the same plan is surgical. Pins against a regression
|
|
/// to <c>.All(eligible)</c> on the wrong side, or to inspecting only <c>First()</c> — either of which would
|
|
/// apply an identity/alarm change as an in-place attribute update and leave the node mis-wired.</summary>
|
|
[Fact]
|
|
public void One_ineligible_raw_tag_delta_among_eligible_ones_still_rebuilds()
|
|
{
|
|
var eligiblePrev = RawTag("Plant/Modbus1/PLC-A/Speed");
|
|
var ineligiblePrev = RawTag("Plant/Modbus1/PLC-A/Temp") with { TagId = "t-2" };
|
|
|
|
var plan = Empty() with
|
|
{
|
|
ChangedRawTags = new[]
|
|
{
|
|
// Surgical: a plain historize toggle. Listed FIRST so a First()-only check would pass it.
|
|
new AddressSpacePlan.RawTagDelta(eligiblePrev, eligiblePrev with { IsHistorized = true }),
|
|
// Ineligible: DriverInstanceId re-routes the node to a different driver point.
|
|
new AddressSpacePlan.RawTagDelta(ineligiblePrev, ineligiblePrev with { DriverInstanceId = "drv-2" }),
|
|
},
|
|
};
|
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
|
|
}
|
|
|
|
/// <summary>Default-closed: an identity field outside the whitelist (DriverInstanceId re-routes the node to
|
|
/// another driver point; Name/TagId/ParentNodeId re-shape identity) still forces a full rebuild.</summary>
|
|
[Theory]
|
|
[InlineData("driver")]
|
|
[InlineData("name")]
|
|
[InlineData("tagId")]
|
|
[InlineData("parent")]
|
|
public void Identity_field_raw_tag_delta_still_rebuilds(string field)
|
|
{
|
|
var prev = RawTag("Plant/Modbus1/PLC-A/Speed");
|
|
var cur = field switch
|
|
{
|
|
"driver" => prev with { DriverInstanceId = "drv-2" },
|
|
"name" => prev with { Name = "Velocity" },
|
|
"tagId" => prev with { TagId = "t-2" },
|
|
"parent" => prev with { ParentNodeId = "Plant/Modbus1/PLC-B" },
|
|
_ => throw new ArgumentOutOfRangeException(nameof(field)),
|
|
};
|
|
|
|
var plan = Empty() with { ChangedRawTags = new[] { new AddressSpacePlan.RawTagDelta(prev, cur) } };
|
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
|
|
}
|
|
|
|
/// <summary>A UNS-reference re-point (BackingRawPath moved) is applied surgically (remove-previous +
|
|
/// recreate-current), NOT as a rebuild. (Was <c>Uns_reference_repoint_is_Rebuild</c>; deliberately
|
|
/// re-pointed by 2026-08-15 Task 4.)</summary>
|
|
[Fact]
|
|
public void Uns_reference_repoint_delta_is_not_a_rebuild()
|
|
{
|
|
var plan = Empty() with
|
|
{
|
|
ChangedUnsReferenceVariables = new[]
|
|
{
|
|
new AddressSpacePlan.UnsReferenceDelta(
|
|
UnsRef("filling/line-1/station-1/Speed"),
|
|
UnsRef("filling/line-1/station-1/Speed") with { BackingRawPath = "Plant/Modbus1/PLC-A/Velocity" }),
|
|
},
|
|
};
|
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly);
|
|
}
|
|
|
|
/// <summary>The other UnsReferenceDelta variant — a display-name override edit keeps the row id while the
|
|
/// NodeId + EffectiveName change. Also surgical (remove-previous + recreate-current).</summary>
|
|
[Fact]
|
|
public void Uns_reference_display_name_delta_is_not_a_rebuild()
|
|
{
|
|
var plan = Empty() with
|
|
{
|
|
ChangedUnsReferenceVariables = new[]
|
|
{
|
|
new AddressSpacePlan.UnsReferenceDelta(
|
|
UnsRef("filling/line-1/station-1/Speed"),
|
|
UnsRef("filling/line-1/station-1/LineSpeed") with { EffectiveName = "LineSpeed" }),
|
|
},
|
|
};
|
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly);
|
|
}
|
|
|
|
/// <summary>The headline end-to-end shape: adding ONE UNS reference produces an added UNS variable PLUS the
|
|
/// induced reference-only delta on the backing raw tag. That must classify PureAdd — one node created,
|
|
/// zero teardown, every client subscription server-wide survives.</summary>
|
|
[Fact]
|
|
public void Uns_reference_add_with_induced_reference_only_raw_delta_is_pure_add()
|
|
{
|
|
var prev = RawTag("Plant/Modbus1/PLC-A/Speed");
|
|
var cur = prev with { ReferencingEquipmentPaths = new[] { "filling/line-1/station-1" } };
|
|
|
|
var plan = Empty() with
|
|
{
|
|
AddedUnsReferenceVariables = new[] { UnsRef("filling/line-1/station-1/Speed") },
|
|
ChangedRawTags = new[] { new AddressSpacePlan.RawTagDelta(prev, cur) },
|
|
};
|
|
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd);
|
|
}
|
|
}
|