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 FocasMessagesBlockTextFixedTreeTests { private const string Host = "focas://10.0.0.7:8193"; /// /// Variant of that returns configurable /// + /// snapshots for the F1-e Messages/External/Latest + Program/CurrentBlock /// fixed-tree (issue #261). /// private sealed class MessagesAwareFakeFocasClient : FakeFocasClient, IFocasClient { public FocasOperatorMessagesInfo? Messages { get; set; } public FocasCurrentBlockInfo? CurrentBlock { get; set; } Task IFocasClient.GetOperatorMessagesAsync(CancellationToken ct) => Task.FromResult(Messages); Task IFocasClient.GetCurrentBlockAsync(CancellationToken ct) => Task.FromResult(CurrentBlock); } [Fact] public async Task DiscoverAsync_emits_Messages_External_Latest_and_Program_CurrentBlock_nodes() { var builder = new RecordingBuilder(); var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host, DeviceName: "Mill-1")], Tags = [], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-msg", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); await drv.DiscoverAsync(builder, CancellationToken.None); builder.Folders.ShouldContain(f => f.BrowseName == "Messages" && f.DisplayName == "Messages"); builder.Folders.ShouldContain(f => f.BrowseName == "External" && f.DisplayName == "External"); builder.Folders.ShouldContain(f => f.BrowseName == "Program" && f.DisplayName == "Program"); var latest = builder.Variables.SingleOrDefault(v => v.Info.FullName == $"{Host}::Messages/External/Latest"); latest.BrowseName.ShouldBe("Latest"); latest.Info.DriverDataType.ShouldBe(DriverDataType.String); latest.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); var block = builder.Variables.SingleOrDefault(v => v.Info.FullName == $"{Host}::Program/CurrentBlock"); block.BrowseName.ShouldBe("CurrentBlock"); block.Info.DriverDataType.ShouldBe(DriverDataType.String); block.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly); } [Fact] public async Task ReadAsync_serves_Messages_Latest_and_CurrentBlock_from_cached_snapshot() { var fake = new MessagesAwareFakeFocasClient { Messages = new FocasOperatorMessagesInfo( [ new FocasOperatorMessage(2001, "OPMSG", "TOOL CHANGE READY"), new FocasOperatorMessage(3010, "EXTERN", "DOOR OPEN"), ]), CurrentBlock = new FocasCurrentBlockInfo("G01 X100. Y200. F500."), }; 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-msg-read", factory); await drv.InitializeAsync("{}", CancellationToken.None); await WaitForAsync(async () => { var snap = (await drv.ReadAsync( [$"{Host}::Program/CurrentBlock"], CancellationToken.None)).Single(); return snap.StatusCode == FocasStatusMapper.Good; }, TimeSpan.FromSeconds(3)); var refs = new[] { $"{Host}::Messages/External/Latest", $"{Host}::Program/CurrentBlock", }; var snaps = await drv.ReadAsync(refs, CancellationToken.None); // "Latest" surfaces the last entry in the message snapshot — issue #261 permits // this minimal "latest message" surface in lieu of full ring-buffer coverage. snaps[0].Value.ShouldBe("DOOR OPEN"); snaps[1].Value.ShouldBe("G01 X100. Y200. F500."); 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 cache populates; the nodes still resolve as known // references but report Bad until the first poll. Mirrors the f1a/f1b/f1c/f1d // policy. var drv = new FocasDriver(new FocasDriverOptions { Devices = [new FocasDeviceOptions(Host)], Tags = [], Probe = new FocasProbeOptions { Enabled = false }, }, "drv-msg-empty", new FakeFocasClientFactory()); await drv.InitializeAsync("{}", CancellationToken.None); var snaps = await drv.ReadAsync( [$"{Host}::Messages/External/Latest", $"{Host}::Program/CurrentBlock"], CancellationToken.None); snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError); } [Fact] public async Task ReadAsync_publishes_empty_string_when_message_snapshot_is_empty() { // Empty snapshot (CNC reported no active messages) still publishes Good + // empty string — operators distinguish "no messages" from "Bad" without // having to read separate availability nodes. var fake = new MessagesAwareFakeFocasClient { Messages = new FocasOperatorMessagesInfo([]), CurrentBlock = new FocasCurrentBlockInfo(""), }; 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-msg-empty-snap", factory); await drv.InitializeAsync("{}", CancellationToken.None); await WaitForAsync(async () => { var snap = (await drv.ReadAsync( [$"{Host}::Messages/External/Latest"], CancellationToken.None)).Single(); return snap.StatusCode == FocasStatusMapper.Good; }, TimeSpan.FromSeconds(3)); var snaps = await drv.ReadAsync( [$"{Host}::Messages/External/Latest", $"{Host}::Program/CurrentBlock"], CancellationToken.None); snaps[0].Value.ShouldBe(string.Empty); snaps[0].StatusCode.ShouldBe(FocasStatusMapper.Good); snaps[1].Value.ShouldBe(string.Empty); snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good); await drv.ShutdownAsync(CancellationToken.None); } [Fact] public async Task FwlibFocasClient_GetOperatorMessages_and_GetCurrentBlock_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/f1d. var client = new FwlibFocasClient(); (await client.GetOperatorMessagesAsync(CancellationToken.None)).ShouldBeNull(); (await client.GetCurrentBlockAsync(CancellationToken.None)).ShouldBeNull(); } [Fact] public void TrimAnsiPadding_strips_trailing_nulls_and_spaces_for_round_trip() { // The CNC right-pads block text + opmsg bodies with NULs or spaces; the // managed side trims them so the same message round-trips with stable text // (issue #261). Stops at the first NUL so reused buffers don't leak old bytes. var buf = new byte[16]; var bytes = System.Text.Encoding.ASCII.GetBytes("G01 X10 "); Array.Copy(bytes, buf, bytes.Length); FwlibFocasClient.TrimAnsiPadding(buf).ShouldBe("G01 X10"); // NUL-terminated mid-buffer with trailing spaces beyond the NUL — trim stops // at the NUL so leftover bytes in the rest of the buffer are ignored. var buf2 = new byte[32]; var bytes2 = System.Text.Encoding.ASCII.GetBytes("OPMSG TEXT"); Array.Copy(bytes2, buf2, bytes2.Length); // After NUL the buffer has zeros — already invisible — but explicit space // padding before the NUL should be trimmed. var buf3 = new byte[32]; var bytes3 = System.Text.Encoding.ASCII.GetBytes("HELLO "); Array.Copy(bytes3, buf3, bytes3.Length); FwlibFocasClient.TrimAnsiPadding(buf2).ShouldBe("OPMSG TEXT"); FwlibFocasClient.TrimAnsiPadding(buf3).ShouldBe("HELLO"); // Empty buffer → empty string (no exception). FwlibFocasClient.TrimAnsiPadding(new byte[8]).ShouldBe(string.Empty); } 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) { } } } }