232 lines
10 KiB
C#
232 lines
10 KiB
C#
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";
|
|
|
|
/// <summary>
|
|
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
|
|
/// <see cref="FocasOperatorMessagesInfo"/> + <see cref="FocasCurrentBlockInfo"/>
|
|
/// snapshots for the F1-e Messages/External/Latest + Program/CurrentBlock
|
|
/// fixed-tree (issue #261).
|
|
/// </summary>
|
|
private sealed class MessagesAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
|
{
|
|
public FocasOperatorMessagesInfo? Messages { get; set; }
|
|
public FocasCurrentBlockInfo? CurrentBlock { get; set; }
|
|
|
|
Task<FocasOperatorMessagesInfo?> IFocasClient.GetOperatorMessagesAsync(CancellationToken ct) =>
|
|
Task.FromResult(Messages);
|
|
|
|
Task<FocasCurrentBlockInfo?> 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<Task<bool>> 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) { } }
|
|
}
|
|
}
|