fix(virtualtags): consume VirtualTag.TimerIntervalMs; add VirtualTagNoEffectiveTrigger gate (lmxopcua#783)
v2-ci / unit-tests (push) Waiting to run

Choice: implement the timer (option a). The field was designed and promised as a
working trigger: the entity doc says ChangeTriggered and TimerIntervalMs together
realize the trigger model, the DB check constraint requires ChangeTriggered OR a
timer >= 50, the AdminUI virtual-tag modal exposes "TimerIntervalMs (optional)" and
UnsTreeService rejects a tag with neither, and the 2026-06-08 design specified
"ChangeTriggered OR TimerIntervalMs>=50". Only the runtime never honoured it (the
standalone TimerTriggerScheduler was deleted in #603), so a timer-only tag sat at
BadWaitingForInitialData forever.

- EquipmentVirtualTagPlan carries ChangeTriggered/TimerIntervalMs (in equality, so
  an edit respawns the child); both the composer and the artifact decode fill them.
- VirtualTagActor schedules a repeating tick (PreStart, cancelled in PostStop,
  clamped to the 50 ms floor). ChangeTriggered=false makes the timer the only
  trigger. A tick during an in-flight async run (#779) is dropped, not stashed, so
  slow scripts never build a backlog of runs.
- DraftValidator VirtualTagNoEffectiveTrigger (hard error, same severity as
  CalculationNoEffectiveTrigger): no timer and no change trigger with a literal
  ctx.GetTag read.

Tests: VirtualTagActorTimerTests, DraftValidatorVirtualTagTriggerTests, artifact
decode test. Mutation-checked: no scheduling, stashing ticks, no cancel in
PostStop, and removing the validator call each fail a test.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 21:27:59 -05:00
parent 7891b3cb54
commit d9b95dcf56
9 changed files with 283 additions and 10 deletions
@@ -136,6 +136,33 @@ public sealed class DeploymentArtifactVirtualTagHistorizeParityTests
decoded.EquipmentVirtualTags.ShouldHaveSingleItem().Historize.ShouldBeFalse();
}
/// <summary>lmxopcua#783 — the triggers survive the artifact decode (they used to be dropped, so the runtime
/// never knew a timer existed); absent fields keep the entity defaults.</summary>
[Fact]
public void ParseComposition_carries_timer_and_change_trigger()
{
var blob = BlobOf(new
{
Namespaces = new[] { new { NamespaceId = "ns-eq", Kind = 0 } },
Scripts = new[] { new { ScriptId = "s-1", SourceCode = "return 1;" } },
VirtualTags = new object[]
{
new { VirtualTagId = "vt-timer", EquipmentId = "eq-1", Name = "T", DataType = "Int32", ScriptId = "s-1",
ChangeTriggered = false, TimerIntervalMs = 1000 },
new { VirtualTagId = "vt-default", EquipmentId = "eq-1", Name = "D", DataType = "Int32", ScriptId = "s-1" },
},
});
var decoded = DeploymentArtifact.ParseComposition(blob);
var t = decoded.EquipmentVirtualTags.Single(p => p.VirtualTagId == "vt-timer");
t.TimerIntervalMs.ShouldBe(1000);
t.ChangeTriggered.ShouldBeFalse();
var d = decoded.EquipmentVirtualTags.Single(p => p.VirtualTagId == "vt-default");
d.TimerIntervalMs.ShouldBeNull();
d.ChangeTriggered.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