feat(vtags): forward historized vtag results to IHistoryWriter (H5c, stillpending §1)

This commit is contained in:
Joseph Doherty
2026-06-15 10:26:25 -04:00
parent 83d3b9f7be
commit 0c6d4c5491
3 changed files with 106 additions and 3 deletions
@@ -4,6 +4,8 @@ using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
@@ -153,6 +155,71 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
secondChild.ShouldNotBe(firstChild);
}
/// <summary>A plan with an explicit Historize flag, so H5c can assert the host historizes a
/// result iff the plan opted in. Mirrors <see cref="Plan"/> but threads <paramref name="historize"/>.</summary>
private static EquipmentVirtualTagPlan PlanH(
string vtagId, string equipmentId, string name, bool historize, string folderPath = "") =>
new(
VirtualTagId: vtagId,
EquipmentId: equipmentId,
FolderPath: folderPath,
Name: name,
DataType: "Double",
Expression: "ctx.GetTag(\"a\")",
DependencyRefs: new[] { "a" },
Historize: historize);
/// <summary>H5c: a result for a vtag whose plan has Historize=true is recorded with the
/// IHistoryWriter under the SAME folder-scoped NodeId the value was published to, carrying the
/// result value and OPC UA Good (0u) status — in addition to the normal publish.</summary>
[Fact]
public void Historized_vtag_result_is_recorded_with_the_history_writer()
{
var publish = CreateTestProbe();
var writer = new CapturingHistoryWriter();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator(), writer));
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
new[] { PlanH("vt-1", "eq-1", "speed-rpm", historize: true) }));
var ts = new DateTime(2026, 6, 7, 12, 0, 0, DateTimeKind.Utc);
host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 42.0, ts, CorrelationId.NewId()));
// The publish still happens — historization is additive, not a replacement.
var update = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
update.NodeId.ShouldBe("eq-1/speed-rpm");
// Wait for the history record to land (delivered on the same actor turn as the publish).
AwaitAssert(() => writer.Calls.Count.ShouldBe(1));
var (path, value) = writer.Calls[0];
path.ShouldBe("eq-1/speed-rpm");
value.Value.ShouldBe(42.0);
value.StatusCode.ShouldBe(0u); // OPC UA Good
value.SourceTimestampUtc.ShouldBe(ts);
value.ServerTimestampUtc.ShouldBe(ts);
}
/// <summary>H5c: a result for a vtag whose plan has Historize=false is NOT recorded — the writer
/// is never called — though the value is still published.</summary>
[Fact]
public void Non_historized_vtag_result_is_not_recorded()
{
var publish = CreateTestProbe();
var writer = new CapturingHistoryWriter();
var host = Sys.ActorOf(VirtualTagHostActor.Props(publish.Ref, mux: null, new StubEvaluator(), writer));
host.Tell(new VirtualTagHostActor.ApplyVirtualTags(
new[] { PlanH("vt-1", "eq-1", "speed-rpm", historize: false) }));
host.Tell(new VirtualTagActor.EvaluationResult("vt-1", 42.0, DateTime.UtcNow, CorrelationId.NewId()));
// The value is still published…
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>();
// …but the historian was never touched.
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
writer.Calls.ShouldBeEmpty();
}
/// <summary>A plan with an explicit Expression + DependencyRefs (the H1b in-place-change case).</summary>
private static EquipmentVirtualTagPlan PlanWithRefs(
string vtagId, string equipmentId, string name, string expression, params string[] refs) =>
@@ -277,6 +344,18 @@ public sealed class VirtualTagHostActorTests : RuntimeActorTestBase
mux.LastSender.ShouldNotBe(firstChild);
}
/// <summary>Capturing <see cref="IHistoryWriter"/>: records every Record call so H5c tests can
/// assert the host historizes (or does not) and with what path + snapshot.</summary>
private sealed class CapturingHistoryWriter : IHistoryWriter
{
public readonly List<(string Path, DataValueSnapshot Value)> Calls = new();
/// <summary>Captures the path + snapshot of a Record call.</summary>
/// <param name="path">The virtual tag path.</param>
/// <param name="value">The data value snapshot.</param>
public void Record(string path, DataValueSnapshot value) => Calls.Add((path, value));
}
/// <summary>Deterministic no-op evaluator: keeps spawned children inert so tests drive the host's
/// OnResult path directly via synthetic EvaluationResults.</summary>
private sealed class StubEvaluator : IVirtualTagEvaluator