87a7414df7
The init-time tag guards THREW: one mis-authored tag (DB1.DBZ0, DB1.DBW0 typed Float64, T5 typed Int32, a writable Bool array) faulted the whole driver and the actor retried the same init every 10 s forever, darkening every tag on the PLC. Neither the editor nor the deploy gate checked any of it. - S7AddressParser moves to S7.Contracts (same namespace) and a new S7TagShapeRules there is the single authority on servable shapes. - S7TagDefinitionFactory.FromTagConfig applies it, so the AdminUI editor, the deploy gate (Inspect, with the specific reason) and the driver init refuse the same tags. Init now skips a refused tag (ISkippedTagReporter) instead of throwing; the throwing RejectUnsupportedTagConfigs is gone. - New rules on top of the old guards: a narrow type at the wrong access width (every read failed) and a Bool array not starting at bit 0 (U4: DB1.DBX0.3 silently read bit 0 of bytes 0..n). - AdminUI test fixtures that used addresses the driver could never serve (DB1.DBS0, a browse "SomeAddress", Int32/Float32 on DBW) now use real ones. Refs #742 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
214 lines
11 KiB
C#
214 lines
11 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
|
|
|
|
/// <summary>
|
|
/// Scaffold-level tests that don't need a live S7 PLC — exercise driver lifecycle shape,
|
|
/// default option values, and failure-mode transitions. PR 64 adds IReadable/IWritable
|
|
/// tests against a mock-server, PR 65 adds discovery + subscribe.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class S7DriverScaffoldTests
|
|
{
|
|
/// <summary>Verifies that default options target S7-1500 slot 0 on port 102.</summary>
|
|
[Fact]
|
|
public void Default_options_target_S7_1500_slot_0_on_port_102()
|
|
{
|
|
var opts = new S7DriverOptions();
|
|
opts.Port.ShouldBe(102, "ISO-on-TCP is always 102 for S7; documented in driver-specs.md §5");
|
|
opts.CpuType.ShouldBe(S7CpuType.S71500);
|
|
opts.Rack.ShouldBe((short)0);
|
|
opts.Slot.ShouldBe((short)0, "S7-1200/1500 onboard PN ports are slot 0 by convention");
|
|
}
|
|
|
|
/// <summary>Verifies that the default probe interval is reasonable for S7 scan cycles.</summary>
|
|
[Fact]
|
|
public void Default_probe_interval_is_reasonable_for_S7_scan_cycle()
|
|
{
|
|
// S7 PLCs scan 2-10 ms but comms mailbox typically processed once per scan.
|
|
// 5 s default probe is lightweight — ~0.001% of comms budget.
|
|
new S7ProbeOptions().Interval.ShouldBe(TimeSpan.FromSeconds(5));
|
|
}
|
|
|
|
/// <summary>Verifies that tag definition defaults to writable with S7 max string length.</summary>
|
|
[Fact]
|
|
public void Tag_definition_defaults_to_writable_with_S7_max_string_length()
|
|
{
|
|
var tag = new S7TagDefinition("T", "DB1.DBW0", S7DataType.Int16);
|
|
tag.Writable.ShouldBeTrue();
|
|
tag.StringLength.ShouldBe(254, "S7 STRING type max length is 254 chars");
|
|
}
|
|
|
|
/// <summary>Verifies that the driver instance reports type and ID before connect.</summary>
|
|
[Fact]
|
|
public void Driver_instance_reports_type_and_id_before_connect()
|
|
{
|
|
var opts = new S7DriverOptions { Host = "127.0.0.1" };
|
|
using var drv = new S7Driver(opts, "s7-test");
|
|
drv.DriverType.ShouldBe("S7");
|
|
drv.DriverInstanceId.ShouldBe("s7-test");
|
|
drv.GetHealth().State.ShouldBe(DriverState.Unknown, "health starts Unknown until InitializeAsync runs");
|
|
}
|
|
|
|
/// <summary>Verifies that Initialize against unreachable host transitions to Faulted and throws.</summary>
|
|
[Fact]
|
|
public async Task Initialize_against_unreachable_host_transitions_to_Faulted_and_throws()
|
|
{
|
|
// Pick an RFC 5737 reserved-for-documentation IP so the connect attempt fails fast
|
|
// (no DNS mismatch, no accidental traffic to a real PLC).
|
|
var opts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250) };
|
|
using var drv = new S7Driver(opts, "s7-unreach");
|
|
|
|
await Should.ThrowAsync<Exception>(async () =>
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
|
|
|
var health = drv.GetHealth();
|
|
health.State.ShouldBe(DriverState.Faulted, "unreachable host must flip the driver to Faulted so operators see it");
|
|
health.LastError.ShouldNotBeNull();
|
|
}
|
|
|
|
// ── Phase 4d T1 — wide-type / Timer-Counter init guards ──────────────────────────────
|
|
//
|
|
// The init flow runs RejectUnsupportedTagConfigs BEFORE plc.OpenAsync, so a guard
|
|
// rejection throws before any TCP connect. The reserved-for-documentation host (192.0.2.1)
|
|
// means a config that PASSES the guard still throws on connect — so a positive case
|
|
// asserts the failure is NOT the guard's NotSupportedException/FormatException (the same
|
|
// shape used by S7DriverCodeReviewFixTests2.Initialize_accepts_implemented_data_types).
|
|
|
|
/// <summary>Verifies that a valid byte-addressed wide scalar (Float64 at DB1.DBB0) passes the init guard.</summary>
|
|
[Fact]
|
|
public async Task Initialize_accepts_byte_addressed_wide_scalar_Float64()
|
|
{
|
|
var opts = new S7DriverOptions
|
|
{
|
|
Host = "192.0.2.1",
|
|
Timeout = TimeSpan.FromMilliseconds(250),
|
|
RawTags = S7RawTags.Entries(new S7TagDefinition("LReal", "DB1.DBB0", S7DataType.Float64)),
|
|
};
|
|
using var drv = new S7Driver(opts, "s7-wide-ok");
|
|
|
|
var ex = await Should.ThrowAsync<Exception>(async () =>
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
|
ex.ShouldNotBeOfType<NotSupportedException>(
|
|
"a byte-addressed wide scalar must pass the init guard — the failure must be the connect");
|
|
ex.ShouldNotBeOfType<FormatException>(
|
|
"DB1.DBB0 parses cleanly — the failure must be the connect, not an address-parse error");
|
|
}
|
|
|
|
/// <summary>Verifies that a wide-type array (Int64 + ArrayCount) is rejected as out-of-scope this phase.</summary>
|
|
[Fact]
|
|
public async Task Initialize_rejects_wide_type_array_with_clear_message()
|
|
{
|
|
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
|
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4), "Int64", "array");
|
|
}
|
|
|
|
/// <summary>Verifies that a wide scalar with a non-byte address (Float64 at DB1.DBW0) is rejected with a byte-address message.</summary>
|
|
[Fact]
|
|
public async Task Initialize_rejects_wide_scalar_with_non_byte_address()
|
|
{
|
|
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
|
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64), "Float64", "byte-addressed");
|
|
}
|
|
|
|
/// <summary>Verifies that a Timer tag with the wrong DataType (Int32, not Float64) is rejected.</summary>
|
|
[Fact]
|
|
public async Task Initialize_rejects_Timer_tag_with_wrong_data_type()
|
|
{
|
|
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
|
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Timer5", "T5", S7DataType.Int32, Writable: false), "T5", "Float64");
|
|
}
|
|
|
|
/// <summary>Verifies that a Counter tag with the wrong DataType (Float32, not Int32) is rejected.</summary>
|
|
[Fact]
|
|
public async Task Initialize_rejects_Counter_tag_with_wrong_data_type()
|
|
{
|
|
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
|
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Counter3", "C3", S7DataType.Float32, Writable: false), "C3", "Int32");
|
|
}
|
|
|
|
// ── Phase 4d bundle-review fix — Writable Timer/Counter guard ───────────────────────
|
|
//
|
|
// A Timer/Counter tag declared Writable=true must be rejected at init: without this guard
|
|
// the node is discovered as Operate-writable but every write returns BadNotSupported
|
|
// (EncodeScalarBlock throws). The guard fires before plc.OpenAsync, so the reserved-for-
|
|
// documentation host never receives traffic.
|
|
|
|
/// <summary>Verifies that a Timer tag declared Writable=true is rejected at init with a Writable message.</summary>
|
|
[Fact]
|
|
public async Task Initialize_rejects_Timer_tag_declared_Writable()
|
|
{
|
|
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
|
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true), "T5", "writable", "read-only");
|
|
}
|
|
|
|
/// <summary>Verifies that a Counter tag declared Writable=true is rejected at init with a Writable message.</summary>
|
|
[Fact]
|
|
public async Task Initialize_rejects_Counter_tag_declared_Writable()
|
|
{
|
|
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
|
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true), "C3", "writable", "read-only");
|
|
}
|
|
|
|
// ── Driver.S7-015 / S7-016 — writable Bool array must fail fast at init ─────────────
|
|
//
|
|
// Non-bool arrays now write as one contiguous block (S7-016, EncodeArrayBlock). A Bool array
|
|
// write would be byte-granular and clobber the neighbouring bits of every byte it touches, so
|
|
// a writable Bool array is still refused at init with an informative message.
|
|
|
|
/// <summary>Verifies that a writable Bool array is rejected at init.</summary>
|
|
[Fact]
|
|
public async Task Initialize_rejects_writable_bool_array_tag_with_NotSupportedException()
|
|
{
|
|
// Refused PER TAG at every gate (it used to throw at init and fault the whole driver).
|
|
await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("ArrTag", "DB1.DBX0.0", S7DataType.Bool, Writable: true, ArrayCount: 4), "Bool array", "writable");
|
|
}
|
|
|
|
/// <summary>Verifies that writable non-bool arrays pass the init guard — the failure
|
|
/// must be the TCP connect (unreachable host), not the array guard (S7-016).</summary>
|
|
[Theory]
|
|
[InlineData(S7DataType.Byte, "DB1.DBB0", 8)]
|
|
[InlineData(S7DataType.Int16, "DB1.DBW0", 4)]
|
|
[InlineData(S7DataType.UInt16, "DB1.DBW0", 4)]
|
|
[InlineData(S7DataType.Int32, "DB1.DBD0", 2)]
|
|
[InlineData(S7DataType.UInt32, "DB1.DBD0", 2)]
|
|
[InlineData(S7DataType.Float32,"DB1.DBD0", 2)]
|
|
public async Task Initialize_accepts_writable_non_bool_array_tag(
|
|
S7DataType dt, string addr, int count)
|
|
{
|
|
var opts = new S7DriverOptions
|
|
{
|
|
Host = "192.0.2.1",
|
|
Timeout = TimeSpan.FromMilliseconds(250),
|
|
RawTags = S7RawTags.Entries(new S7TagDefinition("ArrTag", addr, dt, Writable: true, ArrayCount: count)),
|
|
};
|
|
using var drv = new S7Driver(opts, $"s7-writable-arr-{dt}");
|
|
|
|
var ex = await Should.ThrowAsync<Exception>(async () =>
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
|
ex.ShouldNotBeOfType<NotSupportedException>();
|
|
}
|
|
|
|
/// <summary>Verifies that a read-only (Writable=false) array passes the init guard —
|
|
/// array reads are fully implemented; only writes are gated.</summary>
|
|
[Fact]
|
|
public async Task Initialize_accepts_readonly_array_tag()
|
|
{
|
|
var opts = new S7DriverOptions
|
|
{
|
|
Host = "192.0.2.1",
|
|
Timeout = TimeSpan.FromMilliseconds(250),
|
|
RawTags = S7RawTags.Entries(new S7TagDefinition("ReadArr", "DB1.DBW0", S7DataType.Int16, Writable: false, ArrayCount: 4)),
|
|
};
|
|
using var drv = new S7Driver(opts, "s7-readonly-arr");
|
|
|
|
// Must NOT throw NotSupportedException — the failure must be the TCP connect (unreachable host).
|
|
var ex = await Should.ThrowAsync<Exception>(async () =>
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
|
|
ex.ShouldNotBeOfType<NotSupportedException>(
|
|
"read-only arrays are fully supported — the failure must be the TCP connect, not the array guard");
|
|
}
|
|
}
|