feat(opcua): remove SystemPlatform-mirror GalaxyTags contract end-to-end (composer+applier+artifact, byte-parity)

This commit is contained in:
Joseph Doherty
2026-06-12 21:45:19 -04:00
parent 5dfb797817
commit 95be607a07
13 changed files with 167 additions and 431 deletions
@@ -59,9 +59,8 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st
/// <list type="bullet"> /// <list type="bullet">
/// <item>Equipment driver tag (<c>EquipmentId != null</c>) → the driver <c>FullName</c> /// <item>Equipment driver tag (<c>EquipmentId != null</c>) → the driver <c>FullName</c>
/// extracted from <c>Tag.TagConfig</c> (the verified <c>DependencyMux</c> key).</item> /// extracted from <c>Tag.TagConfig</c> (the verified <c>DependencyMux</c> key).</item>
/// <item>SystemPlatform tag (<c>EquipmentId == null</c>) → the MXAccess dot-ref /// Galaxy points are ordinary equipment tags now (GalaxyMxGateway is a standard
/// (<c>FolderPath.Name</c> when a folder is set, else <c>Name</c>) — see /// Equipment-kind driver), so they resolve by this same <c>FullName</c> key.</item>
/// <c>Phase7Composer</c>'s <c>GalaxyTagPlan.MxAccessRef</c>.</item>
/// <item>VirtualTag → its leaf <c>Name</c> only, as a BEST-EFFORT key (the live resolution /// <item>VirtualTag → its leaf <c>Name</c> only, as a BEST-EFFORT key (the live resolution
/// of virtual-tag cascade/write targets is unconfirmed).</item> /// of virtual-tag cascade/write targets is unconfirmed).</item>
/// </list> /// </list>
@@ -70,22 +70,20 @@ public sealed class Phase7Applier
var changedCount = var changedCount =
plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count + plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count +
plan.ChangedGalaxyTags.Count + plan.ChangedEquipmentTags.Count + plan.ChangedEquipmentTags.Count +
plan.ChangedEquipmentVirtualTags.Count; plan.ChangedEquipmentVirtualTags.Count;
var addedCount = var addedCount =
plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count + plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count +
plan.AddedGalaxyTags.Count + plan.AddedEquipmentTags.Count + plan.AddedEquipmentTags.Count +
plan.AddedEquipmentVirtualTags.Count; plan.AddedEquipmentVirtualTags.Count;
// Any add/remove of Equipment, ScriptedAlarm, Galaxy tag, Equipment tag, or Equipment // Any add/remove of Equipment, ScriptedAlarm, Equipment tag, or Equipment VirtualTag topology
// VirtualTag topology requires a real address-space rebuild. Driver-instance changes don't // requires a real address-space rebuild. Driver-instance changes don't touch the address-space
// touch the address-space topology directly — they go through DriverHostActor's spawn-plan // topology directly — they go through DriverHostActor's spawn-plan in Runtime.
// in Runtime.
// TODO(equipment-virtualtags): when MaterialiseEquipmentVirtualTags drives per-delta sink work, revisit whether ChangedEquipmentVirtualTags should also force needsRebuild. // TODO(equipment-virtualtags): when MaterialiseEquipmentVirtualTags drives per-delta sink work, revisit whether ChangedEquipmentVirtualTags should also force needsRebuild.
var needsRebuild = var needsRebuild =
plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 || plan.AddedEquipment.Count > 0 || plan.RemovedEquipment.Count > 0 ||
plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 || plan.AddedAlarms.Count > 0 || plan.RemovedAlarms.Count > 0 ||
plan.AddedGalaxyTags.Count > 0 || plan.RemovedGalaxyTags.Count > 0 ||
plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 || plan.AddedEquipmentTags.Count > 0 || plan.RemovedEquipmentTags.Count > 0 ||
plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0; plan.AddedEquipmentVirtualTags.Count > 0 || plan.RemovedEquipmentVirtualTags.Count > 0;
@@ -141,46 +139,8 @@ public sealed class Phase7Applier
} }
/// <summary> /// <summary>
/// Materialise Galaxy / SystemPlatform-namespace tags from a composition snapshot: /// Materialise Equipment-namespace tags from a composition snapshot.
/// for each <see cref="GalaxyTagPlan"/>, ensure its FolderPath segment exists (a folder /// For each <see cref="EquipmentTagPlan"/>,
/// under the namespace root), then ensure a Variable node sits inside that folder for
/// the leaf <see cref="GalaxyTagPlan.DisplayName"/>. Variable starts with BadWaitingForInitialData;
/// the Galaxy driver's <c>OnDataChange</c> path fills the value in once SubscribeBulk lands.
/// Idempotent.
/// </summary>
/// <param name="composition">The composition result containing the Galaxy tags to materialise.</param>
public void MaterialiseGalaxyTags(Phase7CompositionResult composition)
{
ArgumentNullException.ThrowIfNull(composition);
if (composition.GalaxyTags.Count == 0) return;
// Folders first — each distinct FolderPath becomes one folder under the root.
var foldersCreated = new HashSet<string>(StringComparer.Ordinal);
foreach (var tag in composition.GalaxyTags)
{
if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue;
if (!foldersCreated.Add(tag.FolderPath)) continue;
SafeEnsureFolder(tag.FolderPath, parentNodeId: null, displayName: tag.FolderPath);
}
// Variables: NodeId is "<FolderPath>.<DisplayName>" so it matches the MXAccess ref the
// Galaxy driver subscribes to. Browse-path lookup via OPC UA Translate is the canonical
// resolution; flat NodeId keeps the address space lookup cheap.
foreach (var tag in composition.GalaxyTags)
{
var nodeId = string.IsNullOrWhiteSpace(tag.FolderPath) ? tag.DisplayName : tag.MxAccessRef;
var parent = string.IsNullOrWhiteSpace(tag.FolderPath) ? null : tag.FolderPath;
SafeEnsureVariable(nodeId, parent, tag.DisplayName, tag.DataType);
}
_logger.LogInformation(
"Phase7Applier: Galaxy tags materialised (tags={Tags}, folders={Folders})",
composition.GalaxyTags.Count, foldersCreated.Count);
}
/// <summary>
/// Materialise Equipment-namespace tags from a composition snapshot — the equipment-signal
/// analogue of <see cref="MaterialiseGalaxyTags"/>. For each <see cref="EquipmentTagPlan"/>,
/// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder, then /// ensure its optional <c>FolderPath</c> sub-folder under the existing equipment folder, then
/// ensure a Variable (NodeId = <c>FullName</c>, the driver-side ref) inside it. Variables /// ensure a Variable (NodeId = <c>FullName</c>, the driver-side ref) inside it. Variables
/// start BadWaitingForInitialData; the driver fills live values in a later milestone. /// start BadWaitingForInitialData; the driver fills live values in a later milestone.
@@ -222,7 +182,7 @@ public sealed class Phase7Applier
// would collide in the sink (EnsureVariable keys on NodeId) and drop all but one machine's // would collide in the sink (EnsureVariable keys on NodeId) and drop all but one machine's
// signal. The driver-side FullName lives on EquipmentTagPlan for the later values milestone to // signal. The driver-side FullName lives on EquipmentTagPlan for the later values milestone to
// route by. Parent is the FolderPath sub-folder when set, else the equipment folder directly. // route by. Parent is the FolderPath sub-folder when set, else the equipment folder directly.
// Like the Galaxy pass, per-variable idempotency relies on the sink's own EnsureVariable. // Per-variable idempotency relies on the sink's own EnsureVariable.
foreach (var tag in composition.EquipmentTags) foreach (var tag in composition.EquipmentTags)
{ {
var parent = string.IsNullOrWhiteSpace(tag.FolderPath) var parent = string.IsNullOrWhiteSpace(tag.FolderPath)
@@ -9,18 +9,15 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>Outcome of <see cref="Phase7Composer.Compose"/> — pure value tuple, no side effects. /// <summary>Outcome of <see cref="Phase7Composer.Compose"/> — pure value tuple, no side effects.
/// <see cref="UnsAreas"/> + <see cref="UnsLines"/> carry the UNS topology so the applier can /// <see cref="UnsAreas"/> + <see cref="UnsLines"/> carry the UNS topology so the applier can
/// materialise the Area/Line/Equipment folder hierarchy in the address space; equipment carries /// materialise the Area/Line/Equipment folder hierarchy in the address space; equipment carries
/// its parent line id so the applier knows where to hang each equipment folder. /// its parent line id so the applier knows where to hang each equipment folder.</summary>
/// <see cref="GalaxyTags"/> carries SystemPlatform-namespace tags (Galaxy hierarchy) so the
/// applier can materialise their FolderPath + Variable nodes ahead of any driver subscribe.</summary>
public sealed record Phase7CompositionResult( public sealed record Phase7CompositionResult(
IReadOnlyList<UnsAreaProjection> UnsAreas, IReadOnlyList<UnsAreaProjection> UnsAreas,
IReadOnlyList<UnsLineProjection> UnsLines, IReadOnlyList<UnsLineProjection> UnsLines,
IReadOnlyList<EquipmentNode> EquipmentNodes, IReadOnlyList<EquipmentNode> EquipmentNodes,
IReadOnlyList<DriverInstancePlan> DriverInstancePlans, IReadOnlyList<DriverInstancePlan> DriverInstancePlans,
IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans, IReadOnlyList<ScriptedAlarmPlan> ScriptedAlarmPlans)
IReadOnlyList<GalaxyTagPlan> GalaxyTags)
{ {
/// <summary>Convenience constructor for tests + earlier callers that don't carry UNS or Galaxy data.</summary> /// <summary>Convenience constructor for tests + earlier callers that don't carry UNS data.</summary>
/// <param name="equipmentNodes">The equipment nodes.</param> /// <param name="equipmentNodes">The equipment nodes.</param>
/// <param name="driverInstancePlans">The driver instance plans.</param> /// <param name="driverInstancePlans">The driver instance plans.</param>
/// <param name="scriptedAlarmPlans">The scripted alarm plans.</param> /// <param name="scriptedAlarmPlans">The scripted alarm plans.</param>
@@ -29,33 +26,17 @@ public sealed record Phase7CompositionResult(
IReadOnlyList<DriverInstancePlan> driverInstancePlans, IReadOnlyList<DriverInstancePlan> driverInstancePlans,
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans) IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
: this(Array.Empty<UnsAreaProjection>(), Array.Empty<UnsLineProjection>(), : this(Array.Empty<UnsAreaProjection>(), Array.Empty<UnsLineProjection>(),
equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty<GalaxyTagPlan>()) equipmentNodes, driverInstancePlans, scriptedAlarmPlans)
{
}
/// <summary>Convenience constructor for callers carrying UNS but not Galaxy data.</summary>
/// <param name="unsAreas">The UNS areas.</param>
/// <param name="unsLines">The UNS lines.</param>
/// <param name="equipmentNodes">The equipment nodes.</param>
/// <param name="driverInstancePlans">The driver instance plans.</param>
/// <param name="scriptedAlarmPlans">The scripted alarm plans.</param>
public Phase7CompositionResult(
IReadOnlyList<UnsAreaProjection> unsAreas,
IReadOnlyList<UnsLineProjection> unsLines,
IReadOnlyList<EquipmentNode> equipmentNodes,
IReadOnlyList<DriverInstancePlan> driverInstancePlans,
IReadOnlyList<ScriptedAlarmPlan> scriptedAlarmPlans)
: this(unsAreas, unsLines, equipmentNodes, driverInstancePlans, scriptedAlarmPlans, Array.Empty<GalaxyTagPlan>())
{ {
} }
/// <summary> /// <summary>
/// Equipment-namespace tags — a <see cref="Tag"/> with non-null <see cref="Tag.EquipmentId"/> /// Equipment-namespace tags — a <see cref="Tag"/> with non-null <see cref="Tag.EquipmentId"/>
/// in an <c>Equipment</c>-kind namespace. Mirror of <see cref="GalaxyTags"/> for the UNS /// in an <c>Equipment</c>-kind namespace. <c>Phase7Applier.MaterialiseEquipmentTags</c>
/// equipment-signal path: <c>Phase7Applier.MaterialiseEquipmentTags</c> materialises each as /// materialises each as a Variable under its existing equipment folder. Declared as an
/// a Variable under its existing equipment folder. Declared as an init-only member defaulting /// init-only member defaulting to empty (rather than a positional parameter) so every existing
/// to empty (rather than a 7th positional parameter) so every existing convenience /// convenience constructor + call site keeps compiling unchanged; new producers set it via
/// constructor + call site keeps compiling unchanged; new producers set it via initializer. /// initializer.
/// </summary> /// </summary>
public IReadOnlyList<EquipmentTagPlan> EquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>(); public IReadOnlyList<EquipmentTagPlan> EquipmentTags { get; init; } = Array.Empty<EquipmentTagPlan>();
@@ -81,21 +62,6 @@ public sealed record EquipmentNode(string EquipmentId, string DisplayName, strin
public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson); public sealed record DriverInstancePlan(string DriverInstanceId, string DriverType, string ConfigJson);
public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate); public sealed record ScriptedAlarmPlan(string ScriptedAlarmId, string EquipmentId, string PredicateScriptId, string MessageTemplate);
/// <summary>
/// One Galaxy / SystemPlatform-namespace tag from a <see cref="Tag"/> row where
/// <see cref="Tag.EquipmentId"/> is null. Carries the FolderPath segment that the applier
/// turns into a folder, the leaf <see cref="DisplayName"/> for the Variable, the OPC UA
/// <see cref="DataType"/>, and the dot-form MXAccess reference (<see cref="MxAccessRef"/>)
/// that the Galaxy driver consumes when subscribing.
/// </summary>
public sealed record GalaxyTagPlan(
string TagId,
string DriverInstanceId,
string FolderPath,
string DisplayName,
string DataType,
string MxAccessRef);
/// <summary> /// <summary>
/// One Equipment-namespace tag from a <see cref="Tag"/> row whose <see cref="Tag.EquipmentId"/> /// One Equipment-namespace tag from a <see cref="Tag"/> row whose <see cref="Tag.EquipmentId"/>
/// is non-null and whose owning driver's namespace is <c>Equipment</c>-kind. Carries the stable /// is non-null and whose owning driver's namespace is <c>Equipment</c>-kind. Carries the stable
@@ -105,8 +71,7 @@ public sealed record GalaxyTagPlan(
/// <see cref="DataType"/>, and the driver-side <see cref="FullName"/> reference (extracted from /// <see cref="DataType"/>, and the driver-side <see cref="FullName"/> reference (extracted from
/// <c>Tag.TagConfig</c>) the later values milestone routes reads/writes by. The variable's NodeId /// <c>Tag.TagConfig</c>) the later values milestone routes reads/writes by. The variable's NodeId
/// is folder-scoped (<c>parent/Name</c>), NOT <see cref="FullName"/>, because a raw driver ref /// is folder-scoped (<c>parent/Name</c>), NOT <see cref="FullName"/>, because a raw driver ref
/// (e.g. a Modbus register) is not unique across identical machines. The equipment-signal /// (e.g. a Modbus register) is not unique across identical machines.
/// analogue of <see cref="GalaxyTagPlan"/>.
/// </summary> /// </summary>
public sealed record EquipmentTagPlan( public sealed record EquipmentTagPlan(
string TagId, string TagId,
@@ -253,7 +218,7 @@ public sealed record EquipmentScriptedAlarmPlan(
/// </summary> /// </summary>
public static class Phase7Composer public static class Phase7Composer
{ {
/// <summary>Convenience overload for legacy callers + tests that don't yet supply UNS / Galaxy data.</summary> /// <summary>Convenience overload for legacy callers + tests that don't supply UNS topology or tags.</summary>
/// <param name="equipment">The equipment.</param> /// <param name="equipment">The equipment.</param>
/// <param name="driverInstances">The driver instances.</param> /// <param name="driverInstances">The driver instances.</param>
/// <param name="scriptedAlarms">The scripted alarms.</param> /// <param name="scriptedAlarms">The scripted alarms.</param>
@@ -265,7 +230,7 @@ public static class Phase7Composer
Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms, Compose(Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), equipment, driverInstances, scriptedAlarms,
Array.Empty<Tag>(), Array.Empty<Namespace>()); Array.Empty<Tag>(), Array.Empty<Namespace>());
/// <summary>UNS-aware overload that doesn't yet supply Galaxy tags.</summary> /// <summary>UNS-aware overload that doesn't supply tags.</summary>
/// <param name="unsAreas">The UNS areas.</param> /// <param name="unsAreas">The UNS areas.</param>
/// <param name="unsLines">The UNS lines.</param> /// <param name="unsLines">The UNS lines.</param>
/// <param name="equipment">The equipment.</param> /// <param name="equipment">The equipment.</param>
@@ -440,7 +405,7 @@ public static class Phase7Composer
Enabled: a.Enabled)); Enabled: a.Enabled));
} }
return new Phase7CompositionResult(areas, lines, nodes, plans, alarms, Array.Empty<GalaxyTagPlan>()) return new Phase7CompositionResult(areas, lines, nodes, plans, alarms)
{ {
EquipmentTags = equipmentTags, EquipmentTags = equipmentTags,
EquipmentVirtualTags = equipmentVirtualTags, EquipmentVirtualTags = equipmentVirtualTags,
@@ -21,10 +21,7 @@ public sealed record Phase7Plan(
IReadOnlyList<Phase7Plan.DriverDelta> ChangedDrivers, IReadOnlyList<Phase7Plan.DriverDelta> ChangedDrivers,
IReadOnlyList<ScriptedAlarmPlan> AddedAlarms, IReadOnlyList<ScriptedAlarmPlan> AddedAlarms,
IReadOnlyList<ScriptedAlarmPlan> RemovedAlarms, IReadOnlyList<ScriptedAlarmPlan> RemovedAlarms,
IReadOnlyList<Phase7Plan.AlarmDelta> ChangedAlarms, IReadOnlyList<Phase7Plan.AlarmDelta> ChangedAlarms)
IReadOnlyList<GalaxyTagPlan> AddedGalaxyTags,
IReadOnlyList<GalaxyTagPlan> RemovedGalaxyTags,
IReadOnlyList<Phase7Plan.GalaxyTagDelta> ChangedGalaxyTags)
{ {
/// <summary> /// <summary>
/// Equipment-namespace tag diff sets, keyed by <see cref="EquipmentTagPlan.TagId"/>. Added as /// Equipment-namespace tag diff sets, keyed by <see cref="EquipmentTagPlan.TagId"/>. Added as
@@ -60,14 +57,12 @@ public sealed record Phase7Plan(
AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 && AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 &&
AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 && AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 &&
AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 && AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 &&
AddedGalaxyTags.Count == 0 && RemovedGalaxyTags.Count == 0 && ChangedGalaxyTags.Count == 0 &&
AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 && AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 &&
AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0; AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0;
public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current); public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current);
public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current); public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current);
public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current); public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current);
public sealed record GalaxyTagDelta(GalaxyTagPlan Previous, GalaxyTagPlan Current);
public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current); public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current);
public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current); public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current);
} }
@@ -102,11 +97,6 @@ public static class Phase7Planner
a => a.ScriptedAlarmId, a => a.ScriptedAlarmId,
(a, b) => new Phase7Plan.AlarmDelta(a, b)); (a, b) => new Phase7Plan.AlarmDelta(a, b));
var (addedGalaxy, removedGalaxy, changedGalaxy) = DiffById(
previous.GalaxyTags, next.GalaxyTags,
t => t.TagId,
(a, b) => new Phase7Plan.GalaxyTagDelta(a, b));
var (addedEqTags, removedEqTags, changedEqTags) = DiffById( var (addedEqTags, removedEqTags, changedEqTags) = DiffById(
previous.EquipmentTags, next.EquipmentTags, previous.EquipmentTags, next.EquipmentTags,
t => t.TagId, t => t.TagId,
@@ -125,8 +115,7 @@ public static class Phase7Planner
return new Phase7Plan( return new Phase7Plan(
addedEq, removedEq, changedEq, addedEq, removedEq, changedEq,
addedDrv, removedDrv, changedDrv, addedDrv, removedDrv, changedDrv,
addedAlarm, removedAlarm, changedAlarm, addedAlarm, removedAlarm, changedAlarm)
addedGalaxy, removedGalaxy, changedGalaxy)
{ {
AddedEquipmentTags = addedEqTags, AddedEquipmentTags = addedEqTags,
RemovedEquipmentTags = removedEqTags, RemovedEquipmentTags = removedEqTags,
@@ -192,12 +192,11 @@ public static class DeploymentArtifact
var equipment = ReadArray(root, "Equipment", ReadEquipmentNode); var equipment = ReadArray(root, "Equipment", ReadEquipmentNode);
var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan); var drivers = ReadArray(root, "DriverInstances", ReadDriverPlan);
var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan); var alarms = ReadArray(root, "ScriptedAlarms", ReadAlarmPlan);
var galaxyTags = BuildGalaxyTagPlans(root, drivers);
var equipmentTags = BuildEquipmentTagPlans(root); var equipmentTags = BuildEquipmentTagPlans(root);
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags); var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags);
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root); var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root);
return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms, galaxyTags) return new Phase7CompositionResult(areas, lines, equipment, drivers, alarms)
{ {
EquipmentTags = equipmentTags, EquipmentTags = equipmentTags,
EquipmentVirtualTags = equipmentVirtualTags, EquipmentVirtualTags = equipmentVirtualTags,
@@ -258,8 +257,7 @@ public static class DeploymentArtifact
keptLines, keptLines,
keptEquipment, keptEquipment,
full.DriverInstancePlans.Where(d => sets.DriverIds.Contains(d.DriverInstanceId)).ToArray(), full.DriverInstancePlans.Where(d => sets.DriverIds.Contains(d.DriverInstanceId)).ToArray(),
full.ScriptedAlarmPlans.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray(), full.ScriptedAlarmPlans.Where(a => sets.EquipmentIds.Contains(a.EquipmentId)).ToArray())
full.GalaxyTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray())
{ {
EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(), EquipmentTags = full.EquipmentTags.Where(t => sets.DriverIds.Contains(t.DriverInstanceId)).ToArray(),
EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(), EquipmentVirtualTags = full.EquipmentVirtualTags.Where(v => sets.EquipmentIds.Contains(v.EquipmentId)).ToArray(),
@@ -354,95 +352,15 @@ public static class DeploymentArtifact
Array.Empty<UnsLineProjection>(), Array.Empty<UnsLineProjection>(),
Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(),
Array.Empty<ScriptedAlarmPlan>(), Array.Empty<ScriptedAlarmPlan>());
Array.Empty<GalaxyTagPlan>());
/// <summary>
/// Cross-reference the artifact's Tags + Namespaces + DriverInstances arrays to find
/// SystemPlatform-namespace tags (Galaxy hierarchy), then emit one <see cref="GalaxyTagPlan"/>
/// per qualifying tag. Mirrors <c>Phase7Composer.Compose</c>'s filter so a compose-side
/// plan and an artifact-decode plan agree on the same set of tags.
/// </summary>
private static IReadOnlyList<GalaxyTagPlan> BuildGalaxyTagPlans(JsonElement root, IReadOnlyList<DriverInstancePlan> drivers)
{
if (!root.TryGetProperty("Tags", out var tagsArr) || tagsArr.ValueKind != JsonValueKind.Array)
return Array.Empty<GalaxyTagPlan>();
if (!root.TryGetProperty("Namespaces", out var nsArr) || nsArr.ValueKind != JsonValueKind.Array)
return Array.Empty<GalaxyTagPlan>();
if (!root.TryGetProperty("DriverInstances", out var diArr) || diArr.ValueKind != JsonValueKind.Array)
return Array.Empty<GalaxyTagPlan>();
// namespaceId → Kind ("SystemPlatform"/"Equipment"/"Simulated") — enum serialises as int by default,
// but ConfigComposer's snapshot uses default JsonSerializer which writes numbers. Tolerate both.
var systemPlatformNamespaces = new HashSet<string>(StringComparer.Ordinal);
foreach (var el in nsArr.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var id = el.TryGetProperty("NamespaceId", out var idEl) ? idEl.GetString() : null;
if (string.IsNullOrWhiteSpace(id)) continue;
if (!el.TryGetProperty("Kind", out var kindEl)) continue;
var isSystemPlatform = kindEl.ValueKind switch
{
JsonValueKind.Number => kindEl.GetInt32() == 1, // NamespaceKind.SystemPlatform = 1
JsonValueKind.String => string.Equals(kindEl.GetString(), "SystemPlatform", StringComparison.Ordinal),
_ => false,
};
if (isSystemPlatform) systemPlatformNamespaces.Add(id!);
}
// driverInstanceId → namespaceId
var driverToNamespace = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var el in diArr.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var id = el.TryGetProperty("DriverInstanceId", out var idEl) ? idEl.GetString() : null;
var ns = el.TryGetProperty("NamespaceId", out var nsEl) ? nsEl.GetString() : null;
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(ns))
driverToNamespace[id!] = ns!;
}
var result = new List<GalaxyTagPlan>(tagsArr.GetArrayLength());
foreach (var el in tagsArr.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
// Skip tags with non-null EquipmentId (Equipment-namespace tags belong to a different path).
if (el.TryGetProperty("EquipmentId", out var eqEl) && eqEl.ValueKind != JsonValueKind.Null) continue;
var tagId = el.TryGetProperty("TagId", out var tEl) ? tEl.GetString() : null;
var di = el.TryGetProperty("DriverInstanceId", out var diEl) ? diEl.GetString() : null;
var name = el.TryGetProperty("Name", out var nmEl) ? nmEl.GetString() : null;
var folder = el.TryGetProperty("FolderPath", out var fpEl) && fpEl.ValueKind != JsonValueKind.Null
? fpEl.GetString() : null;
var dataType = el.TryGetProperty("DataType", out var dtEl) ? dtEl.GetString() : null;
if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name))
continue;
if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue;
if (!systemPlatformNamespaces.Contains(nsId)) continue;
var folderPath = folder ?? string.Empty;
var mxRef = string.IsNullOrWhiteSpace(folderPath) ? name! : $"{folderPath}.{name}";
result.Add(new GalaxyTagPlan(tagId!, di!, folderPath, name!, dataType ?? "BaseDataType", mxRef));
}
result.Sort((a, b) =>
{
var byDriver = string.CompareOrdinal(a.DriverInstanceId, b.DriverInstanceId);
if (byDriver != 0) return byDriver;
var byFolder = string.CompareOrdinal(a.FolderPath, b.FolderPath);
if (byFolder != 0) return byFolder;
return string.CompareOrdinal(a.DisplayName, b.DisplayName);
});
return result;
}
/// <summary> /// <summary>
/// Cross-reference the artifact's Tags + Namespaces + DriverInstances arrays to find /// Cross-reference the artifact's Tags + Namespaces + DriverInstances arrays to find
/// Equipment-namespace tags (non-null EquipmentId, owning namespace Kind == Equipment), then /// Equipment-namespace tags (non-null EquipmentId, owning namespace Kind == Equipment), then
/// emit one <see cref="EquipmentTagPlan"/> per qualifying tag. The artifact-decode mirror of /// emit one <see cref="EquipmentTagPlan"/> per qualifying tag. The artifact-decode mirror of
/// <c>Phase7Composer.Compose</c>'s equipment filter — the inverse of <see cref="BuildGalaxyTagPlans"/> /// <c>Phase7Composer.Compose</c>'s equipment filter — so the compose-side + artifact-decode
/// — so the compose-side + artifact-decode plans agree on the same set of tags. FullName is /// plans agree on the same set of tags. FullName is read from each tag's TagConfig blob
/// read from each tag's TagConfig blob (top-level "FullName" field). /// (top-level "FullName" field).
/// </summary> /// </summary>
private static IReadOnlyList<EquipmentTagPlan> BuildEquipmentTagPlans(JsonElement root) private static IReadOnlyList<EquipmentTagPlan> BuildEquipmentTagPlans(JsonElement root)
{ {
@@ -454,7 +372,7 @@ public static class DeploymentArtifact
return Array.Empty<EquipmentTagPlan>(); return Array.Empty<EquipmentTagPlan>();
// namespaceId → Equipment-kind. Kind serialises as a number by default (Equipment = 0); // namespaceId → Equipment-kind. Kind serialises as a number by default (Equipment = 0);
// tolerate the string form too (matches BuildGalaxyTagPlans's number/string handling). // tolerate the string form too.
var equipmentNamespaces = new HashSet<string>(StringComparer.Ordinal); var equipmentNamespaces = new HashSet<string>(StringComparer.Ordinal);
foreach (var el in nsArr.EnumerateArray()) foreach (var el in nsArr.EnumerateArray())
{ {
@@ -471,11 +389,8 @@ public static class DeploymentArtifact
if (isEquipment) equipmentNamespaces.Add(id!); if (isEquipment) equipmentNamespaces.Add(id!);
} }
// driverInstanceId → namespaceId, and driverInstanceId → DriverType. The DriverType map admits // driverInstanceId → namespaceId.
// a Galaxy alias (a GalaxyMxGateway-backed equipment-scoped tag) that lives in a SystemPlatform
// namespace — byte-parity with the composer's `di.DriverType == "GalaxyMxGateway"` clause.
var driverToNamespace = new Dictionary<string, string>(StringComparer.Ordinal); var driverToNamespace = new Dictionary<string, string>(StringComparer.Ordinal);
var driverToType = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var el in diArr.EnumerateArray()) foreach (var el in diArr.EnumerateArray())
{ {
if (el.ValueKind != JsonValueKind.Object) continue; if (el.ValueKind != JsonValueKind.Object) continue;
@@ -483,9 +398,6 @@ public static class DeploymentArtifact
var ns = el.TryGetProperty("NamespaceId", out var nsEl) ? nsEl.GetString() : null; var ns = el.TryGetProperty("NamespaceId", out var nsEl) ? nsEl.GetString() : null;
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(ns)) if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(ns))
driverToNamespace[id!] = ns!; driverToNamespace[id!] = ns!;
var dtype = el.TryGetProperty("DriverType", out var dtEl) ? dtEl.GetString() : null;
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(dtype))
driverToType[id!] = dtype!;
} }
var result = new List<EquipmentTagPlan>(tagsArr.GetArrayLength()); var result = new List<EquipmentTagPlan>(tagsArr.GetArrayLength());
@@ -508,10 +420,10 @@ public static class DeploymentArtifact
if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) continue; if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(di) || string.IsNullOrWhiteSpace(name)) continue;
if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue; if (!driverToNamespace.TryGetValue(di!, out var nsId)) continue;
// A GalaxyMxGateway-backed alias qualifies even though its namespace is SystemPlatform-kind // Equipment-kind namespace only — byte-parity with the composer's pure
// (not Equipment) — byte-parity with the composer's broadened equipment-tag filter. // `ns.Kind == NamespaceKind.Equipment` predicate (no Galaxy exception). Galaxy points are
var isGalaxyAlias = driverToType.TryGetValue(di!, out var dtype2) && dtype2 == "GalaxyMxGateway"; // ordinary equipment tags now (GalaxyMxGateway is a standard Equipment-kind driver).
if (!equipmentNamespaces.Contains(nsId) && !isGalaxyAlias) continue; if (!equipmentNamespaces.Contains(nsId)) continue;
result.Add(new EquipmentTagPlan( result.Add(new EquipmentTagPlan(
TagId: tagId!, TagId: tagId!,
@@ -547,8 +547,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
} }
/// <summary> /// <summary>
/// SubscribeBulk pass. After an apply, read the deployment's SystemPlatform / Galaxy tags, /// SubscribeBulk pass. After an apply, read the deployment's Equipment-namespace tags,
/// group their dot-form MXAccess references by driver instance, and hand each running driver /// group their driver-side FullName references by driver instance, and hand each running driver
/// child its desired subscription set via <see cref="DriverInstanceActor.SetDesiredSubscriptions"/>. /// child its desired subscription set via <see cref="DriverInstanceActor.SetDesiredSubscriptions"/>.
/// The child retains the set and (re)subscribes on every Connected entry, so values stream into /// The child retains the set and (re)subscribes on every Connected entry, so values stream into
/// the OPC UA sink and resume after reconnects. Drivers with no configured tags get an empty set /// the OPC UA sink and resume after reconnects. Drivers with no configured tags get an empty set
@@ -582,11 +582,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
return; return;
} }
var refsByDriver = composition.GalaxyTags var refsByDriver = composition.EquipmentTags
.GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal) .GroupBy(t => t.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary( .ToDictionary(
g => g.Key, g => g.Key,
g => (IReadOnlyList<string>)g.Select(t => t.MxAccessRef) g => (IReadOnlyList<string>)g.Select(t => t.FullName)
.Distinct(StringComparer.Ordinal) .Distinct(StringComparer.Ordinal)
.ToArray(), .ToArray(),
StringComparer.Ordinal); StringComparer.Ordinal);
@@ -63,8 +63,7 @@ public sealed class OpcUaPublishActor : ReceiveActor
Array.Empty<UnsLineProjection>(), Array.Empty<UnsLineProjection>(),
Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(),
Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(),
Array.Empty<ScriptedAlarmPlan>(), Array.Empty<ScriptedAlarmPlan>());
Array.Empty<GalaxyTagPlan>());
/// <summary>Gets the number of writes performed.</summary> /// <summary>Gets the number of writes performed.</summary>
public int WriteCount => _writes; public int WriteCount => _writes;
@@ -242,10 +241,6 @@ public sealed class OpcUaPublishActor : ReceiveActor
// nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled // nodes (keyed by ScriptedAlarmId so AlarmStateUpdate writes target them); disabled
// alarms are skipped. // alarms are skipped.
_applier.MaterialiseScriptedAlarms(composition); _applier.MaterialiseScriptedAlarms(composition);
// Galaxy / SystemPlatform tags get their own pass: ensures their FolderPath folder
// + Variable node exist so clients can browse them. The Galaxy driver fills values
// on a future SubscribeBulk pass; until then variables show BadWaitingForInitialData.
_applier.MaterialiseGalaxyTags(composition);
// Equipment-namespace tags get their own pass: ensures each signal's Variable (and any // Equipment-namespace tags get their own pass: ensures each signal's Variable (and any
// FolderPath sub-folder) exists under its already-materialised equipment folder so // FolderPath sub-folder) exists under its already-materialised equipment folder so
// clients can browse them. Live values arrive in a later milestone; until then the // clients can browse them. Live values arrive in a later milestone; until then the
@@ -36,8 +36,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") }, UnsLines: new[] { new UnsLineProjection("line-1", "area-1", "Cell A") },
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") }, EquipmentNodes: new[] { new EquipmentNode("eq-1", "Pump-1", "line-1") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(), DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(), ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
GalaxyTags: Array.Empty<GalaxyTagPlan>());
applier.MaterialiseHierarchy(composition); applier.MaterialiseHierarchy(composition);
@@ -60,8 +59,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
UnsLines: Array.Empty<UnsLineProjection>(), UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: new[] { new EquipmentNode("eq-orphan", "Orphan", UnsLineId: "") }, EquipmentNodes: new[] { new EquipmentNode("eq-orphan", "Orphan", UnsLineId: "") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(), DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(), ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>());
GalaxyTags: Array.Empty<GalaxyTagPlan>());
applier.MaterialiseHierarchy(composition); applier.MaterialiseHierarchy(composition);
@@ -95,8 +93,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") }, UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") }, EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(), DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(), ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>()));
GalaxyTags: Array.Empty<GalaxyTagPlan>()));
sdkServer.NodeManager!.FolderCount.ShouldBe(5); // 2 areas + 1 line + 2 equipment sdkServer.NodeManager!.FolderCount.ShouldBe(5); // 2 areas + 1 line + 2 equipment
@@ -106,8 +103,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") }, UnsLines: new[] { new UnsLineProjection("line-1", "area-A", "Line 1") },
EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") }, EquipmentNodes: new[] { new EquipmentNode("eq-1", "Eq 1", "line-1"), new EquipmentNode("eq-2", "Eq 2", "line-1") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(), DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(), ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>()));
GalaxyTags: Array.Empty<GalaxyTagPlan>()));
sdkServer.NodeManager!.FolderCount.ShouldBe(5); sdkServer.NodeManager!.FolderCount.ShouldBe(5);
} }
@@ -143,8 +139,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
UnsLines: Array.Empty<UnsLineProjection>(), UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: new[] { new EquipmentNode("eq-1", "filling-eq", UnsLineId: "") }, EquipmentNodes: new[] { new EquipmentNode("eq-1", "filling-eq", UnsLineId: "") },
DriverInstancePlans: Array.Empty<DriverInstancePlan>(), DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(), ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>())
GalaxyTags: Array.Empty<GalaxyTagPlan>())
{ {
EquipmentTags = new[] EquipmentTags = new[]
{ {
@@ -201,7 +196,7 @@ public sealed class Phase7ApplierHierarchyTests : IDisposable
new[] { area }, new[] { line }, new[] { equipment }, new[] { driver }, new[] { area }, new[] { line }, new[] { equipment }, new[] { driver },
Array.Empty<ScriptedAlarm>(), new[] { tag }, new[] { ns }); Array.Empty<ScriptedAlarm>(), new[] { tag }, new[] { ns });
// Compose-side EquipmentTags extraction (the inverse of the Galaxy filter). // Compose-side EquipmentTags extraction.
var planned = composition.EquipmentTags.ShouldHaveSingleItem(); var planned = composition.EquipmentTags.ShouldHaveSingleItem();
planned.EquipmentId.ShouldBe("eq-1"); planned.EquipmentId.ShouldBe("eq-1");
planned.FullName.ShouldBe("40001"); planned.FullName.ShouldBe("40001");
@@ -59,10 +59,7 @@ public sealed class Phase7ApplierTests
ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(), ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(), AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(), RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(), ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>());
AddedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());
var outcome = applier.Apply(plan); var outcome = applier.Apply(plan);
@@ -93,10 +90,7 @@ public sealed class Phase7ApplierTests
}, },
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(), AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(), RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(), ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>());
AddedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());
var outcome = applier.Apply(plan); var outcome = applier.Apply(plan);
@@ -118,66 +112,6 @@ public sealed class Phase7ApplierTests
outcome.RebuildCalled.ShouldBeTrue(); outcome.RebuildCalled.ShouldBeTrue();
} }
/// <summary>Verifies MaterialiseGalaxyTags creates one folder per distinct FolderPath and one
/// variable per tag, with root-level tags hung directly under the namespace root.</summary>
[Fact]
public void MaterialiseGalaxyTags_creates_folder_per_distinct_path_and_variable_per_tag()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var composition = new Phase7CompositionResult(
UnsAreas: Array.Empty<UnsAreaProjection>(),
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
GalaxyTags: new[]
{
new GalaxyTagPlan("t1", "drv", "section.area", "Temperature", "Float", "section.area.Temperature"),
new GalaxyTagPlan("t2", "drv", "", "Pressure", "Int32", "Pressure"),
});
applier.MaterialiseGalaxyTags(composition);
// One folder for the single distinct non-empty FolderPath; the root-level tag adds none.
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("section.area", null, "section.area"));
// Foldered tag → NodeId is its MxAccessRef under the FolderPath parent.
// Root-level tag → NodeId is its DisplayName under the root (null parent).
sink.VariableCalls.ShouldContain(("section.area.Temperature", "section.area", "Temperature", "Float"));
sink.VariableCalls.ShouldContain(("Pressure", (string?)null, "Pressure", "Int32"));
sink.VariableCalls.Count.ShouldBe(2);
}
/// <summary>Verifies that two tags sharing a FolderPath produce a single EnsureFolder call
/// (deduped) but one EnsureVariable per tag.</summary>
[Fact]
public void MaterialiseGalaxyTags_dedupes_folders_for_tags_sharing_a_path()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var composition = new Phase7CompositionResult(
UnsAreas: Array.Empty<UnsAreaProjection>(),
UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(),
GalaxyTags: new[]
{
new GalaxyTagPlan("t1", "drv", "line.cell", "Speed", "Float", "line.cell.Speed"),
new GalaxyTagPlan("t2", "drv", "line.cell", "Torque", "Float", "line.cell.Torque"),
});
applier.MaterialiseGalaxyTags(composition);
sink.FolderCalls.ShouldHaveSingleItem().ShouldBe(("line.cell", null, "line.cell"));
sink.VariableCalls.Count.ShouldBe(2);
sink.VariableCalls.ShouldContain(("line.cell.Speed", "line.cell", "Speed", "Float"));
sink.VariableCalls.ShouldContain(("line.cell.Torque", "line.cell", "Torque", "Float"));
}
/// <summary>Verifies MaterialiseEquipmentTags creates one Variable per equipment tag directly /// <summary>Verifies MaterialiseEquipmentTags creates one Variable per equipment tag directly
/// under its existing equipment folder, with a folder-scoped NodeId (parent/Name — NOT the raw /// under its existing equipment folder, with a folder-scoped NodeId (parent/Name — NOT the raw
/// FullName), parent == EquipmentId, displayName == Name, and does NOT re-create the equipment /// FullName), parent == EquipmentId, displayName == Name, and does NOT re-create the equipment
@@ -193,8 +127,7 @@ public sealed class Phase7ApplierTests
UnsLines: Array.Empty<UnsLineProjection>(), UnsLines: Array.Empty<UnsLineProjection>(),
EquipmentNodes: Array.Empty<EquipmentNode>(), EquipmentNodes: Array.Empty<EquipmentNode>(),
DriverInstancePlans: Array.Empty<DriverInstancePlan>(), DriverInstancePlans: Array.Empty<DriverInstancePlan>(),
ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>(), ScriptedAlarmPlans: Array.Empty<ScriptedAlarmPlan>())
GalaxyTags: Array.Empty<GalaxyTagPlan>())
{ {
EquipmentTags = new[] EquipmentTags = new[]
{ {
@@ -345,8 +278,8 @@ public sealed class Phase7ApplierTests
} }
/// <summary>Verifies that added equipment tags in an otherwise-empty plan trigger an /// <summary>Verifies that added equipment tags in an otherwise-empty plan trigger an
/// address-space rebuild (parity with the Galaxy-tag path — the planner now diffs equipment /// address-space rebuild (the planner now diffs equipment tags, so a tags-only deploy is no
/// tags, so a tags-only deploy is no longer a silent no-op).</summary> /// longer a silent no-op).</summary>
[Fact] [Fact]
public void Added_equipment_tags_trigger_rebuild() public void Added_equipment_tags_trigger_rebuild()
{ {
@@ -393,42 +326,10 @@ public sealed class Phase7ApplierTests
sink.RebuildCalls.ShouldBe(1); sink.RebuildCalls.ShouldBe(1);
} }
/// <summary>Verifies that added Galaxy tags in an otherwise-empty plan trigger an address-space rebuild.</summary>
[Fact]
public void Added_galaxy_tags_trigger_rebuild()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var plan = new Phase7Plan(
AddedEquipment: Array.Empty<EquipmentNode>(),
RemovedEquipment: Array.Empty<EquipmentNode>(),
ChangedEquipment: Array.Empty<Phase7Plan.EquipmentDelta>(),
AddedDrivers: Array.Empty<DriverInstancePlan>(),
RemovedDrivers: Array.Empty<DriverInstancePlan>(),
ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(),
AddedGalaxyTags: new[]
{
new GalaxyTagPlan("t1", "drv", "section.area", "Temperature", "Float", "section.area.Temperature"),
},
RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
outcome.AddedNodes.ShouldBe(1);
sink.RebuildCalls.ShouldBe(1);
}
private static Phase7Plan EmptyPlan => new( private static Phase7Plan EmptyPlan => new(
Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<Phase7Plan.EquipmentDelta>(), Array.Empty<EquipmentNode>(), Array.Empty<EquipmentNode>(), Array.Empty<Phase7Plan.EquipmentDelta>(),
Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<Phase7Plan.DriverDelta>(), Array.Empty<DriverInstancePlan>(), Array.Empty<DriverInstancePlan>(), Array.Empty<Phase7Plan.DriverDelta>(),
Array.Empty<ScriptedAlarmPlan>(), Array.Empty<ScriptedAlarmPlan>(), Array.Empty<Phase7Plan.AlarmDelta>(), Array.Empty<ScriptedAlarmPlan>(), Array.Empty<ScriptedAlarmPlan>(), Array.Empty<Phase7Plan.AlarmDelta>());
Array.Empty<GalaxyTagPlan>(), Array.Empty<GalaxyTagPlan>(), Array.Empty<Phase7Plan.GalaxyTagDelta>());
private static Phase7Plan WithEquipmentRemoval(params string[] ids) => new( private static Phase7Plan WithEquipmentRemoval(params string[] ids) => new(
AddedEquipment: Array.Empty<EquipmentNode>(), AddedEquipment: Array.Empty<EquipmentNode>(),
@@ -439,10 +340,7 @@ public sealed class Phase7ApplierTests
ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(), ChangedDrivers: Array.Empty<Phase7Plan.DriverDelta>(),
AddedAlarms: Array.Empty<ScriptedAlarmPlan>(), AddedAlarms: Array.Empty<ScriptedAlarmPlan>(),
RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(), RemovedAlarms: Array.Empty<ScriptedAlarmPlan>(),
ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>(), ChangedAlarms: Array.Empty<Phase7Plan.AlarmDelta>());
AddedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
RemovedGalaxyTags: Array.Empty<GalaxyTagPlan>(),
ChangedGalaxyTags: Array.Empty<Phase7Plan.GalaxyTagDelta>());
private sealed class RecordingSink : IOpcUaAddressSpaceSink private sealed class RecordingSink : IOpcUaAddressSpaceSink
{ {
@@ -11,16 +11,15 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// Equipment-kind driver. An equipment-scoped <see cref="Tag"/> (non-null /// Equipment-kind driver. An equipment-scoped <see cref="Tag"/> (non-null
/// <see cref="Tag.EquipmentId"/>) bound to a <c>GalaxyMxGateway</c> driver living in an /// <see cref="Tag.EquipmentId"/>) bound to a <c>GalaxyMxGateway</c> driver living in an
/// <c>Equipment</c>-kind namespace must surface under /// <c>Equipment</c>-kind namespace must surface under
/// <see cref="Phase7CompositionResult.EquipmentTags"/> (carrying its driver-side FullName), and /// <see cref="Phase7CompositionResult.EquipmentTags"/> (carrying its driver-side FullName). The
/// the retired SystemPlatform-mirror producer means <see cref="Phase7CompositionResult.GalaxyTags"/> /// SystemPlatform-mirror <c>GalaxyTags</c> contract is retired entirely.
/// is always empty.
/// </summary> /// </summary>
public sealed class Phase7ComposerAliasTagTests public sealed class Phase7ComposerAliasTagTests
{ {
/// <summary>A <c>GalaxyMxGateway</c> driver in an Equipment-kind namespace carries an /// <summary>A <c>GalaxyMxGateway</c> driver in an Equipment-kind namespace carries an
/// equipment-scoped Galaxy tag (EquipmentId set, FolderPath null, TagConfig FullName = the Galaxy /// equipment-scoped Galaxy tag (EquipmentId set, FolderPath null, TagConfig FullName = the Galaxy
/// ref). Compose must put it in EquipmentTags with its FullName, and GalaxyTags must be empty /// ref). Compose must put it in EquipmentTags with its FullName, coalescing the null FolderPath to
/// (the SystemPlatform mirror producer is gone).</summary> /// <c>string.Empty</c> (the SystemPlatform mirror producer is gone entirely).</summary>
[Fact] [Fact]
public void Compose_admits_galaxy_equipment_tag_in_equipment_tags() public void Compose_admits_galaxy_equipment_tag_in_equipment_tags()
{ {
@@ -77,8 +76,8 @@ public sealed class Phase7ComposerAliasTagTests
tag.Name.ShouldBe("TestChangingInt"); tag.Name.ShouldBe("TestChangingInt");
tag.DataType.ShouldBe("Int32"); tag.DataType.ShouldBe("Int32");
tag.FullName.ShouldBe("TestMachine_020.TestChangingInt"); tag.FullName.ShouldBe("TestMachine_020.TestChangingInt");
// The input Tag.FolderPath is null; the composer coalesces it to string.Empty (the explicit
// The SystemPlatform-mirror producer is retired → GalaxyTags is always empty. // byte-parity null-coalesce the artifact-decode side mirrors).
result.GalaxyTags.ShouldBeEmpty(); tag.FolderPath.ShouldBe(string.Empty);
} }
} }
@@ -7,19 +7,65 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary> /// <summary>
/// Verifies the artifact-decode mirror (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>) /// Verifies the artifact-decode mirror (<see cref="DeploymentArtifact.ParseComposition(System.ReadOnlySpan{byte})"/>)
/// admits a Galaxy alias tag — an equipment-scoped tag (non-null <c>EquipmentId</c>) bound to a /// treats a Galaxy point as an ordinary equipment tag — an equipment-scoped tag (non-null
/// <c>GalaxyMxGateway</c> driver in a <c>SystemPlatform</c>-kind namespace — into the decoded /// <c>EquipmentId</c>) bound to a <c>GalaxyMxGateway</c> driver in an <c>Equipment</c>-kind namespace —
/// <c>EquipmentTags</c> with byte-parity to the live-edit composer path: same FullName, EquipmentId, /// into the decoded <c>EquipmentTags</c> with byte-parity to the live-edit composer path: same FullName,
/// DriverInstanceId, Name, DataType. The composer broadens the same filter by DriverType, so both /// EquipmentId, DriverInstanceId, Name, DataType. Both data-contract sites gate purely on the namespace
/// data-contract sites must agree on which tags qualify. /// Kind being <c>Equipment</c> (no Galaxy/DriverType exception — the SystemPlatform-mirror contract is
/// retired), so they agree on which tags qualify.
/// </summary> /// </summary>
public sealed class DeploymentArtifactAliasParityTests public sealed class DeploymentArtifactAliasParityTests
{ {
/// <summary>An artifact JSON blob with a GalaxyMxGateway driver in a SystemPlatform (Kind=1) /// <summary>An artifact JSON blob with a GalaxyMxGateway driver in an Equipment (Kind=0) namespace and
/// namespace and one equipment-scoped alias tag (EquipmentId set, FolderPath null, FullName = the /// one equipment-scoped tag (EquipmentId set, FolderPath null, FullName = the Galaxy ref). Decode must
/// Galaxy ref). Decode must surface the alias in EquipmentTags carrying its driver-side FullName.</summary> /// surface the tag in EquipmentTags carrying its driver-side FullName, coalescing the null FolderPath to
/// <c>string.Empty</c>.</summary>
[Fact] [Fact]
public void ParseComposition_admits_galaxy_alias_tag_in_equipment_tags() public void ParseComposition_admits_galaxy_equipment_tag_in_equipment_tags()
{
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-eq", Kind = 0 }, // NamespaceKind.Equipment
},
DriverInstances = new[]
{
new { DriverInstanceId = "drv-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", NamespaceId = "ns-eq" },
},
Tags = new object[]
{
new
{
TagId = "tag-galaxy",
DriverInstanceId = "drv-galaxy",
EquipmentId = "eq-1",
Name = "TestChangingInt",
FolderPath = (string?)null,
DataType = "Int32",
TagConfig = "{\"FullName\":\"TestMachine_020.TestChangingInt\"}",
},
},
});
var c = DeploymentArtifact.ParseComposition(blob);
var tag = c.EquipmentTags.ShouldHaveSingleItem();
tag.TagId.ShouldBe("tag-galaxy");
tag.EquipmentId.ShouldBe("eq-1");
tag.DriverInstanceId.ShouldBe("drv-galaxy");
tag.Name.ShouldBe("TestChangingInt");
tag.DataType.ShouldBe("Int32");
tag.FolderPath.ShouldBe(string.Empty);
tag.FullName.ShouldBe("TestMachine_020.TestChangingInt");
}
/// <summary>An equipment-scoped GalaxyMxGateway tag in a SystemPlatform-kind namespace must NOT surface
/// in EquipmentTags — byte-parity with the composer's pure <c>ns.Kind == NamespaceKind.Equipment</c>
/// predicate. The retired SystemPlatform-mirror contract no longer carried a DriverType exception, so a
/// non-Equipment namespace excludes the tag regardless of driver type.</summary>
[Fact]
public void ParseComposition_excludes_galaxy_tag_in_non_equipment_namespace()
{ {
var blob = JsonSerializer.SerializeToUtf8Bytes(new var blob = JsonSerializer.SerializeToUtf8Bytes(new
{ {
@@ -32,54 +78,11 @@ public sealed class DeploymentArtifactAliasParityTests
new { DriverInstanceId = "drv-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", NamespaceId = "ns-sp" }, new { DriverInstanceId = "drv-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", NamespaceId = "ns-sp" },
}, },
Tags = new object[] Tags = new object[]
{
new
{
TagId = "tag-alias",
DriverInstanceId = "drv-galaxy",
EquipmentId = "eq-1",
Name = "TestChangingInt",
FolderPath = (string?)null,
DataType = "Int32",
TagConfig = "{\"FullName\":\"TestMachine_020.TestChangingInt\"}",
},
},
});
var c = DeploymentArtifact.ParseComposition(blob);
var alias = c.EquipmentTags.ShouldHaveSingleItem();
alias.TagId.ShouldBe("tag-alias");
alias.EquipmentId.ShouldBe("eq-1");
alias.DriverInstanceId.ShouldBe("drv-galaxy");
alias.Name.ShouldBe("TestChangingInt");
alias.DataType.ShouldBe("Int32");
alias.FolderPath.ShouldBe(string.Empty);
alias.FullName.ShouldBe("TestMachine_020.TestChangingInt");
}
/// <summary>An equipment-scoped tag bound to a non-Galaxy driver in a SystemPlatform namespace is
/// NOT a Galaxy alias and must stay excluded from EquipmentTags — the broadened clause keys on the
/// GalaxyMxGateway DriverType, not on the namespace kind, so the contract narrows correctly.</summary>
[Fact]
public void ParseComposition_excludes_non_galaxy_systemplatform_equipment_tag()
{
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Namespaces = new[]
{
new { NamespaceId = "ns-sp", Kind = 1 }, // NamespaceKind.SystemPlatform
},
DriverInstances = new[]
{
new { DriverInstanceId = "drv-modbus", DriverType = "Modbus", DriverConfig = "{}", NamespaceId = "ns-sp" },
},
Tags = new object[]
{ {
new new
{ {
TagId = "tag-x", TagId = "tag-x",
DriverInstanceId = "drv-modbus", DriverInstanceId = "drv-galaxy",
EquipmentId = "eq-1", EquipmentId = "eq-1",
Name = "Source", Name = "Source",
FolderPath = (string?)null, FolderPath = (string?)null,
@@ -193,9 +193,10 @@ public sealed class DeploymentArtifactTests
/// <summary> /// <summary>
/// Verifies ParseComposition surfaces Equipment-namespace tags (non-null EquipmentId in an /// Verifies ParseComposition surfaces Equipment-namespace tags (non-null EquipmentId in an
/// <c>Equipment</c>-kind namespace) as <c>EquipmentTags</c>, with <c>FullName</c> extracted /// <c>Equipment</c>-kind namespace) as <c>EquipmentTags</c>, with <c>FullName</c> extracted
/// from the tag's TagConfig blob — the equipment-signal mirror of the Galaxy-tag path. A /// from the tag's TagConfig blob. A tag in a non-Equipment (SystemPlatform) namespace with a
/// SystemPlatform (Galaxy) tag in the same blob must NOT leak into EquipmentTags and must /// null EquipmentId must NOT surface in EquipmentTags — byte-parity with the composer's pure
/// still route to GalaxyTags. /// <c>ns.Kind == NamespaceKind.Equipment</c> predicate (the SystemPlatform-mirror contract is
/// retired, so such a tag routes nowhere).
/// </summary> /// </summary>
[Fact] [Fact]
public void ParseComposition_reads_EquipmentTags_from_equipment_namespace() public void ParseComposition_reads_EquipmentTags_from_equipment_namespace()
@@ -247,8 +248,9 @@ public sealed class DeploymentArtifactTests
tag.DataType.ShouldBe("Float"); tag.DataType.ShouldBe("Float");
tag.FullName.ShouldBe("40001"); // extracted from TagConfig, not the raw blob tag.FullName.ShouldBe("40001"); // extracted from TagConfig, not the raw blob
// The Galaxy tag still routes to GalaxyTags and does NOT leak into EquipmentTags. // The SystemPlatform tag (null EquipmentId, non-Equipment namespace) does NOT leak into
c.GalaxyTags.ShouldContain(g => g.TagId == "tag-gx"); // EquipmentTags — byte-parity with the composer's pure ns.Kind == Equipment predicate.
c.EquipmentTags.ShouldNotContain(t => t.TagId == "tag-gx");
} }
/// <summary> /// <summary>
@@ -387,15 +389,18 @@ public sealed class DeploymentArtifactTests
new { DriverInstanceId = "main-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" }, new { DriverInstanceId = "main-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
new { DriverInstanceId = "sa-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" }, new { DriverInstanceId = "sa-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" },
}, },
// Galaxy points are ordinary equipment tags now — Equipment-kind namespaces with non-null
// EquipmentId, so the cluster-scoped decode filters them via EquipmentTags (by their driver's
// cluster), exactly as it filtered the retired GalaxyTags.
Namespaces = new[] Namespaces = new[]
{ {
new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 1 }, new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 0 },
new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 1 }, new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 0 },
}, },
Tags = new[] Tags = new[]
{ {
new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)null, Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" }, new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)"eq-main", Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)null, Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" }, new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)"eq-sa", Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
}, },
}; };
@@ -406,18 +411,18 @@ public sealed class DeploymentArtifactTests
var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053"); var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053");
main.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "main-galaxy" }); main.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "main-galaxy" });
main.GalaxyTags.Select(t => t.TagId).ShouldBe(new[] { "t-main" }); main.EquipmentTags.Select(t => t.TagId).ShouldBe(new[] { "t-main" });
var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053"); var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053");
siteA.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "sa-galaxy" }); siteA.DriverInstancePlans.Select(d => d.DriverInstanceId).ShouldBe(new[] { "sa-galaxy" });
siteA.GalaxyTags.Select(t => t.TagId).ShouldBe(new[] { "t-sa" }); siteA.EquipmentTags.Select(t => t.TagId).ShouldBe(new[] { "t-sa" });
} }
[Fact] [Fact]
public void ParseComposition_scoped_unknown_node_is_empty() public void ParseComposition_scoped_unknown_node_is_empty()
{ {
var comp = DeploymentArtifact.ParseComposition(BlobOf(MultiClusterSnapshotWithTags()), "ghost-9:4053"); var comp = DeploymentArtifact.ParseComposition(BlobOf(MultiClusterSnapshotWithTags()), "ghost-9:4053");
comp.GalaxyTags.ShouldBeEmpty(); comp.EquipmentTags.ShouldBeEmpty();
comp.DriverInstancePlans.ShouldBeEmpty(); comp.DriverInstancePlans.ShouldBeEmpty();
} }
@@ -102,10 +102,11 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
/// Wiring proof for per-ClusterId scoping (Task 4): a multi-cluster artifact must /// Wiring proof for per-ClusterId scoping (Task 4): a multi-cluster artifact must
/// materialise ONLY the local node's cluster slice. Mirrors the multi-cluster artifact /// materialise ONLY the local node's cluster slice. Mirrors the multi-cluster artifact
/// shape exercised in <c>DeploymentArtifactTests</c> (MAIN + SITE-A, one Galaxy driver + /// shape exercised in <c>DeploymentArtifactTests</c> (MAIN + SITE-A, one Galaxy driver +
/// one SystemPlatform tag each). The scoped rebuild for the SITE-A node must surface the /// one equipment tag each — Galaxy points are ordinary equipment tags now). The scoped
/// SITE-A tag (<c>t-sa</c> → variable <c>F.S1</c>) and NOT MAIN's (<c>t-main</c> → /// rebuild for the SITE-A node must surface the SITE-A tag (<c>t-sa</c> → folder-scoped
/// <c>F.M1</c>); the mirror holds for the MAIN node. Without the production scoping edit, /// variable <c>eq-sa/F/S1</c>) and NOT MAIN's (<c>t-main</c> → <c>eq-main/F/M1</c>); the
/// the unscoped parse would materialise BOTH variables on every node. /// mirror holds for the MAIN node. Without the production scoping edit, the unscoped parse
/// would materialise BOTH variables on every node.
/// </summary> /// </summary>
[Fact] [Fact]
public void Rebuild_materialises_only_the_nodes_cluster() public void Rebuild_materialises_only_the_nodes_cluster()
@@ -125,10 +126,10 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
siteActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); siteActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sinkA.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2)); AwaitAssert(() => sinkA.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));
// t-sa (Name "S1", FolderPath "F") → MxAccessRef "F.S1" → variable node "F.S1". // t-sa (EquipmentId "eq-sa", FolderPath "F", Name "S1")folder-scoped variable "eq-sa/F/S1".
sinkA.Calls.ShouldContain("EV:F.S1"); sinkA.Calls.ShouldContain("EV:eq-sa/F/S1");
// t-main (MAIN cluster) must NOT leak onto the SITE-A node. // t-main (MAIN cluster) must NOT leak onto the SITE-A node.
sinkA.Calls.ShouldNotContain("EV:F.M1"); sinkA.Calls.ShouldNotContain("EV:eq-main/F/M1");
// --- MAIN node: the mirror — only MAIN's tag's variable, never SITE-A's. --- // --- MAIN node: the mirror — only MAIN's tag's variable, never SITE-A's. ---
var dbM = NewInMemoryDbFactory(); var dbM = NewInMemoryDbFactory();
@@ -145,15 +146,15 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId()));
AwaitAssert(() => sinkM.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2)); AwaitAssert(() => sinkM.RebuildCalls.ShouldBe(1), duration: TimeSpan.FromSeconds(2));
sinkM.Calls.ShouldContain("EV:F.M1"); sinkM.Calls.ShouldContain("EV:eq-main/F/M1");
sinkM.Calls.ShouldNotContain("EV:F.S1"); sinkM.Calls.ShouldNotContain("EV:eq-sa/F/S1");
} }
/// <summary> /// <summary>
/// Seal a 2-cluster deployment (MAIN + SITE-A) whose artifact mirrors the multi-cluster /// Seal a 2-cluster deployment (MAIN + SITE-A) whose artifact mirrors the multi-cluster
/// shape the composer emits: a <c>Clusters</c> + <c>Nodes</c> map, one SystemPlatform /// shape the composer emits: a <c>Clusters</c> + <c>Nodes</c> map, one Equipment namespace +
/// namespace + Galaxy driver + Galaxy tag per cluster. Used by /// Galaxy driver + equipment tag per cluster (Galaxy points are ordinary equipment tags now).
/// <see cref="Rebuild_materialises_only_the_nodes_cluster"/>. /// Used by <see cref="Rebuild_materialises_only_the_nodes_cluster"/>.
/// </summary> /// </summary>
private static void SeedMultiClusterDeployment(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) private static void SeedMultiClusterDeployment(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory)
{ {
@@ -165,6 +166,21 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
new { NodeId = "central-1:4053", ClusterId = "MAIN" }, new { NodeId = "central-1:4053", ClusterId = "MAIN" },
new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" }, new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" },
}, },
UnsAreas = new[]
{
new { UnsAreaId = "area-main", ClusterId = "MAIN", Name = "main-area" },
new { UnsAreaId = "area-sa", ClusterId = "SITE-A", Name = "sa-area" },
},
UnsLines = new[]
{
new { UnsLineId = "line-main", UnsAreaId = "area-main", Name = "main-line" },
new { UnsLineId = "line-sa", UnsAreaId = "area-sa", Name = "sa-line" },
},
Equipment = new[]
{
new { EquipmentId = "eq-main", DriverInstanceId = "main-galaxy", UnsLineId = "line-main", Name = "eq-main", MachineCode = "EQ-MAIN" },
new { EquipmentId = "eq-sa", DriverInstanceId = "sa-galaxy", UnsLineId = "line-sa", Name = "eq-sa", MachineCode = "EQ-SA" },
},
DriverInstances = new[] DriverInstances = new[]
{ {
new { DriverInstanceId = "main-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" }, new { DriverInstanceId = "main-galaxy", DriverType = "GalaxyMxGateway", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
@@ -172,13 +188,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase
}, },
Namespaces = new[] Namespaces = new[]
{ {
new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 1 }, // NamespaceKind.SystemPlatform new { NamespaceId = "main-ns", ClusterId = "MAIN", Kind = 0 }, // NamespaceKind.Equipment
new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 1 }, new { NamespaceId = "sa-ns", ClusterId = "SITE-A", Kind = 0 },
}, },
Tags = new[] Tags = new[]
{ {
new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)null, Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" }, new { TagId = "t-main", DriverInstanceId = "main-galaxy", EquipmentId = (string?)"eq-main", Name = "M1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)null, Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" }, new { TagId = "t-sa", DriverInstanceId = "sa-galaxy", EquipmentId = (string?)"eq-sa", Name = "S1", FolderPath = "F", DataType = "Boolean", TagConfig = "{}" },
}, },
ScriptedAlarms = Array.Empty<object>(), ScriptedAlarms = Array.Empty<object>(),
}); });