221 lines
9.5 KiB
C#
221 lines
9.5 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 FocasToolingOffsetsFixedTreeTests
|
|
{
|
|
private const string Host = "focas://10.0.0.7:8193";
|
|
|
|
/// <summary>
|
|
/// Variant of <see cref="FakeFocasClient"/> that returns configurable
|
|
/// <see cref="FocasToolingInfo"/> + <see cref="FocasWorkOffsetsInfo"/> snapshots
|
|
/// for the F1-d Tooling/CurrentTool + Offsets/ fixed-tree (issue #260).
|
|
/// </summary>
|
|
private sealed class ToolingAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
|
{
|
|
public FocasToolingInfo? Tooling { get; set; }
|
|
public FocasWorkOffsetsInfo? WorkOffsets { get; set; }
|
|
|
|
Task<FocasToolingInfo?> IFocasClient.GetToolingAsync(CancellationToken ct) =>
|
|
Task.FromResult(Tooling);
|
|
|
|
Task<FocasWorkOffsetsInfo?> 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<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) { } }
|
|
}
|
|
}
|