157 lines
6.3 KiB
C#
157 lines
6.3 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 FocasProductionFixedTreeTests
|
|
{
|
|
private const string Host = "focas://10.0.0.5:8193";
|
|
|
|
/// <summary>
|
|
/// Variant of <see cref="FakeFocasClient"/> that returns a configurable
|
|
/// <see cref="FocasProductionInfo"/> snapshot from <c>GetProductionAsync</c>.
|
|
/// </summary>
|
|
private sealed class ProductionAwareFakeFocasClient : FakeFocasClient, IFocasClient
|
|
{
|
|
public FocasProductionInfo? Production { get; set; }
|
|
|
|
Task<FocasProductionInfo?> IFocasClient.GetProductionAsync(CancellationToken ct) =>
|
|
Task.FromResult(Production);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscoverAsync_emits_Production_folder_with_4_Int32_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 == "Production" && f.DisplayName == "Production");
|
|
var prodVars = builder.Variables.Where(v =>
|
|
v.Info.FullName.Contains("::Production/")).ToList();
|
|
prodVars.Count.ShouldBe(4);
|
|
string[] expected = ["PartsProduced", "PartsRequired", "PartsTotal", "CycleTimeSeconds"];
|
|
foreach (var name in expected)
|
|
{
|
|
var node = prodVars.SingleOrDefault(v => v.BrowseName == name);
|
|
node.BrowseName.ShouldBe(name);
|
|
node.Info.DriverDataType.ShouldBe(DriverDataType.Int32);
|
|
node.Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
|
node.Info.FullName.ShouldBe($"{Host}::Production/{name}");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_serves_each_Production_field_from_cached_snapshot()
|
|
{
|
|
var fake = new ProductionAwareFakeFocasClient
|
|
{
|
|
Production = new FocasProductionInfo(
|
|
PartsProduced: 17,
|
|
PartsRequired: 100,
|
|
PartsTotal: 4242,
|
|
CycleTimeSeconds: 73),
|
|
};
|
|
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}::Production/PartsProduced"], CancellationToken.None)).Single();
|
|
return snap.StatusCode == FocasStatusMapper.Good;
|
|
}, TimeSpan.FromSeconds(3));
|
|
|
|
var refs = new[]
|
|
{
|
|
$"{Host}::Production/PartsProduced",
|
|
$"{Host}::Production/PartsRequired",
|
|
$"{Host}::Production/PartsTotal",
|
|
$"{Host}::Production/CycleTimeSeconds",
|
|
};
|
|
var snaps = await drv.ReadAsync(refs, CancellationToken.None);
|
|
|
|
snaps[0].Value.ShouldBe(17);
|
|
snaps[1].Value.ShouldBe(100);
|
|
snaps[2].Value.ShouldBe(4242);
|
|
snaps[3].Value.ShouldBe(73);
|
|
foreach (var s in snaps) s.StatusCode.ShouldBe(FocasStatusMapper.Good);
|
|
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReadAsync_returns_BadCommunicationError_when_production_cache_is_empty()
|
|
{
|
|
// Probe disabled — cache never populates; the production 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}::Production/PartsProduced"], CancellationToken.None);
|
|
snaps.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FwlibFocasClient_GetProductionAsync_returns_null_when_disconnected()
|
|
{
|
|
// Construction is licence-safe (no DLL load); calling GetProductionAsync 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.GetProductionAsync(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) { } }
|
|
}
|
|
}
|