efa040813a
Review r0 M9. The AdminUI form wrote coalesceReads:true on every save, pinning today's default into every edited driver. AbLegacyDriverOptions.CoalesceReads is now bool? (null = default ON, never serialised, same encoding as Transport); the form omits the key when the switch is on and writes only an explicit false. Persisted coalesceReads:true keeps loading as on. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
185 lines
8.5 KiB
C#
185 lines
8.5 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
|
|
|
/// <summary>
|
|
/// Review 07 P7: adjacent N/B/F/L elements of one device were never coalesced (one PCCC round trip per tag),
|
|
/// although the array path proves the transport can read a span. Scalar tags near each other on one file now
|
|
/// ride ONE multi-element read and are decoded from their offsets — with exactly the CLR types the single-tag
|
|
/// path produces — and a block the device refuses falls back to per-tag reads.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class AbLegacyReadCoalescingTests
|
|
{
|
|
private const string Host = "ab://10.0.0.5/1,0";
|
|
|
|
private static (AbLegacyDriver Drv, FakeAbLegacyTagFactory Factory, List<AbLegacyTagCreateParams> Created) NewDriver(
|
|
Func<AbLegacyTagCreateParams, FakeAbLegacyTag> make, bool coalesce, params AbLegacyTagDefinition[] tags)
|
|
{
|
|
var created = new List<AbLegacyTagCreateParams>();
|
|
var factory = new FakeAbLegacyTagFactory
|
|
{
|
|
Customise = p =>
|
|
{
|
|
lock (created) created.Add(p);
|
|
return make(p);
|
|
},
|
|
};
|
|
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
|
{
|
|
Devices = [new AbLegacyDeviceOptions(Host)],
|
|
RawTags = AbLegacyRawTags.Entries(tags),
|
|
Probe = new AbLegacyProbeOptions { Enabled = false },
|
|
CoalesceReads = coalesce,
|
|
}, "drv-coalesce", factory);
|
|
return (drv, factory, created);
|
|
}
|
|
|
|
private static AbLegacyTagDefinition Tag(string name, string address, AbLegacyDataType type) =>
|
|
new(name, Host, address, type);
|
|
|
|
[Fact]
|
|
public async Task Nearby_N_file_words_and_bits_ride_one_read_and_decode_like_single_reads()
|
|
{
|
|
var (drv, _, created) = NewDriver(
|
|
p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 10, 11, 12, 4, 14, -15 } },
|
|
coalesce: true,
|
|
Tag("W0", "N7:0", AbLegacyDataType.Int),
|
|
Tag("W1", "N7:1", AbLegacyDataType.AnalogInt),
|
|
Tag("B32", "N7:3/2", AbLegacyDataType.Bit),
|
|
Tag("B31", "N7:3/1", AbLegacyDataType.Bit),
|
|
Tag("W5", "N7:5", AbLegacyDataType.Int));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var r = await drv.ReadAsync(["W0", "W1", "B32", "B31", "W5"], CancellationToken.None);
|
|
|
|
r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
|
|
r[0].Value.ShouldBe(10); // int, exactly as DecodeValue(Int) returns
|
|
r[1].Value.ShouldBe(11);
|
|
r[2].Value.ShouldBe(true); // word 3 = 0b100: bit 2 set
|
|
r[3].Value.ShouldBe(false);
|
|
r[4].Value.ShouldBe(-15);
|
|
created.Count.ShouldBe(1);
|
|
created[0].TagName.ShouldBe("N7:0");
|
|
created[0].ElementCount.ShouldBe(6);
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Float_and_long_files_coalesce_at_their_element_width()
|
|
{
|
|
var (drv, _, created) = NewDriver(
|
|
p => p.TagName.StartsWith('F')
|
|
? new FakeAbLegacyTag(p) { ArrayValue = new[] { 1.5f, 0f, 2.5f } }
|
|
: new FakeAbLegacyTag(p) { ArrayValue = new[] { 7, int.MinValue } },
|
|
coalesce: true,
|
|
Tag("F0", "F8:0", AbLegacyDataType.Float),
|
|
Tag("F2", "F8:2", AbLegacyDataType.Float),
|
|
Tag("L0", "L9:0", AbLegacyDataType.Long),
|
|
Tag("L1b31", "L9:1/31", AbLegacyDataType.Bit));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var r = await drv.ReadAsync(["F0", "F2", "L0", "L1b31"], CancellationToken.None);
|
|
|
|
r.Select(s => s.Value).ShouldBe(new object[] { 1.5f, 2.5f, 7, true });
|
|
created.Select(c => (c.TagName, c.ElementCount)).OrderBy(x => x.TagName)
|
|
.ShouldBe(new[] { ("F8:0", 3), ("L9:0", 2) });
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Far_apart_or_ineligible_tags_keep_the_single_tag_path()
|
|
{
|
|
var (drv, _, created) = NewDriver(
|
|
p => new FakeAbLegacyTag(p) { Value = 1 },
|
|
coalesce: true,
|
|
Tag("Near", "N7:0", AbLegacyDataType.Int),
|
|
Tag("Far", "N7:40", AbLegacyDataType.Int), // gap > MaxCoalesceGap
|
|
Tag("Mistyped", "N7:1", AbLegacyDataType.Float), // width mismatch: single path answers as before
|
|
Tag("Acc", "T4:0.ACC", AbLegacyDataType.Int), // sub-element
|
|
Tag("Status", "S:1", AbLegacyDataType.Int), // no file number
|
|
Tag("Ind", "N7:[N7:0]", AbLegacyDataType.Int)); // indirect
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
_ = await drv.ReadAsync(["Near", "Far", "Mistyped", "Acc", "Status", "Ind"], CancellationToken.None);
|
|
|
|
created.ShouldAllBe(c => c.ElementCount == 1);
|
|
created.Select(c => c.TagName).ShouldContain("N7:40");
|
|
created.Select(c => c.TagName).ShouldContain("T4:0.ACC");
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_refused_block_falls_back_to_single_reads_and_is_remembered_after_a_repeat()
|
|
{
|
|
// N7:9 is past the end of the file: the BLOCK is refused; singly, only that tag is.
|
|
var (drv, _, created) = NewDriver(
|
|
p => p.ElementCount > 1 || p.TagName == "N7:9"
|
|
? new FakeAbLegacyTag(p) { Status = (int)AbStatus.OutOfBounds }
|
|
: new FakeAbLegacyTag(p) { Value = 3 },
|
|
coalesce: true,
|
|
Tag("A", "N7:0", AbLegacyDataType.Int),
|
|
Tag("B", "N7:2", AbLegacyDataType.Int),
|
|
Tag("C", "N7:9", AbLegacyDataType.Int));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
for (var poll = 0; poll < 3; poll++)
|
|
{
|
|
var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
|
|
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
|
|
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
|
|
r[2].StatusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(AbStatus.OutOfBounds));
|
|
}
|
|
// Refused on polls 0 and 1 (each falling back to singles in the same pass); remembered after the repeat,
|
|
// so poll 2 does not try the block again.
|
|
created.Count(c => c.ElementCount > 1).ShouldBe(2);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy); // refusals are not comms faults
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_block_comms_fault_fails_its_members_together_and_degrades()
|
|
{
|
|
var (drv, _, _) = NewDriver(
|
|
p => new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new System.Net.Sockets.SocketException(10054) },
|
|
coalesce: true,
|
|
Tag("A", "N7:0", AbLegacyDataType.Int),
|
|
Tag("B", "N7:1", AbLegacyDataType.Int));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
|
|
|
r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CoalesceReads_false_reads_every_tag_singly()
|
|
{
|
|
var (drv, _, created) = NewDriver(
|
|
p => new FakeAbLegacyTag(p) { Value = 1 },
|
|
coalesce: false,
|
|
Tag("A", "N7:0", AbLegacyDataType.Int),
|
|
Tag("B", "N7:1", AbLegacyDataType.Int));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
|
|
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
|
|
|
|
r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
|
|
created.Count.ShouldBe(2);
|
|
created.ShouldAllBe(c => c.ElementCount == 1);
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("""{"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", true)]
|
|
[InlineData("""{"coalesceReads":false,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", false)]
|
|
[InlineData("""{"coalesceReads":true,"someFutureKey":1,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", true)]
|
|
public void ParseOptions_reads_coalesceReads_defaulting_true(string json, bool expected) =>
|
|
(AbLegacyDriverFactoryExtensions.ParseOptions("drv", json).CoalesceReads != false).ShouldBe(expected);
|
|
}
|