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 FocasStatusFixedTreeTests { private const string Host = "focas://10.0.0.5:8193"; /// /// Variant of that returns a configurable /// snapshot from . Probe /// keeps its existing boolean semantic so the back-compat path stays exercised. /// private sealed class StatusAwareFakeFocasClient : FakeFocasClient, IFocasClient { public FocasStatusInfo? Status { get; set; } // Shadow the default interface implementation with a real one. Explicit interface // form so callers via IFocasClient hit this override; FakeFocasClient itself // doesn't declare a virtual GetStatusAsync (the contract has a default impl). Task IFocasClient.GetStatusAsync(CancellationToken ct) => Task.FromResult(Status); } [Fact] public async Task DiscoverAsync_emits_Status_folder_with_9_Int16_nodes_per_device() { var builder = new RecordingBuilder(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-1")], Tags = [], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Folders.ShouldContain(f => f.BrowseName == "Status" && f.DisplayName == "Status"); var statusVars = builder.Variables.Where(v => v.Info.FullName.Contains("::Status/")).ToList(); statusVars.Count.ShouldBe(9); string[] expected = ["Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy"]; foreach (var name in expected) { var node = statusVars.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}::Status/{name}"); } } [Fact] public async Task ReadAsync_serves_each_Status_field_from_cached_ODBST_snapshot() { var fake = new StatusAwareFakeFocasClient { Status = new FocasStatusInfo( Dummy: 0, Tmmode: 1, Aut: 2, Run: 3, Motion: 4, Mstb: 5, EmergencyStop: 1, Alarm: 7, Edit: 6), }; var factory = new FakeFocasClientFactory { Customise = () => fake }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], Tags = [], Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); // Wait for at least one probe tick to populate the cache. await WaitForAsync(async () => { var snap = (await drv.ReadAsync( [$"{Host}::Status/Tmmode"], CancellationToken.None)).Single(); return snap.StatusCode == FocasStatusMapper.Good; }, TimeSpan.FromSeconds(3)); var refs = new[] { $"{Host}::Status/Tmmode", $"{Host}::Status/Aut", $"{Host}::Status/Run", $"{Host}::Status/Motion", $"{Host}::Status/Mstb", $"{Host}::Status/EmergencyStop", $"{Host}::Status/Alarm", $"{Host}::Status/Edit", $"{Host}::Status/Dummy", }; var snaps = await drv.ReadAsync(refs, CancellationToken.None); snaps[0].Value.ShouldBe((short)1); // Tmmode snaps[1].Value.ShouldBe((short)2); // Aut snaps[2].Value.ShouldBe((short)3); // Run snaps[3].Value.ShouldBe((short)4); // Motion snaps[4].Value.ShouldBe((short)5); // Mstb snaps[5].Value.ShouldBe((short)1); // EmergencyStop snaps[6].Value.ShouldBe((short)7); // Alarm snaps[7].Value.ShouldBe((short)6); // Edit snaps[8].Value.ShouldBe((short)0); // Dummy foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task ReadAsync_returns_BadCommunicationError_when_status_cache_is_empty() { // Probe disabled — cache never populates; the status nodes still resolve as // known references but report Bad until the first successful poll lands. var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], Tags = [], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-1", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); var snaps = await drv.ReadAsync( [$"{Host}::Status/Tmmode"], CancellationToken.None); snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); } [Fact] public async Task Existing_boolean_probe_path_still_works_alongside_GetStatusAsync() { // Back-compat guard: ProbeAsync's existing boolean contract is preserved. A client // that doesn't override GetStatusAsync (default null) leaves the cache untouched // but the probe still flips host state to Running. var fake = new FakeFocasClient { ProbeResult = true }; var factory = new FakeFocasClientFactory { Customise = () => fake }; var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], Tags = [], Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) }, }, "drv-1", factory); await drv.InitializeAsync("{}", CancellationToken.None); await WaitForAsync(() => Task.FromResult( drv.GetHostStatuses().Any(h => h.State == HostState.Running)), TimeSpan.FromSeconds(3)); // No GetStatusAsync override → cache stays empty → status nodes report Bad, // but the rest of the driver keeps functioning. var snap = (await drv.ReadAsync( [$"{Host}::Status/Tmmode"], CancellationToken.None)).Single(); snap.StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task FwlibFocasClient_GetStatusAsync_returns_null_when_disconnected() { // Construction is licence-safe (no DLL load); calling GetStatusAsync on the // unconnected client must not P/Invoke. Returns null → driver leaves the cache // in its current state. var client = new FwlibFocasClient(); var result = await client.GetStatusAsync(CancellationToken.None); result.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) { } } } }