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:
Joseph Doherty
2026-05-17 01:55:28 -04:00
parent 69f02fed7f
commit a25593a9c6
1044 changed files with 365 additions and 343 deletions

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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) { } }
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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>