118 lines
5.4 KiB
C#
118 lines
5.4 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
|
|
|
/// <summary>
|
|
/// Shape tests for <see cref="S7Driver"/>'s <see cref="ITagDiscovery"/>,
|
|
/// <see cref="ISubscribable"/>, and <see cref="IHostConnectivityProbe"/> surfaces that
|
|
/// don't need a live PLC. Wire-level polling round-trips and probe transitions land in a
|
|
/// follow-up PR once we have a mock S7 server.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class S7DiscoveryAndSubscribeTests
|
|
{
|
|
private sealed class RecordingAddressSpaceBuilder : IAddressSpaceBuilder
|
|
{
|
|
public readonly List<string> Folders = new();
|
|
public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new();
|
|
|
|
public IAddressSpaceBuilder Folder(string browseName, string displayName)
|
|
{
|
|
Folders.Add(browseName);
|
|
return this;
|
|
}
|
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
|
{
|
|
Variables.Add((browseName, attributeInfo));
|
|
return new StubHandle();
|
|
}
|
|
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
|
public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { }
|
|
|
|
private sealed class StubHandle : IVariableHandle
|
|
{
|
|
public string FullReference => "stub";
|
|
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
|
|
=> throw new NotImplementedException("S7 driver never calls this — no alarm surfacing");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscoverAsync_projects_every_tag_into_the_address_space()
|
|
{
|
|
var opts = new S7DriverOptions
|
|
{
|
|
Host = "192.0.2.1",
|
|
Tags =
|
|
[
|
|
new("TempSetpoint", "DB1.DBW0", S7DataType.Int16, Writable: true),
|
|
new("FaultBit", "M0.0", S7DataType.Bool, Writable: false),
|
|
new("PIDOutput", "DB5.DBD12", S7DataType.Float32, Writable: true),
|
|
],
|
|
};
|
|
using var drv = new S7Driver(opts, "s7-disco");
|
|
|
|
var builder = new RecordingAddressSpaceBuilder();
|
|
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
|
|
|
|
builder.Folders.ShouldContain("S7");
|
|
builder.Variables.Count.ShouldBe(3);
|
|
builder.Variables[0].Name.ShouldBe("TempSetpoint");
|
|
builder.Variables[0].Attr.SecurityClass.ShouldBe(SecurityClassification.Operate, "writable tags get Operate security class");
|
|
builder.Variables[1].Attr.SecurityClass.ShouldBe(SecurityClassification.ViewOnly, "read-only tags get ViewOnly");
|
|
builder.Variables[2].Attr.DriverDataType.ShouldBe(DriverDataType.Float32);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetHostStatuses_returns_one_row_with_host_port_identity_pre_init()
|
|
{
|
|
var opts = new S7DriverOptions { Host = "plc1.internal", Port = 102 };
|
|
using var drv = new S7Driver(opts, "s7-host");
|
|
|
|
var rows = drv.GetHostStatuses();
|
|
rows.Count.ShouldBe(1);
|
|
rows[0].HostName.ShouldBe("plc1.internal:102");
|
|
rows[0].State.ShouldBe(HostState.Unknown, "pre-init / pre-probe state is Unknown");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SubscribeAsync_returns_unique_handles_and_UnsubscribeAsync_accepts_them()
|
|
{
|
|
var opts = new S7DriverOptions { Host = "192.0.2.1" };
|
|
using var drv = new S7Driver(opts, "s7-sub");
|
|
|
|
// SubscribeAsync does not itself call ReadAsync (the poll task does), so this works
|
|
// even though the driver isn't initialized. The poll task catches the resulting
|
|
// InvalidOperationException and the loop quietly continues — same pattern as the
|
|
// Modbus driver's poll loop tolerating transient transport failures.
|
|
var h1 = await drv.SubscribeAsync(["T1"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
|
var h2 = await drv.SubscribeAsync(["T2"], TimeSpan.FromMilliseconds(200), TestContext.Current.CancellationToken);
|
|
|
|
h1.DiagnosticId.ShouldStartWith("s7-sub-");
|
|
h2.DiagnosticId.ShouldStartWith("s7-sub-");
|
|
h1.DiagnosticId.ShouldNotBe(h2.DiagnosticId);
|
|
|
|
await drv.UnsubscribeAsync(h1, TestContext.Current.CancellationToken);
|
|
await drv.UnsubscribeAsync(h2, TestContext.Current.CancellationToken);
|
|
// UnsubscribeAsync with an unknown handle must be a no-op, not throw.
|
|
await drv.UnsubscribeAsync(h1, TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Subscribe_publishing_interval_is_floored_at_100ms()
|
|
{
|
|
var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } };
|
|
using var drv = new S7Driver(opts, "s7-floor");
|
|
|
|
// 50 ms requested — the floor protects the S7 CPU from sub-scan polling that would
|
|
// just queue wire-side. Test that the subscription is accepted (the floor is applied
|
|
// internally; the floor value isn't exposed, so we're really just asserting that the
|
|
// driver doesn't reject small intervals).
|
|
var h = await drv.SubscribeAsync(["T"], TimeSpan.FromMilliseconds(50), TestContext.Current.CancellationToken);
|
|
h.ShouldNotBeNull();
|
|
await drv.UnsubscribeAsync(h, TestContext.Current.CancellationToken);
|
|
}
|
|
}
|