merge WP1 (composer/planner dual-subtree) into v3/batch4-address-space
This commit is contained in:
@@ -53,10 +53,16 @@ public static class AddressSpaceChangeClassifier
|
||||
|
||||
// 2 — any node-affecting CHANGE forces a full rebuild (default-closed). ChangedEquipment /
|
||||
// ChangedAlarms are always node-affecting; a changed tag is node-affecting UNLESS it is
|
||||
// surgical-eligible, and a changed vtag is node-affecting UNLESS it is node-irrelevant. Evaluated
|
||||
// BEFORE the add/remove split so a non-surgical change alongside pure adds still rebuilds.
|
||||
// surgical-eligible, and a changed vtag is node-affecting UNLESS it is node-irrelevant. The v3
|
||||
// Batch-4 Raw/UNS Changed sets (container re-shape, raw-tag attribute/alarm edit, UNS re-point /
|
||||
// display-name-override) route to Rebuild as the safe default — WP3's applier owns any surgical
|
||||
// in-place refinement for them. Evaluated BEFORE the add/remove split so a non-surgical change
|
||||
// alongside pure adds still rebuilds.
|
||||
if (plan.ChangedEquipment.Count > 0 ||
|
||||
plan.ChangedAlarms.Count > 0 ||
|
||||
plan.ChangedRawContainers.Count > 0 ||
|
||||
plan.ChangedRawTags.Count > 0 ||
|
||||
plan.ChangedUnsReferenceVariables.Count > 0 ||
|
||||
plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) ||
|
||||
plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d)))
|
||||
{
|
||||
@@ -68,10 +74,14 @@ public static class AddressSpaceChangeClassifier
|
||||
// add/remove split below and leave a driver-only plan classified AttributeOnly.
|
||||
var hasAdds =
|
||||
plan.AddedEquipment.Count + plan.AddedAlarms.Count +
|
||||
plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count > 0;
|
||||
plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count +
|
||||
plan.AddedRawContainers.Count + plan.AddedRawTags.Count +
|
||||
plan.AddedUnsReferenceVariables.Count > 0;
|
||||
var hasRemoves =
|
||||
plan.RemovedEquipment.Count + plan.RemovedAlarms.Count +
|
||||
plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count > 0;
|
||||
plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count +
|
||||
plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count +
|
||||
plan.RemovedUnsReferenceVariables.Count > 0;
|
||||
|
||||
// 3 — additions AND removals.
|
||||
if (hasAdds && hasRemoves) return AddressSpaceChangeKind.AddRemoveMix;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Types;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
@@ -53,8 +54,163 @@ public sealed record AddressSpaceComposition(
|
||||
/// constructor + call site keeps compiling unchanged.
|
||||
/// </summary>
|
||||
public IReadOnlyList<EquipmentScriptedAlarmPlan> EquipmentScriptedAlarms { get; init; } = Array.Empty<EquipmentScriptedAlarmPlan>();
|
||||
|
||||
// ----- v3 Batch 4 dual-namespace address space (un-darkened) -----
|
||||
|
||||
/// <summary>
|
||||
/// The Raw subtree's <b>container</b> nodes (Folder → Driver → Device → TagGroup) as
|
||||
/// <c>Object</c>/<c>Folder</c> nodes in the <see cref="AddressSpaceRealm.Raw"/> namespace. Each
|
||||
/// carries its <c>s=<RawPath></c> NodeId and its parent NodeId (<see langword="null"/> = cluster
|
||||
/// root) so <c>AddressSpaceApplier</c> can materialise parents-before-children. Sorted by NodeId
|
||||
/// (ordinal, so a parent precedes each child). Init-only, defaults empty so every existing constructor
|
||||
/// + call site keeps compiling unchanged.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawContainerNode> RawContainers { get; init; } = Array.Empty<RawContainerNode>();
|
||||
|
||||
/// <summary>
|
||||
/// The Raw subtree's <b>tag</b> Variable nodes — one per raw <see cref="Tag"/> — keyed
|
||||
/// <c>(realm=Raw, s=<RawPath>)</c>. The RawPath IS the node identity + the single value source
|
||||
/// the driver binds; every referencing UNS variable fans out from it. Carries DataType / writable /
|
||||
/// historize + array shape, the optional native-alarm intent (attached at the RAW tag —
|
||||
/// ConditionId = RawPath), and the list of referencing equipment UNS folder paths (so WP4 wires the
|
||||
/// multi-notifier alarm). Init-only, defaults empty.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawTagPlan> RawTags { get; init; } = Array.Empty<RawTagPlan>();
|
||||
|
||||
/// <summary>
|
||||
/// The UNS subtree's reference Variable nodes — one per <see cref="UnsTagReference"/> — keyed
|
||||
/// <c>(realm=Uns, s=<Area>/<Line>/<Equipment>/<EffectiveName>)</c>. Each carries
|
||||
/// its backing raw tag's RawPath (the <c>Organizes</c> UNS→Raw target + the fan-out source) plus the
|
||||
/// inherited DataType / writable from that raw tag. UNS nodes never bind a driver directly — they fan
|
||||
/// out from the raw node. Init-only, defaults empty.
|
||||
/// </summary>
|
||||
public IReadOnlyList<UnsReferenceVariable> UnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceVariable>();
|
||||
}
|
||||
|
||||
/// <summary>Which Raw-tree container a <see cref="RawContainerNode"/> is (all are <c>Object</c>/<c>Folder</c>
|
||||
/// nodes; the kind lets the applier pick the OPC UA type + reference).</summary>
|
||||
public enum RawNodeKind
|
||||
{
|
||||
/// <summary>A driver-organizing <see cref="RawFolder"/>.</summary>
|
||||
Folder,
|
||||
|
||||
/// <summary>A <see cref="DriverInstance"/>.</summary>
|
||||
Driver,
|
||||
|
||||
/// <summary>A <see cref="Device"/>.</summary>
|
||||
Device,
|
||||
|
||||
/// <summary>A tag-organizing <see cref="TagGroup"/>.</summary>
|
||||
TagGroup,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One Raw-subtree container node (Folder / Driver / Device / TagGroup). <see cref="NodeId"/> is its
|
||||
/// <c>RawPath</c> (== the <c>ns=Raw</c> <c>s=</c> id); <see cref="ParentNodeId"/> is the parent container's
|
||||
/// RawPath (<see langword="null"/> = directly under the cluster root). <see cref="Realm"/> is always
|
||||
/// <see cref="AddressSpaceRealm.Raw"/> — carried explicitly so the applier chooses the namespace at the
|
||||
/// call site rather than inferring it.
|
||||
/// </summary>
|
||||
/// <param name="NodeId">The container's RawPath (its <c>ns=Raw</c> <c>s=</c> identifier).</param>
|
||||
/// <param name="ParentNodeId">The parent container's RawPath, or <see langword="null"/> for a cluster-root node.</param>
|
||||
/// <param name="DisplayName">The friendly browse name (the container's leaf <c>Name</c>).</param>
|
||||
/// <param name="Kind">Which container this is.</param>
|
||||
/// <param name="Realm">The address-space realm (always <see cref="AddressSpaceRealm.Raw"/>).</param>
|
||||
public sealed record RawContainerNode(
|
||||
string NodeId,
|
||||
string? ParentNodeId,
|
||||
string DisplayName,
|
||||
RawNodeKind Kind,
|
||||
AddressSpaceRealm Realm = AddressSpaceRealm.Raw);
|
||||
|
||||
/// <summary>
|
||||
/// One Raw-subtree tag Variable node. <see cref="NodeId"/> is the tag's <c>RawPath</c> — the single
|
||||
/// identity string at every seam (the <c>ns=Raw</c> <c>s=</c> id, the driver fan-out/write key, the
|
||||
/// historian default tagname, the native-alarm <c>ConditionId</c>). <see cref="ParentNodeId"/> is the
|
||||
/// owning TagGroup's RawPath (or the Device's when the tag sits directly under the device).
|
||||
/// <see cref="Writable"/> mirrors <c>Tag.AccessLevel == ReadWrite</c>. <see cref="Alarm"/> /
|
||||
/// <see cref="IsHistorized"/> / <see cref="HistorianTagname"/> / <see cref="IsArray"/> /
|
||||
/// <see cref="ArrayLength"/> are parsed from <c>Tag.TagConfig</c> via
|
||||
/// <see cref="TagConfigIntent"/> (the single byte-parity authority). <see cref="ReferencingEquipmentPaths"/>
|
||||
/// is the (possibly empty) set of UNS equipment-folder paths (<c>Area/Line/Equipment</c>) that reference
|
||||
/// this tag — WP4 wires one notifier per path so the raw tag's single alarm condition fans to every
|
||||
/// referencing equipment. <see cref="Realm"/> is always <see cref="AddressSpaceRealm.Raw"/>.
|
||||
/// A null <see cref="HistorianTagname"/> means the historian tagname defaults to the RawPath (resolved
|
||||
/// later, not here).
|
||||
/// </summary>
|
||||
public sealed record RawTagPlan(
|
||||
string TagId,
|
||||
string NodeId,
|
||||
string? ParentNodeId,
|
||||
string DriverInstanceId,
|
||||
string Name,
|
||||
string DataType,
|
||||
bool Writable,
|
||||
EquipmentTagAlarmInfo? Alarm,
|
||||
IReadOnlyList<string> ReferencingEquipmentPaths,
|
||||
bool IsHistorized = false,
|
||||
string? HistorianTagname = null,
|
||||
bool IsArray = false,
|
||||
uint? ArrayLength = null,
|
||||
AddressSpaceRealm Realm = AddressSpaceRealm.Raw)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
// Structural equality: the auto-generated record equality compares ReferencingEquipmentPaths (an
|
||||
// interface-typed list) BY REFERENCE, which would flag every raw tag as "changed" on every parse
|
||||
// (fresh list instances). Compare it element-wise so a no-op redeploy diffs empty (mirrors
|
||||
// EquipmentVirtualTagPlan).
|
||||
public bool Equals(RawTagPlan? other) =>
|
||||
other is not null &&
|
||||
TagId == other.TagId &&
|
||||
NodeId == other.NodeId &&
|
||||
ParentNodeId == other.ParentNodeId &&
|
||||
DriverInstanceId == other.DriverInstanceId &&
|
||||
Name == other.Name &&
|
||||
DataType == other.DataType &&
|
||||
Writable == other.Writable &&
|
||||
EqualityComparer<EquipmentTagAlarmInfo?>.Default.Equals(Alarm, other.Alarm) &&
|
||||
IsHistorized == other.IsHistorized &&
|
||||
HistorianTagname == other.HistorianTagname &&
|
||||
IsArray == other.IsArray &&
|
||||
ArrayLength == other.ArrayLength &&
|
||||
Realm == other.Realm &&
|
||||
ReferencingEquipmentPaths.SequenceEqual(other.ReferencingEquipmentPaths, StringComparer.Ordinal);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = new HashCode();
|
||||
hash.Add(TagId); hash.Add(NodeId); hash.Add(ParentNodeId); hash.Add(DriverInstanceId);
|
||||
hash.Add(Name); hash.Add(DataType); hash.Add(Writable); hash.Add(Alarm);
|
||||
hash.Add(IsHistorized); hash.Add(HistorianTagname); hash.Add(IsArray); hash.Add(ArrayLength);
|
||||
hash.Add(Realm);
|
||||
foreach (var p in ReferencingEquipmentPaths) hash.Add(p, StringComparer.Ordinal);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One UNS-subtree reference Variable node projected from a <see cref="UnsTagReference"/>.
|
||||
/// <see cref="NodeId"/> is the slash-joined UNS path <c>Area/Line/Equipment/EffectiveName</c> (the
|
||||
/// <c>ns=UNS</c> <c>s=</c> id); <see cref="EffectiveName"/> is <c>DisplayNameOverride</c> else the backing
|
||||
/// raw tag's <c>Name</c>. <see cref="BackingRawPath"/> is the backing raw tag's RawPath — the
|
||||
/// <c>Organizes</c> UNS→Raw reference target AND the fan-out value source (the UNS node binds no driver;
|
||||
/// it mirrors the raw node). <see cref="DataType"/> / <see cref="Writable"/> are inherited from the
|
||||
/// backing raw tag. <see cref="UnsTagReferenceId"/> is the stable diff identity (a display-name-override
|
||||
/// edit keeps the same id while the NodeId/effective-name changes; a backing-tag rename keeps the same id
|
||||
/// + NodeId while <see cref="BackingRawPath"/> re-points). <see cref="Realm"/> is always
|
||||
/// <see cref="AddressSpaceRealm.Uns"/>.
|
||||
/// </summary>
|
||||
public sealed record UnsReferenceVariable(
|
||||
string UnsTagReferenceId,
|
||||
string EquipmentId,
|
||||
string NodeId,
|
||||
string EffectiveName,
|
||||
string BackingRawPath,
|
||||
string DataType,
|
||||
bool Writable,
|
||||
AddressSpaceRealm Realm = AddressSpaceRealm.Uns);
|
||||
|
||||
public sealed record UnsAreaProjection(string UnsAreaId, string DisplayName);
|
||||
public sealed record UnsLineProjection(string UnsLineId, string UnsAreaId, string DisplayName);
|
||||
|
||||
@@ -301,12 +457,17 @@ public static class AddressSpaceComposer
|
||||
|
||||
/// <summary>
|
||||
/// Composes the address space build plan from the v3 configuration entities.
|
||||
/// <para><b>v3 DARK address space (Batch 1):</b> equipment-tag variable nodes do NOT materialize —
|
||||
/// <c>EquipmentTags</c> is deliberately empty (the raw + UNS variable nodes are lit up in Batch 4's
|
||||
/// dual namespace). The composer carries the UNS folder hierarchy (Area/Line/Equipment) +
|
||||
/// per-equipment VirtualTags + ScriptedAlarms only. Equipment no longer binds a driver/device, so
|
||||
/// <see cref="EquipmentNode"/>'s driver/device hooks are always null. This is the pure reference
|
||||
/// mirror of <c>DeploymentArtifact.ParseComposition</c> (byte-parity target for the corpus tests).</para>
|
||||
/// <para><b>v3 dual namespace (Batch 4):</b> the composer emits BOTH subtrees. The
|
||||
/// <see cref="AddressSpaceComposition.RawContainers"/> + <see cref="AddressSpaceComposition.RawTags"/>
|
||||
/// light up the device-oriented <c>ns=Raw</c> tree (Folder→Driver→Device→TagGroup→Tag, each tag keyed
|
||||
/// <c>s=<RawPath></c>); the <see cref="AddressSpaceComposition.UnsReferenceVariables"/> light up
|
||||
/// the equipment-oriented <c>ns=UNS</c> tree (one Variable per <see cref="UnsTagReference"/>, keyed
|
||||
/// <c>s=<Area>/<Line>/<Equipment>/<EffectiveName></c>, carrying its backing RawPath
|
||||
/// for the <c>Organizes</c> reference + fan-out). Native-alarm intents attach at the RAW tag
|
||||
/// (ConditionId = RawPath) with the list of referencing equipment paths. The legacy
|
||||
/// <see cref="AddressSpaceComposition.EquipmentTags"/> set stays EMPTY (the retired
|
||||
/// equipment-namespace tag path); UNS folders + per-equipment VirtualTags + ScriptedAlarms are
|
||||
/// unchanged.</para>
|
||||
/// </summary>
|
||||
/// <param name="unsAreas">The UNS areas.</param>
|
||||
/// <param name="unsLines">The UNS lines.</param>
|
||||
@@ -366,15 +527,19 @@ public static class AddressSpaceComposer
|
||||
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
|
||||
.ToList();
|
||||
|
||||
// v3 DARK: no equipment-tag variable plans this batch (Batch 4 owns UNS↔Raw fan-out).
|
||||
// v3: the retired equipment-namespace tag path stays empty — raw + UNS variable nodes now carry
|
||||
// every value (see RawTags / UnsReferenceVariables below).
|
||||
var equipmentTags = Array.Empty<EquipmentTagPlan>();
|
||||
|
||||
// Per-equipment {{equip}}/<RefName> reference map (effectiveName → backing-tag RawPath), built
|
||||
// from the equipment's UnsTagReferences + the raw tags + the raw topology via the shared
|
||||
// RawPathResolver — the same identity authority the artifact-decode mirror + the draft validator
|
||||
// use, so the three agree byte-for-byte on the resolved RawPath.
|
||||
var referenceMapByEquip = BuildEquipmentReferenceMap(
|
||||
unsTagReferences, tags, rawFolders, driverInstances, devices, tagGroups);
|
||||
// The shared raw topology (RawPathResolver over the folder/driver/device/group ancestry + the
|
||||
// container RawPath maps). One authority for the Raw subtree emit, the UNS backing RawPaths, and the
|
||||
// {{equip}} reference map — so the three agree byte-for-byte on every RawPath.
|
||||
var topology = RawTopology.Build(rawFolders, driverInstances, devices, tagGroups);
|
||||
|
||||
// Per-equipment {{equip}}/<RefName> reference map (effectiveName → backing-tag RawPath), built via
|
||||
// the shared EquipmentReferenceMap over the SAME resolver — the same identity authority the
|
||||
// artifact-decode mirror + the draft validator use.
|
||||
var referenceMapByEquip = BuildEquipmentReferenceMap(unsTagReferences, tags, topology.Resolver);
|
||||
var emptyRefMap = (IReadOnlyDictionary<string, string>)
|
||||
new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
@@ -451,45 +616,38 @@ public static class AddressSpaceComposer
|
||||
Enabled: a.Enabled));
|
||||
}
|
||||
|
||||
// Raw subtree (device-oriented) + UNS subtree (equipment-oriented) — the Batch-4 dual namespace.
|
||||
var (rawContainers, rawTags) = BuildRawSubtree(
|
||||
rawFolders, driverInstances, devices, tagGroups, tags, unsTagReferences,
|
||||
unsAreas, unsLines, equipment, topology);
|
||||
var unsReferenceVariables = BuildUnsReferenceVariables(
|
||||
unsTagReferences, tags, unsAreas, unsLines, equipment, topology);
|
||||
|
||||
return new AddressSpaceComposition(areas, lines, nodes, plans, alarms)
|
||||
{
|
||||
EquipmentTags = equipmentTags,
|
||||
EquipmentVirtualTags = equipmentVirtualTags,
|
||||
EquipmentScriptedAlarms = equipmentScriptedAlarms,
|
||||
RawContainers = rawContainers,
|
||||
RawTags = rawTags,
|
||||
UnsReferenceVariables = unsReferenceVariables,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Build the per-equipment <c>{{equip}}/<RefName></c> reference map from the v3 entities
|
||||
/// via the shared <see cref="EquipmentReferenceMap"/> + <see cref="RawPathResolver"/> — the entity-side
|
||||
/// mirror of <c>DeploymentArtifact.BuildEquipmentReferenceMap</c> (JSON side). Empty when no references
|
||||
/// are supplied (every test / earlier caller that omits the reference data).</summary>
|
||||
/// via the shared <see cref="EquipmentReferenceMap"/> over the SHARED <paramref name="resolver"/> — the
|
||||
/// entity-side mirror of <c>DeploymentArtifact.BuildEquipmentReferenceMap</c> (JSON side). Empty when no
|
||||
/// references are supplied (every test / earlier caller that omits the reference data).</summary>
|
||||
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> BuildEquipmentReferenceMap(
|
||||
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
||||
IReadOnlyList<Tag>? tags,
|
||||
IReadOnlyList<RawFolder>? rawFolders,
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
IReadOnlyList<Device>? devices,
|
||||
IReadOnlyList<TagGroup>? tagGroups)
|
||||
RawPathResolver resolver)
|
||||
{
|
||||
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
||||
var tagRows = tags ?? Array.Empty<Tag>();
|
||||
if (refs.Count == 0 || tagRows.Count == 0)
|
||||
return new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);
|
||||
|
||||
var folders = (rawFolders ?? Array.Empty<RawFolder>())
|
||||
.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
|
||||
var drivers = driverInstances
|
||||
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
|
||||
var deviceMap = (devices ?? Array.Empty<Device>())
|
||||
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
|
||||
var groups = (tagGroups ?? Array.Empty<TagGroup>())
|
||||
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
|
||||
var resolver = new RawPathResolver(folders, drivers, deviceMap, groups);
|
||||
|
||||
var tagsById = tagRows
|
||||
.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
@@ -503,4 +661,363 @@ public static class AddressSpaceComposer
|
||||
tagsById,
|
||||
resolver);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit the Raw subtree: the container nodes (Folder→Driver→Device→TagGroup) as
|
||||
/// <c>Object</c>/<c>Folder</c> nodes, and the tag Variable nodes keyed <c>(realm=Raw, s=RawPath)</c>.
|
||||
/// A node whose RawPath cannot be resolved (broken/unknown ancestry, or an invalid segment) is
|
||||
/// SKIPPED rather than faulting the whole compose (mirrors <see cref="RawPathResolver"/>'s
|
||||
/// null-not-throw policy). Both lists are sorted by NodeId ordinal so a parent precedes each child.
|
||||
/// </summary>
|
||||
private static (IReadOnlyList<RawContainerNode> Containers, IReadOnlyList<RawTagPlan> Tags) BuildRawSubtree(
|
||||
IReadOnlyList<RawFolder>? rawFolders,
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
IReadOnlyList<Device>? devices,
|
||||
IReadOnlyList<TagGroup>? tagGroups,
|
||||
IReadOnlyList<Tag>? tags,
|
||||
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
||||
IReadOnlyList<UnsArea> unsAreas,
|
||||
IReadOnlyList<UnsLine> unsLines,
|
||||
IReadOnlyList<Equipment> equipment,
|
||||
RawTopology topology)
|
||||
{
|
||||
var containers = new List<RawContainerNode>();
|
||||
|
||||
foreach (var f in rawFolders ?? Array.Empty<RawFolder>())
|
||||
{
|
||||
if (!topology.FolderPaths.TryGetValue(f.RawFolderId, out var path)) continue;
|
||||
var parent = f.ParentRawFolderId is not null
|
||||
? topology.FolderPaths.GetValueOrDefault(f.ParentRawFolderId)
|
||||
: null;
|
||||
containers.Add(new RawContainerNode(path, parent, f.Name, RawNodeKind.Folder));
|
||||
}
|
||||
|
||||
foreach (var d in driverInstances)
|
||||
{
|
||||
if (!topology.DriverPaths.TryGetValue(d.DriverInstanceId, out var path)) continue;
|
||||
var parent = d.RawFolderId is not null
|
||||
? topology.FolderPaths.GetValueOrDefault(d.RawFolderId)
|
||||
: null;
|
||||
containers.Add(new RawContainerNode(path, parent, d.Name, RawNodeKind.Driver));
|
||||
}
|
||||
|
||||
foreach (var dev in devices ?? Array.Empty<Device>())
|
||||
{
|
||||
if (!topology.DevicePaths.TryGetValue(dev.DeviceId, out var path)) continue;
|
||||
var parent = topology.DriverPaths.GetValueOrDefault(dev.DriverInstanceId);
|
||||
containers.Add(new RawContainerNode(path, parent, dev.Name, RawNodeKind.Device));
|
||||
}
|
||||
|
||||
foreach (var g in tagGroups ?? Array.Empty<TagGroup>())
|
||||
{
|
||||
if (!topology.GroupPaths.TryGetValue(g.TagGroupId, out var path)) continue;
|
||||
var parent = g.ParentTagGroupId is not null
|
||||
? topology.GroupPaths.GetValueOrDefault(g.ParentTagGroupId)
|
||||
: topology.DevicePaths.GetValueOrDefault(g.DeviceId);
|
||||
containers.Add(new RawContainerNode(path, parent, g.Name, RawNodeKind.TagGroup));
|
||||
}
|
||||
|
||||
// TagId → the set of referencing equipment UNS folder paths (Area/Line/Equipment), for the
|
||||
// native-alarm multi-notifier. Distinct + ordinal-sorted for determinism.
|
||||
var equipPathByTag = BuildReferencingEquipmentPathsByTag(unsTagReferences, unsAreas, unsLines, equipment);
|
||||
|
||||
var rawTags = new List<RawTagPlan>();
|
||||
foreach (var t in tags ?? Array.Empty<Tag>())
|
||||
{
|
||||
var nodeId = topology.Resolver.TryBuildTagPath(t.DeviceId, t.TagGroupId, t.Name);
|
||||
if (nodeId is null) continue;
|
||||
var parent = t.TagGroupId is not null
|
||||
? topology.GroupPaths.GetValueOrDefault(t.TagGroupId)
|
||||
: topology.DevicePaths.GetValueOrDefault(t.DeviceId);
|
||||
var driverInstanceId = topology.DriverIdByDevice.GetValueOrDefault(t.DeviceId, string.Empty);
|
||||
var intent = TagConfigIntent.Parse(t.TagConfig);
|
||||
var alarm = intent.Alarm is { } a
|
||||
? new EquipmentTagAlarmInfo(a.AlarmType, a.Severity, a.HistorizeToAveva)
|
||||
: null;
|
||||
var refEquipPaths = equipPathByTag.GetValueOrDefault(t.TagId, Array.Empty<string>());
|
||||
|
||||
rawTags.Add(new RawTagPlan(
|
||||
TagId: t.TagId,
|
||||
NodeId: nodeId,
|
||||
ParentNodeId: parent,
|
||||
DriverInstanceId: driverInstanceId,
|
||||
Name: t.Name,
|
||||
DataType: t.DataType,
|
||||
Writable: t.AccessLevel == TagAccessLevel.ReadWrite,
|
||||
Alarm: alarm,
|
||||
ReferencingEquipmentPaths: refEquipPaths,
|
||||
IsHistorized: intent.IsHistorized,
|
||||
HistorianTagname: intent.HistorianTagname,
|
||||
IsArray: intent.IsArray,
|
||||
ArrayLength: intent.ArrayLength));
|
||||
}
|
||||
|
||||
containers.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
|
||||
rawTags.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
|
||||
return (containers, rawTags);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emit the UNS reference Variables: one per <see cref="UnsTagReference"/> whose backing raw tag +
|
||||
/// equipment path resolve, keyed <c>(realm=Uns, s=Area/Line/Equipment/EffectiveName)</c> and carrying
|
||||
/// the backing RawPath (Organizes target + fan-out) plus the inherited DataType / writable. A
|
||||
/// reference whose backing tag is unknown, whose RawPath is unresolvable, or whose UNS path segments
|
||||
/// are invalid is SKIPPED. Sorted by NodeId ordinal.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<UnsReferenceVariable> BuildUnsReferenceVariables(
|
||||
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
||||
IReadOnlyList<Tag>? tags,
|
||||
IReadOnlyList<UnsArea> unsAreas,
|
||||
IReadOnlyList<UnsLine> unsLines,
|
||||
IReadOnlyList<Equipment> equipment,
|
||||
RawTopology topology)
|
||||
{
|
||||
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
||||
if (refs.Count == 0) return Array.Empty<UnsReferenceVariable>();
|
||||
|
||||
var tagsById = (tags ?? Array.Empty<Tag>())
|
||||
.GroupBy(t => t.TagId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);
|
||||
var equipFolderPath = BuildEquipmentFolderPaths(unsAreas, unsLines, equipment);
|
||||
|
||||
var vars = new List<UnsReferenceVariable>();
|
||||
foreach (var r in refs.OrderBy(x => x.UnsTagReferenceId, StringComparer.Ordinal))
|
||||
{
|
||||
if (!tagsById.TryGetValue(r.TagId, out var tag)) continue;
|
||||
if (!equipFolderPath.TryGetValue(r.EquipmentId, out var equipPath)) continue;
|
||||
var rawPath = topology.Resolver.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name);
|
||||
if (rawPath is null) continue;
|
||||
var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride!;
|
||||
|
||||
string nodeId;
|
||||
try
|
||||
{
|
||||
nodeId = V3NodeIds.UnsChild(equipPath, effectiveName);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
continue; // invalid effective-name segment — dropped (deploy gate rejects invalid names)
|
||||
}
|
||||
|
||||
vars.Add(new UnsReferenceVariable(
|
||||
UnsTagReferenceId: r.UnsTagReferenceId,
|
||||
EquipmentId: r.EquipmentId,
|
||||
NodeId: nodeId,
|
||||
EffectiveName: effectiveName,
|
||||
BackingRawPath: rawPath,
|
||||
DataType: tag.DataType,
|
||||
Writable: tag.AccessLevel == TagAccessLevel.ReadWrite));
|
||||
}
|
||||
|
||||
vars.Sort((x, y) => string.CompareOrdinal(x.NodeId, y.NodeId));
|
||||
return vars;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>EquipmentId → UNS folder path</c> (<c>Area/Line/Equipment</c>, slash-joined via
|
||||
/// <see cref="V3NodeIds"/>) for every equipment whose Area/Line ancestry resolves + whose three
|
||||
/// segments are valid. Equipment with a broken ancestry or an invalid segment is absent.
|
||||
/// </summary>
|
||||
private static IReadOnlyDictionary<string, string> BuildEquipmentFolderPaths(
|
||||
IReadOnlyList<UnsArea> unsAreas,
|
||||
IReadOnlyList<UnsLine> unsLines,
|
||||
IReadOnlyList<Equipment> equipment)
|
||||
{
|
||||
var areaName = unsAreas
|
||||
.GroupBy(a => a.UnsAreaId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
|
||||
var line = unsLines
|
||||
.GroupBy(l => l.UnsLineId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => (g.First().UnsAreaId, g.First().Name), StringComparer.Ordinal);
|
||||
|
||||
var byEquip = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var e in equipment)
|
||||
{
|
||||
if (!line.TryGetValue(e.UnsLineId, out var ln)) continue;
|
||||
if (!areaName.TryGetValue(ln.UnsAreaId, out var aName)) continue;
|
||||
try
|
||||
{
|
||||
byEquip[e.EquipmentId] = V3NodeIds.Uns(aName, ln.Name, e.Name);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// invalid Area/Line/Equipment segment — dropped (deploy gate rejects invalid names).
|
||||
}
|
||||
}
|
||||
return byEquip;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>TagId → distinct, ordinal-sorted list of referencing equipment UNS folder paths</c>
|
||||
/// (<c>Area/Line/Equipment</c>). Drives the native-alarm multi-notifier (WP4): the raw tag's single
|
||||
/// alarm condition fans one event to each referencing equipment's UNS folder. A tag with no
|
||||
/// resolvable reference is absent (⇒ an empty list at the call site).
|
||||
/// </summary>
|
||||
private static IReadOnlyDictionary<string, IReadOnlyList<string>> BuildReferencingEquipmentPathsByTag(
|
||||
IReadOnlyList<UnsTagReference>? unsTagReferences,
|
||||
IReadOnlyList<UnsArea> unsAreas,
|
||||
IReadOnlyList<UnsLine> unsLines,
|
||||
IReadOnlyList<Equipment> equipment)
|
||||
{
|
||||
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
|
||||
if (refs.Count == 0) return new Dictionary<string, IReadOnlyList<string>>(StringComparer.Ordinal);
|
||||
|
||||
var equipFolderPath = BuildEquipmentFolderPaths(unsAreas, unsLines, equipment);
|
||||
var byTag = new Dictionary<string, SortedSet<string>>(StringComparer.Ordinal);
|
||||
foreach (var r in refs)
|
||||
{
|
||||
if (!equipFolderPath.TryGetValue(r.EquipmentId, out var equipPath)) continue;
|
||||
if (!byTag.TryGetValue(r.TagId, out var set))
|
||||
byTag[r.TagId] = set = new SortedSet<string>(StringComparer.Ordinal);
|
||||
set.Add(equipPath);
|
||||
}
|
||||
return byTag.ToDictionary(
|
||||
kv => kv.Key,
|
||||
kv => (IReadOnlyList<string>)kv.Value.ToList(),
|
||||
StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shared Raw-tree topology: a <see cref="RawPathResolver"/> over the folder/driver/device/group
|
||||
/// ancestry plus the resolved container RawPath maps (memoised) + the device→driver id map. Built
|
||||
/// once per compose and reused for the Raw subtree, the UNS backing RawPaths, and the <c>{{equip}}</c>
|
||||
/// reference map so every RawPath is produced by the single authority.
|
||||
/// </summary>
|
||||
private sealed class RawTopology
|
||||
{
|
||||
public required RawPathResolver Resolver { get; init; }
|
||||
|
||||
/// <summary><c>RawFolderId → RawPath</c> (absent when the ancestry is broken).</summary>
|
||||
public required IReadOnlyDictionary<string, string> FolderPaths { get; init; }
|
||||
|
||||
/// <summary><c>DriverInstanceId → RawPath</c> (absent when the ancestry is broken).</summary>
|
||||
public required IReadOnlyDictionary<string, string> DriverPaths { get; init; }
|
||||
|
||||
/// <summary><c>DeviceId → RawPath</c> (absent when the ancestry is broken).</summary>
|
||||
public required IReadOnlyDictionary<string, string> DevicePaths { get; init; }
|
||||
|
||||
/// <summary><c>TagGroupId → RawPath</c> (absent when the ancestry is broken).</summary>
|
||||
public required IReadOnlyDictionary<string, string> GroupPaths { get; init; }
|
||||
|
||||
/// <summary><c>DeviceId → DriverInstanceId</c> — the raw tag's owning driver (for RawTagPlan).</summary>
|
||||
public required IReadOnlyDictionary<string, string> DriverIdByDevice { get; init; }
|
||||
|
||||
public static RawTopology Build(
|
||||
IReadOnlyList<RawFolder>? rawFolders,
|
||||
IReadOnlyList<DriverInstance> driverInstances,
|
||||
IReadOnlyList<Device>? devices,
|
||||
IReadOnlyList<TagGroup>? tagGroups)
|
||||
{
|
||||
var folderRows = (rawFolders ?? Array.Empty<RawFolder>())
|
||||
.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
|
||||
var driverRows = driverInstances
|
||||
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
|
||||
var deviceRows = (devices ?? Array.Empty<Device>())
|
||||
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
|
||||
var groupRows = (tagGroups ?? Array.Empty<TagGroup>())
|
||||
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
|
||||
|
||||
var resolver = new RawPathResolver(folderRows, driverRows, deviceRows, groupRows);
|
||||
|
||||
// Container RawPaths — reuse the resolver for driver prefix + memoise folder/device/group chains.
|
||||
var folderPaths = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var id in folderRows.Keys)
|
||||
ResolveFolder(id, folderRows, folderPaths, 0);
|
||||
|
||||
var driverPaths = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var id in driverRows.Keys)
|
||||
{
|
||||
var p = resolver.TryBuildDriverPrefix(id);
|
||||
if (p is not null) driverPaths[id] = p;
|
||||
}
|
||||
|
||||
var devicePaths = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var (id, dev) in deviceRows)
|
||||
{
|
||||
if (!driverPaths.TryGetValue(dev.DriverInstanceId, out var driverPrefix)) continue;
|
||||
try { devicePaths[id] = RawPaths.Combine(driverPrefix, dev.Name); }
|
||||
catch (ArgumentException) { /* invalid device name — dropped */ }
|
||||
}
|
||||
|
||||
var groupPaths = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var groupDeviceById = (tagGroups ?? Array.Empty<TagGroup>())
|
||||
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.First().DeviceId, StringComparer.Ordinal);
|
||||
foreach (var id in groupRows.Keys)
|
||||
ResolveGroup(id, groupRows, groupDeviceById, devicePaths, groupPaths, 0);
|
||||
|
||||
var driverIdByDevice = deviceRows.ToDictionary(kv => kv.Key, kv => kv.Value.DriverInstanceId, StringComparer.Ordinal);
|
||||
|
||||
return new RawTopology
|
||||
{
|
||||
Resolver = resolver,
|
||||
FolderPaths = folderPaths,
|
||||
DriverPaths = driverPaths,
|
||||
DevicePaths = devicePaths,
|
||||
GroupPaths = groupPaths,
|
||||
DriverIdByDevice = driverIdByDevice,
|
||||
};
|
||||
}
|
||||
|
||||
private const int MaxDepth = 256;
|
||||
|
||||
private static string? ResolveFolder(
|
||||
string id,
|
||||
IReadOnlyDictionary<string, (string? ParentId, string Name)> rows,
|
||||
Dictionary<string, string> memo,
|
||||
int depth)
|
||||
{
|
||||
if (depth > MaxDepth) return null;
|
||||
if (memo.TryGetValue(id, out var cached)) return cached;
|
||||
if (!rows.TryGetValue(id, out var row)) return null;
|
||||
var parentPath = row.ParentId is null ? null : ResolveFolder(row.ParentId, rows, memo, depth + 1);
|
||||
if (row.ParentId is not null && parentPath is null) return null; // broken parent chain
|
||||
try
|
||||
{
|
||||
var path = RawPaths.Combine(parentPath, row.Name);
|
||||
memo[id] = path;
|
||||
return path;
|
||||
}
|
||||
catch (ArgumentException) { return null; }
|
||||
}
|
||||
|
||||
private static string? ResolveGroup(
|
||||
string id,
|
||||
IReadOnlyDictionary<string, (string? ParentId, string Name)> rows,
|
||||
IReadOnlyDictionary<string, string> groupDeviceById,
|
||||
IReadOnlyDictionary<string, string> devicePaths,
|
||||
Dictionary<string, string> memo,
|
||||
int depth)
|
||||
{
|
||||
if (depth > MaxDepth) return null;
|
||||
if (memo.TryGetValue(id, out var cached)) return cached;
|
||||
if (!rows.TryGetValue(id, out var row)) return null;
|
||||
|
||||
string? parentPath;
|
||||
if (row.ParentId is not null)
|
||||
{
|
||||
parentPath = ResolveGroup(row.ParentId, rows, groupDeviceById, devicePaths, memo, depth + 1);
|
||||
if (parentPath is null) return null; // broken parent chain
|
||||
}
|
||||
else
|
||||
{
|
||||
// Root group: parent is the owning device's RawPath.
|
||||
if (!groupDeviceById.TryGetValue(id, out var deviceId)
|
||||
|| !devicePaths.TryGetValue(deviceId, out parentPath))
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var path = RawPaths.Combine(parentPath, row.Name);
|
||||
memo[id] = path;
|
||||
return path;
|
||||
}
|
||||
catch (ArgumentException) { return null; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,46 @@ public sealed record AddressSpacePlan(
|
||||
/// </summary>
|
||||
public IReadOnlyList<FolderRename> RenamedFolders { get; init; } = Array.Empty<FolderRename>();
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 — the Raw subtree's <b>container</b> diff (Folder/Driver/Device/TagGroup nodes),
|
||||
/// keyed by <see cref="RawContainerNode.NodeId"/> (the RawPath). A rename of any container changes
|
||||
/// its RawPath (and every descendant's), so it manifests as remove(old)+add(new) — an OPC UA NodeId
|
||||
/// is immutable identity. Init-only, defaulting empty, so existing construction sites compile
|
||||
/// unchanged (consistent with the EquipmentTag diff sets above).
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawContainerNode> AddedRawContainers { get; init; } = Array.Empty<RawContainerNode>();
|
||||
/// <inheritdoc cref="AddedRawContainers"/>
|
||||
public IReadOnlyList<RawContainerNode> RemovedRawContainers { get; init; } = Array.Empty<RawContainerNode>();
|
||||
/// <inheritdoc cref="AddedRawContainers"/>
|
||||
public IReadOnlyList<RawContainerDelta> ChangedRawContainers { get; init; } = Array.Empty<RawContainerDelta>();
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 — the Raw subtree's <b>tag</b> diff, keyed by <see cref="RawTagPlan.NodeId"/> (the
|
||||
/// RawPath). A raw tag <b>rename</b> changes its RawPath ⇒ remove(old NodeId)+add(new NodeId) here
|
||||
/// (the immutable-NodeId rule); an attribute-only edit (historize / writable / array / alarm) keeps
|
||||
/// the same NodeId ⇒ a <see cref="RawTagDelta"/>. Init-only, defaulting empty.
|
||||
/// </summary>
|
||||
public IReadOnlyList<RawTagPlan> AddedRawTags { get; init; } = Array.Empty<RawTagPlan>();
|
||||
/// <inheritdoc cref="AddedRawTags"/>
|
||||
public IReadOnlyList<RawTagPlan> RemovedRawTags { get; init; } = Array.Empty<RawTagPlan>();
|
||||
/// <inheritdoc cref="AddedRawTags"/>
|
||||
public IReadOnlyList<RawTagDelta> ChangedRawTags { get; init; } = Array.Empty<RawTagDelta>();
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 — the UNS subtree's reference-Variable diff, keyed by
|
||||
/// <see cref="UnsReferenceVariable.UnsTagReferenceId"/> (the stable row id, NOT the NodeId). Keying on
|
||||
/// the row id is what makes a backing-raw-tag rename a <b>re-point</b> (same reference row, same
|
||||
/// effective name ⇒ same UNS NodeId, but <see cref="UnsReferenceVariable.BackingRawPath"/> — the
|
||||
/// <c>Organizes</c> target + fan-out source — moves) rather than a remove+add; and a
|
||||
/// display-name-override edit a <see cref="UnsReferenceDelta"/> (same row id, NodeId + effective name
|
||||
/// change) with NO raw-side change. Init-only, defaulting empty.
|
||||
/// </summary>
|
||||
public IReadOnlyList<UnsReferenceVariable> AddedUnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceVariable>();
|
||||
/// <inheritdoc cref="AddedUnsReferenceVariables"/>
|
||||
public IReadOnlyList<UnsReferenceVariable> RemovedUnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceVariable>();
|
||||
/// <inheritdoc cref="AddedUnsReferenceVariables"/>
|
||||
public IReadOnlyList<UnsReferenceDelta> ChangedUnsReferenceVariables { get; init; } = Array.Empty<UnsReferenceDelta>();
|
||||
|
||||
/// <summary>Gets a value indicating whether the composition plan contains no changes.</summary>
|
||||
public bool IsEmpty =>
|
||||
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
|
||||
@@ -76,6 +116,9 @@ public sealed record AddressSpacePlan(
|
||||
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
|
||||
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 &&
|
||||
AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0 &&
|
||||
AddedRawContainers.Count == 0 && RemovedRawContainers.Count == 0 && ChangedRawContainers.Count == 0 &&
|
||||
AddedRawTags.Count == 0 && RemovedRawTags.Count == 0 && ChangedRawTags.Count == 0 &&
|
||||
AddedUnsReferenceVariables.Count == 0 && RemovedUnsReferenceVariables.Count == 0 && ChangedUnsReferenceVariables.Count == 0 &&
|
||||
RenamedFolders.Count == 0;
|
||||
|
||||
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
|
||||
@@ -84,6 +127,16 @@ public sealed record AddressSpacePlan(
|
||||
public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
|
||||
public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current);
|
||||
|
||||
/// <summary>A Raw container node present in both snapshots (same RawPath) with ≥1 differing field.</summary>
|
||||
public sealed record RawContainerDelta(RawContainerNode Previous, RawContainerNode Current);
|
||||
/// <summary>A Raw tag present in both snapshots (same RawPath) with ≥1 differing attribute (historize /
|
||||
/// writable / array / alarm / referencing-equipment set). A rename is NOT a delta — it is remove+add.</summary>
|
||||
public sealed record RawTagDelta(RawTagPlan Previous, RawTagPlan Current);
|
||||
/// <summary>A UNS reference variable present in both snapshots (same UnsTagReferenceId) with ≥1 differing
|
||||
/// field — a backing-tag re-point (<c>BackingRawPath</c> moved) or a display-name-override edit (NodeId +
|
||||
/// effective name changed).</summary>
|
||||
public sealed record UnsReferenceDelta(UnsReferenceVariable Previous, UnsReferenceVariable Current);
|
||||
|
||||
/// <summary>One renamed UNS Area / Line folder: the stable folder
|
||||
/// <paramref name="FolderNodeId"/> (the area's <c>UnsAreaId</c> or line's <c>UnsLineId</c>, the exact
|
||||
/// NodeId <c>MaterialiseHierarchy</c> placed the folder at) and the <paramref name="NewDisplayName"/>
|
||||
@@ -139,6 +192,32 @@ public static class AddressSpacePlanner
|
||||
t => t.VirtualTagId,
|
||||
(a, b) => new AddressSpacePlan.EquipmentVirtualTagDelta(a, b));
|
||||
|
||||
// Raw subtree — containers keyed by RawPath (NodeId). A rename changes the RawPath, so it surfaces
|
||||
// as remove(old)+add(new) here (an OPC UA NodeId is immutable identity). Attribute-only container
|
||||
// changes (none today — a container carries only DisplayName == leaf name, which lives in the
|
||||
// RawPath) fall to Changed, kept for completeness / future fields.
|
||||
var (addedRawContainers, removedRawContainers, changedRawContainers) = DiffById(
|
||||
previous.RawContainers, next.RawContainers,
|
||||
c => c.NodeId,
|
||||
(a, b) => new AddressSpacePlan.RawContainerDelta(a, b));
|
||||
|
||||
// Raw subtree — tags keyed by RawPath (NodeId). A rename ⇒ remove(old NodeId)+add(new NodeId); an
|
||||
// attribute-only edit (historize / writable / array / alarm) ⇒ Changed. RawTagPlan overrides record
|
||||
// equality to compare ReferencingEquipmentPaths element-wise so a no-op redeploy diffs empty.
|
||||
var (addedRawTags, removedRawTags, changedRawTags) = DiffById(
|
||||
previous.RawTags, next.RawTags,
|
||||
t => t.NodeId,
|
||||
(a, b) => new AddressSpacePlan.RawTagDelta(a, b));
|
||||
|
||||
// UNS subtree — reference variables keyed by the STABLE UnsTagReferenceId (NOT the NodeId). Keying on
|
||||
// the row id is what makes a backing-raw-tag rename a re-point (same row + effective name ⇒ same UNS
|
||||
// NodeId, but BackingRawPath moves) rather than a remove+add, and a display-name-override edit a
|
||||
// Changed delta (same row id, NodeId + effective name changed) with no raw-side entry.
|
||||
var (addedUnsRefs, removedUnsRefs, changedUnsRefs) = DiffById(
|
||||
previous.UnsReferenceVariables, next.UnsReferenceVariables,
|
||||
v => v.UnsTagReferenceId,
|
||||
(a, b) => new AddressSpacePlan.UnsReferenceDelta(a, b));
|
||||
|
||||
// UNS Area / Line renames: a folder whose stable id is unchanged but whose
|
||||
// DisplayName differs. Diffed by stable id (UnsAreaId / UnsLineId) so an Area/Line whose ONLY
|
||||
// change is its friendly name is no longer a silent no-op at the IsEmpty gate. The folder NodeId
|
||||
@@ -159,6 +238,15 @@ public static class AddressSpacePlanner
|
||||
AddedEquipmentVirtualTags = addedVTags,
|
||||
RemovedEquipmentVirtualTags = removedVTags,
|
||||
ChangedEquipmentVirtualTags = changedVTags,
|
||||
AddedRawContainers = addedRawContainers,
|
||||
RemovedRawContainers = removedRawContainers,
|
||||
ChangedRawContainers = changedRawContainers,
|
||||
AddedRawTags = addedRawTags,
|
||||
RemovedRawTags = removedRawTags,
|
||||
ChangedRawTags = changedRawTags,
|
||||
AddedUnsReferenceVariables = addedUnsRefs,
|
||||
RemovedUnsReferenceVariables = removedUnsRefs,
|
||||
ChangedUnsReferenceVariables = changedUnsRefs,
|
||||
RenamedFolders = renamedFolders,
|
||||
};
|
||||
}
|
||||
|
||||
+55
-46
@@ -4,10 +4,12 @@ using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
@@ -15,15 +17,21 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
/// <summary>
|
||||
/// End-to-end deploy of a UNS folder hierarchy through the <b>real</b> <c>ConfigComposer</c>: seed a
|
||||
/// 1-area / 1-line / 1-equipment UNS plus a v3 raw-tag chain (RawFolder → DriverInstance → Device →
|
||||
/// Tag), <c>StartDeployment</c>, then assert the deployment's persisted artifact decodes (via
|
||||
/// Tag) + a <see cref="UnsTagReference"/> projecting the raw tag into the equipment, <c>StartDeployment</c>,
|
||||
/// then assert the deployment's persisted artifact decodes (via
|
||||
/// <see cref="DeploymentArtifact.ParseComposition"/>).
|
||||
/// <para>
|
||||
/// <b>v3 DARK address space (Batch 1):</b> the composer + artifact deliberately emit an EMPTY
|
||||
/// equipment-tag plan set — raw-tag variable nodes are lit up in Batch 4's dual-namespace UNS↔Raw
|
||||
/// fan-out. So the live assertion here is the DARK contract: the Area/Line/Equipment folder nodes
|
||||
/// decode with their friendly UNS names, and <c>EquipmentTags</c> is empty EVEN THOUGH a raw tag was
|
||||
/// seeded. The full equipment-signal materialization (raw tag → EquipmentTag with FullName) is
|
||||
/// preserved as a Batch-4-pending Skipped test below (the plan explicitly says keep it, don't delete).
|
||||
/// <b>v3 dual namespace (Batch 4):</b> the <c>AddressSpaceComposer</c> un-darkens BOTH subtrees. The
|
||||
/// <see cref="Deploying_a_uns_hierarchy_carries_folder_nodes_and_leaves_equipment_tags_dark"/> case pins the
|
||||
/// artifact-decode invariant that the retired equipment-namespace <c>EquipmentTags</c> set stays EMPTY
|
||||
/// (values now flow through the raw + UNS variable nodes). The
|
||||
/// <see cref="Composing_the_seeded_config_emits_the_raw_tag_and_uns_reference_variables"/> case drives the
|
||||
/// real <c>AddressSpaceComposer</c> over the SAME seeded config (loaded back from the deploy DB) and pins
|
||||
/// the Batch-4 dual-tree: the seeded raw tag surfaces as a Raw-realm Variable keyed by its RawPath, and the
|
||||
/// UnsTagReference surfaces as a UNS-realm Variable carrying that RawPath (the Organizes target + fan-out).
|
||||
/// <para><i>Note:</i> the composer is what WP1 lights up; the artifact-decode seam
|
||||
/// (<c>DeploymentArtifact.ParseComposition</c>) is migrated by WP3, so the dual-tree assertion runs through
|
||||
/// the composer directly rather than the full deploy → artifact round-trip.</para>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The OPC UA address-space browse is exercised separately against a real SDK node manager in
|
||||
@@ -33,12 +41,6 @@ namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
/// </summary>
|
||||
public sealed class EquipmentNamespaceMaterializationTests
|
||||
{
|
||||
private const string Batch4Pending =
|
||||
"v3 dark address space: equipment-tag variable plans (raw tag → EquipmentTag with FullName) do " +
|
||||
"not materialize until Batch 4 (dual-namespace UNS↔Raw fan-out). The composer + artifact emit an " +
|
||||
"empty equipment-tag plan set this batch; re-enable this full-materialization assertion (re-authored " +
|
||||
"against the UnsTagReference fan-out) when Batch 4 lights the raw/UNS variable nodes.";
|
||||
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
// Equipment.EquipmentId must equal DraftValidator.DeriveEquipmentId(EquipmentUuid) — the
|
||||
@@ -95,47 +97,49 @@ public sealed class EquipmentNamespaceMaterializationTests
|
||||
composition.EquipmentTags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>BATCH-4-PENDING: the full equipment-signal materialization — the seeded raw tag decodes
|
||||
/// (via the real ConfigComposer → ParseComposition) into an EquipmentTag carrying its FullName. Dark
|
||||
/// until Batch 4's UNS↔Raw fan-out; preserved (not deleted) so re-enabling is a one-line change once
|
||||
/// the equipment-tag plan set is lit up.</summary>
|
||||
[Fact(Skip = Batch4Pending)]
|
||||
public async Task Deploying_an_equipment_namespace_carries_the_signal_into_the_artifact()
|
||||
/// <summary>BATCH-4 dual namespace: the real <see cref="AddressSpaceComposer"/> over the seeded config
|
||||
/// (loaded back from the deploy DB) emits BOTH subtrees — the seeded raw tag as a Raw-realm Variable
|
||||
/// keyed by its RawPath, and the UnsTagReference as a UNS-realm Variable carrying that RawPath (the
|
||||
/// Organizes UNS→Raw target + fan-out source) and inheriting the raw tag's DataType. Drives the composer
|
||||
/// directly (not the artifact-decode round-trip, which WP3 migrates).</summary>
|
||||
[Fact]
|
||||
public async Task Composing_the_seeded_config_emits_the_raw_tag_and_uns_reference_variables()
|
||||
{
|
||||
await using var harness = await TwoNodeClusterHarness.StartAsync();
|
||||
await harness.SeedDefaultClusterAsync("c1");
|
||||
await SeedUnsHierarchyAsync(harness);
|
||||
|
||||
await using var scope = harness.NodeA.Services.CreateAsyncScope();
|
||||
var client = scope.ServiceProvider.GetRequiredService<IAdminOperationsClient>();
|
||||
await using var db = await CreateDbAsync(harness);
|
||||
var composition = AddressSpaceComposer.Compose(
|
||||
await db.UnsAreas.AsNoTracking().ToListAsync(Ct),
|
||||
await db.UnsLines.AsNoTracking().ToListAsync(Ct),
|
||||
await db.Equipment.AsNoTracking().ToListAsync(Ct),
|
||||
await db.DriverInstances.AsNoTracking().ToListAsync(Ct),
|
||||
await db.ScriptedAlarms.AsNoTracking().ToListAsync(Ct),
|
||||
unsTagReferences: await db.UnsTagReferences.AsNoTracking().ToListAsync(Ct),
|
||||
tags: await db.Tags.AsNoTracking().ToListAsync(Ct),
|
||||
rawFolders: await db.RawFolders.AsNoTracking().ToListAsync(Ct),
|
||||
devices: await db.Devices.AsNoTracking().ToListAsync(Ct),
|
||||
tagGroups: await db.TagGroups.AsNoTracking().ToListAsync(Ct));
|
||||
|
||||
var result = await client.StartDeploymentAsync(createdBy: "alice@test", Ct);
|
||||
result.Outcome.ShouldBe(StartDeploymentOutcome.Accepted, $"Deploy not accepted: {result.Message}");
|
||||
var deploymentId = result.DeploymentId!.Value.Value;
|
||||
// Raw subtree: the seeded raw tag is a Raw-realm Variable keyed by its RawPath (Plant/Modbus/dev-1/Speed).
|
||||
var rawTag = composition.RawTags.ShouldHaveSingleItem();
|
||||
rawTag.TagId.ShouldBe("tag-speed");
|
||||
rawTag.Realm.ShouldBe(AddressSpaceRealm.Raw);
|
||||
rawTag.NodeId.ShouldBe("Plant/Modbus/dev-1/Speed");
|
||||
rawTag.DataType.ShouldBe("Float");
|
||||
|
||||
var artifact = Array.Empty<byte>();
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
await using var db = await CreateDbAsync(harness);
|
||||
var d = await db.Deployments.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.DeploymentId == deploymentId, Ct);
|
||||
if (d is { ArtifactBlob.Length: > 0 })
|
||||
{
|
||||
artifact = d.ArtifactBlob;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, TimeSpan.FromSeconds(15));
|
||||
// UNS subtree: the UnsTagReference is a UNS-realm Variable carrying the backing RawPath + inherited type.
|
||||
var unsVar = composition.UnsReferenceVariables.ShouldHaveSingleItem();
|
||||
unsVar.Realm.ShouldBe(AddressSpaceRealm.Uns);
|
||||
unsVar.EquipmentId.ShouldBe(EquipmentId);
|
||||
unsVar.NodeId.ShouldBe("filling/line-1/station-1/Speed");
|
||||
unsVar.EffectiveName.ShouldBe("Speed");
|
||||
unsVar.BackingRawPath.ShouldBe("Plant/Modbus/dev-1/Speed"); // Organizes UNS→Raw + fan-out
|
||||
unsVar.DataType.ShouldBe("Float");
|
||||
|
||||
var composition = DeploymentArtifact.ParseComposition(artifact);
|
||||
|
||||
// Batch 4: the raw tag surfaces as an EquipmentTag with FullName pulled from TagConfig.
|
||||
var tag = composition.EquipmentTags.ShouldHaveSingleItem();
|
||||
tag.TagId.ShouldBe("tag-speed");
|
||||
tag.EquipmentId.ShouldBe(EquipmentId);
|
||||
tag.Name.ShouldBe("Speed");
|
||||
tag.DataType.ShouldBe("Float");
|
||||
tag.FullName.ShouldBe("40001");
|
||||
// The retired equipment-namespace tag path stays empty (values flow through raw + UNS now).
|
||||
composition.EquipmentTags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private static async Task SeedUnsHierarchyAsync(TwoNodeClusterHarness harness)
|
||||
@@ -170,6 +174,11 @@ public sealed class EquipmentNamespaceMaterializationTests
|
||||
Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"FullName\":\"40001\"}",
|
||||
});
|
||||
// v3 Batch 4: the equipment projects the raw tag by reference (reference-only UNS).
|
||||
db.UnsTagReferences.Add(new UnsTagReference
|
||||
{
|
||||
UnsTagReferenceId = "ref-speed", EquipmentId = EquipmentId, TagId = "tag-speed",
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync(Ct);
|
||||
}
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
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), and any Changed raw/uns delta
|
||||
/// routes to Rebuild (the safe default — WP3's applier owns surgical refinement).
|
||||
/// </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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Changed_raw_tag_is_Rebuild()
|
||||
{
|
||||
var plan = Empty() with
|
||||
{
|
||||
ChangedRawTags = new[]
|
||||
{
|
||||
new AddressSpacePlan.RawTagDelta(
|
||||
RawTag("Plant/Modbus1/PLC-A/Speed"),
|
||||
RawTag("Plant/Modbus1/PLC-A/Speed") with { IsHistorized = true }),
|
||||
},
|
||||
};
|
||||
AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Uns_reference_repoint_is_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.Rebuild);
|
||||
}
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 (B4-WP1) — the composer un-darkens BOTH subtrees. Verifies the Raw subtree
|
||||
/// (Folder→Driver→Device→TagGroup container nodes + raw-tag Variables keyed <c>s=<RawPath></c>,
|
||||
/// realm <see cref="AddressSpaceRealm.Raw"/>) and the UNS subtree (one Variable per
|
||||
/// <see cref="UnsTagReference"/> keyed <c>s=<Area>/<Line>/<Equipment>/<EffectiveName></c>,
|
||||
/// realm <see cref="AddressSpaceRealm.Uns"/>, carrying its backing RawPath for the Organizes ref +
|
||||
/// fan-out). Native-alarm intents attach at the RAW tag and carry the referencing equipment paths.
|
||||
/// </summary>
|
||||
public sealed class AddressSpaceComposerDualNamespaceTests
|
||||
{
|
||||
// A raw chain: RawFolder "Plant" → Driver "Modbus1" → Device "PLC-A" → Group "Motors" → tags.
|
||||
// Speed sits under the group; Status directly under the device; Levels is an array tag.
|
||||
private static AddressSpaceComposition ComposeFullFixture()
|
||||
{
|
||||
var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = "Plant" };
|
||||
var driver = new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant",
|
||||
Name = "Modbus1", DriverType = "Modbus", DriverConfig = "{}",
|
||||
};
|
||||
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = "PLC-A", DeviceConfig = "{}" };
|
||||
var group = new TagGroup { TagGroupId = "g1", DeviceId = "dev-1", Name = "Motors" };
|
||||
|
||||
var speed = new Tag
|
||||
{
|
||||
TagId = "t-speed", DeviceId = "dev-1", TagGroupId = "g1", Name = "Speed",
|
||||
DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite,
|
||||
TagConfig = "{\"isHistorized\":true,\"historianTagname\":\"speed_hist\"," +
|
||||
"\"alarm\":{\"alarmType\":\"LimitAlarm\",\"severity\":700}}",
|
||||
};
|
||||
var status = new Tag
|
||||
{
|
||||
TagId = "t-status", DeviceId = "dev-1", Name = "Status",
|
||||
DataType = "Boolean", AccessLevel = TagAccessLevel.Read, TagConfig = "{}",
|
||||
};
|
||||
var levels = new Tag
|
||||
{
|
||||
TagId = "t-levels", DeviceId = "dev-1", Name = "Levels",
|
||||
DataType = "Int32", AccessLevel = TagAccessLevel.Read,
|
||||
TagConfig = "{\"isArray\":true,\"arrayLength\":8}",
|
||||
};
|
||||
|
||||
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
||||
var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" };
|
||||
|
||||
var refSpeed = new UnsTagReference { UnsTagReferenceId = "ref-speed", EquipmentId = "eq-1", TagId = "t-speed" };
|
||||
var refStatus = new UnsTagReference
|
||||
{
|
||||
UnsTagReferenceId = "ref-status", EquipmentId = "eq-1", TagId = "t-status",
|
||||
DisplayNameOverride = "MachineStatus",
|
||||
};
|
||||
|
||||
return AddressSpaceComposer.Compose(
|
||||
new[] { area }, new[] { line }, new[] { equip },
|
||||
new[] { driver }, Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: new[] { refSpeed, refStatus },
|
||||
tags: new[] { speed, status, levels },
|
||||
rawFolders: new[] { folder },
|
||||
devices: new[] { device },
|
||||
tagGroups: new[] { group });
|
||||
}
|
||||
|
||||
/// <summary>The Raw subtree emits Folder/Driver/Device/TagGroup container nodes keyed by RawPath, each
|
||||
/// realm=Raw, with parents-before-children ordering + correct parent NodeIds.</summary>
|
||||
[Fact]
|
||||
public void Raw_container_nodes_materialise_with_rawpath_nodeids_and_parents()
|
||||
{
|
||||
var c = ComposeFullFixture();
|
||||
|
||||
c.RawContainers.ShouldAllBe(n => n.Realm == AddressSpaceRealm.Raw);
|
||||
|
||||
var folder = c.RawContainers.Single(n => n.Kind == RawNodeKind.Folder);
|
||||
folder.NodeId.ShouldBe("Plant");
|
||||
folder.ParentNodeId.ShouldBeNull(); // cluster root
|
||||
folder.DisplayName.ShouldBe("Plant");
|
||||
|
||||
var driver = c.RawContainers.Single(n => n.Kind == RawNodeKind.Driver);
|
||||
driver.NodeId.ShouldBe("Plant/Modbus1");
|
||||
driver.ParentNodeId.ShouldBe("Plant");
|
||||
|
||||
var device = c.RawContainers.Single(n => n.Kind == RawNodeKind.Device);
|
||||
device.NodeId.ShouldBe("Plant/Modbus1/PLC-A");
|
||||
device.ParentNodeId.ShouldBe("Plant/Modbus1");
|
||||
|
||||
var group = c.RawContainers.Single(n => n.Kind == RawNodeKind.TagGroup);
|
||||
group.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors");
|
||||
group.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A");
|
||||
|
||||
// Sorted by NodeId ordinal ⇒ a parent always precedes each of its children.
|
||||
c.RawContainers.Select(n => n.NodeId)
|
||||
.ShouldBe(new[] { "Plant", "Plant/Modbus1", "Plant/Modbus1/PLC-A", "Plant/Modbus1/PLC-A/Motors" });
|
||||
}
|
||||
|
||||
/// <summary>Raw tags are Variables keyed <c>s=<RawPath></c> (realm=Raw), carrying DataType /
|
||||
/// writable / historize + historian tagname / array shape, and hang under their group (or device).</summary>
|
||||
[Fact]
|
||||
public void Raw_tags_materialise_as_variables_keyed_by_rawpath_with_carried_attributes()
|
||||
{
|
||||
var c = ComposeFullFixture();
|
||||
|
||||
c.RawTags.ShouldAllBe(t => t.Realm == AddressSpaceRealm.Raw);
|
||||
|
||||
var speed = c.RawTags.Single(t => t.TagId == "t-speed");
|
||||
speed.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors/Speed");
|
||||
speed.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A/Motors");
|
||||
speed.DriverInstanceId.ShouldBe("drv-modbus");
|
||||
speed.DataType.ShouldBe("Float");
|
||||
speed.Writable.ShouldBeTrue();
|
||||
speed.IsHistorized.ShouldBeTrue();
|
||||
speed.HistorianTagname.ShouldBe("speed_hist");
|
||||
|
||||
var status = c.RawTags.Single(t => t.TagId == "t-status");
|
||||
status.NodeId.ShouldBe("Plant/Modbus1/PLC-A/Status"); // no group ⇒ under the device
|
||||
status.ParentNodeId.ShouldBe("Plant/Modbus1/PLC-A");
|
||||
status.Writable.ShouldBeFalse();
|
||||
status.IsHistorized.ShouldBeFalse();
|
||||
|
||||
var levels = c.RawTags.Single(t => t.TagId == "t-levels");
|
||||
levels.IsArray.ShouldBeTrue();
|
||||
levels.ArrayLength.ShouldBe(8u);
|
||||
}
|
||||
|
||||
/// <summary>A native-alarm intent attaches at the RAW tag (ConditionId = RawPath) and carries the list
|
||||
/// of referencing equipment UNS folder paths — one alarm at the raw tag, not one per equipment.</summary>
|
||||
[Fact]
|
||||
public void Native_alarm_attaches_at_raw_tag_with_referencing_equipment_paths()
|
||||
{
|
||||
var c = ComposeFullFixture();
|
||||
|
||||
var speed = c.RawTags.Single(t => t.TagId == "t-speed");
|
||||
speed.Alarm.ShouldNotBeNull();
|
||||
speed.Alarm!.AlarmType.ShouldBe("LimitAlarm");
|
||||
speed.Alarm.Severity.ShouldBe(700);
|
||||
speed.ReferencingEquipmentPaths.ShouldBe(new[] { "filling/line-1/station-1" });
|
||||
|
||||
// A non-alarm tag carries no condition + its own referencing-equipment list.
|
||||
var status = c.RawTags.Single(t => t.TagId == "t-status");
|
||||
status.Alarm.ShouldBeNull();
|
||||
status.ReferencingEquipmentPaths.ShouldBe(new[] { "filling/line-1/station-1" });
|
||||
|
||||
// An unreferenced tag has an empty referencing-equipment set.
|
||||
var levels = c.RawTags.Single(t => t.TagId == "t-levels");
|
||||
levels.ReferencingEquipmentPaths.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Each UnsTagReference emits a UNS Variable keyed
|
||||
/// <c>s=<Area>/<Line>/<Equipment>/<EffectiveName></c> (realm=Uns), carrying its backing
|
||||
/// RawPath (the Organizes target + fan-out) and inheriting DataType + writable from the raw tag. Effective
|
||||
/// name = DisplayNameOverride else the backing raw tag's Name.</summary>
|
||||
[Fact]
|
||||
public void Uns_reference_variables_carry_backing_rawpath_and_inherit_type_and_access()
|
||||
{
|
||||
var c = ComposeFullFixture();
|
||||
|
||||
c.UnsReferenceVariables.ShouldAllBe(v => v.Realm == AddressSpaceRealm.Uns);
|
||||
|
||||
var speedRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-speed");
|
||||
speedRef.EffectiveName.ShouldBe("Speed"); // no override ⇒ backing raw tag Name
|
||||
speedRef.NodeId.ShouldBe("filling/line-1/station-1/Speed");
|
||||
speedRef.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Motors/Speed"); // Organizes UNS→Raw + fan-out
|
||||
speedRef.DataType.ShouldBe("Float");
|
||||
speedRef.Writable.ShouldBeTrue(); // inherited from the ReadWrite raw tag
|
||||
|
||||
var statusRef = c.UnsReferenceVariables.Single(v => v.UnsTagReferenceId == "ref-status");
|
||||
statusRef.EffectiveName.ShouldBe("MachineStatus"); // display-name override wins
|
||||
statusRef.NodeId.ShouldBe("filling/line-1/station-1/MachineStatus");
|
||||
statusRef.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Status");
|
||||
statusRef.DataType.ShouldBe("Boolean");
|
||||
statusRef.Writable.ShouldBeFalse(); // inherited from the Read raw tag
|
||||
}
|
||||
|
||||
/// <summary>A composition with no raw/UNS inputs leaves all three new subtree sets empty (the legacy
|
||||
/// convenience overloads + earlier callers keep working).</summary>
|
||||
[Fact]
|
||||
public void No_raw_or_uns_inputs_leaves_both_subtrees_empty()
|
||||
{
|
||||
var c = AddressSpaceComposer.Compose(
|
||||
equipment: Array.Empty<Equipment>(),
|
||||
driverInstances: Array.Empty<DriverInstance>(),
|
||||
scriptedAlarms: Array.Empty<ScriptedAlarm>());
|
||||
|
||||
c.RawContainers.ShouldBeEmpty();
|
||||
c.RawTags.ShouldBeEmpty();
|
||||
c.UnsReferenceVariables.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>The Raw + UNS emit is deterministic — repeated calls produce element-identical output
|
||||
/// (RawTagPlan/UnsReferenceVariable compare by value, so ShouldBe is a content comparison).</summary>
|
||||
[Fact]
|
||||
public void Dual_subtree_emit_is_deterministic()
|
||||
{
|
||||
var a = ComposeFullFixture();
|
||||
var b = ComposeFullFixture();
|
||||
|
||||
a.RawContainers.ShouldBe(b.RawContainers);
|
||||
a.RawTags.ShouldBe(b.RawTags);
|
||||
a.UnsReferenceVariables.ShouldBe(b.UnsReferenceVariables);
|
||||
}
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// v3 Batch 4 (B4-WP1) — the planner diffs per realm. Raw containers + raw tags diff by RawPath
|
||||
/// (NodeId), so a rename is remove(old)+add(new); UNS reference variables diff by the stable
|
||||
/// <c>UnsTagReferenceId</c>, so a backing-tag rename is a re-point (BackingRawPath moves, NodeId stable)
|
||||
/// and a display-name-override edit is a UNS-only change with no raw delta.
|
||||
/// <para><b>The pinned invariant:</b> a raw-tag rename = remove+add in Raw AND re-point in UNS.</para>
|
||||
/// </summary>
|
||||
public sealed class AddressSpacePlannerDualNamespaceTests
|
||||
{
|
||||
private const string RawFolderName = "Plant";
|
||||
private const string DriverName = "Modbus1";
|
||||
private const string DeviceName = "PLC-A";
|
||||
|
||||
// Compose a fixture with one raw tag (given name) under Plant/Modbus1/PLC-A and one UNS reference to it
|
||||
// (with an optional display-name override so the effective name can be held stable across a raw rename).
|
||||
private static AddressSpaceComposition Compose(string tagName, string? displayOverride)
|
||||
{
|
||||
var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = RawFolderName };
|
||||
var driver = new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant",
|
||||
Name = DriverName, DriverType = "Modbus", DriverConfig = "{}",
|
||||
};
|
||||
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = DeviceName, DeviceConfig = "{}" };
|
||||
var tag = new Tag
|
||||
{
|
||||
TagId = "t-speed", DeviceId = "dev-1", Name = tagName,
|
||||
DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{}",
|
||||
};
|
||||
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
|
||||
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
|
||||
var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "station-1", MachineCode = "STATION_001" };
|
||||
var reference = new UnsTagReference
|
||||
{
|
||||
UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "t-speed",
|
||||
DisplayNameOverride = displayOverride,
|
||||
};
|
||||
|
||||
return AddressSpaceComposer.Compose(
|
||||
new[] { area }, new[] { line }, new[] { equip },
|
||||
new[] { driver }, Array.Empty<ScriptedAlarm>(),
|
||||
unsTagReferences: new[] { reference },
|
||||
tags: new[] { tag },
|
||||
rawFolders: new[] { folder },
|
||||
devices: new[] { device });
|
||||
}
|
||||
|
||||
/// <summary>A newly-added raw tag surfaces in AddedRawTags (keyed by RawPath); the plan is non-empty.</summary>
|
||||
[Fact]
|
||||
public void Added_raw_tag_goes_to_AddedRawTags()
|
||||
{
|
||||
var prev = Compose("Speed", displayOverride: null);
|
||||
var next = Compose("Speed", displayOverride: null);
|
||||
// prev without the tag: strip RawTags/UNS by composing an empty raw side.
|
||||
var empty = AddressSpaceComposer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>());
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(empty, next);
|
||||
|
||||
plan.IsEmpty.ShouldBeFalse();
|
||||
plan.AddedRawTags.Select(t => t.NodeId).ShouldContain("Plant/Modbus1/PLC-A/Speed");
|
||||
plan.RemovedRawTags.ShouldBeEmpty();
|
||||
_ = prev;
|
||||
}
|
||||
|
||||
/// <summary>A disappeared raw tag surfaces in RemovedRawTags.</summary>
|
||||
[Fact]
|
||||
public void Removed_raw_tag_goes_to_RemovedRawTags()
|
||||
{
|
||||
var prev = Compose("Speed", displayOverride: null);
|
||||
var empty = AddressSpaceComposer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>());
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(prev, empty);
|
||||
|
||||
plan.RemovedRawTags.Select(t => t.NodeId).ShouldContain("Plant/Modbus1/PLC-A/Speed");
|
||||
plan.AddedRawTags.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>A newly-added UNS reference surfaces in AddedUnsReferenceVariables.</summary>
|
||||
[Fact]
|
||||
public void Added_uns_reference_goes_to_AddedUnsReferenceVariables()
|
||||
{
|
||||
var empty = AddressSpaceComposer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>());
|
||||
var next = Compose("Speed", displayOverride: null);
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(empty, next);
|
||||
|
||||
plan.AddedUnsReferenceVariables.Single().UnsTagReferenceId.ShouldBe("ref-1");
|
||||
plan.RemovedUnsReferenceVariables.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>A disappeared UNS reference surfaces in RemovedUnsReferenceVariables.</summary>
|
||||
[Fact]
|
||||
public void Removed_uns_reference_goes_to_RemovedUnsReferenceVariables()
|
||||
{
|
||||
var prev = Compose("Speed", displayOverride: null);
|
||||
var empty = AddressSpaceComposer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>());
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(prev, empty);
|
||||
|
||||
plan.RemovedUnsReferenceVariables.Single().UnsTagReferenceId.ShouldBe("ref-1");
|
||||
plan.AddedUnsReferenceVariables.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary><b>THE PIN:</b> a raw-tag rename = remove+add in Raw AND re-point in UNS. With a display-name
|
||||
/// override holding the effective name stable, the reference row is unchanged (same UnsTagReferenceId,
|
||||
/// same UNS NodeId) but its backing NodeId moved ⇒ the UNS variable's Organizes target (BackingRawPath)
|
||||
/// re-points via a ChangedUnsReferenceVariables delta.</summary>
|
||||
[Fact]
|
||||
public void Raw_rename_is_remove_add_in_raw_and_repoint_in_uns()
|
||||
{
|
||||
var prev = Compose("Speed", displayOverride: "Spd");
|
||||
var next = Compose("Velocity", displayOverride: "Spd"); // raw tag renamed; effective name held stable
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(prev, next);
|
||||
|
||||
// Raw realm: remove the old RawPath, add the new one (immutable NodeId ⇒ not a Changed delta).
|
||||
plan.RemovedRawTags.Select(t => t.NodeId).ShouldBe(new[] { "Plant/Modbus1/PLC-A/Speed" });
|
||||
plan.AddedRawTags.Select(t => t.NodeId).ShouldBe(new[] { "Plant/Modbus1/PLC-A/Velocity" });
|
||||
plan.ChangedRawTags.ShouldBeEmpty();
|
||||
|
||||
// UNS realm: the reference row is unchanged in identity + NodeId, but re-points to the new RawPath.
|
||||
plan.AddedUnsReferenceVariables.ShouldBeEmpty();
|
||||
plan.RemovedUnsReferenceVariables.ShouldBeEmpty();
|
||||
var repoint = plan.ChangedUnsReferenceVariables.ShouldHaveSingleItem();
|
||||
repoint.Previous.UnsTagReferenceId.ShouldBe("ref-1");
|
||||
repoint.Current.UnsTagReferenceId.ShouldBe("ref-1");
|
||||
repoint.Previous.NodeId.ShouldBe(repoint.Current.NodeId); // effective name stable ⇒ NodeId stable
|
||||
repoint.Previous.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Speed");
|
||||
repoint.Current.BackingRawPath.ShouldBe("Plant/Modbus1/PLC-A/Velocity");
|
||||
|
||||
// The raw container topology (Folder/Driver/Device) is untouched by a tag rename.
|
||||
plan.AddedRawContainers.ShouldBeEmpty();
|
||||
plan.RemovedRawContainers.ShouldBeEmpty();
|
||||
plan.ChangedRawContainers.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>A display-name-override change is a UNS-only change (no raw delta): same reference row id,
|
||||
/// the effective name + UNS NodeId move, the backing RawPath is unchanged.</summary>
|
||||
[Fact]
|
||||
public void Display_name_override_change_is_uns_only_no_raw_change()
|
||||
{
|
||||
var prev = Compose("Speed", displayOverride: "Spd");
|
||||
var next = Compose("Speed", displayOverride: "Speedy"); // only the override changed
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(prev, next);
|
||||
|
||||
// No raw-side change whatsoever.
|
||||
plan.AddedRawTags.ShouldBeEmpty();
|
||||
plan.RemovedRawTags.ShouldBeEmpty();
|
||||
plan.ChangedRawTags.ShouldBeEmpty();
|
||||
plan.AddedRawContainers.ShouldBeEmpty();
|
||||
plan.RemovedRawContainers.ShouldBeEmpty();
|
||||
plan.ChangedRawContainers.ShouldBeEmpty();
|
||||
|
||||
// UNS: same reference row id, effective name + NodeId changed, backing RawPath unchanged.
|
||||
var delta = plan.ChangedUnsReferenceVariables.ShouldHaveSingleItem();
|
||||
delta.Previous.UnsTagReferenceId.ShouldBe("ref-1");
|
||||
delta.Previous.EffectiveName.ShouldBe("Spd");
|
||||
delta.Current.EffectiveName.ShouldBe("Speedy");
|
||||
delta.Previous.NodeId.ShouldNotBe(delta.Current.NodeId);
|
||||
delta.Previous.BackingRawPath.ShouldBe(delta.Current.BackingRawPath);
|
||||
plan.AddedUnsReferenceVariables.ShouldBeEmpty();
|
||||
plan.RemovedUnsReferenceVariables.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>An attribute-only raw-tag edit (historize toggle) keeps the same RawPath ⇒ a
|
||||
/// ChangedRawTags delta (not remove+add).</summary>
|
||||
[Fact]
|
||||
public void Raw_tag_attribute_edit_keeps_nodeid_and_routes_to_ChangedRawTags()
|
||||
{
|
||||
var prev = Compose("Speed", displayOverride: null);
|
||||
// next: same identity + name, but toggle historize on the raw tag.
|
||||
var folder = new RawFolder { RawFolderId = "raw-plant", ClusterId = "c1", Name = RawFolderName };
|
||||
var driver = new DriverInstance
|
||||
{
|
||||
DriverInstanceId = "drv-modbus", ClusterId = "c1", RawFolderId = "raw-plant",
|
||||
Name = DriverName, DriverType = "Modbus", DriverConfig = "{}",
|
||||
};
|
||||
var device = new Device { DeviceId = "dev-1", DriverInstanceId = "drv-modbus", Name = DeviceName, DeviceConfig = "{}" };
|
||||
var tag = new Tag
|
||||
{
|
||||
TagId = "t-speed", DeviceId = "dev-1", Name = "Speed",
|
||||
DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{\"isHistorized\":true}",
|
||||
};
|
||||
var next = AddressSpaceComposer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
new[] { driver }, Array.Empty<ScriptedAlarm>(),
|
||||
tags: new[] { tag }, rawFolders: new[] { folder }, devices: new[] { device });
|
||||
|
||||
var prevRawOnly = AddressSpaceComposer.Compose(
|
||||
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
|
||||
new[] { driver }, Array.Empty<ScriptedAlarm>(),
|
||||
tags: new[] { new Tag { TagId = "t-speed", DeviceId = "dev-1", Name = "Speed", DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{}" } },
|
||||
rawFolders: new[] { folder }, devices: new[] { device });
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(prevRawOnly, next);
|
||||
|
||||
plan.AddedRawTags.ShouldBeEmpty();
|
||||
plan.RemovedRawTags.ShouldBeEmpty();
|
||||
var changed = plan.ChangedRawTags.ShouldHaveSingleItem();
|
||||
changed.Previous.NodeId.ShouldBe(changed.Current.NodeId);
|
||||
changed.Previous.IsHistorized.ShouldBeFalse();
|
||||
changed.Current.IsHistorized.ShouldBeTrue();
|
||||
_ = prev;
|
||||
}
|
||||
|
||||
/// <summary>Identical compositions diff to an empty plan across both realms (fresh list instances must
|
||||
/// not spuriously flag RawTags/UnsReferenceVariables as changed).</summary>
|
||||
[Fact]
|
||||
public void Identical_dual_namespace_compositions_diff_to_empty()
|
||||
{
|
||||
var prev = Compose("Speed", displayOverride: "Spd");
|
||||
var next = Compose("Speed", displayOverride: "Spd");
|
||||
|
||||
var plan = AddressSpacePlanner.Compute(prev, next);
|
||||
|
||||
plan.IsEmpty.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user