@@ -0,0 +1,197 @@
|
||||
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 FocasStatusFixedTreeTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
/// <summary>
|
||||
/// Variant of <see cref="FakeFocasClient"/> that returns a configurable
|
||||
/// <see cref="FocasStatusInfo"/> snapshot from <see cref="GetStatusAsync"/>. Probe
|
||||
/// keeps its existing boolean semantic so the back-compat path stays exercised.
|
||||
/// </summary>
|
||||
private sealed class StatusAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
||||
{
|
||||
public FocasStatusInfo? Status { get; set; }
|
||||
|
||||
// Shadow the default interface implementation with a real one. Explicit interface
|
||||
// form so callers via IFocasClient hit this override; FakeFocasClient itself
|
||||
// doesn't declare a virtual GetStatusAsync (the contract has a default impl).
|
||||
Task<FocasStatusInfo?> IFocasClient.GetStatusAsync(CancellationToken ct) =>
|
||||
Task.FromResult(Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_Status_folder_with_9_Int16_nodes_per_device()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host, DeviceName: "Lathe-1")],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.DiscoverAsync(builder, CancellationToken.None);
|
||||
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "Status" && f.DisplayName == "Status");
|
||||
var statusVars = builder.Variables.Where(v =>
|
||||
v.Info.FullName.Contains("::Status/")).ToList();
|
||||
statusVars.Count.ShouldBe(9);
|
||||
string[] expected = ["Tmmode", "Aut", "Run", "Motion", "Mstb", "EmergencyStop", "Alarm", "Edit", "Dummy"];
|
||||
foreach (var name in expected)
|
||||
{
|
||||
var node = statusVars.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}::Status/{name}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_serves_each_Status_field_from_cached_ODBST_snapshot()
|
||||
{
|
||||
var fake = new StatusAwareFakeFocasClient
|
||||
{
|
||||
Status = new FocasStatusInfo(
|
||||
Dummy: 0, Tmmode: 1, Aut: 2, Run: 3, Motion: 4,
|
||||
Mstb: 5, EmergencyStop: 1, Alarm: 7, Edit: 6),
|
||||
};
|
||||
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-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Wait for at least one probe tick to populate the cache.
|
||||
await WaitForAsync(async () =>
|
||||
{
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Status/Tmmode"], CancellationToken.None)).Single();
|
||||
return snap.StatusCode == FocasStatusMapper.Good;
|
||||
}, TimeSpan.FromSeconds(3));
|
||||
|
||||
var refs = new[]
|
||||
{
|
||||
$"{Host}::Status/Tmmode",
|
||||
$"{Host}::Status/Aut",
|
||||
$"{Host}::Status/Run",
|
||||
$"{Host}::Status/Motion",
|
||||
$"{Host}::Status/Mstb",
|
||||
$"{Host}::Status/EmergencyStop",
|
||||
$"{Host}::Status/Alarm",
|
||||
$"{Host}::Status/Edit",
|
||||
$"{Host}::Status/Dummy",
|
||||
};
|
||||
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
snaps[0].Value.ShouldBe((short)1); // Tmmode
|
||||
snaps[1].Value.ShouldBe((short)2); // Aut
|
||||
snaps[2].Value.ShouldBe((short)3); // Run
|
||||
snaps[3].Value.ShouldBe((short)4); // Motion
|
||||
snaps[4].Value.ShouldBe((short)5); // Mstb
|
||||
snaps[5].Value.ShouldBe((short)1); // EmergencyStop
|
||||
snaps[6].Value.ShouldBe((short)7); // Alarm
|
||||
snaps[7].Value.ShouldBe((short)6); // Edit
|
||||
snaps[8].Value.ShouldBe((short)0); // Dummy
|
||||
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReadAsync_returns_BadCommunicationError_when_status_cache_is_empty()
|
||||
{
|
||||
// Probe disabled — cache never populates; the status nodes still resolve as
|
||||
// known references but report Bad until the first successful poll lands.
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snaps = await drv.ReadAsync(
|
||||
[$"{Host}::Status/Tmmode"], CancellationToken.None);
|
||||
snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Existing_boolean_probe_path_still_works_alongside_GetStatusAsync()
|
||||
{
|
||||
// Back-compat guard: ProbeAsync's existing boolean contract is preserved. A client
|
||||
// that doesn't override GetStatusAsync (default null) leaves the cache untouched
|
||||
// but the probe still flips host state to Running.
|
||||
var fake = new FakeFocasClient { ProbeResult = true };
|
||||
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-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await WaitForAsync(() => Task.FromResult(
|
||||
drv.GetHostStatuses().Any(h => h.State == HostState.Running)),
|
||||
TimeSpan.FromSeconds(3));
|
||||
|
||||
// No GetStatusAsync override → cache stays empty → status nodes report Bad,
|
||||
// but the rest of the driver keeps functioning.
|
||||
var snap = (await drv.ReadAsync(
|
||||
[$"{Host}::Status/Tmmode"], CancellationToken.None)).Single();
|
||||
snap.StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FwlibFocasClient_GetStatusAsync_returns_null_when_disconnected()
|
||||
{
|
||||
// Construction is licence-safe (no DLL load); calling GetStatusAsync on the
|
||||
// unconnected client must not P/Invoke. Returns null → driver leaves the cache
|
||||
// in its current state.
|
||||
var client = new FwlibFocasClient();
|
||||
var result = await client.GetStatusAsync(CancellationToken.None);
|
||||
result.ShouldBeNull();
|
||||
}
|
||||
|
||||
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) { } }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user