c2bdb7d5de
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
237 lines
9.6 KiB
C#
237 lines
9.6 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using S7NetDataType = global::S7.Net.DataType;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
|
|
|
/// <summary>
|
|
/// TSAP / connection-type override (Gitea #295) and negotiated-PDU surfacing (Gitea #294).
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class S7TsapAndPduTests
|
|
{
|
|
// ---- TSAP text ----
|
|
|
|
[Theory]
|
|
[InlineData("01.00", 0x0100)]
|
|
[InlineData("03.01", 0x0301)]
|
|
[InlineData("3.1", 0x0301)]
|
|
[InlineData("0301", 0x0301)]
|
|
[InlineData("0x0301", 0x0301)]
|
|
[InlineData("03:21", 0x0321)]
|
|
[InlineData(" 4d.57 ", 0x4D57)]
|
|
public void Tsap_text_parses(string text, int expected)
|
|
{
|
|
S7Tsap.TryParse(text, out var v).ShouldBeTrue();
|
|
v.ShouldBe((ushort)expected);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData("zz.00")]
|
|
[InlineData("100.00")]
|
|
[InlineData("01.00.00")]
|
|
[InlineData("12345")]
|
|
[InlineData("-1")]
|
|
public void Tsap_garbage_is_rejected(string text) => S7Tsap.TryParse(text, out _).ShouldBeFalse();
|
|
|
|
[Fact]
|
|
public void Tsap_formats_canonically() => S7Tsap.Format(0x0301).ShouldBe("03.01");
|
|
|
|
// ---- resolution ----
|
|
|
|
private static (byte, byte, byte, byte) Bytes(S7DriverOptions o)
|
|
{
|
|
var p = S7Tsap.Resolve(o).ShouldNotBeNull();
|
|
return (p.Local.FirstByte, p.Local.SecondByte, p.Remote.FirstByte, p.Remote.SecondByte);
|
|
}
|
|
|
|
[Fact]
|
|
public void No_override_resolves_to_null_so_the_plain_constructor_is_kept() =>
|
|
S7Tsap.Resolve(new S7DriverOptions { CpuType = S7CpuType.S7300, Rack = 0, Slot = 2 }).ShouldBeNull();
|
|
|
|
[Fact]
|
|
public void Local_override_replaces_only_the_local_tsap()
|
|
{
|
|
// S7-300 rack 0 slot 2 default remote = 03.02 (S7.Net slot-derived).
|
|
var o = new S7DriverOptions { CpuType = S7CpuType.S7300, Rack = 0, Slot = 2, LocalTsap = "02.00" };
|
|
Bytes(o).ShouldBe(((byte)2, (byte)0, (byte)3, (byte)2));
|
|
}
|
|
|
|
[Fact]
|
|
public void Remote_override_replaces_the_whole_remote_tsap()
|
|
{
|
|
var o = new S7DriverOptions { CpuType = S7CpuType.S7300, Rack = 0, Slot = 2, RemoteTsap = "03.01" };
|
|
Bytes(o).ShouldBe(((byte)1, (byte)0, (byte)3, (byte)1));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(S7ConnectionType.PG, 1)]
|
|
[InlineData(S7ConnectionType.OP, 2)]
|
|
[InlineData(S7ConnectionType.S7Basic, 3)]
|
|
public void Connection_type_sets_only_the_first_remote_byte_and_keeps_rack_slot(S7ConnectionType ct, int first)
|
|
{
|
|
// rack 1 slot 3 → second byte 1*0x20 + 3 = 0x23.
|
|
var o = new S7DriverOptions { CpuType = S7CpuType.S7400, Rack = 1, Slot = 3, ConnectionType = ct };
|
|
var (_, _, rFirst, rSecond) = Bytes(o);
|
|
rFirst.ShouldBe((byte)first);
|
|
rSecond.ShouldBe((byte)0x23);
|
|
}
|
|
|
|
// ---- factory JSON ----
|
|
|
|
[Fact]
|
|
public void Json_accepts_string_number_and_enum_name_and_normalises()
|
|
{
|
|
var o = S7DriverFactoryExtensions.ParseOptions("d",
|
|
"""{"host":"h","localTsap":"1.0","remoteTsap":769,"cpuType":"S7300"}""");
|
|
o.LocalTsap.ShouldBe("01.00");
|
|
o.RemoteTsap.ShouldBe("03.01");
|
|
o.ConnectionType.ShouldBeNull();
|
|
|
|
var ct = S7DriverFactoryExtensions.ParseOptions("d", """{"host":"h","connectionType":"pg"}""");
|
|
ct.ConnectionType.ShouldBe(S7ConnectionType.PG);
|
|
ct.RemoteTsap.ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void Json_without_the_keys_leaves_the_defaults()
|
|
{
|
|
var o = S7DriverFactoryExtensions.ParseOptions("d", """{"host":"h"}""");
|
|
o.LocalTsap.ShouldBeNull();
|
|
o.RemoteTsap.ShouldBeNull();
|
|
o.ConnectionType.ShouldBeNull();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("""{"host":"h","localTsap":"nope"}""")]
|
|
[InlineData("""{"host":"h","remoteTsap":"01.00.00"}""")]
|
|
[InlineData("""{"host":"h","remoteTsap":99999999}""")]
|
|
[InlineData("""{"host":"h","connectionType":"Bogus"}""")]
|
|
[InlineData("""{"host":"h","remoteTsap":"03.01","connectionType":"OP"}""")]
|
|
public void Json_rejects_bad_tsap_settings(string json) =>
|
|
Should.Throw<InvalidOperationException>(() => S7DriverFactoryExtensions.ParseOptions("d", json));
|
|
|
|
[Fact]
|
|
public async Task Driver_hands_the_full_options_to_the_factory()
|
|
{
|
|
var factory = new CapturingFactory();
|
|
var opts = new S7DriverOptions
|
|
{
|
|
Host = "192.0.2.1",
|
|
Probe = new S7ProbeOptions { Enabled = false },
|
|
RemoteTsap = "03.01",
|
|
LocalTsap = "01.00",
|
|
};
|
|
using var drv = new S7Driver(opts, "s7-tsap", factory);
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
factory.Seen.ShouldNotBeNull().RemoteTsap.ShouldBe("03.01");
|
|
factory.Seen!.LocalTsap.ShouldBe("01.00");
|
|
}
|
|
|
|
// ---- PDU ----
|
|
|
|
[Fact]
|
|
public async Task Negotiated_pdu_size_is_surfaced_after_connect_and_cleared_on_shutdown()
|
|
{
|
|
var factory = new CapturingFactory { Pdu = 480 };
|
|
var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } };
|
|
using var drv = new S7Driver(opts, "s7-pdu", factory);
|
|
drv.NegotiatedPduSize.ShouldBe(0);
|
|
drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(0);
|
|
((IDriverDiagnostics)drv).GetDiagnostics().ShouldBeEmpty("0 is 'not negotiated yet', not a size (#640)");
|
|
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
drv.NegotiatedPduSize.ShouldBe(480);
|
|
drv.GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe(480);
|
|
((IDriverDiagnostics)drv).GetDiagnostics()["S7.NegotiatedPduSize"].ShouldBe("480");
|
|
|
|
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
|
drv.NegotiatedPduSize.ShouldBe(0);
|
|
((IDriverDiagnostics)drv).GetDiagnostics().ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_plc_that_does_not_report_a_pdu_size_yields_zero_not_a_throw()
|
|
{
|
|
var opts = new S7DriverOptions { Host = "192.0.2.1", Probe = new S7ProbeOptions { Enabled = false } };
|
|
using var drv = new S7Driver(opts, "s7-pdu0", new ConnectingFakeS7PlcFactory());
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
drv.NegotiatedPduSize.ShouldBe(0);
|
|
}
|
|
|
|
private sealed class CapturingFactory : IS7PlcFactory
|
|
{
|
|
public S7DriverOptions? Seen { get; private set; }
|
|
public int Pdu { get; init; }
|
|
|
|
public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) =>
|
|
new Plc(Pdu);
|
|
|
|
public IS7Plc Create(S7DriverOptions options)
|
|
{
|
|
Seen = options;
|
|
return new Plc(Pdu);
|
|
}
|
|
|
|
private sealed class Plc(int pdu) : IS7Plc
|
|
{
|
|
public bool IsConnected { get; private set; }
|
|
public int MaxPduSize => IsConnected ? pdu : 0;
|
|
public Task OpenAsync(CancellationToken ct) { IsConnected = true; return Task.CompletedTask; }
|
|
public void Close() => IsConnected = false;
|
|
public Task<object?> ReadAsync(string a, CancellationToken ct) => throw new NotSupportedException();
|
|
public Task<byte[]> ReadBytesAsync(S7NetDataType area, int db, int s, int c, CancellationToken ct) => throw new NotSupportedException();
|
|
public Task<IReadOnlyList<global::S7.Net.Types.DataItem>> ReadMultipleVarsAsync(
|
|
List<global::S7.Net.Types.DataItem> i, CancellationToken ct) => throw new NotSupportedException();
|
|
public Task WriteAsync(string a, object v, CancellationToken ct) => throw new NotSupportedException();
|
|
public Task WriteBytesAsync(S7NetDataType area, int db, int s, byte[] v, CancellationToken ct) => throw new NotSupportedException();
|
|
public Task ReadStatusAsync(CancellationToken ct) => Task.CompletedTask;
|
|
public void Dispose() => IsConnected = false;
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Factory_conflict_message_matches_the_shared_detail()
|
|
{
|
|
var ex = Should.Throw<InvalidOperationException>(() => S7DriverFactoryExtensions.ParseOptions("d",
|
|
"""{"host":"h","remoteTsap":"03.01","connectionType":"OP"}"""));
|
|
ex.Message.ShouldContain(S7DriverOptions.TsapConflictDetail);
|
|
}
|
|
|
|
// ---- Test Connect data-access check (PUT/GET) ----
|
|
|
|
[Fact]
|
|
public async Task Probe_data_access_succeeds_when_the_read_works() =>
|
|
(await S7DriverProbe.CheckDataAccessAsync(_ => Task.FromResult(new byte[2]), TestContext.Current.CancellationToken))
|
|
.ShouldBeNull();
|
|
|
|
[Fact]
|
|
public async Task Probe_fails_with_actionable_message_when_put_get_is_disabled()
|
|
{
|
|
var denied = new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData,
|
|
new Exception("Accessing object not allowed"));
|
|
var r = await S7DriverProbe.CheckDataAccessAsync(_ => throw denied, TestContext.Current.CancellationToken);
|
|
r.ShouldNotBeNull().Ok.ShouldBeFalse();
|
|
r.Message!.ShouldContain("PUT/GET");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Probe_tolerates_an_address_not_available_read_error()
|
|
{
|
|
var missing = new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData, new Exception("Object does not exist"));
|
|
(await S7DriverProbe.CheckDataAccessAsync(_ => throw missing, TestContext.Current.CancellationToken)).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Probe_reports_any_other_read_fault_as_a_failure()
|
|
{
|
|
var r = await S7DriverProbe.CheckDataAccessAsync(
|
|
_ => throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ConnectionError, "socket dropped"),
|
|
TestContext.Current.CancellationToken);
|
|
r.ShouldNotBeNull().Ok.ShouldBeFalse();
|
|
r.Message!.ShouldContain("socket dropped");
|
|
}
|
|
}
|