chore: organize solution into module folders (Core/Server/Drivers/Client/Tooling)
Group all 69 projects into category subfolders under src/ and tests/ so the Rider Solution Explorer mirrors the module structure. Folders: Core, Server, Drivers (with a nested Driver CLIs subfolder), Client, Tooling. - Move every project folder on disk with git mv (history preserved as renames). - Recompute relative paths in 57 .csproj files: cross-category ProjectReferences, the lib/ HintPath+None refs in Driver.Historian.Wonderware, and the external mxaccessgw refs in Driver.Galaxy and its test project. - Rebuild ZB.MOM.WW.OtOpcUa.slnx with nested solution folders. - Re-prefix project paths in functional scripts (e2e, compliance, smoke SQL, integration, install). Build green (0 errors); unit tests pass. Docs left for a separate pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
internal class FakeFocasClient : IFocasClient
|
||||
{
|
||||
public bool IsConnected { get; private set; }
|
||||
public int ConnectCount { get; private set; }
|
||||
public int DisposeCount { get; private set; }
|
||||
public bool ThrowOnConnect { get; set; }
|
||||
public bool ThrowOnRead { get; set; }
|
||||
public bool ThrowOnWrite { get; set; }
|
||||
public bool ProbeResult { get; set; } = true;
|
||||
public Exception? Exception { get; set; }
|
||||
|
||||
public Dictionary<string, object?> Values { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, uint> ReadStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public Dictionary<string, uint> WriteStatuses { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public List<(FocasAddress addr, FocasDataType type, object? value)> WriteLog { get; } = new();
|
||||
|
||||
public virtual Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
ConnectCount++;
|
||||
if (ThrowOnConnect) throw Exception ?? new InvalidOperationException();
|
||||
IsConnected = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public virtual Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
|
||||
var key = address.Canonical;
|
||||
var status = ReadStatuses.TryGetValue(key, out var s) ? s : FocasStatusMapper.Good;
|
||||
var value = Values.TryGetValue(key, out var v) ? v : null;
|
||||
return Task.FromResult((value, status));
|
||||
}
|
||||
|
||||
public virtual Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
|
||||
{
|
||||
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
|
||||
WriteLog.Add((address, type, value));
|
||||
Values[address.Canonical] = value;
|
||||
var status = WriteStatuses.TryGetValue(address.Canonical, out var s) ? s : FocasStatusMapper.Good;
|
||||
return Task.FromResult(status);
|
||||
}
|
||||
|
||||
public virtual Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(ProbeResult);
|
||||
|
||||
public List<FocasActiveAlarm> Alarms { get; } = [];
|
||||
|
||||
public virtual Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasActiveAlarm>>([.. Alarms]);
|
||||
|
||||
// ---- Fixed-tree T1 ----
|
||||
public FocasSysInfo SysInfo { get; set; } = new(0, 3, "M", "M", "30i", "A1.0", 3);
|
||||
public List<FocasAxisName> AxisNames { get; } = [new("X", ""), new("Y", ""), new("Z", "")];
|
||||
public List<FocasSpindleName> SpindleNames { get; } = [new("S", "1", "", "")];
|
||||
public Dictionary<int, FocasDynamicSnapshot> DynamicByAxis { get; } = [];
|
||||
|
||||
public virtual Task<FocasSysInfo> GetSysInfoAsync(CancellationToken ct) => Task.FromResult(SysInfo);
|
||||
public virtual Task<IReadOnlyList<FocasAxisName>> GetAxisNamesAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasAxisName>>([.. AxisNames]);
|
||||
public virtual Task<IReadOnlyList<FocasSpindleName>> GetSpindleNamesAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasSpindleName>>([.. SpindleNames]);
|
||||
public virtual Task<FocasDynamicSnapshot> ReadDynamicAsync(int axisIndex, CancellationToken ct)
|
||||
{
|
||||
if (!DynamicByAxis.TryGetValue(axisIndex, out var snap))
|
||||
snap = new FocasDynamicSnapshot(axisIndex, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
|
||||
return Task.FromResult(snap);
|
||||
}
|
||||
|
||||
public FocasProgramInfo ProgramInfo { get; set; } = new("O0001", 1, 0, 1);
|
||||
public virtual Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken ct) =>
|
||||
Task.FromResult(ProgramInfo);
|
||||
|
||||
public Dictionary<FocasTimerKind, FocasTimer> Timers { get; } = [];
|
||||
public virtual Task<FocasTimer> GetTimerAsync(FocasTimerKind kind, CancellationToken ct)
|
||||
{
|
||||
if (!Timers.TryGetValue(kind, out var t))
|
||||
t = new FocasTimer(kind, 0, 0);
|
||||
return Task.FromResult(t);
|
||||
}
|
||||
|
||||
public List<FocasServoLoad> ServoLoads { get; } = [];
|
||||
public virtual Task<IReadOnlyList<FocasServoLoad>> GetServoLoadsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<FocasServoLoad>>([.. ServoLoads]);
|
||||
|
||||
public List<int> SpindleLoads { get; } = [];
|
||||
public List<int> SpindleMaxRpms { get; } = [];
|
||||
public virtual Task<IReadOnlyList<int>> GetSpindleLoadsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<int>>([.. SpindleLoads]);
|
||||
public virtual Task<IReadOnlyList<int>> GetSpindleMaxRpmsAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<int>>([.. SpindleMaxRpms]);
|
||||
|
||||
public virtual void Dispose()
|
||||
{
|
||||
DisposeCount++;
|
||||
IsConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class FakeFocasClientFactory : IFocasClientFactory
|
||||
{
|
||||
public List<FakeFocasClient> Clients { get; } = new();
|
||||
public Func<FakeFocasClient>? Customise { get; set; }
|
||||
|
||||
public IFocasClient Create()
|
||||
{
|
||||
var c = Customise?.Invoke() ?? new FakeFocasClient();
|
||||
Clients.Add(c);
|
||||
return c;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasAlarmProjectionTests
|
||||
{
|
||||
private const string Host = "focas://10.0.0.5:8193";
|
||||
|
||||
private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(bool alarmsEnabled)
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions(Host)],
|
||||
Tags = [],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
AlarmProjection = new FocasAlarmProjectionOptions
|
||||
{
|
||||
Enabled = alarmsEnabled,
|
||||
PollInterval = TimeSpan.FromMilliseconds(30),
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_without_Enable_throws_NotSupported()
|
||||
{
|
||||
var (drv, _) = NewDriver(alarmsEnabled: false);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Should.ThrowAsync<NotSupportedException>(() =>
|
||||
drv.SubscribeAlarmsAsync([], CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Raise_then_clear_emits_both_events()
|
||||
{
|
||||
var (drv, factory) = NewDriver(alarmsEnabled: true);
|
||||
factory.Customise = () => new FakeFocasClient();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => { lock (events) events.Add(e); };
|
||||
|
||||
var sub = await drv.SubscribeAlarmsAsync([], CancellationToken.None);
|
||||
|
||||
// First tick creates the client via EnsureConnectedAsync — wait for it before we
|
||||
// poke the alarm list so we don't race the poll loop.
|
||||
await WaitFor(() => factory.Clients.Count > 0, TimeSpan.FromSeconds(3));
|
||||
var client = factory.Clients[0];
|
||||
client.Alarms.Add(new FocasActiveAlarm(500, FocasAlarmType.Overtravel, 1, "Axis 1 overtravel"));
|
||||
await WaitFor(() => events.Any(e => e.Message.Contains("overtravel")), TimeSpan.FromSeconds(3));
|
||||
|
||||
// Clear — the clear event wraps the original message with "(cleared)".
|
||||
client.Alarms.Clear();
|
||||
await WaitFor(() => events.Any(e => e.Message.Contains("cleared")), TimeSpan.FromSeconds(3));
|
||||
|
||||
await drv.UnsubscribeAlarmsAsync(sub, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
events.ShouldContain(e => e.AlarmType == "Overtravel" && e.Severity == AlarmSeverity.Critical);
|
||||
events.ShouldContain(e => e.Message.Contains("cleared"));
|
||||
events[0].SourceNodeId.ShouldBe(Host);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tick_diffs_raises_and_clears_without_polling_loop()
|
||||
{
|
||||
// Drive Tick directly so the test isn't timing-dependent. The projection's
|
||||
// Tick() is internal so we reach it through the driver using a handcrafted
|
||||
// subscription — simpler than standing up the full loop.
|
||||
var (drv, factory) = NewDriver(alarmsEnabled: true);
|
||||
factory.Customise = () => new FakeFocasClient();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var projection = new FocasAlarmProjection(drv, TimeSpan.FromMinutes(1));
|
||||
var sub = new FocasAlarmProjection.Subscription(
|
||||
new FocasAlarmSubscriptionHandle(1), deviceFilter: null,
|
||||
new CancellationTokenSource());
|
||||
|
||||
var events = new List<AlarmEventArgs>();
|
||||
drv.OnAlarmEvent += (_, e) => events.Add(e);
|
||||
|
||||
// Tick 1 — raise two alarms.
|
||||
projection.Tick(sub, Host, [
|
||||
new FocasActiveAlarm(100, FocasAlarmType.Parameter, 0, "Param 100"),
|
||||
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
|
||||
]);
|
||||
events.Count.ShouldBe(2);
|
||||
events[0].Severity.ShouldBe(AlarmSeverity.Medium);
|
||||
events[1].Severity.ShouldBe(AlarmSeverity.Critical);
|
||||
|
||||
// Tick 2 — same alarms stay active → no new events.
|
||||
events.Clear();
|
||||
projection.Tick(sub, Host, [
|
||||
new FocasActiveAlarm(100, FocasAlarmType.Parameter, 0, "Param 100"),
|
||||
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
|
||||
]);
|
||||
events.ShouldBeEmpty();
|
||||
|
||||
// Tick 3 — one clears, one stays → one "cleared" event only.
|
||||
projection.Tick(sub, Host, [
|
||||
new FocasActiveAlarm(200, FocasAlarmType.Servo, 1, "Servo 200"),
|
||||
]);
|
||||
events.Count.ShouldBe(1);
|
||||
events[0].Message.ShouldEndWith("(cleared)");
|
||||
events[0].AlarmType.ShouldBe("Parameter");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Severity_mapping_matches_docs()
|
||||
{
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Overtravel).ShouldBe(AlarmSeverity.Critical);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Servo).ShouldBe(AlarmSeverity.Critical);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.PulseCode).ShouldBe(AlarmSeverity.Critical);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Parameter).ShouldBe(AlarmSeverity.Medium);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.MacroAlarm).ShouldBe(AlarmSeverity.Medium);
|
||||
FocasAlarmProjection.MapSeverity(FocasAlarmType.Overheat).ShouldBe(AlarmSeverity.High);
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (pred()) return;
|
||||
await Task.Delay(30);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Version-matrix coverage for <see cref="FocasCapabilityMatrix"/>. Encodes the
|
||||
/// documented Fanuc FOCAS Developer Kit support boundaries per CNC series so a
|
||||
/// config-time change that widens or narrows a range without updating
|
||||
/// <c>docs/v2/focas-version-matrix.md</c> fails a test. Every assertion cites the
|
||||
/// specific matrix row it reflects.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasCapabilityMatrixTests
|
||||
{
|
||||
// ---- Macro ranges ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, 999, true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, 1000, false)] // above legacy ceiling
|
||||
[InlineData(FocasCncSeries.Zero_i_D, 999, true)]
|
||||
[InlineData(FocasCncSeries.Zero_i_D, 9999, false)] // 0i-D is still legacy-ceiling
|
||||
[InlineData(FocasCncSeries.Zero_i_F, 9999, true)] // widened on 0i-F
|
||||
[InlineData(FocasCncSeries.Zero_i_F, 10000, false)]
|
||||
[InlineData(FocasCncSeries.Thirty_i, 99999, true)] // highest-end
|
||||
[InlineData(FocasCncSeries.Thirty_i, 100000, false)]
|
||||
[InlineData(FocasCncSeries.PowerMotion_i, 999, true)]
|
||||
[InlineData(FocasCncSeries.PowerMotion_i, 1000, false)] // atypical coverage
|
||||
public void Macro_range_matches_series(FocasCncSeries series, int number, bool accepted)
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Macro, null, number, null);
|
||||
var result = FocasCapabilityMatrix.Validate(series, address);
|
||||
(result is null).ShouldBe(accepted,
|
||||
$"Macro #{number} on {series}: expected {(accepted ? "accept" : "reject")}, got {(result ?? "accept")}");
|
||||
}
|
||||
|
||||
// ---- Parameter ranges ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, 9999, true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, 10000, false)] // 16i capped at 9999
|
||||
[InlineData(FocasCncSeries.Zero_i_F, 14999, true)]
|
||||
[InlineData(FocasCncSeries.Zero_i_F, 15000, false)]
|
||||
[InlineData(FocasCncSeries.Thirty_i, 29999, true)]
|
||||
[InlineData(FocasCncSeries.Thirty_i, 30000, false)]
|
||||
public void Parameter_range_matches_series(FocasCncSeries series, int number, bool accepted)
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Parameter, null, number, null);
|
||||
var result = FocasCapabilityMatrix.Validate(series, address);
|
||||
(result is null).ShouldBe(accepted);
|
||||
}
|
||||
|
||||
// ---- PMC letters ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "X", true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "Y", true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "R", true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "F", false)] // 16i has no F/G signal groups
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "G", false)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "K", false)]
|
||||
[InlineData(FocasCncSeries.Zero_i_D, "E", true)] // widened since 0i-D
|
||||
[InlineData(FocasCncSeries.Zero_i_D, "F", false)] // still no F on 0i-D
|
||||
[InlineData(FocasCncSeries.Zero_i_F, "F", true)] // F/G added on 0i-F
|
||||
[InlineData(FocasCncSeries.Zero_i_F, "K", false)] // K/T still 30i-only
|
||||
[InlineData(FocasCncSeries.Thirty_i, "K", true)]
|
||||
[InlineData(FocasCncSeries.Thirty_i, "T", true)]
|
||||
[InlineData(FocasCncSeries.Thirty_i, "Q", false)] // unsupported even on 30i
|
||||
public void Pmc_letter_matches_series(FocasCncSeries series, string letter, bool accepted)
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Pmc, letter, 0, null);
|
||||
var result = FocasCapabilityMatrix.Validate(series, address);
|
||||
(result is null).ShouldBe(accepted,
|
||||
$"PMC letter '{letter}' on {series}: expected {(accepted ? "accept" : "reject")}, got {(result ?? "accept")}");
|
||||
}
|
||||
|
||||
// ---- PMC number ceiling ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "R", 999, true)]
|
||||
[InlineData(FocasCncSeries.Sixteen_i, "R", 1000, false)]
|
||||
[InlineData(FocasCncSeries.Zero_i_D, "R", 1999, true)]
|
||||
[InlineData(FocasCncSeries.Zero_i_D, "R", 2000, false)]
|
||||
[InlineData(FocasCncSeries.Zero_i_F, "R", 9999, true)]
|
||||
[InlineData(FocasCncSeries.Zero_i_F, "R", 10000, false)]
|
||||
[InlineData(FocasCncSeries.Thirty_i, "R", 59999, true)]
|
||||
[InlineData(FocasCncSeries.Thirty_i, "R", 60000, false)]
|
||||
public void Pmc_number_ceiling_matches_series(FocasCncSeries series, string letter, int number, bool accepted)
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Pmc, letter, number, null);
|
||||
var result = FocasCapabilityMatrix.Validate(series, address);
|
||||
(result is null).ShouldBe(accepted);
|
||||
}
|
||||
|
||||
// ---- Unknown series is permissive ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("Z", 999_999)] // absurd PMC address
|
||||
[InlineData("Q", 0)] // non-existent letter
|
||||
public void Unknown_series_accepts_any_PMC(string letter, int number)
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Pmc, letter, number, null);
|
||||
FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_series_accepts_any_macro_number()
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Macro, null, 999_999, null);
|
||||
FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unknown_series_accepts_any_parameter_number()
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Parameter, null, 999_999, null);
|
||||
FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull();
|
||||
}
|
||||
|
||||
// ---- Reason messages include enough context to diagnose ----
|
||||
|
||||
[Fact]
|
||||
public void Rejection_message_names_series_and_limit()
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Macro, null, 100_000, null);
|
||||
var reason = FocasCapabilityMatrix.Validate(FocasCncSeries.Zero_i_F, address);
|
||||
reason.ShouldNotBeNull();
|
||||
reason.ShouldContain("100000");
|
||||
reason.ShouldContain("Zero_i_F");
|
||||
reason.ShouldContain("9999");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Pmc_rejection_lists_accepted_letters()
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Pmc, "Q", 0, null);
|
||||
var reason = FocasCapabilityMatrix.Validate(FocasCncSeries.Thirty_i, address);
|
||||
reason.ShouldNotBeNull();
|
||||
reason.ShouldContain("'Q'");
|
||||
reason.ShouldContain("X"); // some accepted letter should appear
|
||||
reason.ShouldContain("Y");
|
||||
}
|
||||
|
||||
// ---- PMC address letter is case-insensitive ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("x")]
|
||||
[InlineData("X")]
|
||||
[InlineData("f")]
|
||||
public void Pmc_letter_match_is_case_insensitive_on_30i(string letter)
|
||||
{
|
||||
var address = new FocasAddress(FocasAreaKind.Pmc, letter, 0, null);
|
||||
FocasCapabilityMatrix.Validate(FocasCncSeries.Thirty_i, address).ShouldBeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
using System.Collections.Concurrent;
|
||||
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 FocasCapabilityTests
|
||||
{
|
||||
// ---- ITagDiscovery ----
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoverAsync_emits_pre_declared_tags()
|
||||
{
|
||||
var builder = new RecordingBuilder();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193", DeviceName: "Lathe-1")],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
||||
new FocasTagDefinition("Alarm", "focas://10.0.0.5:8193", "R200", FocasDataType.Byte, Writable: false),
|
||||
],
|
||||
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 == "FOCAS");
|
||||
builder.Folders.ShouldContain(f => f.BrowseName == "focas://10.0.0.5:8193" && f.DisplayName == "Lathe-1");
|
||||
builder.Variables.Single(v => v.BrowseName == "Run").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
|
||||
builder.Variables.Single(v => v.BrowseName == "Alarm").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
|
||||
}
|
||||
|
||||
// ---- ISubscribable ----
|
||||
|
||||
[Fact]
|
||||
public async Task Subscribe_initial_poll_raises_OnDataChange()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)42 } },
|
||||
};
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(200), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(2));
|
||||
|
||||
events.First().Snapshot.Value.ShouldBe((sbyte)42);
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_cancels_active_subscriptions()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } },
|
||||
};
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = [new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
|
||||
_ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
await WaitForAsync(() => events.Count >= 1, TimeSpan.FromSeconds(1));
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
var afterShutdown = events.Count;
|
||||
await Task.Delay(200);
|
||||
events.Count.ShouldBe(afterShutdown);
|
||||
}
|
||||
|
||||
// ---- IHostConnectivityProbe ----
|
||||
|
||||
[Fact]
|
||||
public async Task GetHostStatuses_returns_entry_per_device()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions("focas://10.0.0.5:8193"),
|
||||
new FocasDeviceOptions("focas://10.0.0.6:8193"),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.GetHostStatuses().Count.ShouldBe(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Running_on_success()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new FakeFocasClient { ProbeResult = true },
|
||||
};
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Probe = new FocasProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Running), TimeSpan.FromSeconds(2));
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Probe_transitions_to_Stopped_on_failure()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory
|
||||
{
|
||||
Customise = () => new FakeFocasClient { ProbeResult = false },
|
||||
};
|
||||
var transitions = new ConcurrentQueue<HostStatusChangedEventArgs>();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Probe = new FocasProbeOptions
|
||||
{
|
||||
Enabled = true, Interval = TimeSpan.FromMilliseconds(100),
|
||||
Timeout = TimeSpan.FromMilliseconds(50),
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
drv.OnHostStatusChanged += (_, e) => transitions.Enqueue(e);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitForAsync(() => transitions.Any(t => t.NewState == HostState.Stopped), TimeSpan.FromSeconds(2));
|
||||
|
||||
drv.GetHostStatuses().Single().State.ShouldBe(HostState.Stopped);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_returns_declared_device_for_known_tag()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions("focas://10.0.0.5:8193"),
|
||||
new FocasDeviceOptions("focas://10.0.0.6:8193"),
|
||||
],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
||||
new FocasTagDefinition("B", "focas://10.0.0.6:8193", "R100", FocasDataType.Byte),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("A").ShouldBe("focas://10.0.0.5:8193");
|
||||
drv.ResolveHost("B").ShouldBe("focas://10.0.0.6:8193");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_first_device_for_unknown()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("missing").ShouldBe("focas://10.0.0.5:8193");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveHost_falls_back_to_DriverInstanceId_when_no_devices()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions(), "drv-1", new FakeFocasClientFactory());
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.ResolveHost("anything").ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!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) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasHandleRecycleTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Recycle_loop_disposes_client_on_interval_reads_reopen_fresh_one()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = [new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
HandleRecycle = new FocasHandleRecycleOptions
|
||||
{
|
||||
Enabled = true,
|
||||
Interval = TimeSpan.FromMilliseconds(80),
|
||||
},
|
||||
}, "drv-1", factory);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// First read forces the initial connect.
|
||||
await drv.ReadAsync(["R"], CancellationToken.None);
|
||||
var initialClients = factory.Clients.Count;
|
||||
initialClients.ShouldBe(1);
|
||||
|
||||
// Wait for a recycle tick, then read again — a new client must have been created.
|
||||
await WaitFor(() => factory.Clients[0].DisposeCount > 0, TimeSpan.FromSeconds(3));
|
||||
await drv.ReadAsync(["R"], CancellationToken.None);
|
||||
|
||||
factory.Clients.Count.ShouldBeGreaterThan(initialClients);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Recycle_loop_stays_off_when_not_enabled()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = [new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte)],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await drv.ReadAsync(["R"], CancellationToken.None);
|
||||
await Task.Delay(150);
|
||||
|
||||
// With recycle off the same client stays live — no Dispose during the window.
|
||||
factory.Clients.Count.ShouldBe(1);
|
||||
factory.Clients[0].DisposeCount.ShouldBe(0);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
if (pred()) return;
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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 FocasPmcBitRmwTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Fake client simulating PMC byte storage + exposing it as a sbyte so RMW callers can
|
||||
/// observe the read-modify-write round-trip. ReadAsync for a Bit with bitIndex surfaces
|
||||
/// the current bit; WriteAsync stores the full byte the driver issues.
|
||||
/// </summary>
|
||||
private sealed class PmcRmwFake : FakeFocasClient
|
||||
{
|
||||
public byte[] PmcBytes { get; } = new byte[1024];
|
||||
|
||||
public override Task<(object? value, uint status)> ReadAsync(
|
||||
FocasAddress address, FocasDataType type, CancellationToken ct)
|
||||
{
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Byte)
|
||||
return Task.FromResult(((object?)(sbyte)PmcBytes[address.Number], FocasStatusMapper.Good));
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
|
||||
return Task.FromResult(((object?)((PmcBytes[address.Number] & (1 << bit)) != 0), FocasStatusMapper.Good));
|
||||
return base.ReadAsync(address, type, ct);
|
||||
}
|
||||
|
||||
public override Task<uint> WriteAsync(
|
||||
FocasAddress address, FocasDataType type, object? value, CancellationToken ct)
|
||||
{
|
||||
// Driver writes the full byte after RMW (type==Byte with full byte value), OR a raw
|
||||
// bit write (type==Bit, bitIndex non-null) — depending on how the driver routes it.
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Byte)
|
||||
{
|
||||
PmcBytes[address.Number] = (byte)Convert.ToSByte(value);
|
||||
return Task.FromResult(FocasStatusMapper.Good);
|
||||
}
|
||||
if (address.Kind == FocasAreaKind.Pmc && type == FocasDataType.Bit && address.BitIndex is int bit)
|
||||
{
|
||||
var current = PmcBytes[address.Number];
|
||||
PmcBytes[address.Number] = Convert.ToBoolean(value)
|
||||
? (byte)(current | (1 << bit))
|
||||
: (byte)(current & ~(1 << bit));
|
||||
return Task.FromResult(FocasStatusMapper.Good);
|
||||
}
|
||||
return base.WriteAsync(address, type, value, ct);
|
||||
}
|
||||
}
|
||||
|
||||
private static (FocasDriver drv, PmcRmwFake fake) NewDriver(params FocasTagDefinition[] tags)
|
||||
{
|
||||
var fake = new PmcRmwFake();
|
||||
var factory = new FakeFocasClientFactory { Customise = () => fake };
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = tags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
return (drv, fake);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_set_surfaces_as_Good_status_and_flips_bit()
|
||||
{
|
||||
var (drv, fake) = NewDriver(
|
||||
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100.3", FocasDataType.Bit));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.PmcBytes[100] = 0b0000_0001;
|
||||
|
||||
var results = await drv.WriteAsync([new WriteRequest("Run", true)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
fake.PmcBytes[100].ShouldBe((byte)0b0000_1001);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_clear_preserves_other_bits()
|
||||
{
|
||||
var (drv, fake) = NewDriver(
|
||||
new FocasTagDefinition("Flag", "focas://10.0.0.5:8193", "R100.3", FocasDataType.Bit));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.PmcBytes[100] = 0xFF;
|
||||
|
||||
await drv.WriteAsync([new WriteRequest("Flag", false)], CancellationToken.None);
|
||||
|
||||
fake.PmcBytes[100].ShouldBe((byte)0b1111_0111);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Subsequent_bit_sets_in_same_byte_compose_correctly()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 8)
|
||||
.Select(b => new FocasTagDefinition($"Bit{b}", "focas://10.0.0.5:8193", $"R100.{b}", FocasDataType.Bit))
|
||||
.ToArray();
|
||||
var (drv, fake) = NewDriver(tags);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
fake.PmcBytes[100] = 0;
|
||||
|
||||
for (var b = 0; b < 8; b++)
|
||||
await drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None);
|
||||
|
||||
fake.PmcBytes[100].ShouldBe((byte)0xFF);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_write_to_different_bytes_does_not_contend()
|
||||
{
|
||||
var tags = Enumerable.Range(0, 4)
|
||||
.Select(i => new FocasTagDefinition($"Bit{i}", "focas://10.0.0.5:8193", $"R{50 + i}.0", FocasDataType.Bit))
|
||||
.ToArray();
|
||||
var (drv, fake) = NewDriver(tags);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await Task.WhenAll(Enumerable.Range(0, 4).Select(i =>
|
||||
drv.WriteAsync([new WriteRequest($"Bit{i}", true)], CancellationToken.None)));
|
||||
|
||||
for (var i = 0; i < 4; i++)
|
||||
fake.PmcBytes[50 + i].ShouldBe((byte)0x01);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
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 FocasReadWriteTests
|
||||
{
|
||||
private static (FocasDriver drv, FakeFocasClientFactory factory) NewDriver(params FocasTagDefinition[] tags)
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags = tags,
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
// ---- Read ----
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_reference_maps_to_BadNodeIdUnknown()
|
||||
{
|
||||
var (drv, _) = NewDriver();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var snapshots = await drv.ReadAsync(["missing"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_PMC_read_returns_Good_value()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Run", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)5 } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Run"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe((sbyte)5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Parameter_read_routes_through_FocasAddress_Parameter_kind()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Accel", "focas://10.0.0.5:8193", "PARAM:1820", FocasDataType.Int32));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["PARAM:1820"] = 1500 } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Accel"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
snapshots.Single().Value.ShouldBe(1500);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Macro_read_routes_through_FocasAddress_Macro_kind()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("CustomVar", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["MACRO:500"] = 3.14159 } };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["CustomVar"], CancellationToken.None);
|
||||
snapshots.Single().Value.ShouldBe(3.14159);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repeat_read_reuses_connection()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } };
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
factory.Clients.Count.ShouldBe(1);
|
||||
factory.Clients[0].ConnectCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FOCAS_error_status_maps_via_status_mapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Ghost", "focas://10.0.0.5:8193", "R999", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () =>
|
||||
{
|
||||
var c = new FakeFocasClient();
|
||||
c.ReadStatuses["R999"] = FocasStatusMapper.BadNodeIdUnknown;
|
||||
return c;
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["Ghost"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_exception_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { ThrowOnRead = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_failure_disposes_client_and_surfaces_BadCommunicationError()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { ThrowOnConnect = true };
|
||||
|
||||
var snapshots = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
snapshots.Single().StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
|
||||
factory.Clients[0].DisposeCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batched_reads_preserve_order_across_areas()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
||||
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "PARAM:1820", FocasDataType.Int32),
|
||||
new FocasTagDefinition("C", "focas://10.0.0.5:8193", "MACRO:500", FocasDataType.Float64));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
Values =
|
||||
{
|
||||
["R100"] = (sbyte)5,
|
||||
["PARAM:1820"] = 1500,
|
||||
["MACRO:500"] = 2.718,
|
||||
},
|
||||
};
|
||||
|
||||
var snapshots = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
||||
snapshots[0].Value.ShouldBe((sbyte)5);
|
||||
snapshots[1].Value.ShouldBe(1500);
|
||||
snapshots[2].Value.ShouldBe(2.718);
|
||||
}
|
||||
|
||||
// ---- Write ----
|
||||
|
||||
[Fact]
|
||||
public async Task Non_writable_tag_rejected_with_BadNotWritable()
|
||||
{
|
||||
var (drv, _) = NewDriver(
|
||||
new FocasTagDefinition("RO", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte, Writable: false));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("RO", 1)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Successful_write_logs_address_type_value()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Speed", "focas://10.0.0.5:8193", "R100", FocasDataType.Int16));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Speed", (short)1800)], CancellationToken.None);
|
||||
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
var write = factory.Clients[0].WriteLog.Single();
|
||||
write.addr.Canonical.ShouldBe("R100");
|
||||
write.type.ShouldBe(FocasDataType.Int16);
|
||||
write.value.ShouldBe((short)1800);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Write_status_code_maps_via_FocasStatusMapper()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("Protected", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () =>
|
||||
{
|
||||
var c = new FakeFocasClient();
|
||||
c.WriteStatuses["R100"] = FocasStatusMapper.BadNotWritable;
|
||||
return c;
|
||||
};
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[new WriteRequest("Protected", (sbyte)1)], CancellationToken.None);
|
||||
results.Single().StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Batch_write_preserves_order_across_outcomes()
|
||||
{
|
||||
var factory = new FakeFocasClientFactory();
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Tags =
|
||||
[
|
||||
new FocasTagDefinition("A", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte),
|
||||
new FocasTagDefinition("B", "focas://10.0.0.5:8193", "R101", FocasDataType.Byte, Writable: false),
|
||||
],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var results = await drv.WriteAsync(
|
||||
[
|
||||
new WriteRequest("A", (sbyte)1),
|
||||
new WriteRequest("B", (sbyte)2),
|
||||
new WriteRequest("Unknown", (sbyte)3),
|
||||
], CancellationToken.None);
|
||||
|
||||
results[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
|
||||
results[1].StatusCode.ShouldBe(FocasStatusMapper.BadNotWritable);
|
||||
results[2].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cancellation_propagates()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient
|
||||
{
|
||||
ThrowOnRead = true,
|
||||
Exception = new OperationCanceledException(),
|
||||
};
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(
|
||||
() => drv.ReadAsync(["X"], CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_disposes_client()
|
||||
{
|
||||
var (drv, factory) = NewDriver(
|
||||
new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1 } };
|
||||
|
||||
await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
factory.Clients[0].DisposeCount.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
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 FocasScaffoldingTests
|
||||
{
|
||||
// ---- FocasHostAddress ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("focas://10.0.0.5:8193", "10.0.0.5", 8193)]
|
||||
[InlineData("focas://10.0.0.5", "10.0.0.5", 8193)] // default port
|
||||
[InlineData("focas://cnc-01.factory.internal:8193", "cnc-01.factory.internal", 8193)]
|
||||
[InlineData("focas://10.0.0.5:12345", "10.0.0.5", 12345)]
|
||||
[InlineData("FOCAS://10.0.0.5:8193", "10.0.0.5", 8193)] // case-insensitive scheme
|
||||
public void HostAddress_parses_valid(string input, string host, int port)
|
||||
{
|
||||
var parsed = FocasHostAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Host.ShouldBe(host);
|
||||
parsed.Port.ShouldBe(port);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("http://10.0.0.5/")]
|
||||
[InlineData("focas:10.0.0.5:8193")] // missing //
|
||||
[InlineData("focas://")] // empty body
|
||||
[InlineData("focas://10.0.0.5:0")] // port 0
|
||||
[InlineData("focas://10.0.0.5:65536")] // port out of range
|
||||
[InlineData("focas://10.0.0.5:abc")] // non-numeric port
|
||||
public void HostAddress_rejects_invalid(string? input)
|
||||
{
|
||||
FocasHostAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HostAddress_ToString_strips_default_port()
|
||||
{
|
||||
new FocasHostAddress("10.0.0.5", 8193).ToString().ShouldBe("focas://10.0.0.5");
|
||||
new FocasHostAddress("10.0.0.5", 12345).ToString().ShouldBe("focas://10.0.0.5:12345");
|
||||
}
|
||||
|
||||
// ---- FocasAddress ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("X0.0", FocasAreaKind.Pmc, "X", 0, 0)]
|
||||
[InlineData("X0", FocasAreaKind.Pmc, "X", 0, null)]
|
||||
[InlineData("Y10", FocasAreaKind.Pmc, "Y", 10, null)]
|
||||
[InlineData("F20.3", FocasAreaKind.Pmc, "F", 20, 3)]
|
||||
[InlineData("G54", FocasAreaKind.Pmc, "G", 54, null)]
|
||||
[InlineData("R100", FocasAreaKind.Pmc, "R", 100, null)]
|
||||
[InlineData("D200", FocasAreaKind.Pmc, "D", 200, null)]
|
||||
[InlineData("C300", FocasAreaKind.Pmc, "C", 300, null)]
|
||||
[InlineData("K400", FocasAreaKind.Pmc, "K", 400, null)]
|
||||
[InlineData("A500", FocasAreaKind.Pmc, "A", 500, null)]
|
||||
[InlineData("E600", FocasAreaKind.Pmc, "E", 600, null)]
|
||||
[InlineData("T50.4", FocasAreaKind.Pmc, "T", 50, 4)]
|
||||
public void Address_parses_PMC_forms(string input, FocasAreaKind kind, string letter, int num, int? bit)
|
||||
{
|
||||
var a = FocasAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.Kind.ShouldBe(kind);
|
||||
a.PmcLetter.ShouldBe(letter);
|
||||
a.Number.ShouldBe(num);
|
||||
a.BitIndex.ShouldBe(bit);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("PARAM:1020", FocasAreaKind.Parameter, 1020, null)]
|
||||
[InlineData("PARAM:1815/0", FocasAreaKind.Parameter, 1815, 0)]
|
||||
[InlineData("PARAM:1815/31", FocasAreaKind.Parameter, 1815, 31)]
|
||||
public void Address_parses_parameter_forms(string input, FocasAreaKind kind, int num, int? bit)
|
||||
{
|
||||
var a = FocasAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.Kind.ShouldBe(kind);
|
||||
a.PmcLetter.ShouldBeNull();
|
||||
a.Number.ShouldBe(num);
|
||||
a.BitIndex.ShouldBe(bit);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("MACRO:100", FocasAreaKind.Macro, 100)]
|
||||
[InlineData("MACRO:500", FocasAreaKind.Macro, 500)]
|
||||
public void Address_parses_macro_forms(string input, FocasAreaKind kind, int num)
|
||||
{
|
||||
var a = FocasAddress.TryParse(input);
|
||||
a.ShouldNotBeNull();
|
||||
a.Kind.ShouldBe(kind);
|
||||
a.Number.ShouldBe(num);
|
||||
a.BitIndex.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("Z0")] // unknown PMC letter
|
||||
[InlineData("X")] // missing number
|
||||
[InlineData("X-1")] // negative number
|
||||
[InlineData("Xabc")] // non-numeric
|
||||
[InlineData("X0.8")] // bit out of range (0-7)
|
||||
[InlineData("X0.-1")] // negative bit
|
||||
[InlineData("PARAM:")] // missing number
|
||||
[InlineData("PARAM:1815/32")] // bit out of range (0-31)
|
||||
[InlineData("MACRO:abc")] // non-numeric
|
||||
public void Address_rejects_invalid_forms(string? input)
|
||||
{
|
||||
FocasAddress.TryParse(input).ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("X0.0")]
|
||||
[InlineData("R100")]
|
||||
[InlineData("F20.3")]
|
||||
[InlineData("PARAM:1020")]
|
||||
[InlineData("PARAM:1815/0")]
|
||||
[InlineData("MACRO:100")]
|
||||
public void Address_Canonical_roundtrips(string input)
|
||||
{
|
||||
var parsed = FocasAddress.TryParse(input);
|
||||
parsed.ShouldNotBeNull();
|
||||
parsed.Canonical.ShouldBe(input);
|
||||
}
|
||||
|
||||
// ---- FocasDataType ----
|
||||
|
||||
[Fact]
|
||||
public void DataType_mapping_covers_atomic_focas_types()
|
||||
{
|
||||
FocasDataType.Bit.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
|
||||
FocasDataType.Int16.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
FocasDataType.Int32.ToDriverDataType().ShouldBe(DriverDataType.Int32);
|
||||
FocasDataType.Float32.ToDriverDataType().ShouldBe(DriverDataType.Float32);
|
||||
FocasDataType.Float64.ToDriverDataType().ShouldBe(DriverDataType.Float64);
|
||||
FocasDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
|
||||
}
|
||||
|
||||
// ---- FocasStatusMapper ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, FocasStatusMapper.Good)]
|
||||
[InlineData(3, FocasStatusMapper.BadOutOfRange)] // EW_NUMBER
|
||||
[InlineData(4, FocasStatusMapper.BadOutOfRange)] // EW_LENGTH
|
||||
[InlineData(5, FocasStatusMapper.BadNotWritable)] // EW_PROT
|
||||
[InlineData(6, FocasStatusMapper.BadNotSupported)] // EW_NOOPT
|
||||
[InlineData(8, FocasStatusMapper.BadNodeIdUnknown)] // EW_DATA
|
||||
[InlineData(-1, FocasStatusMapper.BadDeviceFailure)] // EW_BUSY
|
||||
[InlineData(-8, FocasStatusMapper.BadInternalError)] // EW_HANDLE
|
||||
[InlineData(-16, FocasStatusMapper.BadCommunicationError)] // EW_SOCKET
|
||||
[InlineData(999, FocasStatusMapper.BadCommunicationError)] // unknown → generic
|
||||
public void StatusMapper_covers_known_focas_returns(int ret, uint expected)
|
||||
{
|
||||
FocasStatusMapper.MapFocasReturn(ret).ShouldBe(expected);
|
||||
}
|
||||
|
||||
// ---- FocasDriver ----
|
||||
|
||||
[Fact]
|
||||
public void DriverType_is_FOCAS()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions(), "drv-1");
|
||||
drv.DriverType.ShouldBe("FOCAS");
|
||||
drv.DriverInstanceId.ShouldBe("drv-1");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_parses_device_addresses()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices =
|
||||
[
|
||||
new FocasDeviceOptions("focas://10.0.0.5:8193"),
|
||||
new FocasDeviceOptions("focas://10.0.0.6:12345", DeviceName: "CNC-2"),
|
||||
],
|
||||
}, "drv-1");
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.DeviceCount.ShouldBe(2);
|
||||
drv.GetDeviceState("focas://10.0.0.5:8193")!.ParsedAddress.Port.ShouldBe(8193);
|
||||
drv.GetDeviceState("focas://10.0.0.6:12345")!.Options.DeviceName.ShouldBe("CNC-2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeAsync_malformed_address_faults()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("not-an-address")],
|
||||
}, "drv-1");
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
() => drv.InitializeAsync("{}", CancellationToken.None));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ShutdownAsync_clears_devices()
|
||||
{
|
||||
var drv = new FocasDriver(new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
Probe = new FocasProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
// ---- UnimplementedFocasClientFactory ----
|
||||
|
||||
[Fact]
|
||||
public void Unimplemented_factory_throws_on_Create_with_config_pointer()
|
||||
{
|
||||
var factory = new UnimplementedFocasClientFactory();
|
||||
var ex = Should.Throw<NotSupportedException>(() => factory.Create());
|
||||
ex.Message.ShouldContain("wire");
|
||||
ex.Message.ShouldContain("docs/drivers/FOCAS.md");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="xunit.v3" Version="1.1.0"/>
|
||||
<PackageReference Include="Shouldly" Version="4.3.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-37gx-xxp4-5rgx"/>
|
||||
<NuGetAuditSuppress Include="https://github.com/advisories/GHSA-w3x6-4m5h-cxqf"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user