using System.Buffers.Binary;
using System.Globalization;
using System.Text;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests.Differential;
///
/// Hardware-evidence legs of Gitea #592 (hw4 lane evidence), READ-ONLY by construction and covered by the same
/// source/IL guard as the rest of this folder. Both legs are opt-in on top of AB_DIFF_ENDPOINT because each
/// is its own press session (the browse is ~3000 tags):
///
/// AB_EVID_MIX=1 — #582: reads a STRING, an array element, a whole array and scalars concurrently
/// through the native transport and proves, from the exchange tap on the connection, that they travelled
/// as ONE Multiple Service Packet and decode to what individual reads return. Tag set:
/// AB_EVID_TAGS = name|Type|count entries separated by ; (count > 1 or
/// [] suffix on Type ⇒ array).
/// AB_EVID_BROWSE=1 — #572: one native @tags browse with every listing page's instance-id
/// sequence recorded, reporting any page that is not ascending.
///
///
[Collection(DifferentialCollection.Name)]
[Trait("Category", "LiveIntegration")]
public sealed class EvidenceTests
{
private const string DefaultMix =
"Program:UN01_Press.Sts_CycleTime_STR|String|1;Program:EM01_PowerUnit._SEQ_EP[1]|DInt|1;" +
"Program:EM01_PowerUnit._SEQ_EP|DInt|32;test2|DInt|1;Program:UN01_Press.xxtest|Bool|1";
private sealed record ExchangeRecord(byte Service, int RequestBytes, int ReplyBytes, int Embedded, string Detail);
[Fact]
public async Task Mixed_string_array_and_scalar_reads_travel_as_one_multiple_service_packet()
{
var target = DifferentialTarget.FromEnvironment();
if (target is null) Assert.Skip(DifferentialTarget.SkipReason);
if (Environment.GetEnvironmentVariable("AB_EVID_MIX") != "1") Assert.Skip("AB_EVID_MIX=1 not set.");
var specs = ParseMix(Environment.GetEnvironmentVariable("AB_EVID_TAGS") is { Length: > 0 } t ? t : DefaultMix);
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
var ct = cts.Token;
var diagnostics = DeviceSessionPool.Shared.Diagnostics;
var exchanges = new List();
var factory = AbCipTransportFactories.For(AbCipTransport.Native).Tags;
const int Attempts = 5;
var ((before, attempts, after), opens, closes) =
await DifferentialRun.WithNativeAnchorAsync("evidence mix", target, async checkOpens =>
{
var runtimes = new List<(MixSpec Spec, IAbCipTagRuntime Rt)>();
try
{
foreach (var s in specs)
{
await DifferentialRun.PaceAsync(target, ct);
var rt = factory.Create(target.Params(s.Name, s.Count, s.Count > 1, s.Type));
await rt.InitializeAsync(ct); // one read each (create-read)
checkOpens($"create of '{s.Name}'");
rt.GetStatus().ShouldBe((int)AbStatus.Ok, $"'{s.Name}' must create/read Ok");
runtimes.Add((s, rt));
}
async Task> SequentialAsync()
{
var values = new List();
foreach (var (spec, rt) in runtimes)
{
await DifferentialRun.PaceAsync(target, ct);
await rt.ReadAsync(ct);
checkOpens($"single read of '{spec.Name}'");
values.Add(Format(spec, rt));
await Task.Delay(1000, ct); // >= 1 s between repeated reads
}
return values;
}
var beforeValues = await SequentialAsync();
// The batch: every read issued at once so the scheduler packs them. Whether the pump wakes
// before or after the last request is queued is a scheduling race, so the batch is repeated
// (>= 1 s apart) and each attempt reports how it was packed.
var done = new List<(int Requests, List Records, List Values)>();
for (var attempt = 0; attempt < Attempts; attempt++)
{
exchanges.Clear();
diagnostics.ExchangeObserver = (req, rep) => exchanges.Add(Describe(req, rep));
var requestsBefore = diagnostics.RequestsSent;
await Task.WhenAll(runtimes.Select(r => r.Rt.ReadAsync(ct)));
var requests = diagnostics.RequestsSent - requestsBefore;
diagnostics.ExchangeObserver = null;
checkOpens($"batch {attempt + 1}");
done.Add((requests, exchanges.ToList(), runtimes.Select(r => Format(r.Spec, r.Rt)).ToList()));
await Task.Delay(1000, ct);
}
var afterValues = await SequentialAsync();
return (beforeValues, done, afterValues);
}
finally
{
diagnostics.ExchangeObserver = null;
foreach (var (_, rt) in runtimes) rt.Dispose();
}
});
Log($"## Mixed batch — {target}");
Log($"Tags: {string.Join(" ; ", specs.Select(s => $"{s.Name} ({s.Type}{(s.Count > 1 ? $"[{s.Count}]" : "")})"))}");
Log($"Native Forward Opens: {opens}; Forward Closes sent/acked: {closes.Sent}/{closes.Acked}");
var oneMsp = 0;
for (var n = 0; n < attempts.Count; n++)
{
var (requests, records, values) = attempts[n];
var single = requests == 1 && records.Count == 1 && records[0].Service == 0x0A && records[0].Embedded == specs.Count;
if (single) oneMsp++;
Log($"Attempt {n + 1}: {specs.Count} concurrent reads -> {requests} channel send(s); {(single ? "ONE MSP carrying all" : "split")}:");
foreach (var e in records)
Log($" - request service 0x{e.Service:X2}, {e.RequestBytes} B -> reply {e.ReplyBytes} B, embedded={e.Embedded}: {e.Detail}");
records.ShouldAllBe(e => !e.Detail.Contains("status=0x06"), "a 0x06 inside the MSP would mean a truncated reply");
records.Sum(e => e.Embedded).ShouldBe(specs.Count);
records.ShouldAllBe(e => e.Service == 0x0A || e.Embedded == 1);
}
Log("| Tag | single (before) | " + string.Join(" | ", Enumerable.Range(1, attempts.Count).Select(i => $"batch {i}")) + " | single (after) | all batch values match a bracketing single |");
Log("|---|---|" + string.Concat(Enumerable.Repeat("---|", attempts.Count)) + "---|---|");
var failures = new List();
for (var i = 0; i < specs.Count; i++)
{
var ok = attempts.All(a => a.Values[i] == before[i] || a.Values[i] == after[i]);
if (!ok) failures.Add(specs[i].Name);
Log($"| `{specs[i].Name}` | {before[i]} | {string.Join(" | ", attempts.Select(a => a.Values[i]))} | {after[i]} | {(ok ? "yes" : "**NO**")} |");
}
Log($"Attempts that carried every read in ONE MSP: {oneMsp}/{attempts.Count}");
opens.ShouldBe(1);
closes.Sent.ShouldBe(1);
oneMsp.ShouldBeGreaterThan(0, "at least one attempt must put the whole mixed batch in a single Multiple Service Packet");
failures.ShouldBeEmpty("a batch value matched neither bracketing single read");
}
[Fact]
public async Task Symbol_listing_pages_are_ascending_on_a_real_controller()
{
var target = DifferentialTarget.FromEnvironment();
if (target is null) Assert.Skip(DifferentialTarget.SkipReason);
if (Environment.GetEnvironmentVariable("AB_EVID_BROWSE") != "1") Assert.Skip("AB_EVID_BROWSE=1 not set.");
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10));
var ct = cts.Token;
var diagnostics = DeviceSessionPool.Shared.Diagnostics;
var pages = new List<(string Path, List Ids, bool More)>();
var gate = new object();
var factory = AbCipTransportFactories.For(AbCipTransport.Native).Enumerators;
var (count, opens, closes) = await DifferentialRun.WithNativeAnchorAsync("evidence browse", target, async checkOpens =>
{
diagnostics.ExchangeObserver = (req, rep) =>
{
if (req.Span.Length == 0 || req.Span[0] != 0x55) return;
var page = ParseListingPage(req.Span, rep.Span);
if (page is { } p) lock (gate) pages.Add(p);
};
try
{
var n = 0;
await DifferentialRun.PaceAsync(target, ct);
using var enumerator = factory.Create();
await foreach (var _ in enumerator.EnumerateAsync(target.Params("@tags"), ct))
{
n++;
if (n % 200 == 0) checkOpens($"browse entry {n}");
}
checkOpens("browse");
return n;
}
finally { diagnostics.ExchangeObserver = null; }
});
Log($"## Listing order — {target}");
Log($"Tags browsed: {count}; listing pages observed: {pages.Count}; Forward Opens {opens}; Forward Closes {closes.Sent}/{closes.Acked}");
var nonAscending = 0;
for (var i = 0; i < pages.Count; i++)
{
var (path, ids, more) = pages[i];
var strictly = true;
for (var k = 1; k < ids.Count; k++) if (ids[k] <= ids[k - 1]) { strictly = false; break; }
if (!strictly) nonAscending++;
var sample = ids.Count <= 12
? string.Join(",", ids)
: string.Join(",", ids.Take(6)) + ",...," + string.Join(",", ids.Skip(ids.Count - 4));
Log($"page {i + 1}: scope={path} entries={ids.Count} first={(ids.Count > 0 ? ids[0] : 0)} last={(ids.Count > 0 ? ids[^1] : 0)} " +
$"max={(ids.Count > 0 ? ids.Max() : 0)} more={more} ascending={strictly} ids=[{sample}]");
}
Log($"Non-ascending pages: {nonAscending}");
Log(nonAscending == 0 ? "RESULT: every listing page strictly ascending." : $"RESULT: {nonAscending} NON-ASCENDING page(s) observed.");
pages.ShouldNotBeEmpty();
opens.ShouldBe(1);
}
private sealed record MixSpec(string Name, AbCipDataType Type, int Count);
private static List ParseMix(string raw) => raw.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(e =>
{
var p = e.Split('|');
return new MixSpec(p[0], Enum.Parse(p[1], ignoreCase: true),
p.Length > 2 ? int.Parse(p[2], CultureInfo.InvariantCulture) : 1);
}).ToList();
private static string Format(MixSpec spec, IAbCipTagRuntime rt)
{
if (spec.Count > 1)
return rt.DecodeArray(spec.Type, spec.Count) is System.Collections.IEnumerable e
? "[" + string.Join(",", e.Cast