Auto: abcip-3.3 — read-strategy selector (WholeUdt / MultiPacket / Auto)
Closes #237
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Emulate;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-3.3 — golden-box-tier MultiPacket read-strategy test against Logix Emulate.
|
||||
/// Exercises the sparse-UDT case the strategy is designed for: a 50-member UDT instance
|
||||
/// where the OPC UA client subscribed to 5 members. Asserts the driver routes the read
|
||||
/// through the MultiPacket planner (<see cref="AbCipDriver.DeviceState.MultiPacketGroupsExecuted"/>
|
||||
/// counter increments) and returns Good StatusCodes for every member.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Required Emulate project state</b> (see <c>LogixProject/README.md</c> for
|
||||
/// the L5X export that seeds this; ship the project once Emulate is on the integration
|
||||
/// host):</para>
|
||||
/// <list type="bullet">
|
||||
/// <item>UDT <c>Tank_50</c> with 50 DINT members <c>M0</c>..<c>M49</c> — a deliberately
|
||||
/// oversized UDT so a 5-member subscription is sparse enough for the
|
||||
/// <see cref="AbCipDeviceOptions.MultiPacketSparsityThreshold"/> default of 0.25 to
|
||||
/// pick MultiPacket.</item>
|
||||
/// <item>Controller-scope tag <c>Tank1 : Tank_50</c> with each <c>M{i}</c> seeded to
|
||||
/// <c>i * 10</c> so each subscribed member returns a distinct value.</item>
|
||||
/// </list>
|
||||
/// <para>Runs only when <c>AB_SERVER_PROFILE=emulate</c>. With the default ab_server the
|
||||
/// test skips cleanly — ab_server lacks UDT / Multi-Service-Packet emulation depth so a
|
||||
/// wire-level pass against it would be vacuous regardless. Note: the libplctag .NET
|
||||
/// wrapper (1.5.x) does not expose explicit Multi-Service-Packet bundling, so the
|
||||
/// driver's MultiPacket runtime today issues N member reads sequentially. The planner-tier
|
||||
/// dispatch is what's under test here — the wire-level bundling lands when the upstream
|
||||
/// wrapper exposes the 0x0A service primitive (see
|
||||
/// <c>docs/drivers/AbCip-Performance.md</c> §"Read strategy").</para>
|
||||
/// </remarks>
|
||||
[Collection("AbServerEmulate")]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Tier", "Emulate")]
|
||||
public sealed class AbCipEmulateMultiPacketReadTests
|
||||
{
|
||||
[AbServerFact]
|
||||
public async Task Sparse_5_of_50_member_subscription_dispatches_through_MultiPacket()
|
||||
{
|
||||
AbServerProfileGate.SkipUnless(AbServerProfileGate.Emulate);
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AB_SERVER_ENDPOINT")
|
||||
?? throw new InvalidOperationException(
|
||||
"AB_SERVER_ENDPOINT must be set to the Logix Emulate instance " +
|
||||
"(e.g. '10.0.0.42:44818') when AB_SERVER_PROFILE=emulate.");
|
||||
|
||||
// Build a 50-member declared UDT — the planner needs the full member set to compute
|
||||
// the subscribed-fraction in the Auto heuristic and to place MultiPacket member offsets.
|
||||
var members = new AbCipStructureMember[50];
|
||||
for (var i = 0; i < 50; i++)
|
||||
members[i] = new AbCipStructureMember($"M{i}", AbCipDataType.DInt);
|
||||
|
||||
var options = new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(
|
||||
HostAddress: $"ab://{endpoint}/1,0",
|
||||
PlcFamily: AbCipPlcFamily.ControlLogix,
|
||||
ReadStrategy: ReadStrategy.MultiPacket)],
|
||||
Tags = [
|
||||
new AbCipTagDefinition(
|
||||
Name: "Tank1",
|
||||
DeviceHostAddress: $"ab://{endpoint}/1,0",
|
||||
TagPath: "Tank1",
|
||||
DataType: AbCipDataType.Structure,
|
||||
Members: members),
|
||||
],
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
};
|
||||
|
||||
await using var drv = new AbCipDriver(options, driverInstanceId: "emulate-multipacket-smoke");
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
|
||||
// Sparse pick: 5 of 50 = 0.10 < default threshold 0.25 → MultiPacket planner. Force
|
||||
// the strategy explicitly above so the test isn't sensitive to threshold drift.
|
||||
var refs = new[] { "Tank1.M0", "Tank1.M3", "Tank1.M7", "Tank1.M22", "Tank1.M49" };
|
||||
var snapshots = await drv.ReadAsync(refs, TestContext.Current.CancellationToken);
|
||||
|
||||
snapshots.Count.ShouldBe(5);
|
||||
foreach (var s in snapshots) s.StatusCode.ShouldBe(AbCipStatusMapper.Good);
|
||||
|
||||
// Plan-stats counter assertion — the device-level counter increments once per parent
|
||||
// UDT routed through the MultiPacket path. Sibling counter for WholeUdt must stay zero.
|
||||
var deviceState = drv.GetDeviceState($"ab://{endpoint}/1,0");
|
||||
deviceState.ShouldNotBeNull();
|
||||
deviceState!.MultiPacketGroupsExecuted.ShouldBeGreaterThan(0);
|
||||
deviceState.WholeUdtGroupsExecuted.ShouldBe(0);
|
||||
|
||||
// Sanity-check the seeded values land at the right indices: M{i} == i * 10 in the
|
||||
// emulate fixture's startup routine.
|
||||
Convert.ToInt32(snapshots[0].Value).ShouldBe(0);
|
||||
Convert.ToInt32(snapshots[1].Value).ShouldBe(30);
|
||||
Convert.ToInt32(snapshots[2].Value).ShouldBe(70);
|
||||
Convert.ToInt32(snapshots[3].Value).ShouldBe(220);
|
||||
Convert.ToInt32(snapshots[4].Value).ShouldBe(490);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbCip.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// PR abcip-3.3 — coverage for the per-device <see cref="ReadStrategy"/> selector. Three
|
||||
/// resolution layers under test: (a) <see cref="AbCipDriver.ResolveReadStrategy"/> at
|
||||
/// device init (MultiPacket-against-Micro800 fall-back, plain pass-through otherwise),
|
||||
/// (b) <see cref="AbCipMultiPacketReadPlanner.ChooseStrategyForGroup"/> sparsity heuristic
|
||||
/// (Auto-mode dispatch), (c) end-to-end <see cref="AbCipDriver.ReadAsync"/> dispatch
|
||||
/// verified by the per-device WholeUdt / MultiPacket counters.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbCipReadStrategyTests
|
||||
{
|
||||
private const string Device = "ab://10.0.0.5/1,0";
|
||||
private const string Micro = "ab://10.0.0.6/";
|
||||
|
||||
// ---- Device init resolution ----
|
||||
|
||||
[Fact]
|
||||
public async Task Default_ReadStrategy_resolves_to_Auto_on_DeviceState()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device, AbCipPlcFamily.ControlLogix)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.Auto);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task User_forced_WholeUdt_passes_through_init()
|
||||
{
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device, AbCipPlcFamily.ControlLogix,
|
||||
ReadStrategy: ReadStrategy.WholeUdt)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.WholeUdt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task User_forced_MultiPacket_on_ControlLogix_passes_through_init()
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Device, AbCipPlcFamily.ControlLogix,
|
||||
ReadStrategy: ReadStrategy.MultiPacket)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
OnWarning = warnings.Add,
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.MultiPacket);
|
||||
warnings.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task User_forced_MultiPacket_on_Micro800_falls_back_to_WholeUdt_with_warning()
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Micro, AbCipPlcFamily.Micro800,
|
||||
ReadStrategy: ReadStrategy.MultiPacket)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
OnWarning = warnings.Add,
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.GetDeviceState(Micro)!.ReadStrategy.ShouldBe(ReadStrategy.WholeUdt);
|
||||
warnings.ShouldHaveSingleItem();
|
||||
warnings[0].ShouldContain("Micro800");
|
||||
warnings[0].ShouldContain("Multi-Service Packet");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Auto_on_Micro800_stays_Auto_at_init_planner_caps_to_WholeUdt_per_batch()
|
||||
{
|
||||
// Auto resolution does not warn on non-packing families — the per-batch planner caps
|
||||
// the strategy to WholeUdt at dispatch time. Keeping Auto here means a future PR can
|
||||
// change the family-cap policy in one place without touching device init.
|
||||
var warnings = new List<string>();
|
||||
var drv = new AbCipDriver(new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(Micro, AbCipPlcFamily.Micro800,
|
||||
ReadStrategy: ReadStrategy.Auto)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
OnWarning = warnings.Add,
|
||||
}, "drv-1");
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
drv.GetDeviceState(Micro)!.ReadStrategy.ShouldBe(ReadStrategy.Auto);
|
||||
warnings.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
// ---- Heuristic ----
|
||||
|
||||
[Fact]
|
||||
public void Heuristic_picks_MultiPacket_when_subscribed_fraction_below_threshold()
|
||||
{
|
||||
// 5 of 50 subscribed = 0.10, threshold = 0.25 → MultiPacket
|
||||
AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(5, 50, 0.25).ShouldBe(ReadStrategy.MultiPacket);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Heuristic_picks_WholeUdt_when_subscribed_fraction_above_threshold()
|
||||
{
|
||||
// 40 of 50 subscribed = 0.80, threshold = 0.25 → WholeUdt
|
||||
AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(40, 50, 0.25).ShouldBe(ReadStrategy.WholeUdt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Heuristic_at_threshold_boundary_picks_WholeUdt()
|
||||
{
|
||||
// Strictly less than → MultiPacket; equal → WholeUdt. Deterministic boundary behaviour
|
||||
// so tests can pin exact picks without hand-wringing about float comparison drift.
|
||||
AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(10, 40, 0.25).ShouldBe(ReadStrategy.WholeUdt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Heuristic_with_zero_total_members_defaults_to_WholeUdt()
|
||||
{
|
||||
AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(0, 0, 0.25).ShouldBe(ReadStrategy.WholeUdt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Heuristic_clamps_threshold_below_zero_to_zero()
|
||||
{
|
||||
// Negative threshold collapses to "never MultiPacket" — even a 0-of-N read picks WholeUdt.
|
||||
AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(0, 10, -0.5).ShouldBe(ReadStrategy.WholeUdt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Heuristic_clamps_threshold_above_one_to_one()
|
||||
{
|
||||
// Threshold > 1 saturates so any subscribed fraction triggers MultiPacket.
|
||||
AbCipMultiPacketReadPlanner.ChooseStrategyForGroup(9, 10, 5.0).ShouldBe(ReadStrategy.MultiPacket);
|
||||
}
|
||||
|
||||
// ---- Driver-level dispatch / counters ----
|
||||
|
||||
private static AbCipTagDefinition BuildLargeUdt(string name, int memberCount)
|
||||
{
|
||||
var members = new AbCipStructureMember[memberCount];
|
||||
for (var i = 0; i < memberCount; i++)
|
||||
members[i] = new AbCipStructureMember($"M{i}", AbCipDataType.DInt);
|
||||
return new AbCipTagDefinition(name, Device, name, AbCipDataType.Structure, Members: members);
|
||||
}
|
||||
|
||||
private static AbCipDriverOptions BuildOptions(ReadStrategy strategy, double threshold = 0.25,
|
||||
AbCipPlcFamily family = AbCipPlcFamily.ControlLogix, params AbCipTagDefinition[] tags)
|
||||
{
|
||||
var host = family == AbCipPlcFamily.Micro800 ? Micro : Device;
|
||||
// Re-bind tag DeviceHostAddress when family flips so single-test reuse keeps
|
||||
// working — the supplied tags are built against Device by default.
|
||||
var rebuiltTags = tags.Select(t => new AbCipTagDefinition(
|
||||
t.Name, host, t.TagPath, t.DataType, t.Writable, t.WriteIdempotent,
|
||||
t.Members, t.SafetyTag, t.StringLength, t.Description)).ToArray();
|
||||
return new AbCipDriverOptions
|
||||
{
|
||||
Devices = [new AbCipDeviceOptions(host, family, ReadStrategy: strategy,
|
||||
MultiPacketSparsityThreshold: threshold)],
|
||||
Probe = new AbCipProbeOptions { Enabled = false },
|
||||
Tags = rebuiltTags,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Auto_with_sparse_subscription_dispatches_through_MultiPacket()
|
||||
{
|
||||
// 5 subscribed of 50 = 0.10 < 0.25 → MultiPacket
|
||||
var udt = BuildLargeUdt("Tank", 50);
|
||||
var options = BuildOptions(ReadStrategy.Auto, threshold: 0.25, tags: udt);
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(options, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var refs = Enumerable.Range(0, 5).Select(i => $"Tank.M{i}").ToArray();
|
||||
await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Device)!;
|
||||
state.MultiPacketGroupsExecuted.ShouldBe(1);
|
||||
state.WholeUdtGroupsExecuted.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Auto_with_dense_subscription_dispatches_through_WholeUdt()
|
||||
{
|
||||
// 40 subscribed of 50 = 0.80 > 0.25 → WholeUdt
|
||||
var udt = BuildLargeUdt("Tank", 50);
|
||||
var options = BuildOptions(ReadStrategy.Auto, threshold: 0.25, tags: udt);
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(options, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var refs = Enumerable.Range(0, 40).Select(i => $"Tank.M{i}").ToArray();
|
||||
await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Device)!;
|
||||
state.WholeUdtGroupsExecuted.ShouldBe(1);
|
||||
state.MultiPacketGroupsExecuted.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task User_forced_MultiPacket_dispatches_through_MultiPacket_regardless_of_density()
|
||||
{
|
||||
// 40-of-50 dense reads still hit MultiPacket when the user forces it.
|
||||
var udt = BuildLargeUdt("Tank", 50);
|
||||
var options = BuildOptions(ReadStrategy.MultiPacket, threshold: 0.25, tags: udt);
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(options, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var refs = Enumerable.Range(0, 40).Select(i => $"Tank.M{i}").ToArray();
|
||||
await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Device)!;
|
||||
state.MultiPacketGroupsExecuted.ShouldBe(1);
|
||||
state.WholeUdtGroupsExecuted.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task User_forced_WholeUdt_dispatches_through_WholeUdt_regardless_of_sparsity()
|
||||
{
|
||||
// 1 sparse read of 50 still hits WholeUdt when the user forces it. Note: the WholeUdt
|
||||
// planner demotes 1-member groups to fallback because a single member doesn't beat the
|
||||
// whole-UDT-buffer cost. Verify ReadCount on the parent's runtime stays zero — the
|
||||
// member runtime did the work.
|
||||
var udt = BuildLargeUdt("Tank", 50);
|
||||
var options = BuildOptions(ReadStrategy.WholeUdt, threshold: 0.25, tags: udt);
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(options, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
await drv.ReadAsync(["Tank.M0"], CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Device)!;
|
||||
state.MultiPacketGroupsExecuted.ShouldBe(0);
|
||||
// 1-member groups skip WholeUdt grouping per the existing planner contract — the
|
||||
// counter increments only when the planner emits a group, not for the per-tag fallback.
|
||||
state.WholeUdtGroupsExecuted.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Threshold_tunable_higher_value_picks_MultiPacket_for_denser_reads()
|
||||
{
|
||||
// 12 of 50 = 0.24, threshold = 0.5 → MultiPacket (would have been WholeUdt at 0.25).
|
||||
var udt = BuildLargeUdt("Tank", 50);
|
||||
var options = BuildOptions(ReadStrategy.Auto, threshold: 0.5, tags: udt);
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(options, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var refs = Enumerable.Range(0, 12).Select(i => $"Tank.M{i}").ToArray();
|
||||
await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
drv.GetDeviceState(Device)!.MultiPacketGroupsExecuted.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Auto_on_Micro800_caps_to_WholeUdt_even_when_sparse()
|
||||
{
|
||||
// Family doesn't support request packing → Auto must NEVER pick MultiPacket.
|
||||
var udt = BuildLargeUdt("Tank", 50);
|
||||
var options = BuildOptions(ReadStrategy.Auto, threshold: 0.25,
|
||||
family: AbCipPlcFamily.Micro800, tags: udt);
|
||||
var factory = new FakeAbCipTagFactory();
|
||||
var drv = new AbCipDriver(options, "drv-1", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var refs = Enumerable.Range(0, 5).Select(i => $"Tank.M{i}").ToArray();
|
||||
await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Micro)!;
|
||||
state.MultiPacketGroupsExecuted.ShouldBe(0);
|
||||
state.WholeUdtGroupsExecuted.ShouldBe(1);
|
||||
}
|
||||
|
||||
// ---- Family-profile compatibility ----
|
||||
|
||||
[Fact]
|
||||
public void Family_profiles_advertise_request_packing_correctly()
|
||||
{
|
||||
AbCipPlcFamilyProfile.ControlLogix.SupportsRequestPacking.ShouldBeTrue();
|
||||
AbCipPlcFamilyProfile.CompactLogix.SupportsRequestPacking.ShouldBeTrue();
|
||||
AbCipPlcFamilyProfile.GuardLogix.SupportsRequestPacking.ShouldBeTrue();
|
||||
AbCipPlcFamilyProfile.Micro800.SupportsRequestPacking.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- DTO round-trip ----
|
||||
|
||||
[Fact]
|
||||
public async Task DTO_round_trips_ReadStrategy_MultiPacket_through_config_json()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"Devices": [
|
||||
{
|
||||
"HostAddress": "ab://10.0.0.5/1,0",
|
||||
"PlcFamily": "ControlLogix",
|
||||
"ReadStrategy": "MultiPacket",
|
||||
"MultiPacketSparsityThreshold": 0.5
|
||||
}
|
||||
],
|
||||
"Probe": { "Enabled": false }
|
||||
}
|
||||
""";
|
||||
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
|
||||
await drv.InitializeAsync(json, CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Device)!;
|
||||
state.ReadStrategy.ShouldBe(ReadStrategy.MultiPacket);
|
||||
state.Options.MultiPacketSparsityThreshold.ShouldBe(0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DTO_round_trips_ReadStrategy_WholeUdt_through_config_json()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"Devices": [
|
||||
{
|
||||
"HostAddress": "ab://10.0.0.5/1,0",
|
||||
"PlcFamily": "ControlLogix",
|
||||
"ReadStrategy": "WholeUdt"
|
||||
}
|
||||
],
|
||||
"Probe": { "Enabled": false }
|
||||
}
|
||||
""";
|
||||
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
|
||||
await drv.InitializeAsync(json, CancellationToken.None);
|
||||
drv.GetDeviceState(Device)!.ReadStrategy.ShouldBe(ReadStrategy.WholeUdt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DTO_omitted_ReadStrategy_falls_back_to_Auto_with_default_threshold()
|
||||
{
|
||||
var json = """
|
||||
{
|
||||
"Devices": [
|
||||
{
|
||||
"HostAddress": "ab://10.0.0.5/1,0",
|
||||
"PlcFamily": "ControlLogix"
|
||||
}
|
||||
],
|
||||
"Probe": { "Enabled": false }
|
||||
}
|
||||
""";
|
||||
var drv = AbCipDriverFactoryExtensions.CreateInstance("drv-1", json);
|
||||
await drv.InitializeAsync(json, CancellationToken.None);
|
||||
|
||||
var state = drv.GetDeviceState(Device)!;
|
||||
state.ReadStrategy.ShouldBe(ReadStrategy.Auto);
|
||||
state.Options.MultiPacketSparsityThreshold.ShouldBe(0.25);
|
||||
}
|
||||
|
||||
// ---- Planner output shape (sanity) ----
|
||||
|
||||
[Fact]
|
||||
public void MultiPacketPlanner_groups_subscribed_members_by_parent()
|
||||
{
|
||||
var udt = BuildLargeUdt("Tank", 50);
|
||||
var tagsByName = new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Tank"] = udt,
|
||||
};
|
||||
for (var i = 0; i < 50; i++)
|
||||
{
|
||||
tagsByName[$"Tank.M{i}"] = new AbCipTagDefinition(
|
||||
$"Tank.M{i}", Device, $"Tank.M{i}", AbCipDataType.DInt);
|
||||
}
|
||||
|
||||
var refs = new[] { "Tank.M0", "Tank.M3", "Tank.M7" };
|
||||
var plan = AbCipMultiPacketReadPlanner.Build(refs, tagsByName);
|
||||
|
||||
plan.Batches.Count.ShouldBe(1);
|
||||
plan.Batches[0].ParentName.ShouldBe("Tank");
|
||||
plan.Batches[0].Members.Count.ShouldBe(3);
|
||||
plan.Fallbacks.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MultiPacketPlanner_does_not_demote_singletons_unlike_WholeUdt_planner()
|
||||
{
|
||||
// A 1-of-N read is the canonical sparse case — MultiPacket emits a Batch with one
|
||||
// member where WholeUdt would demote to fallback. This is the load-bearing difference.
|
||||
var udt = BuildLargeUdt("Tank", 50);
|
||||
var tagsByName = new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Tank"] = udt,
|
||||
["Tank.M0"] = new AbCipTagDefinition("Tank.M0", Device, "Tank.M0", AbCipDataType.DInt),
|
||||
};
|
||||
|
||||
var plan = AbCipMultiPacketReadPlanner.Build(["Tank.M0"], tagsByName);
|
||||
|
||||
plan.Batches.Count.ShouldBe(1);
|
||||
plan.Batches[0].Members.Count.ShouldBe(1);
|
||||
plan.Fallbacks.ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user