fix(s7): refuse unservable tag shapes per tag at every gate (R07:H3, R07:U4)

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
This commit is contained in:
Joseph Doherty
2026-10-22 15:04:19 -04:00
parent 7bcb6331ed
commit 87a7414df7
19 changed files with 598 additions and 368 deletions
@@ -25,26 +25,10 @@ public sealed class S7DriverCodeReviewFixTests
[InlineData("C10")]
public async Task Initialize_rejects_timer_or_counter_tag_with_NotSupportedException(string address)
{
// A Timer/Counter address parses cleanly but the read path has no decode case for it,
// so it must fail fast at init rather than throw a misleading type-mismatch on every
// read. The host is reserved-for-documentation so the TCP connect can never succeed —
// the unsupported-address guard runs before the connect, so the NotSupportedException
// is what surfaces.
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
RawTags = S7RawTags.Entries(new S7TagDefinition("Quirk", address, S7DataType.Int16)),
};
using var drv = new S7Driver(opts, "s7-tc");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain(address);
var health = drv.GetHealth();
health.State.ShouldBe(DriverState.Faulted, "an unsupported-address config error must Fault the driver");
health.LastError.ShouldNotBeNull();
// A Timer/Counter address typed anything but Float64 (Timer) / Int32 (Counter) cannot be decoded.
// It is refused PER TAG at every gate; it used to throw at init and fault every tag on the PLC.
await S7TagSkipAssert.RefusedPerTagAsync(
new S7TagDefinition("Quirk", address, S7DataType.Int16, Writable: false), address);
}
/// <summary>Verifies that initialize accepts DB and MIQ addresses without triggering the unsupported guard.</summary>
@@ -102,19 +86,21 @@ public sealed class S7DriverCodeReviewFixTests
[Fact]
public async Task Initialize_rejects_a_timer_tag_supplied_only_through_driverConfigJson()
{
// The Timer/Counter guard must run against the *re-parsed* config, not just the
// constructor options — proves Driver.S7-001 and Driver.S7-011 compose correctly.
// The shape gate must run against the *re-parsed* config, not just the constructor options —
// proves the per-tag skip and Driver.S7-011 compose correctly.
var ctorOpts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250) };
using var drv = new S7Driver(ctorOpts, "s7-cfg-tc");
using var drv = new S7Driver(ctorOpts, "s7-cfg-tc", new ScriptedS7PlcFactory());
const string json = """
{ "Host": "192.0.2.1", "TimeoutMs": 250,
{ "Host": "192.0.2.1", "TimeoutMs": 250, "Probe": { "Enabled": false },
"RawTags": [ { "RawPath": "TimerTag", "TagConfig": "{\"address\":\"T5\",\"dataType\":\"Int16\"}", "WriteIdempotent": false } ] }
""";
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync(json, TestContext.Current.CancellationToken));
ex.Message.ShouldContain("T5");
await drv.InitializeAsync(json, TestContext.Current.CancellationToken);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
var skipped = drv.GetSkippedTags().ShouldNotBeNull().Sample.ShouldHaveSingleItem();
skipped.RawPath.ShouldBe("TimerTag");
skipped.Reason.ShouldContain("T5");
}
/// <summary>Verifies that reinitialize applies a changed driverConfigJson.</summary>