feat(driver-calc): B2-WP7 Calculation driver — IDependencyConsumer + triggers + deploy cycle gate

Adds the Calculation pseudo-driver (tags computed by C# scripts over other tags' live
values) plus the host seam that feeds it:

- IDependencyConsumer capability interface (Core.Abstractions) + DriverTypeNames.Calculation.
- CalculationDriver : IDriver, ISubscribable, IReadable, IDependencyConsumer — change-gate +
  dedupe (VirtualTagActor parity), timer-group scheduler, Bad-transition + script-log emission,
  Good recovery; reuses the T0-4 CalculationEvaluator.
- DriverHostActor spawns a DependencyConsumerMuxAdapter per dependency-consuming driver,
  registering its refs on the per-node DependencyMuxActor and forwarding DependencyValueChanged
  → OnDependencyValue; re-registers on every apply, torn down with the driver.
- Factory + probe registered in DriverFactoryBootstrap (keeps the T0-3 guard green); guard test
  csproj references the driver so the bin-scan discovers the factory.
- DeploymentArtifact injects the resolved scriptSource (scriptId → SourceCode) into a calc tag's
  delivered TagConfig so the host-blind driver can compile it.
- DraftValidator deploy gates: CalculationScriptMissing / CalculationScriptNotFound (scriptId
  existence) + CalculationDependencyCycle (DependencyGraph.DetectCycles over calc→calc edges).
- Tests: driver units (dep-ref extraction, change-gate, dedupe, Bad+script-log+recovery, timer,
  read), tag-config parsing, DraftValidator gates (self/2-cycle/cross-driver-terminal), and a
  Runtime integration test proving values FLOW mux → adapter → driver → calc-of-calc end-to-end.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 04:29:37 -04:00
