using System.Collections.Concurrent;
using System.Text.Json;
using Akka.Actor;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
///
/// Review 02 P5 — a rebuild's artifact parse + plan no longer run on the pinned OPC UA thread. The
/// observable: value batches sent right after a large rebuild land on the sink BEFORE that rebuild's first
/// materialise call, because the actor is free while the thread pool parses and plans; two rebuilds sent
/// back to back still apply in arrival order; a plan that throws still answers the requester degraded.
///
public sealed class OpcUaPublishActorOffThreadPlanTests : RuntimeActorTestBase
{
[Fact]
public void Value_batches_keep_flowing_while_a_large_plan_computes()
{
var sink = new OrderedSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
// A value node the batch names; the sink is a recorder, so it need not exist.
var batch = new OpcUaPublishActor.AttributeValueBatchUpdate(
[new ValueWrite("Plant/Modbus/dev1/live", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw)]);
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), RawTagArtifact(50_000), ReportOutcome: true));
actor.Tell(batch);
var completed = ExpectMsg(TimeSpan.FromSeconds(120), cancellationToken: TestContext.Current.CancellationToken);
completed.Degraded.ShouldBeFalse(completed.DegradedReason);
var calls = sink.Calls.ToList();
var batchIndex = calls.IndexOf("WV:Plant/Modbus/dev1/live");
var firstMaterialise = calls.FindIndex(c => c.StartsWith("EV:", StringComparison.Ordinal) || c.StartsWith("EF:", StringComparison.Ordinal));
batchIndex.ShouldBeGreaterThanOrEqualTo(0, "the batch must reach the sink");
firstMaterialise.ShouldBeGreaterThanOrEqualTo(0, "the plan must still be applied (positive control)");
batchIndex.ShouldBeLessThan(firstMaterialise, "the batch sent after the rebuild must land before the rebuild's materialisation — the parse/plan step no longer holds the actor thread");
calls.Count(c => c.StartsWith("EV:", StringComparison.Ordinal)).ShouldBe(50_000);
}
[Fact]
public void Back_to_back_rebuilds_apply_in_arrival_order()
{
var sink = new OrderedSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
// The first (large) plan is still computing when the second (tiny) arrives; the second must wait for
// it — its plan is a diff against what the first applies.
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), RawTagArtifact(20_000), ReportOutcome: true));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), RawTagArtifact(2), ReportOutcome: true));
var first = ExpectMsg(TimeSpan.FromSeconds(120), cancellationToken: TestContext.Current.CancellationToken);
var second = ExpectMsg(TimeSpan.FromSeconds(120), cancellationToken: TestContext.Current.CancellationToken);
first.Degraded.ShouldBeFalse(first.DegradedReason);
second.Degraded.ShouldBeFalse(second.DegradedReason);
// The second composition is a strict subset (2 of the same tags), so it diffs as a PureRemove of the
// other 19,998 — which only works if it was planned AFTER the first apply, against its composition.
sink.Calls.Count(c => c.StartsWith("RV:", StringComparison.Ordinal)).ShouldBe(19_998);
}
[Fact]
public void Unreadable_bytes_answer_degraded_and_keep_the_served_space()
{
var sink = new OrderedSink();
var applier = new AddressSpaceApplier(sink, NullLogger.Instance);
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier));
actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), "not json"u8.ToArray(), ReportOutcome: true));
var completed = ExpectMsg(PresenceBudget, cancellationToken: TestContext.Current.CancellationToken);
completed.Degraded.ShouldBeTrue();
completed.DegradedReason.ShouldNotBeNull().ShouldContain("threw");
sink.Calls.ShouldBeEmpty("nothing may be torn down for bytes that could not be read");
sink.Completed.ShouldBe(1, "the apply is still closed");
}
private static byte[] RawTagArtifact(int tagCount) =>
JsonSerializer.SerializeToUtf8Bytes(new
{
UnsAreas = Array.Empty