using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.IntegrationTests; /// /// End-to-end smoke for the model-change watch (PR-10). Boots a real session against /// opc-plc, asserts the driver wires up the model-change subscription without /// destabilising the rest of the capability surface, and asserts a synthetic event /// injection still runs the debounced re-import path under live conditions. /// /// /// opc-plc doesn't currently expose a stable HTTP control endpoint for forcing a /// GeneralModelChangeEventType from outside the simulator. The native /// OpcPlc.AddSlowNode method is invocable via OPC UA Call and does /// trigger the event, but it requires elevated permissions on the simulator's /// security model that the default --aa deployment doesn't grant. So this /// smoke uses the driver's InjectModelChangeForTest seam — the same code /// path a real upstream notification takes — and asserts the debounced re-import /// ran end-to-end against the live session. /// [Collection(OpcPlcCollection.Name)] [Trait("Category", "Integration")] [Trait("Simulator", "opc-plc")] public sealed class OpcUaClientModelChangeSmokeTests(OpcPlcFixture sim) { [Fact] public async Task Driver_initializes_with_model_change_watch_enabled_against_live_simulator() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); // Default options have WatchModelChanges=true; a successful Initialize against // opc-plc proves the EventFilter + WhereClause + monitored-item create path is // accepted by an independent OPC UA stack. var options = OpcPlcProfile.BuildOptions(sim.EndpointUrl); await using var drv = new OpcUaClientDriver(options, "opcua-modelchange-init"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // Driver should be Healthy after init even though we created an extra // subscription on top of any pre-existing ones. drv.GetHealth().State.ShouldBe(DriverState.Healthy); // Reads still work — i.e. the model-change subscription didn't starve the // session of publish slots or otherwise destabilise the data path. var snaps = await drv.ReadAsync([OpcPlcProfile.StepUp], TestContext.Current.CancellationToken); snaps.Count.ShouldBe(1); snaps[0].StatusCode.ShouldBe(0u); } [Fact] public async Task Driver_reimports_on_model_change_event() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); var debounce = TimeSpan.FromMilliseconds(500); var baseOpts = OpcPlcProfile.BuildOptions(sim.EndpointUrl); var options = new OpcUaClientDriverOptions { EndpointUrl = baseOpts.EndpointUrl, SecurityPolicy = baseOpts.SecurityPolicy, SecurityMode = baseOpts.SecurityMode, AuthType = baseOpts.AuthType, AutoAcceptCertificates = baseOpts.AutoAcceptCertificates, Timeout = baseOpts.Timeout, SessionTimeout = baseOpts.SessionTimeout, ModelChangeDebounce = debounce, }; await using var drv = new OpcUaClientDriver(options, "opcua-modelchange-reimport"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); // Use the test seam so we don't depend on opc-plc's HTTP control endpoint; // the production wiring takes the same OnModelChangeNotification path. var fires = 0; drv.ModelChangeReimportHookForTest = _ => { Interlocked.Increment(ref fires); return Task.CompletedTask; }; // Burst of 5 events within the debounce window → exactly one re-import. for (var i = 0; i < 5; i++) { drv.InjectModelChangeForTest(); await Task.Delay(50, TestContext.Current.CancellationToken); } await Task.Delay(debounce + TimeSpan.FromMilliseconds(500), TestContext.Current.CancellationToken); fires.ShouldBe(1, "burst within debounce window must coalesce to one re-import"); } [Fact] public async Task Driver_initializes_with_model_change_watch_disabled() { if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); // Operators who don't want the brief browse-gap on topology change can flip // WatchModelChanges off — Initialize must still succeed end-to-end. var baseOpts = OpcPlcProfile.BuildOptions(sim.EndpointUrl); var options = new OpcUaClientDriverOptions { EndpointUrl = baseOpts.EndpointUrl, SecurityPolicy = baseOpts.SecurityPolicy, SecurityMode = baseOpts.SecurityMode, AuthType = baseOpts.AuthType, AutoAcceptCertificates = baseOpts.AutoAcceptCertificates, Timeout = baseOpts.Timeout, SessionTimeout = baseOpts.SessionTimeout, WatchModelChanges = false, }; await using var drv = new OpcUaClientDriver(options, "opcua-modelchange-disabled"); await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } }