Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorCalculationTests.cs
T
Joseph Doherty ef2eebe6b6 fix(scripting): review minors: configured limit in summaries, dispose gate/CTS, validator, docs (fix round 1)
- ScriptLogRateLimiter.LimitPerWindow. The ScriptLogTopicSink and
  VirtualTagActor summaries now print the configured limit, not
  DefaultLimitPerWindow. Tests assert "limit N/window" for a non-default
  N.
- CalculationDriver.Dispose cancels, then takes _evalGate (bounded 2 s)
  and disposes the gate and _evalCts. The pump and timer ticks enter the
  gate through TryEnterEvalGateAsync, which stops cleanly once the
  driver is disposed. Test: Dispose with a 6 s runaway run in flight
  returns promptly, disposes both, and a late trigger is harmless.
- ScriptedAlarmEngine captures _shutdownToken once and disposes
  _shutdownCts in Dispose when every drained task has completed. A task
  stuck past the grace leaves the CTS to the GC. Lifecycle tests assert
  the disposal.
- DraftValidator: CalculationNoEffectiveTrigger is skipped for a
  change-triggered tag whose scriptId does not resolve. That case is
  already CalculationScriptMissing or CalculationScriptNotFound, and
  there are no reads to count. Both existing tests now assert the
  absence (red before the change).
- docs/VirtualTags.md: System.Threading is denied as a whole namespace,
  with an allow-list; the type list is now accurate (Unsafe,
  RuntimeHelpers, and the ForbiddenMembers on System.Type); the resource
  limits section covers the run guard and the allocation budget.
  docs/ScriptEditor.md: accurate deny-list and a new "Runtime safety"
  section covering timeout, refusal, quarantine after 3 timeouts,
  allocation budget, lifting, and rate-limit summaries that appear only
  on the source's next entry after a burst then quiet.

Refs #734

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 13:41:15 -04:00

217 lines
9.0 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}")],
};
var errors = DraftValidator.Validate(draft);
errors.ShouldContain(e => e.Code == "CalculationScriptMissing" && e.Context == "t-1");
errors.ShouldNotContain(e => e.Code == "CalculationNoEffectiveTrigger",
"an unresolved script has no reads to count — the missing script is the one error to report");
}
[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 = [],
};
var errors = DraftValidator.Validate(draft);
errors.ShouldContain(e => e.Code == "CalculationScriptNotFound" && e.Context == "t-1");
errors.ShouldNotContain(e => e.Code == "CalculationNoEffectiveTrigger",
"an unresolved script has no reads to count — the missing script is the one error to report");
}
[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");
}
}