feat(s7): unblock wide-type/Timer-Counter init guards + fix Int64/UInt64 node mapping

This commit is contained in:
Joseph Doherty
2026-06-17 05:24:48 -04:00
parent b1256bcbf2
commit 06b858eb02
3 changed files with 284 additions and 43 deletions
@@ -68,4 +68,112 @@ public sealed class S7DriverScaffoldTests
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),
Tags = [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()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
Tags = [new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4)],
};
using var drv = new S7Driver(opts, "s7-wide-array");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Batch64");
ex.Message.ShouldContain("array", Case.Insensitive);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <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()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
Tags = [new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64)],
};
using var drv = new S7Driver(opts, "s7-wide-nonbyte");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("WideWord");
ex.Message.ShouldContain("byte", Case.Insensitive);
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <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()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
Tags = [new S7TagDefinition("Timer5", "T5", S7DataType.Int32)],
};
using var drv = new S7Driver(opts, "s7-timer-bad");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Timer5");
ex.Message.ShouldContain("Float64");
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
/// <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()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Timeout = TimeSpan.FromMilliseconds(250),
Tags = [new S7TagDefinition("Counter3", "C3", S7DataType.Float32)],
};
using var drv = new S7Driver(opts, "s7-counter-bad");
var ex = await Should.ThrowAsync<NotSupportedException>(async () =>
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
ex.Message.ShouldContain("Counter3");
ex.Message.ShouldContain("Int32");
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
}
@@ -1,5 +1,6 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;
@@ -190,4 +191,86 @@ public sealed class S7TypeMappingTests
{
Should.Throw<OverflowException>(() => S7Driver.BoxValueForWrite(S7DataType.UInt16, 65_536));
}
// ── MapDataType (via DiscoverAsync) — Int64/UInt64 now map to their own members ───────
// MapDataType is private; reach it through DiscoverAsync with a capturing builder — the
// same indirection S7DiscoveryAndSubscribeTests uses. T1 split the formerly-lossy
// Int64/UInt64 → Int32 line so 64-bit tags surface as the matching DriverDataType.
private sealed class CapturingBuilder : IAddressSpaceBuilder
{
public readonly List<(string Name, DriverAttributeInfo Attr)> Variables = new();
/// <summary>Records the folder and returns this builder for chaining.</summary>
/// <param name="browseName">The browse name of the folder.</param>
/// <param name="displayName">The display name of the folder.</param>
/// <returns>This builder instance.</returns>
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
/// <summary>Records the variable's name + attribute info.</summary>
/// <param name="browseName">The browse name of the variable.</param>
/// <param name="displayName">The display name of the variable.</param>
/// <param name="attributeInfo">The attribute information for the variable.</param>
/// <returns>A stub handle.</returns>
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add((browseName, attributeInfo));
return new StubHandle();
}
/// <summary>No-op property sink.</summary>
/// <param name="browseName">The browse name of the property.</param>
/// <param name="dataType">The data type of the property.</param>
/// <param name="value">The initial value of the property.</param>
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class StubHandle : IVariableHandle
{
/// <summary>Gets the full reference of the variable.</summary>
public string FullReference => "stub";
/// <summary>Marks this variable as an alarm condition.</summary>
/// <param name="info">The alarm condition information.</param>
/// <returns>An alarm condition sink.</returns>
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
=> throw new NotImplementedException("S7 driver never calls this");
}
}
/// <summary>Verifies that an Int64 tag discovers a node with DriverDataType.Int64 (no longer lossily mapped to Int32).</summary>
[Fact]
public async Task DiscoverAsync_Int64_tag_maps_to_DriverDataType_Int64()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Tags = [new S7TagDefinition("Counter64", "DB1.DBB0", S7DataType.Int64)],
};
using var drv = new S7Driver(opts, "s7-i64-map");
var builder = new CapturingBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
builder.Variables.Single(v => v.Name == "Counter64").Attr.DriverDataType
.ShouldBe(DriverDataType.Int64, "Int64 now maps to its own DriverDataType member, not lossy Int32");
}
/// <summary>Verifies that a UInt64 tag discovers a node with DriverDataType.UInt64.</summary>
[Fact]
public async Task DiscoverAsync_UInt64_tag_maps_to_DriverDataType_UInt64()
{
var opts = new S7DriverOptions
{
Host = "192.0.2.1",
Tags = [new S7TagDefinition("Total64", "DB1.DBB0", S7DataType.UInt64)],
};
using var drv = new S7Driver(opts, "s7-u64-map");
var builder = new CapturingBuilder();
await drv.DiscoverAsync(builder, TestContext.Current.CancellationToken);
builder.Variables.Single(v => v.Name == "Total64").Attr.DriverDataType
.ShouldBe(DriverDataType.UInt64, "UInt64 now maps to its own DriverDataType member, not lossy Int32");
}
}