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 FocasProductionFixedTreeTests { private const string Host = "focas://10.0.0.5:8193"; /// /// Variant of that returns a configurable /// snapshot from GetProductionAsync. /// private sealed class ProductionAwareFakeFocasClient : FakeFocasClient, IFocasClient { public FocasProductionInfo? Production { get; set; } Task IFocasClient.GetProductionAsync(CancellationToken ct) => Task.FromResult(Production); } [Fact] public async Task DiscoverAsync_emits_Production_folder_with_4_Int32_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 == "Production" && f.DisplayName == "Production"); var prodVars = builder.Variables.Where(v => v.Info.FullName.Contains("::Production/")).ToList(); prodVars.Count.ShouldBe(4); string[] expected = ["PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds"]; foreach (var name in expected) { var node = prodVars.SingleOrDefault(v => v.BrowseName == name); node.BrowseName.ShouldBe(name); node.Info.DriverDataType.ShouldBe(DriverDataType.Int32); node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); node.Info.FullName.ShouldBe($"{Host}::Production/{name}"); } } [Fact] public async Task ReadAsync_serves_each_Production_field_from_cached_snapshot() { var fake = new ProductionAwareFakeFocasClient { Production = new FocasProductionInfo( PartsProduced: 17, PartsRequired: 100, PartsTotal: 4242, CycleTimeSeconds: 73), }; 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}::Production/PartsProduced"], CancellationToken.None)).Single(); return snap.StatusCode == FocasStatusMapper.Good; }, TimeSpan.FromSeconds(3)); var refs = new[] { $"{Host}::Production/PartsProduced", $"{Host}::Production/PartsRequired", $"{Host}::Production/PartsTotal", $"{Host}::Production/CycleTimeSeconds", }; var snaps = await drv.ReadAsync(refs, CancellationToken.None); snaps[0].Value.ShouldBe(17); snaps[1].Value.ShouldBe(100); snaps[2].Value.ShouldBe(4242); snaps[3].Value.ShouldBe(73); foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task ReadAsync_returns_BadCommunicationError_when_production_cache_is_empty() { // Probe disabled — cache never populates; the production 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}::Production/PartsProduced"], CancellationToken.None); snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); } [Fact] public async Task FwlibFocasClient_GetProductionAsync_returns_null_when_disconnected() { // Construction is licence-safe (no DLL load); calling GetProductionAsync 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.GetProductionAsync(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) { } } } }