parent ae9f906f52
commit 53d222e0f7
24 changed files with 1558 additions and 3 deletions
@@ -0,0 +1,187 @@
using System.Collections.Concurrent;
using Serilog;
using Serilog.Events;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
/// <summary>
/// WP7 unit tests for <see cref="CalculationDriver"/>: dependency-ref extraction from the authored
/// tags, the change-gate (no publish until every declared dep has arrived), value dedup, the
/// Good→Bad transition + <c>ScriptLogEntry</c> emission + Good recovery, and timer-trigger recompute.
/// </summary>
public sealed class CalculationDriverTests
{
private const uint StatusGood = 0u;
private const uint StatusBadInternalError = 0x80020000u;
private const uint StatusBadWaitingForInitialData = 0x80320000u;
private static RawTagEntry CalcTag(string rawPath, string source, bool changeTriggered = true, int? timerMs = null)
{
var timer = timerMs is { } ms ? $",\"timerIntervalMs\":{ms}" : "";
var json = $"{{\"scriptId\":\"s-{rawPath}\",\"changeTriggered\":{(changeTriggered ? "true" : "false")}{timer}," +
$"\"scriptSource\":{System.Text.Json.JsonSerializer.Serialize(source)}}}";
return new RawTagEntry(rawPath, json, WriteIdempotent: false);
}
private static (CalculationDriver Driver, ConcurrentQueue<DataChangeEventArgs> Published, List<ScriptLogEntry> Logs)
Build(params RawTagEntry[] tags)
{
var logs = new List<ScriptLogEntry>();
var sink = new ScriptLogTopicSink(new CapturingPublisher(logs), LogEventLevel.Information);
var scriptRoot = new ScriptRootLogger(new LoggerConfiguration().WriteTo.Sink(sink).CreateLogger());
var driver = new CalculationDriver(new CalculationDriverOptions { RawTags = tags }, "calc-drv", scriptRoot);
var published = new ConcurrentQueue<DataChangeEventArgs>();
driver.OnDataChange += (_, e) => published.Enqueue(e);
return (driver, published, logs);
}
// ── dependency-ref extraction ─────────────────────────────────────────────────────────────
[Fact]
public void DependencyRefs_are_the_union_of_authored_tag_scripts()
{
var (driver, _, _) = Build(
CalcTag("calc/e/x", "return (double)ctx.GetTag(\"src/a\").Value + (double)ctx.GetTag(\"src/b\").Value;"),
CalcTag("calc/e/y", "return (double)ctx.GetTag(\"src/b\").Value + (double)ctx.GetTag(\"src/c\").Value;"));
driver.DependencyRefs.OrderBy(r => r).ShouldBe(new[] { "src/a", "src/b", "src/c" });
}
// ── change-gate ───────────────────────────────────────────────────────────────────────────
[Fact]
public async Task No_publish_until_every_declared_dep_has_arrived()
{
var (driver, published, _) = Build(
CalcTag("calc/e/sum", "return (double)ctx.GetTag(\"src/a\").Value + (double)ctx.GetTag(\"src/b\").Value;"));
await driver.InitializeAsync("{}", default);
// Only one of two deps arrived → the change-gate holds.
driver.OnDependencyValue("src/a", 10.0, StatusGood, DateTime.UtcNow);
published.ShouldBeEmpty();
// Second dep arrives → now it computes + publishes 30.
driver.OnDependencyValue("src/b", 20.0, StatusGood, DateTime.UtcNow);
published.Count.ShouldBe(1);
published.TryDequeue(out var e).ShouldBeTrue();
e!.FullReference.ShouldBe("calc/e/sum");
e.Snapshot.Value.ShouldBe(30.0);
e.Snapshot.StatusCode.ShouldBe(StatusGood);
}
// ── dedup + recompute ─────────────────────────────────────────────────────────────────────
[Fact]
public async Task Equal_result_is_deduped_and_a_changed_result_republishes()
{
var (driver, published, _) = Build(
CalcTag("calc/e/dbl", "return (double)ctx.GetTag(\"src/a\").Value * 2.0;"));
await driver.InitializeAsync("{}", default);
driver.OnDependencyValue("src/a", 5.0, StatusGood, DateTime.UtcNow); // → 10 (publish)
driver.OnDependencyValue("src/a", 5.0, StatusGood, DateTime.UtcNow); // → 10 (dedup, no publish)
driver.OnDependencyValue("src/a", 7.0, StatusGood, DateTime.UtcNow); // → 14 (publish)
published.Select(p => p.Snapshot.Value).ShouldBe(new object?[] { 10.0, 14.0 });
}
// ── Bad transition + script-log + recovery ────────────────────────────────────────────────
[Fact]
public async Task Failure_publishes_Bad_once_per_transition_emits_script_log_and_recovers()
{
var (driver, published, logs) = Build(
CalcTag("calc/e/div", "return 100 / (int)ctx.GetTag(\"src/a\").Value;"));
await driver.InitializeAsync("{}", default);
// a = 0 → DivideByZero → Failure → Bad (carrying last-known = null) + one script-log.
driver.OnDependencyValue("src/a", 0, StatusGood, DateTime.UtcNow);
published.Count.ShouldBe(1);
published.TryDequeue(out var bad).ShouldBeTrue();
bad!.Snapshot.StatusCode.ShouldBe(StatusBadInternalError);
logs.Count.ShouldBe(1);
logs[0].Level.ShouldBe("Warning");
logs[0].ScriptId.ShouldBe("s-calc/e/div");
logs[0].VirtualTagId.ShouldBe("calc/e/div");
// a = 0 again → still failing, but NOT a fresh transition → no second Bad publish (script-log still emits).
driver.OnDependencyValue("src/a", 0, StatusGood, DateTime.UtcNow);
published.ShouldBeEmpty();
logs.Count.ShouldBe(2);
// a = 5 → 20 → Good recovery is force-published.
driver.OnDependencyValue("src/a", 5, StatusGood, DateTime.UtcNow);
published.Count.ShouldBe(1);
published.TryDequeue(out var good).ShouldBeTrue();
good!.Snapshot.Value.ShouldBe(20);
good.Snapshot.StatusCode.ShouldBe(StatusGood);
}
// ── timer trigger ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task Timer_only_tag_computes_on_its_interval()
{
// No dependencies (constant script) so only the timer can drive it.
var (driver, published, _) = Build(
CalcTag("calc/e/const", "return 42.0;", changeTriggered: false, timerMs: 50));
await driver.InitializeAsync("{}", default);
var fired = await WaitUntil(() => !published.IsEmpty, TimeSpan.FromSeconds(3));
fired.ShouldBeTrue("the timer trigger should have computed the tag within 3s");
published.TryDequeue(out var e).ShouldBeTrue();
e!.FullReference.ShouldBe("calc/e/const");
e.Snapshot.Value.ShouldBe(42.0);
await driver.ShutdownAsync(default);
}
// ── IReadable ─────────────────────────────────────────────────────────────────────────────
[Fact]
public async Task Read_returns_BadWaitingForInitialData_before_first_eval_then_the_computed_value()
{
var (driver, _, _) = Build(CalcTag("calc/e/x", "return (double)ctx.GetTag(\"src/a\").Value * 2.0;"));
await driver.InitializeAsync("{}", default);
var before = await driver.ReadAsync(new[] { "calc/e/x" }, default);
before[0].StatusCode.ShouldBe(StatusBadWaitingForInitialData);
driver.OnDependencyValue("src/a", 3.0, StatusGood, DateTime.UtcNow);
var after = await driver.ReadAsync(new[] { "calc/e/x" }, default);
after[0].StatusCode.ShouldBe(StatusGood);
after[0].Value.ShouldBe(6.0);
}
[Fact]
public void Health_is_always_Connected()
{
var (driver, _, _) = Build(CalcTag("calc/e/x", "return 1.0;"));
driver.GetHealth().State.ShouldBe(DriverState.Healthy);
}
private static async Task<bool> WaitUntil(Func<bool> predicate, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (predicate()) return true;
await Task.Delay(20);
}
return predicate();
}
private sealed class CapturingPublisher(List<ScriptLogEntry> sink) : IScriptLogPublisher
{
private readonly object _lock = new();
public void Publish(ScriptLogEntry entry)
{
lock (_lock) sink.Add(entry);
}
}
}
@@ -0,0 +1,55 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests;
/// <summary>Parsing contract for <see cref="CalculationTagDefinition.TryFromTagConfig"/> — the calc tag's
/// <c>TagConfig</c> blob (<c>scriptId</c> + resolved <c>scriptSource</c> + trigger fields) → typed def with
/// its static dependency refs.</summary>
public sealed class CalculationTagDefinitionTests
{
[Fact]
public void Missing_scriptId_is_not_a_calc_tag()
{
CalculationTagDefinition.TryFromTagConfig("{\"changeTriggered\":true}", "calc/e/x", out _).ShouldBeFalse();
}
[Fact]
public void Malformed_json_is_a_miss()
{
CalculationTagDefinition.TryFromTagConfig("not json", "calc/e/x", out _).ShouldBeFalse();
}
[Fact]
public void Parses_scriptId_source_triggers_and_extracts_dependency_refs()
{
var json = "{\"scriptId\":\"s1\",\"changeTriggered\":true,\"timerIntervalMs\":5000," +
"\"scriptSource\":\"return (double)ctx.GetTag(\\\"src/a\\\").Value + (double)ctx.GetTag(\\\"src/b\\\").Value;\"}";
CalculationTagDefinition.TryFromTagConfig(json, "calc/e/x", out var def).ShouldBeTrue();
def.RawPath.ShouldBe("calc/e/x");
def.ScriptId.ShouldBe("s1");
def.ChangeTriggered.ShouldBeTrue();
def.TimerInterval.ShouldBe(TimeSpan.FromMilliseconds(5000));
def.DependencyRefs.ShouldBe(new[] { "src/a", "src/b" });
}
[Fact]
public void ChangeTriggered_defaults_true_and_timer_is_optional()
{
CalculationTagDefinition.TryFromTagConfig(
"{\"scriptId\":\"s1\",\"scriptSource\":\"return 1.0;\"}", "calc/e/x", out var def).ShouldBeTrue();
def.ChangeTriggered.ShouldBeTrue();
def.TimerInterval.ShouldBeNull();
def.DependencyRefs.ShouldBeEmpty();
}
[Fact]
public void Present_scriptId_with_empty_source_still_maps()
{
CalculationTagDefinition.TryFromTagConfig(
"{\"scriptId\":\"s1\",\"changeTriggered\":false}", "calc/e/x", out var def).ShouldBeTrue();
def.ScriptSource.ShouldBe("");
def.ChangeTriggered.ShouldBeFalse();
}
}