using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// /// Regression coverage for the FOCAS driver factory and fixed-tree capability /// bootstrap — Driver.FOCAS-001 (config sections dropped) and Driver.FOCAS-002 /// (false-positive ProgramInfo capability). /// [Trait("Category", "Unit")] public sealed class FocasFactoryConfigTests { // ---- Driver.FOCAS-001: FixedTree / AlarmProjection / HandleRecycle config sections ---- [Fact] public void CreateInstance_maps_FixedTree_section_onto_options() { const string json = """ { "Backend": "unimplemented", "FixedTree": { "Enabled": true, "PollInterval": "00:00:00.250", "ProgramPollInterval": "00:00:01", "TimerPollInterval": "00:00:30" } } """; var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json); drv.Options.FixedTree.Enabled.ShouldBeTrue(); drv.Options.FixedTree.PollInterval.ShouldBe(TimeSpan.FromMilliseconds(250)); drv.Options.FixedTree.ProgramPollInterval.ShouldBe(TimeSpan.FromSeconds(1)); drv.Options.FixedTree.TimerPollInterval.ShouldBe(TimeSpan.FromSeconds(30)); } [Fact] public void CreateInstance_maps_AlarmProjection_section_onto_options() { const string json = """ { "Backend": "unimplemented", "AlarmProjection": { "Enabled": true, "PollInterval": "00:00:02" } } """; var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json); drv.Options.AlarmProjection.Enabled.ShouldBeTrue(); drv.Options.AlarmProjection.PollInterval.ShouldBe(TimeSpan.FromSeconds(2)); } [Fact] public void CreateInstance_maps_HandleRecycle_section_onto_options() { const string json = """ { "Backend": "unimplemented", "HandleRecycle": { "Enabled": true, "Interval": "01:00:00" } } """; var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json); drv.Options.HandleRecycle.Enabled.ShouldBeTrue(); drv.Options.HandleRecycle.Interval.ShouldBe(TimeSpan.FromHours(1)); } [Fact] public void CreateInstance_round_trips_all_three_opt_in_sections_together() { const string json = """ { "Backend": "unimplemented", "FixedTree": { "Enabled": true }, "AlarmProjection": { "Enabled": true }, "HandleRecycle": { "Enabled": true } } """; var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", json); drv.Options.FixedTree.Enabled.ShouldBeTrue(); drv.Options.AlarmProjection.Enabled.ShouldBeTrue(); drv.Options.HandleRecycle.Enabled.ShouldBeTrue(); } [Fact] public void CreateInstance_keeps_disabled_defaults_when_sections_absent() { var drv = FocasDriverFactoryExtensions.CreateInstance("drv-1", """{ "Backend": "unimplemented" }"""); drv.Options.FixedTree.Enabled.ShouldBeFalse(); drv.Options.AlarmProjection.Enabled.ShouldBeFalse(); drv.Options.HandleRecycle.Enabled.ShouldBeFalse(); } [Fact] public void CreateInstance_keeps_per_field_defaults_when_only_Enabled_supplied() { var defaults = new FocasFixedTreeOptions(); var drv = FocasDriverFactoryExtensions.CreateInstance( "drv-1", """{ "Backend": "unimplemented", "FixedTree": { "Enabled": true } }"""); drv.Options.FixedTree.PollInterval.ShouldBe(defaults.PollInterval); drv.Options.FixedTree.ProgramPollInterval.ShouldBe(defaults.ProgramPollInterval); drv.Options.FixedTree.TimerPollInterval.ShouldBe(defaults.TimerPollInterval); } // ---- Driver.FOCAS-002: fixed-tree bootstrap must not declare a false ProgramInfo capability ---- [Fact] public async Task FixedTree_bootstrap_marks_ProgramInfo_unsupported_when_probe_throws() { // A CNC series without cnc_exeprgname2 / cnc_rdopmode now surfaces as a thrown // exception from GetProgramInfoAsync — SafeTryProbe must classify it as unsupported. var factory = new FakeFocasClientFactory { Customise = () => new ProgramInfoFailingFocasClient(), }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], Probe = new FocasProbeOptions { Enabled = false }, FixedTree = new FocasFixedTreeOptions { Enabled = true }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await WaitForAsync( () => drv.GetDeviceState("focas://10.0.0.5:8193")?.FixedTreeCache is not null, TimeSpan.FromSeconds(2)); var caps = drv.GetDeviceState("focas://10.0.0.5:8193")!.FixedTreeCache!.Capabilities; caps.ProgramInfo.ShouldBeFalse(); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task FixedTree_bootstrap_marks_ProgramInfo_supported_when_probe_succeeds() { var factory = new FakeFocasClientFactory(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")], Probe = new FocasProbeOptions { Enabled = false }, FixedTree = new FocasFixedTreeOptions { Enabled = true }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await WaitForAsync( () => drv.GetDeviceState("focas://10.0.0.5:8193")?.FixedTreeCache is not null, TimeSpan.FromSeconds(2)); var caps = drv.GetDeviceState("focas://10.0.0.5:8193")!.FixedTreeCache!.Capabilities; caps.ProgramInfo.ShouldBeTrue(); await drv.ShutdownAsync(CancellationToken.None); } // ---- helpers ---- private static async Task WaitForAsync(Func condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (!condition() && DateTime.UtcNow < deadline) await Task.Delay(20); } /// /// Fake whose throws — mirrors the fixed /// behaviour on a series /// that answers EW_FUNC / EW_NOOPT for cnc_exeprgname2 / cnc_rdopmode. /// private sealed class ProgramInfoFailingFocasClient : FakeFocasClient { public override Task GetProgramInfoAsync(CancellationToken ct) => throw new InvalidOperationException( "cnc_exeprgname2 failed EW_6 and cnc_rdopmode failed EW_6."); } }