4d3fc9c816
- R02:H1 ScriptedAlarmHostActor gains a factory Props (Func<(upstream, engine)>) so every incarnation builds and owns its own engine; a supervision restart no longer re-creates the actor around a disposed engine. The legacy instance-taking Props stays source-compatible and is now restart-safe in the weaker sense (PreRestart marks the stop as a restart, PostStop then detaches instead of disposing). DriverHostActor still spawns through the legacy overload (not owned by this lane — F3). - R02:H3 OnAlarmCommand runs every engine op under a 10 s token linked to the actor's lifetime (DefaultCommandTimeout / Props commandTimeout) and logs a Warning on timeout instead of suspending the mailbox on a stalled store. - R02:L3 DependencyMuxTagUpstreamSource.RetainOnly(loaded dep-ref union), called after every successful load. - R02:P6 VirtualTagActor's per-failure ScriptLogEntry is rate-limited per tag (ScriptLogRateLimiter, 20/min + one summary); the Bad publish and the metric are untouched. - R07:H6 DependencyMuxActor retains the last (value, status, ts) per ever-registered ref (through unregisters) and replays them in one batch to a RegisterInterest(ReplayRetained: true) — VirtualTagActor, the Calculation mux adapter and the alarm host opt in; the historization recorder does not (a replay there would record a stale sample per deploy). CalculationDriver records inputs regardless of _running so a replay landing before InitializeAsync still satisfies the change-gate. - R07:P4 CalculationEvaluator negative-caches compile failures by source hash (one Roslyn attempt + one Warning per source until redeploy); the driver's script-log entry fires on the Good→Bad transition or a change of reason, matching the once-per-transition Bad publish. - R07:H8 array results dedup structurally. - R07:L2 ShutdownAsync drops the compiled scripts (collectible ALCs). - R07:U5 DraftValidator rule CalculationNoEffectiveTrigger: a tag with no timer whose change trigger is off or whose script has no literal ctx.GetTag read can never evaluate. Refs #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
211 lines
8.6 KiB
C#
211 lines
8.6 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
|
|
|
|
/// <summary>
|
|
/// WP7 Calculation deploy gates in <see cref="DraftValidator"/>: <c>scriptId</c> existence and the
|
|
/// calc→calc dependency-cycle gate (self-cycle, 2-cycle, and the cross-driver-terminal negative case).
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class DraftValidatorCalculationTests
|
|
{
|
|
// A calc DriverInstance "calc" + one Device "engine" → calc tags get RawPath "calc/engine/<name>".
|
|
private static DriverInstance CalcDriver() => new()
|
|
{
|
|
DriverInstanceId = "di-calc", ClusterId = "c", Name = "calc",
|
|
DriverType = "Calculation", DriverConfig = "{}",
|
|
};
|
|
|
|
private static DriverInstance ModbusDriver() => new()
|
|
{
|
|
DriverInstanceId = "di-mb", ClusterId = "c", Name = "mb",
|
|
DriverType = "Modbus", DriverConfig = "{}",
|
|
};
|
|
|
|
private static Device Device(string id, string driverInstanceId, string name) => new()
|
|
{
|
|
DeviceId = id, DriverInstanceId = driverInstanceId, Name = name, DeviceConfig = "{}",
|
|
};
|
|
|
|
private static Tag CalcTagRow(string tagId, string deviceId, string name, string tagConfig) => new()
|
|
{
|
|
TagId = tagId, DeviceId = deviceId, Name = name, DataType = "Double",
|
|
AccessLevel = TagAccessLevel.Read, TagConfig = tagConfig,
|
|
};
|
|
|
|
private static Script ScriptRow(string id, string source) => new()
|
|
{
|
|
ScriptId = id, Name = id, SourceCode = source, SourceHash = "h-" + id,
|
|
};
|
|
|
|
private static string Cfg(string scriptId) => $"{{\"scriptId\":\"{scriptId}\",\"changeTriggered\":true}}";
|
|
|
|
[Fact]
|
|
public void Missing_scriptId_is_a_deploy_error()
|
|
{
|
|
var draft = new DraftSnapshot
|
|
{
|
|
GenerationId = 1, ClusterId = "c",
|
|
DriverInstances = [CalcDriver()],
|
|
Devices = [Device("dev-1", "di-calc", "engine")],
|
|
Tags = [CalcTagRow("t-1", "dev-1", "a", "{\"changeTriggered\":true}")],
|
|
};
|
|
|
|
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "CalculationScriptMissing" && e.Context == "t-1");
|
|
}
|
|
|
|
[Fact]
|
|
public void Unknown_scriptId_is_a_deploy_error()
|
|
{
|
|
var draft = new DraftSnapshot
|
|
{
|
|
GenerationId = 1, ClusterId = "c",
|
|
DriverInstances = [CalcDriver()],
|
|
Devices = [Device("dev-1", "di-calc", "engine")],
|
|
Tags = [CalcTagRow("t-1", "dev-1", "a", Cfg("s-nope"))],
|
|
Scripts = [],
|
|
};
|
|
|
|
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "CalculationScriptNotFound" && e.Context == "t-1");
|
|
}
|
|
|
|
[Fact]
|
|
public void Existing_scriptId_with_no_cycle_is_accepted()
|
|
{
|
|
var draft = new DraftSnapshot
|
|
{
|
|
GenerationId = 1, ClusterId = "c",
|
|
DriverInstances = [CalcDriver()],
|
|
Devices = [Device("dev-1", "di-calc", "engine")],
|
|
Tags = [CalcTagRow("t-1", "dev-1", "a", Cfg("s1"))],
|
|
// A read, so the change trigger is effective (a constant change-triggered script with no timer
|
|
// is now itself a deploy error — CalculationNoEffectiveTrigger).
|
|
Scripts = [ScriptRow("s1", "return (double)ctx.GetTag(\"mb/plc/reg\").Value + 42.0;")],
|
|
};
|
|
|
|
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code.StartsWith("Calculation"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Self_cycle_is_rejected()
|
|
{
|
|
// calc/engine/a reads itself.
|
|
var draft = new DraftSnapshot
|
|
{
|
|
GenerationId = 1, ClusterId = "c",
|
|
DriverInstances = [CalcDriver()],
|
|
Devices = [Device("dev-1", "di-calc", "engine")],
|
|
Tags = [CalcTagRow("t-a", "dev-1", "a", Cfg("s-a"))],
|
|
Scripts = [ScriptRow("s-a", "return (double)ctx.GetTag(\"calc/engine/a\").Value;")],
|
|
};
|
|
|
|
DraftValidator.Validate(draft).ShouldContain(e =>
|
|
e.Code == "CalculationDependencyCycle" && e.Message.Contains("calc/engine/a"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Two_node_cycle_names_both_members()
|
|
{
|
|
// a → b and b → a.
|
|
var draft = new DraftSnapshot
|
|
{
|
|
GenerationId = 1, ClusterId = "c",
|
|
DriverInstances = [CalcDriver()],
|
|
Devices = [Device("dev-1", "di-calc", "engine")],
|
|
Tags =
|
|
[
|
|
CalcTagRow("t-a", "dev-1", "a", Cfg("s-a")),
|
|
CalcTagRow("t-b", "dev-1", "b", Cfg("s-b")),
|
|
],
|
|
Scripts =
|
|
[
|
|
ScriptRow("s-a", "return (double)ctx.GetTag(\"calc/engine/b\").Value;"),
|
|
ScriptRow("s-b", "return (double)ctx.GetTag(\"calc/engine/a\").Value;"),
|
|
],
|
|
};
|
|
|
|
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "CalculationDependencyCycle");
|
|
var msg = DraftValidator.Validate(draft).First(e => e.Code == "CalculationDependencyCycle").Message;
|
|
msg.ShouldContain("calc/engine/a");
|
|
msg.ShouldContain("calc/engine/b");
|
|
}
|
|
|
|
// ── effective-trigger gate (R07:U5) ─────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Change_triggered_tag_with_no_literal_read_and_no_timer_is_rejected()
|
|
{
|
|
var draft = new DraftSnapshot
|
|
{
|
|
GenerationId = 1, ClusterId = "c",
|
|
DriverInstances = [CalcDriver()],
|
|
Devices = [Device("dev-1", "di-calc", "engine")],
|
|
Tags = [CalcTagRow("t-1", "dev-1", "a", Cfg("s1"))],
|
|
Scripts = [ScriptRow("s1", "return ctx.Now.Hour;")],
|
|
};
|
|
|
|
DraftValidator.Validate(draft).ShouldContain(e =>
|
|
e.Code == "CalculationNoEffectiveTrigger" && e.Context == "t-1" && e.Message.Contains("no literal ctx.GetTag"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Tag_with_change_trigger_off_and_no_timer_is_rejected()
|
|
{
|
|
var draft = new DraftSnapshot
|
|
{
|
|
GenerationId = 1, ClusterId = "c",
|
|
DriverInstances = [CalcDriver()],
|
|
Devices = [Device("dev-1", "di-calc", "engine")],
|
|
Tags = [CalcTagRow("t-1", "dev-1", "a", "{\"scriptId\":\"s1\",\"changeTriggered\":false}")],
|
|
Scripts = [ScriptRow("s1", "return (double)ctx.GetTag(\"mb/plc/reg\").Value;")],
|
|
};
|
|
|
|
DraftValidator.Validate(draft).ShouldContain(e => e.Code == "CalculationNoEffectiveTrigger" && e.Context == "t-1");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("{\"scriptId\":\"s1\",\"changeTriggered\":true,\"timerIntervalMs\":1000}", "return ctx.Now.Hour;")] // timer rescues a read-less script
|
|
[InlineData("{\"scriptId\":\"s1\",\"changeTriggered\":false,\"timerIntervalMs\":500}", "return 1.0;")] // timer-only tag
|
|
[InlineData("{\"scriptId\":\"s1\",\"changeTriggered\":true}", "return (double)ctx.GetTag(\"mb/plc/reg\").Value;")] // change trigger with a read
|
|
[InlineData("{\"scriptId\":\"s1\"}", "return (double)ctx.GetTag(\"mb/plc/reg\").Value;")] // changeTriggered defaults to true
|
|
public void A_tag_with_an_effective_trigger_is_accepted(string cfg, string source)
|
|
{
|
|
var draft = new DraftSnapshot
|
|
{
|
|
GenerationId = 1, ClusterId = "c",
|
|
DriverInstances = [CalcDriver()],
|
|
Devices = [Device("dev-1", "di-calc", "engine")],
|
|
Tags = [CalcTagRow("t-1", "dev-1", "a", cfg)],
|
|
Scripts = [ScriptRow("s1", source)],
|
|
};
|
|
|
|
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "CalculationNoEffectiveTrigger");
|
|
}
|
|
|
|
[Fact]
|
|
public void Cross_driver_ref_is_terminal_not_an_edge()
|
|
{
|
|
// A calc tag reads a Modbus tag's RawPath ("mb/plc/reg"); that is a terminal node, never an edge,
|
|
// so it can never form a cycle — even if the Modbus tag existed.
|
|
var draft = new DraftSnapshot
|
|
{
|
|
GenerationId = 1, ClusterId = "c",
|
|
DriverInstances = [CalcDriver(), ModbusDriver()],
|
|
Devices = [Device("dev-1", "di-calc", "engine"), Device("dev-2", "di-mb", "plc")],
|
|
Tags =
|
|
[
|
|
CalcTagRow("t-a", "dev-1", "a", Cfg("s-a")),
|
|
new Tag { TagId = "t-reg", DeviceId = "dev-2", Name = "reg", DataType = "Int32",
|
|
AccessLevel = TagAccessLevel.Read, TagConfig = "{}" },
|
|
],
|
|
Scripts = [ScriptRow("s-a", "return (double)ctx.GetTag(\"mb/plc/reg\").Value;")],
|
|
};
|
|
|
|
DraftValidator.Validate(draft).ShouldNotContain(e => e.Code == "CalculationDependencyCycle");
|
|
}
|
|
}
|