test(heidenhain): end-to-end through the real DriverInstanceActor (#803)

Real Heidenhain driver (factory-built from a merged config) against the in-process LSV2
simulator inside DriverInstanceActor: subscribe publishes Good, an OPC write is refused by
the runtime (no IWritable) with nothing sent to the control, an outage stamps
BadNoCommunication, recovery re-publishes the identical value Good, and only allowlisted
telegrams ever reach the simulated control.
This commit is contained in:
Joseph Doherty
2026-11-10 23:19:31 -05:00
parent 3641d56154
commit bba6d81c16
2 changed files with 88 additions and 0 deletions
@@ -0,0 +1,84 @@
using System.Text.Json;
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain;
using ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests.Simulator;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// lmxopcua#803 end-to-end through the REAL <see cref="DriverInstanceActor"/>: the real Heidenhain driver
/// (built by its factory from a merged config) against the in-process LSV2 simulator — subscribe, OPC write
/// refusal, controller-side outbound restriction, outage invalidation and recovery.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HeidenhainDriverEndToEndTests : RuntimeActorTestBase
{
private const string Exec = "Plant/H/dev1/Status/ExecutionState";
private const string Model = "Plant/H/dev1/Controller/Model";
private static string Config(int port) => JsonSerializer.Serialize(new
{
Devices = new[] { new { Host = "127.0.0.1", Port = port, DeviceName = "dev1" } },
RawTags = new[]
{
new { RawPath = Exec, TagConfig = """{"address":"Status/ExecutionState"}""", WriteIdempotent = false, DeviceName = "dev1" },
new { RawPath = Model, TagConfig = """{"address":"Controller/Model"}""", WriteIdempotent = false, DeviceName = "dev1" },
},
TimeoutMs = 500,
Polling = new { StatusMs = 100, CustomMs = 100, SlowMs = 200 },
Reconnect = new { InitialDelayMs = 100, MaxDelayMs = 200 },
});
private DriverInstanceActor.AttributeValueItem? AwaitItem(Akka.TestKit.TestProbe parent, string reference, Func<DriverInstanceActor.AttributeValueItem, bool> match)
{
DriverInstanceActor.AttributeValueItem? found = null;
parent.FishForMessage<DriverInstanceActor.AttributeValueBatchPublished>(b =>
{
foreach (var i in b.Items)
if (i.FullReference == reference && match(i)) { found = i; return true; }
return false;
}, TimeSpan.FromSeconds(10), cancellationToken: TestContext.Current.CancellationToken);
return found;
}
[Fact]
public async Task Subscribe_write_refusal_outage_and_recovery_through_the_real_actor()
{
var control = new SimulatedControl();
control.Runtime[23] = () => SimulatedControl.U16(4);
await using var sim = Lsv2Simulator.WithControl(control);
var driver = HeidenhainDriverFactoryExtensions.CreateInstance("hh-e2e", Config(sim.Port));
var parent = CreateTestProbe();
parent.IgnoreMessages(m => m is not DriverInstanceActor.AttributeValueBatchPublished);
var actor = parent.ChildActorOf(
DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromMilliseconds(100),
healthPollInterval: TimeSpan.FromMilliseconds(100), commsLossTimeout: TimeSpan.FromSeconds(1)),
TestContext.Current.CancellationToken);
actor.Tell(new DriverInstanceActor.SetDesiredSubscriptions([Exec, Model], TimeSpan.FromMilliseconds(100)));
actor.Tell(new DriverInstanceActor.InitializeRequested(Config(sim.Port)));
AwaitItem(parent, Exec, i => i.StatusCode == OpcUaStatus.Good)!.Value.Value.ShouldBe(4);
// OPC write: refused by the runtime (the driver implements no IWritable) — nothing reaches the control.
var write = await actor.Ask<DriverInstanceActor.WriteAttributeResult>(
new DriverInstanceActor.WriteAttribute(Exec, 9), TimeSpan.FromSeconds(3), TestContext.Current.CancellationToken);
write.Success.ShouldBeFalse();
// Outage: every remote value goes BadNoCommunication.
sim.GoOffline();
AwaitItem(parent, Exec, i => i.StatusCode == HeidenhainStatus.BadNoCommunication).ShouldNotBeNull();
// Recovery: the control comes back holding the SAME value, and it re-publishes Good.
sim.GoOnline();
AwaitItem(parent, Exec, i => i.StatusCode == OpcUaStatus.Good)!.Value.Value.ShouldBe(4);
control.Violations.ShouldBeEmpty();
sim.Requests.Select(r => r.Command).Distinct().ShouldBeSubsetOf(new[] { "A_LG", "R_VR", "R_RI" });
}
}
@@ -40,11 +40,15 @@
<!-- Lane S7 review I-1: the real S7 driver (over a scripted IS7Plc), so the comms-loss watchdog test proves an
access-denied (PUT/GET disabled) CPU is NOT reconnected every 90 s, against the real actor. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.S7\ZB.MOM.WW.OtOpcUa.Driver.S7.csproj"/>
<!-- lmxopcua#803: the real Heidenhain LSV2 driver over the in-process LSV2 simulator (linked below), so the
end-to-end test proves subscribe / write refusal / comms-loss watchdog recovery through the real actor. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.csproj"/>
</ItemGroup>
<ItemGroup>
<!-- Shared bind-race retry helper for tests that boot a real SDK server on a probed port. -->
<Compile Include="..\ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests\OpcUaHostStart.cs" Link="OpcUaHostStart.cs"/>
<Compile Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Heidenhain.Tests\Simulator\*.cs" LinkBase="HeidenhainSimulator"/>
</ItemGroup>
</Project>