using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; [Trait("Category", "Unit")] public sealed class FocasModalOverrideFixedTreeTests { private const string Host = "focas://10.0.0.6:8193"; /// /// Variant of that returns configurable /// + snapshots. /// private sealed class ModalAwareFakeFocasClient : FakeFocasClient, IFocasClient { public FocasModalInfo? Modal { get; set; } public FocasOverrideInfo? Override { get; set; } public FocasOverrideParameters? LastOverrideParams { get; private set; } Task IFocasClient.GetModalAsync(CancellationToken ct) => Task.FromResult(Modal); Task IFocasClient.GetOverrideAsync( FocasOverrideParameters parameters, CancellationToken ct) { LastOverrideParams = parameters; return Task.FromResult(Override); } } [Fact] public async Task DiscoverAsync_emits_Modal_folder_with_4_Int16_codes_per_device() { var builder = new RecordingBuilder(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-2")], Tags = [], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-modal", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Folders.ShouldContain(f => f.BrowseName == "Modal" && f.DisplayName == "Modal"); var modalVars = builder.Variables.Where(v => v.Info.FullName.Contains("::Modal/")).ToList(); modalVars.Count.ShouldBe(4); string[] expected = ["MCode", "SCode", "TCode", "BCode"]; foreach (var name in expected) { var node = modalVars.SingleOrDefault(v => v.BrowseName == name); node.BrowseName.ShouldBe(name); node.Info.DriverDataType.ShouldBe(DriverDataType.Int16); node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); node.Info.FullName.ShouldBe($"{Host}::Modal/{name}"); } } [Fact] public async Task DiscoverAsync_omits_Override_folder_when_no_parameters_configured() { var builder = new RecordingBuilder(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], // OverrideParameters defaults to null Tags = [], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-no-overrides", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Folders.ShouldNotContain(f => f.BrowseName == "Override"); builder.Variables.ShouldNotContain(v => v.Info.FullName.Contains("::Override/")); } [Fact] public async Task DiscoverAsync_emits_only_configured_Override_fields() { // Spindle + Jog suppressed (null parameters) — only Feed + Rapid show up. var builder = new RecordingBuilder(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [ new FocasDeviceOptions(Host, OverrideParameters: new FocasOverrideParameters(6010, 6011, null, null)), ], Tags = [], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-partial-overrides", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Folders.ShouldContain(f => f.BrowseName == "Override"); var overrideVars = builder.Variables.Where(v => v.Info.FullName.Contains("::Override/")).ToList(); overrideVars.Count.ShouldBe(2); overrideVars.ShouldContain(v => v.BrowseName == "Feed"); overrideVars.ShouldContain(v => v.BrowseName == "Rapid"); overrideVars.ShouldNotContain(v => v.BrowseName == "Spindle"); overrideVars.ShouldNotContain(v => v.BrowseName == "Jog"); } [Fact] public async Task ReadAsync_serves_Modal_and_Override_fields_from_cached_snapshot() { var fake = new ModalAwareFakeFocasClient { Modal = new FocasModalInfo(MCode: 8, SCode: 1200, TCode: 101, BCode: 0), Override = new FocasOverrideInfo(Feed: 100, Rapid: 50, Spindle: 110, Jog: 25), }; var factory = new FakeFocasClientFactory { Customise = () => fake }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [ new FocasDeviceOptions(Host, OverrideParameters: FocasOverrideParameters.Default), ], Tags = [], Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) }, }, "drv-modal-read", factory); await drv.InitializeAsync("{}", CancellationToken.None); // Wait for at least one probe tick to populate both caches. await WaitForAsync(async () => { var snap = (await drv.ReadAsync( [$"{Host}::Modal/MCode"], CancellationToken.None)).Single(); return snap.StatusCode == FocasStatusMapper.Good; }, TimeSpan.FromSeconds(3)); var refs = new[] { $"{Host}::Modal/MCode", $"{Host}::Modal/SCode", $"{Host}::Modal/TCode", $"{Host}::Modal/BCode", $"{Host}::Override/Feed", $"{Host}::Override/Rapid", $"{Host}::Override/Spindle", $"{Host}::Override/Jog", }; var snaps = await drv.ReadAsync(refs, CancellationToken.None); snaps[0].Value.ShouldBe((short)8); snaps[1].Value.ShouldBe((short)1200); snaps[2].Value.ShouldBe((short)101); snaps[3].Value.ShouldBe((short)0); snaps[4].Value.ShouldBe((short)100); snaps[5].Value.ShouldBe((short)50); snaps[6].Value.ShouldBe((short)110); snaps[7].Value.ShouldBe((short)25); foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good); // The driver hands the device's configured override parameters to the wire client // verbatim — defaulting to 30i numbers. fake.LastOverrideParams.ShouldNotBeNull(); fake.LastOverrideParams!.FeedParam.ShouldBe(6010); fake.LastOverrideParams.RapidParam.ShouldBe(6011); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty() { // Probe disabled — neither modal nor override caches populate; the nodes still // resolve as known references but report Bad until the first successful poll. var drv = new FocasDriver(new FocasDriverOptions { Devices = [ new FocasDeviceOptions(Host, OverrideParameters: FocasOverrideParameters.Default), ], Tags = [], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-empty-cache", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); var snaps = await drv.ReadAsync( [$"{Host}::Modal/MCode", $"{Host}::Override/Feed"], CancellationToken.None); snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); } [Fact] public async Task FwlibFocasClient_GetModal_and_GetOverride_return_null_when_disconnected() { // Construction is licence-safe (no DLL load); the unconnected client must short- // circuit before P/Invoke. Returns null → driver leaves the cache untouched. var client = new FwlibFocasClient(); (await client.GetModalAsync(CancellationToken.None)).ShouldBeNull(); (await client.GetOverrideAsync( FocasOverrideParameters.Default, CancellationToken.None)).ShouldBeNull(); } private static async Task WaitForAsync(Func> condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (!await condition() && DateTime.UtcNow < deadline) await Task.Delay(20); } private sealed class RecordingBuilder : IAddressSpaceBuilder { public List<(string BrowseName, string DisplayName)> Folders { get; } = new(); public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new(); public IAddressSpaceBuilder Folder(string browseName, string displayName) { Folders.Add((browseName, displayName)); return this; } public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info) { Variables.Add((browseName, info)); return new Handle(info.FullName); } public void AddProperty(string _, DriverDataType __, object? ___) { } private sealed class Handle(string fullRef) : IVariableHandle { public string FullReference => fullRef; public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink(); } private sealed class NullSink : IAlarmConditionSink { public void OnTransition(AlarmEventArgs args) { } } } }