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 FocasToolingOffsetsFixedTreeTests { private const string Host = "focas://10.0.0.7:8193"; /// /// Variant of that returns configurable /// + snapshots /// for the F1-d Tooling/CurrentTool + Offsets/ fixed-tree (issue #260). /// private sealed class ToolingAwareFakeFocasClient : FakeFocasClient, IFocasClient { public FocasToolingInfo? Tooling { get; set; } public FocasWorkOffsetsInfo? WorkOffsets { get; set; } Task IFocasClient.GetToolingAsync(CancellationToken ct) => Task.FromResult(Tooling); Task IFocasClient.GetWorkOffsetsAsync(CancellationToken ct) => Task.FromResult(WorkOffsets); } [Fact] public async Task DiscoverAsync_emits_Tooling_folder_with_CurrentTool_node() { var builder = new RecordingBuilder(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")], Tags = [], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-tooling", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Folders.ShouldContain(f => f.BrowseName == "Tooling" && f.DisplayName == "Tooling"); var toolingVars = builder.Variables.Where(v => v.Info.FullName.Contains("::Tooling/")).ToList(); toolingVars.Count.ShouldBe(1); var node = toolingVars.Single(); node.BrowseName.ShouldBe("CurrentTool"); node.Info.DriverDataType.ShouldBe(DriverDataType.Int16); node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); node.Info.FullName.ShouldBe($"{Host}::Tooling/CurrentTool"); } [Fact] public async Task DiscoverAsync_emits_Offsets_folder_with_G54_to_G59_each_with_3_axes() { // Six standard slots (G54..G59) * three axes (X/Y/Z) = 18 Float64 nodes per // device. Extended G54.1 P1..P48 deferred per the F1-d plan. var builder = new RecordingBuilder(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")], Tags = [], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-offsets", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Folders.ShouldContain(f => f.BrowseName == "Offsets"); string[] expectedSlots = ["G54", "G55", "G56", "G57", "G58", "G59"]; foreach (var slot in expectedSlots) builder.Folders.ShouldContain(f => f.BrowseName == slot); var offsetVars = builder.Variables.Where(v => v.Info.FullName.Contains("::Offsets/")).ToList(); offsetVars.Count.ShouldBe(6 * 3); foreach (var slot in expectedSlots) foreach (var axis in new[] { "X", "Y", "Z" }) { var fullRef = $"{Host}::Offsets/{slot}/{axis}"; var node = offsetVars.SingleOrDefault(v => v.Info.FullName == fullRef); node.Info.DriverDataType.ShouldBe(DriverDataType.Float64); node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); } } [Fact] public async Task ReadAsync_serves_Tooling_and_Offsets_fields_from_cached_snapshot() { var fake = new ToolingAwareFakeFocasClient { Tooling = new FocasToolingInfo(CurrentTool: 17), WorkOffsets = new FocasWorkOffsetsInfo( [ new FocasWorkOffset("G54", X: 100.5, Y: 200.25, Z: -50.0), new FocasWorkOffset("G55", X: 0, Y: 0, Z: 0), new FocasWorkOffset("G56", X: 0, Y: 0, Z: 0), new FocasWorkOffset("G57", X: 0, Y: 0, Z: 0), new FocasWorkOffset("G58", X: 0, Y: 0, Z: 0), new FocasWorkOffset("G59", X: 1, Y: 2, Z: 3), ]), }; 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-tooling-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}::Tooling/CurrentTool"], CancellationToken.None)).Single(); return snap.StatusCode == FocasStatusMapper.Good; }, TimeSpan.FromSeconds(3)); var refs = new[] { $"{Host}::Tooling/CurrentTool", $"{Host}::Offsets/G54/X", $"{Host}::Offsets/G54/Y", $"{Host}::Offsets/G54/Z", $"{Host}::Offsets/G59/X", }; var snaps = await drv.ReadAsync(refs, CancellationToken.None); snaps[0].Value.ShouldBe((short)17); snaps[1].Value.ShouldBe(100.5); snaps[2].Value.ShouldBe(200.25); snaps[3].Value.ShouldBe(-50.0); snaps[4].Value.ShouldBe(1.0); foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty() { // Probe disabled — neither tooling nor offsets caches populate; the nodes // still resolve as known references but report Bad until the first poll. var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], Tags = [], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-empty-tooling", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); var snaps = await drv.ReadAsync( [$"{Host}::Tooling/CurrentTool", $"{Host}::Offsets/G54/X"], CancellationToken.None); snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); } [Fact] public async Task FwlibFocasClient_GetTooling_and_GetWorkOffsets_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, matching the policy in f1a/f1b/f1c. var client = new FwlibFocasClient(); (await client.GetToolingAsync(CancellationToken.None)).ShouldBeNull(); (await client.GetWorkOffsetsAsync(CancellationToken.None)).ShouldBeNull(); } [Fact] public void DecodeOfsbAxis_applies_decimal_point_count_like_macro_decode() { // Layout per fwlib32.h: int data, short dec, short unit, short disp = 10 bytes. // Three axes (X=12345 / dec=3 = 12.345; Y=-500 / dec=2 = -5.00; Z=0 / dec=0 = 0). var buf = new byte[80]; WriteAxis(buf, 0, raw: 12345, dec: 3); WriteAxis(buf, 1, raw: -500, dec: 2); WriteAxis(buf, 2, raw: 0, dec: 0); FwlibFocasClient.DecodeOfsbAxis(buf, 0).ShouldBe(12.345, tolerance: 1e-9); FwlibFocasClient.DecodeOfsbAxis(buf, 1).ShouldBe(-5.0, tolerance: 1e-9); FwlibFocasClient.DecodeOfsbAxis(buf, 2).ShouldBe(0.0, tolerance: 1e-9); } private static void WriteAxis(byte[] buf, int axisIndex, int raw, short dec) { var offset = axisIndex * 10; System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(buf.AsSpan(offset, 4), raw); System.Buffers.Binary.BinaryPrimitives.WriteInt16LittleEndian(buf.AsSpan(offset + 4, 2), dec); } 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) { } } } }