fix(virtualtags): a disabled VirtualTag is no longer deployed or evaluated (lmxopcua#787)
v2-ci / unit-tests (push) Waiting to run

VirtualTag.Enabled was authorable but ignored by both runtime-plan seams.
AddressSpaceComposer.Compose and DeploymentArtifact.BuildEquipmentVirtualTagPlans
now drop disabled rows (byte-parity), so no node is materialised and no
VirtualTagActor is spawned - the same outcome as a disabled scripted alarm.
DraftValidator exempts a disabled VT from VirtualTagNoEffectiveTrigger.
Calculation tags have no Enabled flag, so no equivalent gap. docs/VirtualTags.md updated.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-10 03:15:22 -05:00
parent 1ead8f458a
commit 963b0d66fc
7 changed files with 86 additions and 2 deletions
@@ -163,6 +163,36 @@ public sealed class DeploymentArtifactVirtualTagHistorizeParityTests
d.ChangeTriggered.ShouldBeTrue();
}
/// <summary>lmxopcua#787 — a disabled VirtualTag is dropped by both the artifact decode and the composer
/// (byte-parity), so it is neither deployed nor evaluated; absent/non-bool Enabled keeps the default true.</summary>
[Fact]
public void ParseComposition_drops_disabled_virtual_tag_in_parity_with_composer()
{
var area = new UnsArea { UnsAreaId = "area-1", ClusterId = "c1", Name = "filling" };
var line = new UnsLine { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "line-1" };
var equip = new Equipment { EquipmentId = "eq-1", UnsLineId = "line-1", Name = "M1", MachineCode = "M1" };
var script = new Script { ScriptId = "s-1", Name = "p", SourceCode = "return 1;", SourceHash = "h" };
var on = new VirtualTag { VirtualTagId = "vt-on", EquipmentId = "eq-1", Name = "On", DataType = "Int32", ScriptId = "s-1", TimerIntervalMs = 1000 };
var off = new VirtualTag { VirtualTagId = "vt-off", EquipmentId = "eq-1", Name = "Off", DataType = "Int32", ScriptId = "s-1", TimerIntervalMs = 1000, Enabled = false };
var composed = AddressSpaceComposer.Compose(
new[] { area }, new[] { line }, new[] { equip },
Array.Empty<DriverInstance>(), Array.Empty<ScriptedAlarm>(),
virtualTags: new[] { on, off }, scripts: new[] { script });
var blob = BlobOf(new
{
Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } },
Scripts = new[] { new { ScriptId = "s-1", SourceCode = "return 1;" } },
VirtualTags = new[] { ToSnapshot(on), ToSnapshot(off) },
});
var decoded = DeploymentArtifact.ParseComposition(blob);
composed.EquipmentVirtualTags.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-on");
decoded.EquipmentVirtualTags.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-on");
decoded.EquipmentVirtualTags.SequenceEqual(composed.EquipmentVirtualTags).ShouldBeTrue();
}
// The Pascal-case snapshot a VirtualTag EF entity serialises to (matches ConfigComposer's
// DefaultIgnoreCondition.Never whole-entity serialisation — includes Historize).
private static object ToSnapshot(VirtualTag v) => new
@@ -1,3 +1,4 @@
using System.Linq;
using System.Text.Json;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
@@ -83,8 +84,31 @@ public sealed class DriverHostActorVirtualTagTests : RuntimeActorTestBase
apply.Plans.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-1");
}
/// <summary>lmxopcua#787: a deployment carrying a disabled VirtualTag next to an enabled one hands the
/// VirtualTag host ONLY the enabled plan — the disabled tag is never spawned, so never evaluated.</summary>
[Fact]
public void Apply_does_not_forward_disabled_VirtualTag_to_virtual_tag_host()
{
var db = NewInMemoryDbFactory();
var deploymentId = SeedDeploymentWithVirtualTag(db, RevA, withDisabledSibling: true);
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));
apply.Plans.ShouldHaveSingleItem().VirtualTagId.ShouldBe("vt-1");
}
private static DeploymentId SeedDeploymentWithVirtualTag(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev)
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev, bool withDisabledSibling = false)
{
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
@@ -105,8 +129,18 @@ public sealed class DriverHostActorVirtualTagTests : RuntimeActorTestBase
Name = "Doubled",
DataType = "Float",
ScriptId = "scr-1",
Enabled = true,
},
},
new
{
VirtualTagId = "vt-off",
EquipmentId = "eq-1",
Name = "Off",
DataType = "Float",
ScriptId = "scr-1",
Enabled = false,
},
}.Take(withDisabledSibling ? 2 : 1).ToArray(),
});
var id = DeploymentId.NewId();