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
@@ -0,0 +1,57 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>lmxopcua#783 — <c>VirtualTagNoEffectiveTrigger</c>, the VirtualTag mirror of
/// <c>CalculationNoEffectiveTrigger</c>.</summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorVirtualTagTriggerTests
{
private static Script ScriptRow(string id, string source) => new()
{
ScriptId = id, Name = id, SourceCode = source, SourceHash = "h-" + id,
};
private static VirtualTag Vt(bool changeTriggered, int? timer, string scriptId = "s1") => new()
{
VirtualTagId = "vt-1", EquipmentId = "eq-1", Name = "V", DataType = "Double", ScriptId = scriptId,
ChangeTriggered = changeTriggered, TimerIntervalMs = timer,
};
private static IEnumerable<ValidationError> Run(VirtualTag vt, string source) =>
DraftValidator.Validate(new DraftSnapshot
{
GenerationId = 1, ClusterId = "c",
VirtualTags = [vt],
Scripts = [ScriptRow("s1", source)],
}).Where(e => e.Code == "VirtualTagNoEffectiveTrigger");
[Fact]
public void Change_triggered_with_no_read_and_no_timer_is_rejected() =>
Run(Vt(true, null), "return 1;").ShouldHaveSingleItem().Context.ShouldBe("vt-1");
[Fact]
public void Not_change_triggered_and_no_timer_is_rejected() =>
Run(Vt(false, null), "return (double)ctx.GetTag(\"a/b\").Value;").ShouldHaveSingleItem();
[Fact]
public void A_timer_alone_is_an_effective_trigger() =>
Run(Vt(true, 1000), "return 1;").ShouldBeEmpty();
[Fact]
public void A_literal_read_with_change_trigger_is_an_effective_trigger() =>
Run(Vt(true, null), "return (double)ctx.GetTag(\"a/b\").Value;").ShouldBeEmpty();
[Fact]
public void An_unresolved_script_reports_no_trigger_error()
{
var errors = DraftValidator.Validate(new DraftSnapshot
{
GenerationId = 1, ClusterId = "c", VirtualTags = [Vt(true, null, "s-missing")],
});
errors.ShouldNotContain(e => e.Code == "VirtualTagNoEffectiveTrigger");
}
}