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 FocasModalOverrideFixedTreeTests
{
private const string Host = "focas://10.0.0.6:8193";
///
/// Variant of that returns configurable
/// + snapshots.
///
private sealed class ModalAwareFakeFocasClient : FakeFocasClient, IFocasClient
{
public FocasModalInfo? Modal { get; set; }
public FocasOverrideInfo? Override { get; set; }
public FocasOverrideParameters? LastOverrideParams { get; private set; }
Task IFocasClient.GetModalAsync(CancellationToken ct) =>
Task.FromResult(Modal);
Task IFocasClient.GetOverrideAsync(
FocasOverrideParameters parameters, CancellationToken ct)
{
LastOverrideParams = parameters;
return Task.FromResult(Override);
}
}
[Fact]
public async Task DiscoverAsync_emits_Modal_folder_with_4_Int16_codes_per_device()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-2")],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-modal", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Modal" && f.DisplayName == "Modal");
var modalVars = builder.Variables.Where(v =>
v.Info.FullName.Contains("::Modal/")).ToList();
modalVars.Count.ShouldBe(4);
string[] expected = ["MCode", "SCode", "TCode", "BCode"];
foreach (var name in expected)
{
var node = modalVars.SingleOrDefault(v => v.BrowseName == name);
node.BrowseName.ShouldBe(name);
node.Info.DriverDataType.ShouldBe(DriverDataType.Int16);
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
node.Info.FullName.ShouldBe($"{Host}::Modal/{name}");
}
}
[Fact]
public async Task DiscoverAsync_omits_Override_folder_when_no_parameters_configured()
{
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(Host)], // OverrideParameters defaults to null
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-no-overrides", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldNotContain(f => f.BrowseName == "Override");
builder.Variables.ShouldNotContain(v => v.Info.FullName.Contains("::Override/"));
}
[Fact]
public async Task DiscoverAsync_emits_only_configured_Override_fields()
{
// Spindle + Jog suppressed (null parameters) — only Feed + Rapid show up.
var builder = new RecordingBuilder();
var drv = new FocasDriver(new FocasDriverOptions
{
Devices =
[
new FocasDeviceOptions(Host,
OverrideParameters: new FocasOverrideParameters(6010, 6011, null, null)),
],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-partial-overrides", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.DiscoverAsync(builder, CancellationToken.None);
builder.Folders.ShouldContain(f => f.BrowseName == "Override");
var overrideVars = builder.Variables.Where(v =>
v.Info.FullName.Contains("::Override/")).ToList();
overrideVars.Count.ShouldBe(2);
overrideVars.ShouldContain(v => v.BrowseName == "Feed");
overrideVars.ShouldContain(v => v.BrowseName == "Rapid");
overrideVars.ShouldNotContain(v => v.BrowseName == "Spindle");
overrideVars.ShouldNotContain(v => v.BrowseName == "Jog");
}
[Fact]
public async Task ReadAsync_serves_Modal_and_Override_fields_from_cached_snapshot()
{
var fake = new ModalAwareFakeFocasClient
{
Modal = new FocasModalInfo(MCode: 8, SCode: 1200, TCode: 101, BCode: 0),
Override = new FocasOverrideInfo(Feed: 100, Rapid: 50, Spindle: 110, Jog: 25),
};
var factory = new FakeFocasClientFactory { Customise = () => fake };
var drv = new FocasDriver(new FocasDriverOptions
{
Devices =
[
new FocasDeviceOptions(Host,
OverrideParameters: FocasOverrideParameters.Default),
],
Tags = [],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
}, "drv-modal-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}::Modal/MCode"], CancellationToken.None)).Single();
return snap.StatusCode == FocasStatusMapper.Good;
}, TimeSpan.FromSeconds(3));
var refs = new[]
{
$"{Host}::Modal/MCode",
$"{Host}::Modal/SCode",
$"{Host}::Modal/TCode",
$"{Host}::Modal/BCode",
$"{Host}::Override/Feed",
$"{Host}::Override/Rapid",
$"{Host}::Override/Spindle",
$"{Host}::Override/Jog",
};
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
snaps[0].Value.ShouldBe((short)8);
snaps[1].Value.ShouldBe((short)1200);
snaps[2].Value.ShouldBe((short)101);
snaps[3].Value.ShouldBe((short)0);
snaps[4].Value.ShouldBe((short)100);
snaps[5].Value.ShouldBe((short)50);
snaps[6].Value.ShouldBe((short)110);
snaps[7].Value.ShouldBe((short)25);
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
// The driver hands the device's configured override parameters to the wire client
// verbatim — defaulting to 30i numbers.
fake.LastOverrideParams.ShouldNotBeNull();
fake.LastOverrideParams!.FeedParam.ShouldBe(6010);
fake.LastOverrideParams.RapidParam.ShouldBe(6011);
await drv.ShutdownAsync(CancellationToken.None);
}
[Fact]
public async Task ReadAsync_returns_BadCommunicationError_when_caches_are_empty()
{
// Probe disabled — neither modal nor override caches populate; the nodes still
// resolve as known references but report Bad until the first successful poll.
var drv = new FocasDriver(new FocasDriverOptions
{
Devices =
[
new FocasDeviceOptions(Host,
OverrideParameters: FocasOverrideParameters.Default),
],
Tags = [],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-empty-cache", new FakeFocasClientFactory());
await drv.InitializeAsync("{}", CancellationToken.None);
var snaps = await drv.ReadAsync(
[$"{Host}::Modal/MCode", $"{Host}::Override/Feed"], CancellationToken.None);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
}
[Fact]
public async Task FwlibFocasClient_GetModal_and_GetOverride_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.
var client = new FwlibFocasClient();
(await client.GetModalAsync(CancellationToken.None)).ShouldBeNull();
(await client.GetOverrideAsync(
FocasOverrideParameters.Default, CancellationToken.None)).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) { } }
}
}