using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
///
/// Issue #249 — verify ST string read/write round-trips through the driver. The wire format
/// (1-word length prefix + 82 ASCII bytes) is owned by libplctag's GetString/
/// SetString; this test fixture pins the driver-level guarantees:
///
/// - Reads round-trip strings of any length up to the 82-char ST cap.
/// - Writes longer than 82 chars are rejected with BadOutOfRange at the driver
/// level — preventing libplctag from silently truncating.
/// - Embedded nulls and non-ASCII characters flow through without throwing — the latter
/// is libplctag's responsibility to round-trip or degrade.
/// - Both Slc500 and Plc5 families share the 82-byte ST file convention.
///
///
[Trait("Category", "Unit")]
public sealed class AbLegacyStringEncodingTests
{
private static (AbLegacyDriver drv, FakeAbLegacyTagFactory factory) NewDriver(
AbLegacyPlcFamily family,
params AbLegacyTagDefinition[] tags)
{
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", family)],
Tags = tags,
}, "drv-1", factory);
return (drv, factory);
}
// ---- Read round-trip ----
[Theory]
[InlineData(AbLegacyPlcFamily.Slc500, "")]
[InlineData(AbLegacyPlcFamily.Slc500, "Hello")]
[InlineData(AbLegacyPlcFamily.Plc5, "Hello")]
public async Task Read_returns_string_value_unchanged(AbLegacyPlcFamily family, string value)
{
var (drv, factory) = NewDriver(family,
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = value };
var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
snapshots.Single().Value.ShouldBe(value);
}
[Fact]
public async Task Read_returns_full_82_char_string_at_ST_capacity()
{
var full = new string('A', 82);
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = full };
var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var actual = snapshots.Single().Value.ShouldBeOfType();
actual.Length.ShouldBe(82);
actual.ShouldBe(full);
}
[Fact]
public async Task Read_preserves_embedded_null_byte()
{
// libplctag returns the C-string as the .NET String with whatever bytes the PLC stored.
// We assert the driver doesn't strip or truncate at an embedded NUL.
var withNull = "AB\0CD";
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = withNull };
var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
snapshots.Single().Value.ShouldBe(withNull);
}
[Fact]
public async Task Read_preserves_extended_latin_payload()
{
// PLC ST files are byte-oriented; non-ASCII passes through whatever round-trip libplctag
// applies. The driver itself must not transform.
var latin = "café résumé";
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
await drv.InitializeAsync("{}", CancellationToken.None);
factory.Customise = p => new FakeAbLegacyTag(p) { Value = latin };
var snapshots = await drv.ReadAsync(["Msg"], CancellationToken.None);
snapshots.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
snapshots.Single().Value.ShouldBe(latin);
}
// ---- Write round-trip ----
[Theory]
[InlineData(AbLegacyPlcFamily.Slc500, "")]
[InlineData(AbLegacyPlcFamily.Slc500, "Short msg")]
[InlineData(AbLegacyPlcFamily.Slc500, "AB\0CD")] // embedded NUL
[InlineData(AbLegacyPlcFamily.Plc5, "Hello PLC5")]
public async Task Write_succeeds_and_forwards_string_to_runtime(AbLegacyPlcFamily family, string value)
{
var (drv, factory) = NewDriver(family,
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Msg", value)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Tags["ST9:0"].Value.ShouldBe(value);
factory.Tags["ST9:0"].WriteCount.ShouldBe(1);
}
[Fact]
public async Task Write_succeeds_for_41_char_mid_length_string()
{
var mid = new string('M', 41);
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Msg", mid)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Tags["ST9:0"].Value.ShouldBe(mid);
}
[Fact]
public async Task Write_succeeds_at_82_char_boundary()
{
var full = new string('Z', 82);
var (drv, factory) = NewDriver(AbLegacyPlcFamily.Slc500,
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String));
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Msg", full)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
((string)factory.Tags["ST9:0"].Value!).Length.ShouldBe(82);
}
// ---- Length guard ----
[Theory]
[InlineData(AbLegacyPlcFamily.Slc500, 83)]
[InlineData(AbLegacyPlcFamily.Slc500, 100)]
[InlineData(AbLegacyPlcFamily.Plc5, 200)]
public async Task Write_over_82_chars_returns_BadOutOfRange(AbLegacyPlcFamily family, int len)
{
// The runtime layer (LibplctagLegacyTagRuntime.EncodeValue) rejects with
// ArgumentOutOfRangeException; the driver maps that to BadOutOfRange so the OPC UA client
// gets a clean failure rather than a silent libplctag truncation. We use the production
// runtime for the encode step but stub the I/O via a delegating factory so the test does
// not need a real PLC.
var oversized = new string('X', len);
var factory = new FakeAbLegacyTagFactory
{
// Reuse the production EncodeValue by routing through a fake that delegates the
// length check itself — we model the runtime contract: > 82 chars must throw.
Customise = p => new EncodeOnlyLengthCheckingFake(p),
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", family)],
Tags =
[
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String),
],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Msg", oversized)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
// The write must NOT have reached libplctag's WriteAsync — guard fires before flush.
factory.Tags["ST9:0"].WriteCount.ShouldBe(0);
}
[Fact]
public async Task Write_at_exactly_82_chars_does_not_trip_length_guard()
{
var atBoundary = new string('B', 82);
var factory = new FakeAbLegacyTagFactory
{
Customise = p => new EncodeOnlyLengthCheckingFake(p),
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500)],
Tags =
[
new AbLegacyTagDefinition("Msg", "ab://10.0.0.5/1,0", "ST9:0", AbLegacyDataType.String),
],
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync(
[new WriteRequest("Msg", atBoundary)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
factory.Tags["ST9:0"].WriteCount.ShouldBe(1);
}
///
/// Test fake that mirrors 's ST length guard so we
/// can assert the driver-level mapping (ArgumentOutOfRangeException → BadOutOfRange)
/// without instantiating a real libplctag Tag (which would try to open a TCP
/// connection in InitializeAsync).
///
private sealed class EncodeOnlyLengthCheckingFake(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
{
public override void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value)
{
if (type == AbLegacyDataType.String)
{
var s = Convert.ToString(value) ?? string.Empty;
if (s.Length > 82)
throw new ArgumentOutOfRangeException(
nameof(value),
$"ST string write exceeds 82-byte file element capacity (was {s.Length}).");
}
base.EncodeValue(type, bitIndex, value);
}
}
}