merge: equipment-namespace live values (VirtualTag route)
v2-ci / build (push) Failing after 36s
v2-ci / unit-tests (tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.ControlPlane.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests) (push) Has been skipped
v2-ci / unit-tests (tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests) (push) Has been skipped
v2-ci / integration (tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests) (push) Has been skipped

This commit is contained in:
Joseph Doherty
2026-06-07 09:33:21 -04:00
17 changed files with 1671 additions and 13 deletions
@@ -257,6 +257,60 @@ public sealed class Phase7ApplierTests
sink.VariableCalls.ShouldContain(("eq-2/Speed", "eq-2", "Speed", "Float"));
}
/// <summary>Verifies MaterialiseEquipmentVirtualTags creates one Variable per VirtualTag directly
/// under its existing equipment folder, with a folder-scoped NodeId (EquipmentId/Name — NOT the
/// VirtualTagId or Expression), parent == EquipmentId, displayName == Name, and does NOT re-create
/// the equipment folder (no sub-folder when FolderPath is empty).</summary>
[Fact]
public void MaterialiseEquipmentVirtualTags_creates_variable_under_equipment_folder()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var composition = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64",
Expression: "ctx.GetTag(\"x\") * 60", DependencyRefs: new[] { "x" }),
},
};
applier.MaterialiseEquipmentVirtualTags(composition);
sink.FolderCalls.ShouldBeEmpty(); // equipment folder already exists; no sub-folder needed
sink.VariableCalls.ShouldHaveSingleItem().ShouldBe(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64"));
}
/// <summary>Two VirtualTags under the SAME equipment produce two distinct folder-scoped variables
/// (one EnsureVariable each, no NodeId collision), parented to the equipment folder.</summary>
[Fact]
public void MaterialiseEquipmentVirtualTags_two_under_same_equipment_do_not_collide()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var composition = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-a", "eq-1", FolderPath: "", Name: "speed-rpm", DataType: "Float64",
Expression: "ctx.GetTag(\"a\")", DependencyRefs: new[] { "a" }),
new EquipmentVirtualTagPlan("vt-b", "eq-1", FolderPath: "", Name: "load-pct", DataType: "Float64",
Expression: "ctx.GetTag(\"b\")", DependencyRefs: new[] { "b" }),
},
};
applier.MaterialiseEquipmentVirtualTags(composition);
sink.FolderCalls.ShouldBeEmpty();
sink.VariableCalls.Count.ShouldBe(2);
sink.VariableCalls.ShouldContain(("eq-1/speed-rpm", "eq-1", "speed-rpm", "Float64"));
sink.VariableCalls.ShouldContain(("eq-1/load-pct", "eq-1", "load-pct", "Float64"));
}
/// <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
/// tags, so a tags-only deploy is no longer a silent no-op).</summary>
@@ -281,6 +335,31 @@ public sealed class Phase7ApplierTests
sink.RebuildCalls.ShouldBe(1);
}
/// <summary>Verifies that added Equipment VirtualTags in an otherwise-empty plan trigger an
/// address-space rebuild (parity with the equipment-tag path — the planner now diffs VirtualTags,
/// so a VirtualTag-only deploy is no longer a silent no-op).</summary>
[Fact]
public void Added_equipment_virtual_tags_trigger_rebuild()
{
var sink = new RecordingSink();
var applier = new Phase7Applier(sink, NullLogger<Phase7Applier>.Instance);
var plan = EmptyPlan with
{
AddedEquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "a + b", DependencyRefs: new[] { "a", "b" }),
},
};
var outcome = applier.Apply(plan);
outcome.RebuildCalled.ShouldBeTrue();
outcome.AddedNodes.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()
@@ -91,6 +91,125 @@ public sealed class Phase7ComposerPurityTests
node.DisplayName.ShouldNotBe("FILLING-EQ"); // not the colloquial MachineCode
}
[Fact]
public void Composition_carries_empty_equipment_virtualtags_by_default()
{
var r = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
r.EquipmentVirtualTags.ShouldBeEmpty();
}
[Fact]
public void EquipmentVirtualTagPlan_holds_id_equipment_name_datatype_expression_and_deps()
{
var p = new EquipmentVirtualTagPlan("vt-1", "eq-1", "", "speed-rpm", "Float64",
"return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;",
new[] { "TestMachine_001.TestDouble" });
p.VirtualTagId.ShouldBe("vt-1");
p.EquipmentId.ShouldBe("eq-1");
p.Name.ShouldBe("speed-rpm");
p.DependencyRefs.ShouldHaveSingleItem();
}
/// <summary>Compose joins a <see cref="VirtualTag"/> to its <see cref="Script"/> by ScriptId,
/// emitting one <see cref="EquipmentVirtualTagPlan"/> carrying the script source as the
/// Expression and the parsed <c>ctx.GetTag("…")</c> literals as DependencyRefs.</summary>
[Fact]
public void Compose_emits_equipment_virtualtag_plan_joined_to_script()
{
var vt = new VirtualTag
{
VirtualTagId = "vt-1",
EquipmentId = "eq-1",
Name = "speed-rpm",
DataType = "Float64",
ScriptId = "s-1",
};
var script = new Script
{
ScriptId = "s-1",
Name = "speed-script",
SourceCode = "return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;",
SourceHash = "hash-1",
};
var result = Phase7Composer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(),
Array.Empty<Tag>(), Array.Empty<Namespace>(),
virtualTags: new[] { vt },
scripts: new[] { script });
var plan = result.EquipmentVirtualTags.ShouldHaveSingleItem();
plan.VirtualTagId.ShouldBe("vt-1");
plan.EquipmentId.ShouldBe("eq-1");
plan.FolderPath.ShouldBe("");
plan.Name.ShouldBe("speed-rpm");
plan.DataType.ShouldBe("Float64");
plan.Expression.ShouldBe("return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;");
plan.DependencyRefs.ShouldBe(new[] { "TestMachine_001.TestDouble" });
}
/// <summary>DependencyRefs are the distinct <c>ctx.GetTag("…")</c> literals in first-seen
/// order — a repeated ref collapses to one.</summary>
[Fact]
public void Compose_extracts_distinct_dependency_refs()
{
var vt = new VirtualTag
{
VirtualTagId = "vt-1",
EquipmentId = "eq-1",
Name = "sum",
DataType = "Float64",
ScriptId = "s-1",
};
var script = new Script
{
ScriptId = "s-1",
Name = "sum-script",
SourceCode = "return ctx.GetTag(\"A.X\").Value + ctx.GetTag(\"B.Y\").Value + ctx.GetTag(\"A.X\").Value;",
SourceHash = "hash-1",
};
var result = Phase7Composer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(),
Array.Empty<Tag>(), Array.Empty<Namespace>(),
virtualTags: new[] { vt },
scripts: new[] { script });
var plan = result.EquipmentVirtualTags.ShouldHaveSingleItem();
plan.DependencyRefs.ShouldBe(new[] { "A.X", "B.Y" });
}
/// <summary>A <see cref="VirtualTag"/> whose <c>ScriptId</c> has no matching <see cref="Script"/>
/// in the supplied list falls back to an empty Expression and an empty DependencyRefs —
/// the plan is always emitted (never dropped) and never carries a null Expression.</summary>
[Fact]
public void Compose_virtualtag_with_missing_script_yields_empty_expression_and_deps()
{
var vt = new VirtualTag
{
VirtualTagId = "vt-missing",
EquipmentId = "eq-1",
Name = "mystery-tag",
DataType = "Float64",
ScriptId = "s-does-not-exist",
};
var result = Phase7Composer.Compose(
Array.Empty<UnsArea>(), Array.Empty<UnsLine>(), Array.Empty<Equipment>(),
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(),
Array.Empty<Tag>(), Array.Empty<Namespace>(),
virtualTags: new[] { vt },
scripts: Array.Empty<Script>());
var plan = result.EquipmentVirtualTags.ShouldHaveSingleItem();
plan.VirtualTagId.ShouldBe("vt-missing");
plan.Expression.ShouldBe(string.Empty);
plan.DependencyRefs.ShouldBeEmpty();
}
private static Equipment NewEquipment(string id) => new()
{
EquipmentId = id,
@@ -55,6 +55,128 @@ public sealed class Phase7PlannerTests
plan.ChangedEquipmentTags.ShouldBeEmpty();
}
/// <summary>Verifies a VirtualTag-only delta (no equipment/driver/alarm/galaxy/tag change)
/// yields a NON-empty plan with the new VirtualTag in AddedEquipmentVirtualTags, so a deploy that
/// only adds VirtualTags is no longer a silent no-op at the IsEmpty gate.</summary>
[Fact]
public void Equipment_virtual_tag_only_change_yields_non_empty_plan_with_added_tag()
{
var prev = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var next = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "a + b", DependencyRefs: new[] { "a", "b" }),
},
};
var plan = Phase7Planner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
plan.AddedEquipmentVirtualTags.Single().VirtualTagId.ShouldBe("vt-1");
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>Verifies a disappeared VirtualTag routes to RemovedEquipmentVirtualTags.</summary>
[Fact]
public void Disappeared_virtual_tag_goes_to_RemovedEquipmentVirtualTags()
{
var prev = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "a + b", DependencyRefs: new[] { "a", "b" }),
},
};
var next = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>());
var plan = Phase7Planner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
plan.RemovedEquipmentVirtualTags.Single().VirtualTagId.ShouldBe("vt-1");
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>Verifies a VirtualTag with the same id but a different Expression routes to
/// ChangedEquipmentVirtualTags (the diff identity is VirtualTagId; any field difference,
/// including the evaluated Expression, moves it from stable to changed).</summary>
[Fact]
public void Same_id_with_different_expression_routes_to_ChangedEquipmentVirtualTags()
{
var prev = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "a + b", DependencyRefs: new[] { "a", "b" }),
},
};
var next = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "a - b", DependencyRefs: new[] { "a", "b" }),
},
};
var plan = Phase7Planner.Compute(prev, next);
plan.IsEmpty.ShouldBeFalse();
plan.ChangedEquipmentVirtualTags.Single().Previous.Expression.ShouldBe("a + b");
plan.ChangedEquipmentVirtualTags.Single().Current.Expression.ShouldBe("a - b");
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>Regression guard for structural equality on <see cref="EquipmentVirtualTagPlan.DependencyRefs"/>:
/// two snapshots containing the SAME VirtualTag built from SEPARATE list instances must diff to an empty plan
/// (IReadOnlyList equality is BY REFERENCE without the custom Equals override, so every VirtualTag with
/// dependencies would be wrongly flagged "Changed" on every parse, preventing IsEmpty short-circuits).</summary>
[Fact]
public void Identical_virtualtag_snapshots_diff_to_empty_plan()
{
// Two separate list instances with identical contents — proves structural (not reference) equality.
var refsA = new[] { "EQ1.Speed", "EQ1.Torque" };
var refsB = new[] { "EQ1.Speed", "EQ1.Torque" };
var prev = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "ctx.GetTag(\"EQ1.Speed\") / ctx.GetTag(\"EQ1.Torque\")", DependencyRefs: refsA),
},
};
var next = new Phase7CompositionResult(
Array.Empty<EquipmentNode>(), Array.Empty<DriverInstancePlan>(), Array.Empty<ScriptedAlarmPlan>())
{
EquipmentVirtualTags = new[]
{
new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float",
Expression: "ctx.GetTag(\"EQ1.Speed\") / ctx.GetTag(\"EQ1.Torque\")", DependencyRefs: refsB),
},
};
var plan = Phase7Planner.Compute(prev, next);
plan.IsEmpty.ShouldBeTrue();
plan.ChangedEquipmentVirtualTags.ShouldBeEmpty();
plan.AddedEquipmentVirtualTags.ShouldBeEmpty();
plan.RemovedEquipmentVirtualTags.ShouldBeEmpty();
}
/// <summary>Verifies that new equipment goes to the AddedEquipment list.</summary>
[Fact]
public void New_equipment_goes_to_AddedEquipment()
@@ -251,6 +251,50 @@ public sealed class DeploymentArtifactTests
c.GalaxyTags.ShouldContain(g => g.TagId == "tag-gx");
}
/// <summary>
/// Verifies ParseComposition surfaces Equipment-namespace VirtualTags (joined to their Script
/// by ScriptId for the expression source) as <c>EquipmentVirtualTags</c>, with the
/// <c>DependencyRefs</c> extracted from the script's <c>ctx.GetTag("…")</c> literals — the
/// artifact-decode mirror of <c>Phase7Composer.Compose</c>'s VirtualTag producer.
/// </summary>
[Fact]
public void ParseComposition_reads_EquipmentVirtualTags_from_virtualtags_and_scripts()
{
var blob = JsonSerializer.SerializeToUtf8Bytes(new
{
Scripts = new[]
{
new
{
ScriptId = "scr-1",
SourceCode = "return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;",
},
},
VirtualTags = new[]
{
new
{
VirtualTagId = "vt-1",
EquipmentId = "eq-1",
Name = "Doubled",
DataType = "Float",
ScriptId = "scr-1",
},
},
});
var c = DeploymentArtifact.ParseComposition(blob);
var vt = c.EquipmentVirtualTags.ShouldHaveSingleItem();
vt.VirtualTagId.ShouldBe("vt-1");
vt.EquipmentId.ShouldBe("eq-1");
vt.Name.ShouldBe("Doubled");
vt.DataType.ShouldBe("Float");
vt.FolderPath.ShouldBe("");
vt.Expression.ShouldBe("return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;");
vt.DependencyRefs.ShouldBe(new[] { "TestMachine_001.TestDouble" });
}
/// <summary>
/// Verifies ParseComposition sets the equipment folder DisplayName to the UNS <c>Name</c>
/// segment — the source the live rebuild actually uses — not the colloquial MachineCode, so
@@ -377,6 +421,47 @@ public sealed class DeploymentArtifactTests
comp.DriverInstancePlans.ShouldBeEmpty();
}
/// <summary>Verifies the cluster-scoped overload keeps only EquipmentVirtualTags whose EquipmentId
/// belongs to an in-cluster driver (mirroring how EquipmentTags + ScriptedAlarms are filtered).</summary>
[Fact]
public void ParseComposition_scoped_keeps_only_my_clusters_virtual_tags()
{
var blob = BlobOf(new
{
Clusters = new[] { new { ClusterId = "MAIN" }, new { ClusterId = "SITE-A" } },
Nodes = new[]
{
new { NodeId = "central-1:4053", ClusterId = "MAIN" },
new { NodeId = "site-a-1:4053", ClusterId = "SITE-A" },
},
DriverInstances = new[]
{
new { DriverInstanceId = "main-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "MAIN", NamespaceId = "main-ns" },
new { DriverInstanceId = "sa-modbus", DriverType = "Modbus", DriverConfig = "{}", ClusterId = "SITE-A", NamespaceId = "sa-ns" },
},
Equipment = new[]
{
new { EquipmentId = "eq-main", Name = "eqm", UnsLineId = "l1", DriverInstanceId = "main-modbus" },
new { EquipmentId = "eq-sa", Name = "eqs", UnsLineId = "l2", DriverInstanceId = "sa-modbus" },
},
Scripts = new[]
{
new { ScriptId = "scr", SourceCode = "return 1;" },
},
VirtualTags = new[]
{
new { VirtualTagId = "vt-main", EquipmentId = "eq-main", Name = "VM", DataType = "Float", ScriptId = "scr" },
new { VirtualTagId = "vt-sa", EquipmentId = "eq-sa", Name = "VS", DataType = "Float", ScriptId = "scr" },
},
});
var main = DeploymentArtifact.ParseComposition(blob, "central-1:4053");
main.EquipmentVirtualTags.Select(v => v.VirtualTagId).ShouldBe(new[] { "vt-main" });
var siteA = DeploymentArtifact.ParseComposition(blob, "site-a-1:4053");
siteA.EquipmentVirtualTags.Select(v => v.VirtualTagId).ShouldBe(new[] { "vt-sa" });
}
[Fact]
public void ParseComposition_single_cluster_node_id_overload_matches_legacy()
{
@@ -0,0 +1,141 @@
using System.Text.Json;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
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.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Verifies the live-deploy wiring of <see cref="VirtualTagHostActor"/>: the
/// <see cref="DriverHostActor"/> must forward the composition's
/// <c>EquipmentVirtualTags</c> to its spawned VirtualTag host via
/// <see cref="VirtualTagHostActor.ApplyVirtualTags"/> on BOTH the fresh-apply path and the
/// bootstrap-restore path (both route through <c>PushDesiredSubscriptions</c>). The host is
/// injected as a <see cref="Akka.TestKit.TestProbe"/> via the Props override seam so the
/// ApplyVirtualTags can be intercepted.
/// </summary>
public sealed class DriverHostActorVirtualTagTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-vt-test");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
/// <summary>Fresh apply: dispatching a deployment whose artifact carries one Equipment
/// VirtualTag forwards an <see cref="VirtualTagHostActor.ApplyVirtualTags"/> carrying that
/// plan to the injected VirtualTag host.</summary>
[Fact]
public void Apply_forwards_EquipmentVirtualTags_to_virtual_tag_host()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedDeploymentWithVirtualTag(db, RevA);
var coordinator = CreateTestProbe();
var vtHost = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: vtHost.Ref));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
var apply = vtHost.ExpectMsg<VirtualTagHostActor.ApplyVirtualTags>(TimeSpan.FromSeconds(5));
var plan = apply.Plans.ShouldHaveSingleItem();
plan.VirtualTagId.ShouldBe("vt-1");
plan.EquipmentId.ShouldBe("eq-1");
plan.Name.ShouldBe("Doubled");
}
/// <summary>Bootstrap-restore: a node that already has an <c>Applied</c> NodeDeploymentState
/// row for a VirtualTag-carrying deployment re-forwards the
/// <see cref="VirtualTagHostActor.ApplyVirtualTags"/> on PreStart (no dispatch needed), so a
/// restarted node restores its live VirtualTag children.</summary>
[Fact]
public void Restore_on_bootstrap_forwards_EquipmentVirtualTags_to_virtual_tag_host()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedDeploymentWithVirtualTag(db, RevA);
SeedAppliedNodeState(db, deploymentId);
var coordinator = CreateTestProbe();
var vtHost = CreateTestProbe();
// No DispatchDeployment — Bootstrap() should detect the Applied row and run RestoreApplied,
// which routes through PushDesiredSubscriptions and forwards ApplyVirtualTags.
Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: vtHost.Ref));
var apply = vtHost.ExpectMsg<VirtualTagHostActor.ApplyVirtualTags>(TimeSpan.FromSeconds(5));
apply.Plans.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-1");
}
private static DeploymentId SeedDeploymentWithVirtualTag(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
Scripts = new[]
{
new
{
ScriptId = "scr-1",
SourceCode = "return ctx.GetTag(\"TestMachine_001.TestDouble\").Value;",
},
},
VirtualTags = new[]
{
new
{
VirtualTagId = "vt-1",
EquipmentId = "eq-1",
Name = "Doubled",
DataType = "Float",
ScriptId = "scr-1",
},
},
});
var id = DeploymentId.NewId();
using var ctx = db.CreateDbContext();
ctx.Deployments.Add(new Deployment
{
DeploymentId = id.Value,
RevisionHash = rev.Value,
Status = DeploymentStatus.Sealed,
CreatedBy = "test",
SealedAtUtc = DateTime.UtcNow,
ArtifactBlob = artifact,
});
ctx.SaveChanges();
return id;
}
private static void SeedAppliedNodeState(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
{
using var ctx = db.CreateDbContext();
ctx.NodeDeploymentStates.Add(new NodeDeploymentState
{
NodeId = TestNode.Value,
DeploymentId = deploymentId.Value,
Status = NodeDeploymentStatus.Applied,
StartedAtUtc = DateTime.UtcNow,
AppliedAtUtc = DateTime.UtcNow,
});
ctx.SaveChanges();
}
}
@@ -0,0 +1,168 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.VirtualTags;
/// <summary>
/// Verifies <see cref="VirtualTagHostActor"/> reconciles a desired set of
/// <see cref="EquipmentVirtualTagPlan"/> into child <see cref="VirtualTagActor"/>s and bridges each
/// child's <see cref="VirtualTagActor.EvaluationResult"/> onto an
/// <see cref="OpcUaPublishActor.AttributeValueUpdate"/> carrying the folder-scoped NodeId computed by
/// the materialiser.
/// </summary>
public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
{
/// <summary>A plan with no FolderPath maps onto NodeId "EquipmentId/Name".</summary>
private static EquipmentVirtualTagPlan Plan(
string vtagId, string equipmentId, string name, string folderPath = "") =>
new(
VirtualTagId: vtagId,
EquipmentId: equipmentId,
FolderPath: folderPath,
Name: name,
DataType: "Double",
Expression: "ctx.GetTag(\"a\")",
DependencyRefs: new[] { "a" });
/// <summary>Spawn: an apply with one plan spins up exactly one live child VirtualTagActor.</summary>
[Fact]
public void ApplyVirtualTags_spawns_one_child_per_plan()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new StubEvaluator()));
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
// The child self-registers with the mux in PreStart, so a RegisterInterest landing on the
// mux probe is proof the host spawned a live child.
var reg = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
reg.TagRefs.ShouldContain("a");
}
/// <summary>KEY TEST: a child EvaluationResult is bridged to the publish actor with the
/// folder-scoped NodeId, Value, Good quality, and source timestamp preserved.</summary>
[Fact]
public void EvaluationResult_is_bridged_with_folder_scoped_NodeId()
{
var publish = CreateTestProbe();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
var ts = new DateTime(2026, 6, 7, 12, 0, 0, DateTimeKind.Utc);
host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 42.0, ts, CorrelationId.NewId()));
var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
update.NodeId.ShouldBe("eq-1/speed-rpm");
update.Value.ShouldBe(42.0);
update.Quality.ShouldBe(OpcUaQuality.Good);
update.TimestampUtc.ShouldBe(ts);
}
/// <summary>FolderPath is honoured in the published NodeId (EquipmentId/FolderPath/Name).</summary>
[Fact]
public void EvaluationResult_NodeId_includes_folder_path_when_set()
{
var publish = CreateTestProbe();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
new[] { Plan("vt-1", "eq-1", "speed-rpm", folderPath: "metrics") }));
host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 1.0, DateTime.UtcNow, CorrelationId.NewId()));
var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
update.NodeId.ShouldBe("eq-1/metrics/speed-rpm");
}
/// <summary>Stop-removed: a vtag dropped from the desired set is unmapped, so a later result for
/// it produces NO publish.</summary>
[Fact]
public void Removed_vtag_is_no_longer_bridged()
{
var publish = CreateTestProbe();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
// Re-apply without vt-1 — it should be stopped + unmapped.
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(Array.Empty<EquipmentVirtualTagPlan>()));
host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 99.0, DateTime.UtcNow, CorrelationId.NewId()));
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>Unmapped result dropped: a result for an unknown vtagId is silently ignored.</summary>
[Fact]
public void Result_for_unknown_vtag_is_dropped()
{
var publish = CreateTestProbe();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator()));
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(new[] { Plan("vt-1", "eq-1", "speed-rpm") }));
host.Tell(new VirtualTagActor.EvaluationResult("vt-unknown", 7.0, DateTime.UtcNow, CorrelationId.NewId()));
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>
/// After a child actor terminates unexpectedly, a subsequent ApplyVirtualTags (still containing
/// that vtag) must re-spawn it. Proof: two distinct RegisterInterest messages arrive at the mux
/// probe — one for the original child and one for the replacement.
/// </summary>
[Fact]
public void Child_is_respawned_after_unexpected_termination()
{
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux.Ref, new StubEvaluator()));
var plan = new[] { Plan("vt-1", "eq-1", "speed-rpm") };
// First apply — child self-registers; capture the child ref from the message sender.
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
mux.ExpectMsg<DependencyMuxActor.RegisterInterest>();
var firstChild = mux.LastSender;
// Watch the child from the test side so we can await its death deterministically before
// re-applying, avoiding any race between Terminated delivery to the host and the re-apply.
Watch(firstChild);
Sys.Stop(firstChild);
ExpectTerminated(firstChild);
// The dying child's PostStop sends UnregisterInterest to the mux — drain it so the mux probe
// mailbox is clean before we look for the new RegisterInterest.
mux.ExpectMsg<DependencyMuxActor.UnregisterInterest>(TimeSpan.FromSeconds(5));
// Re-apply with the same plan — host should see vt-1 absent from _children and spawn fresh.
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(plan));
var reg2 = mux.ExpectMsg<DependencyMuxActor.RegisterInterest>(TimeSpan.FromSeconds(5));
reg2.TagRefs.ShouldContain("a");
// The new child must be a different actor ref than the one we killed.
var secondChild = mux.LastSender;
secondChild.ShouldNotBe(firstChild);
}
/// <summary>Deterministic no-op evaluator: keeps spawned children inert so tests drive the host's
/// OnResult path directly via synthetic EvaluationResults.</summary>
private sealed class StubEvaluator : IVirtualTagEvaluator
{
/// <summary>Returns NoChange so the child never emits on its own.</summary>
/// <param name="id">The tag identifier.</param>
/// <param name="expr">The expression string.</param>
/// <param name="deps">The dependency values.</param>
/// <returns>A NoChange result.</returns>
public VirtualTagEvalResult Evaluate(string id, string expr, IReadOnlyDictionary<string, object?> deps)
=> VirtualTagEvalResult.NoChange;
}
}