using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// T15 — verifies the non-blocking historian-provisioning hook in /// . The hook fires AFTER the address-space work and /// dispatches fire-and-forget, so a slow or /// throwing provisioner can never block or break a deploy on the OPC UA publish actor's pinned /// thread. /// public sealed class AddressSpaceApplierProvisioningTests { /// Capturing double. Records the requests it was /// handed and signals a when invoked, so a test can await the /// fire-and-forget dispatch deterministically (never poll/sleep). A flag /// simulates a synchronous provisioner fault. private sealed class CapturingProvisioner : IHistorianProvisioning { private readonly TaskCompletionSource _called = new(TaskCreationOptions.RunContinuationsAsynchronously); /// Gets the requests the hook handed to . public List Seen { get; } = new(); /// When true, throws synchronously (a fault before any await). public bool Throw { get; init; } /// Completes once has been invoked. public Task Called => _called.Task; /// public Task EnsureTagsAsync( IReadOnlyList requests, CancellationToken ct) { if (Throw) { _called.TrySetResult(); throw new InvalidOperationException("boom"); } Seen.AddRange(requests); _called.TrySetResult(); return Task.FromResult(new HistorianProvisionResult(requests.Count, requests.Count, 0, 0)); } } /// The hook provisions ONLY historized added tags, with the resolved historian name /// (override when set, else the driver-side FullName). [Fact] public async Task Apply_provisions_only_historized_added_tags() { var prov = new CapturingProvisioner(); var applier = new AddressSpaceApplier(NullOpcUaAddressSpaceSink.Instance, NullLogger.Instance, prov); // Leaf display name "Temp"; historian override "Pump1.Temp". var plan = PlanWithAddedTags( HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32"), NonHistorizedTag(displayName: "Run", dataType: "Boolean")); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeTrue(); // Fire-and-forget: await the capturing double's signal so the assertion is deterministic. await prov.Called.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); prov.Seen.Count.ShouldBe(1); prov.Seen[0].TagName.ShouldBe("Pump1.Temp"); // resolved historian name (override) prov.Seen[0].DataType.ShouldBe(DriverDataType.Float32); prov.Seen[0].Description.ShouldBe("Temp"); // leaf display name } /// A null/blank historian-name override resolves to the driver-side FullName — mirroring /// the materialiser's resolution exactly. [Fact] public async Task Apply_resolves_historian_name_from_fullname_when_override_blank() { var prov = new CapturingProvisioner(); var applier = new AddressSpaceApplier(NullOpcUaAddressSpaceSink.Instance, NullLogger.Instance, prov); // IsHistorized but no override → historian name falls back to FullName ("40001"). var plan = PlanWithAddedTags( HistorizedTag(displayName: "Speed", historianName: null, dataType: "Int32", fullName: "40001")); applier.Apply(plan); await prov.Called.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); prov.Seen.Count.ShouldBe(1); prov.Seen[0].TagName.ShouldBe("40001"); prov.Seen[0].DataType.ShouldBe(DriverDataType.Int32); } /// A synchronously-throwing provisioner must NOT block or break the publish: the /// synchronous still completes its address-space work and /// returns its normal outcome. [Fact] public void Provisioner_throw_does_not_block_publish() { var applier = new AddressSpaceApplier( NullOpcUaAddressSpaceSink.Instance, NullLogger.Instance, new CapturingProvisioner { Throw = true }); var outcome = applier.Apply(PlanWithAddedTags( HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32"))); outcome.RebuildCalled.ShouldBeTrue(); // address-space work still completed } /// The default ctor (no provisioner) binds the no-op /// and never faults a deploy — preserving every existing call site. [Fact] public void Default_ctor_uses_null_provisioning_and_does_not_throw() { var applier = new AddressSpaceApplier(NullOpcUaAddressSpaceSink.Instance, NullLogger.Instance); var outcome = applier.Apply(PlanWithAddedTags( HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32"))); outcome.RebuildCalled.ShouldBeTrue(); } /// An added historized tag whose DataType string is not a is /// skipped (no request) — the hook never throws on an unparseable type. [Fact] public async Task Apply_skips_added_tag_with_unparseable_datatype() { var prov = new CapturingProvisioner(); var applier = new AddressSpaceApplier(NullOpcUaAddressSpaceSink.Instance, NullLogger.Instance, prov); // "Float" is NOT a DriverDataType member (the members are Float32/Float64); it must be skipped. var plan = PlanWithAddedTags( HistorizedTag(displayName: "Bad", historianName: "Pump1.Bad", dataType: "Float"), HistorizedTag(displayName: "Good", historianName: "Pump1.Good", dataType: "Float32")); applier.Apply(plan); await prov.Called.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); prov.Seen.Count.ShouldBe(1); prov.Seen[0].TagName.ShouldBe("Pump1.Good"); } /// Capturing double. Records the add/remove /// ref deltas the applier feeds it. A flag simulates a faulting feed. private sealed class CapturingSubscriptionSink : IHistorizedTagSubscriptionSink { /// Refs the applier fed as ADDED. public List Added { get; } = new(); /// Refs the applier fed as REMOVED. public List Removed { get; } = new(); /// When true, throws synchronously. public bool Throw { get; init; } /// public void UpdateHistorizedRefs(IReadOnlyList added, IReadOnlyList removed) { if (Throw) throw new InvalidOperationException("boom"); Added.AddRange(added); Removed.AddRange(removed); } } /// The feed pushes ONLY historized added refs, resolved (override-or-FullName) exactly like /// the provisioning hook — non-historized tags never reach the recorder. [Fact] public void Apply_feeds_historized_added_refs_to_the_subscription_sink() { var sink = new CapturingSubscriptionSink(); var applier = new AddressSpaceApplier( NullOpcUaAddressSpaceSink.Instance, NullLogger.Instance, historizedSubscriptions: sink); var plan = PlanWithAddedTags( HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32"), HistorizedTag(displayName: "Speed", historianName: null, dataType: "Int32", fullName: "40001"), NonHistorizedTag(displayName: "Run", dataType: "Boolean")); applier.Apply(plan); sink.Added.ShouldBe(new[] { "Pump1.Temp", "40001" }, ignoreOrder: true); // override + FullName fallback sink.Removed.ShouldBeEmpty(); } /// Removed historized tags are fed as REMOVED refs; a changed tag whose historian override is /// renamed feeds the old ref removed + the new ref added (the recorder converges the full set). [Fact] public void Apply_feeds_removed_and_renamed_historized_refs() { var sink = new CapturingSubscriptionSink(); var applier = new AddressSpaceApplier( NullOpcUaAddressSpaceSink.Instance, NullLogger.Instance, historizedSubscriptions: sink); var removedTag = HistorizedTag(displayName: "Old", historianName: "Pump1.Old", dataType: "Float32"); // Same TagId ("tag-T"), historian override renamed A → B (both historized) → remove A, add B. var prev = HistorizedTag(displayName: "T", historianName: "Pump1.A", dataType: "Float32"); var cur = HistorizedTag(displayName: "T", historianName: "Pump1.B", dataType: "Float32"); var plan = new AddressSpacePlan( AddedEquipment: Array.Empty(), RemovedEquipment: Array.Empty(), ChangedEquipment: Array.Empty(), AddedDrivers: Array.Empty(), RemovedDrivers: Array.Empty(), ChangedDrivers: Array.Empty(), AddedAlarms: Array.Empty(), RemovedAlarms: Array.Empty(), ChangedAlarms: Array.Empty()) { RemovedEquipmentTags = new[] { removedTag }, ChangedEquipmentTags = new[] { new AddressSpacePlan.EquipmentTagDelta(prev, cur) }, }; applier.Apply(plan); sink.Added.ShouldBe(new[] { "Pump1.B" }, ignoreOrder: true); sink.Removed.ShouldBe(new[] { "Pump1.Old", "Pump1.A" }, ignoreOrder: true); } /// A synchronously-throwing subscription sink must NOT block or break the publish — the /// address-space work still completes and returns its outcome. [Fact] public void Subscription_sink_throw_does_not_block_publish() { var applier = new AddressSpaceApplier( NullOpcUaAddressSpaceSink.Instance, NullLogger.Instance, historizedSubscriptions: new CapturingSubscriptionSink { Throw = true }); var outcome = applier.Apply(PlanWithAddedTags( HistorizedTag(displayName: "Temp", historianName: "Pump1.Temp", dataType: "Float32"))); outcome.RebuildCalled.ShouldBeTrue(); // address-space work still completed } private static EquipmentTagPlan HistorizedTag(string displayName, string? historianName, string dataType, string fullName = "ref") => new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: fullName, Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: historianName); private static EquipmentTagPlan NonHistorizedTag(string displayName, string dataType) => new("tag-" + displayName, "eq-1", "drv", FolderPath: "", Name: displayName, DataType: dataType, FullName: "ref", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null); private static AddressSpacePlan PlanWithAddedTags(params EquipmentTagPlan[] tags) => new( AddedEquipment: Array.Empty(), RemovedEquipment: Array.Empty(), ChangedEquipment: Array.Empty(), AddedDrivers: Array.Empty(), RemovedDrivers: Array.Empty(), ChangedDrivers: Array.Empty(), AddedAlarms: Array.Empty(), RemovedAlarms: Array.Empty(), ChangedAlarms: Array.Empty()) { AddedEquipmentTags = tags, }; }