Merge lane AbLegacy: AbLegacy driver remediation (#738)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -32,6 +32,27 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
|
||||
"LogixPccc unverified).")]
|
||||
public AbLegacyCliTransport Transport { get; init; } = AbLegacyCliTransport.Default;
|
||||
|
||||
/// <summary>
|
||||
/// Runs a one-shot command's driver work so a Ctrl-C (cancellation of <paramref name="ct"/>) prints
|
||||
/// <c>Cancelled.</c> instead of an unhandled <see cref="OperationCanceledException"/> stack trace (review 07 U7).
|
||||
/// Any other exception — and an OCE the operator did not ask for — still propagates.
|
||||
/// </summary>
|
||||
/// <param name="console">The CLI console.</param>
|
||||
/// <param name="ct">The console's Ctrl-C token.</param>
|
||||
/// <param name="body">The command body.</param>
|
||||
/// <returns>A task that completes when the body has finished or was cancelled.</returns>
|
||||
internal static async ValueTask RunCancellableAsync(CliFx.Infrastructure.IConsole console, CancellationToken ct, Func<Task> body)
|
||||
{
|
||||
try
|
||||
{
|
||||
await body();
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
await console.Error.WriteLineAsync("Cancelled.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the per-operation timeout in milliseconds.</summary>
|
||||
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
|
||||
public int TimeoutMs { get; init; } = 5000;
|
||||
|
||||
@@ -43,17 +43,20 @@ public sealed class ProbeCommand : AbLegacyCommandBase
|
||||
var driver = new AbLegacyDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||
var health = driver.GetHealth();
|
||||
await RunCancellableAsync(console, ct, async () =>
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync(["__probe"], ct);
|
||||
var health = driver.GetHealth();
|
||||
|
||||
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
|
||||
await console.Output.WriteLineAsync($"PLC type: {PlcType}");
|
||||
await console.Output.WriteLineAsync($"Health: {health.State}");
|
||||
if (health.LastError is { } err)
|
||||
await console.Output.WriteLineAsync($"Last error: {err}");
|
||||
await console.Output.WriteLineAsync();
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||
await console.Output.WriteLineAsync($"Gateway: {Gateway}");
|
||||
await console.Output.WriteLineAsync($"PLC type: {PlcType}");
|
||||
await console.Output.WriteLineAsync($"Health: {health.State}");
|
||||
if (health.LastError is { } err)
|
||||
await console.Output.WriteLineAsync($"Last error: {err}");
|
||||
await console.Output.WriteLineAsync();
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -45,9 +45,12 @@ public sealed class ReadCommand : AbLegacyCommandBase
|
||||
var driver = new AbLegacyDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||
await RunCancellableAsync(console, ct, async () =>
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var snapshot = await driver.ReadAsync([tagName], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -55,9 +55,12 @@ public sealed class WriteCommand : AbLegacyCommandBase
|
||||
var driver = new AbLegacyDriver(options, DriverInstanceId);
|
||||
try
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
|
||||
await RunCancellableAsync(console, ct, async () =>
|
||||
{
|
||||
await driver.InitializeAsync("{}", ct);
|
||||
var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
|
||||
await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -48,6 +48,16 @@ public sealed class AbLegacyDriverOptions
|
||||
/// <summary>Gets or sets the default timeout for read/write operations.</summary>
|
||||
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>
|
||||
/// Whether scalar tags on adjacent elements of one N / B / F / L file are read as ONE multi-element PCCC read
|
||||
/// (driver-config key <c>coalesceReads</c>, default <see langword="true"/>). A block the device refuses falls
|
||||
/// back to per-tag reads on its own; set this false to read every tag singly. <see langword="null"/> (the key
|
||||
/// absent) means the default, ON - and null is never written, so a save through any serializer (the AdminUI
|
||||
/// form included) keeps a defaulted driver defaulted instead of pinning today's default (review 07 r0 M9).
|
||||
/// </summary>
|
||||
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? CoalesceReads { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
|
||||
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
|
||||
|
||||
+79
@@ -77,6 +77,10 @@ public static class AbLegacyTagDefinitionFactory
|
||||
if (rawLength >= 1)
|
||||
arrayLength = Math.Min(rawLength, AbLegacyArray.MaxElements);
|
||||
}
|
||||
// A deterministic refusal (review 07 H16): String / Timer / Counter / Control elements are structured
|
||||
// or variable-width and do not lay out as a flat array, so both runtimes refuse to decode them. Accepting
|
||||
// the tag only bought a handle create + a NotSupportedException on every poll.
|
||||
if (arrayLength is not null && !IsArrayElementTypeSupported(dataType)) return false;
|
||||
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
|
||||
// authorable.
|
||||
var writable = TagConfigJson.ReadWritable(root);
|
||||
@@ -173,6 +177,9 @@ public static class AbLegacyTagDefinitionFactory
|
||||
{
|
||||
if (AbLegacyAddress.Validate(accepted.Address, family) is { } addressError)
|
||||
warnings.Add($"AbLegacy tag address: {addressError} The driver reads this tag as BadNodeIdUnknown.");
|
||||
else if (accepted.ArrayLength is not null
|
||||
&& ArrayFileMismatch(accepted.DataType, accepted.Address) is { } widthError)
|
||||
warnings.Add(widthError);
|
||||
return warnings;
|
||||
}
|
||||
|
||||
@@ -224,6 +231,78 @@ public static class AbLegacyTagDefinitionFactory
|
||||
}
|
||||
|
||||
if (TagConfigJson.DescribeInvalidEnum<AbLegacyDataType>(root, "dataType") is { } w) warnings.Add(w);
|
||||
|
||||
if (IsArrayFlag(root) && ReadInt(root, "arrayLength") >= 1
|
||||
&& TagConfigJson.TryReadEnumStrict(root, "dataType", AbLegacyDataType.Int, out var dt)
|
||||
&& !IsArrayElementTypeSupported(dt))
|
||||
warnings.Add(ArrayElementTypeError(dt));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether <paramref name="type"/> can be the element type of an array tag. Only the flat word/long/float/bit
|
||||
/// files lay out as a contiguous array; String, Timer, Counter and Control elements are structured.
|
||||
/// </summary>
|
||||
/// <param name="type">The authored element data type.</param>
|
||||
/// <returns><see langword="true"/> for Bit, Int, AnalogInt, Long and Float.</returns>
|
||||
public static bool IsArrayElementTypeSupported(AbLegacyDataType type) => type is
|
||||
AbLegacyDataType.Bit or AbLegacyDataType.Int or AbLegacyDataType.AnalogInt
|
||||
or AbLegacyDataType.Long or AbLegacyDataType.Float;
|
||||
|
||||
/// <summary>The authoring / deploy-gate message for an array of an unsupported element type.</summary>
|
||||
/// <param name="type">The authored element data type.</param>
|
||||
/// <returns>The message.</returns>
|
||||
public static string ArrayElementTypeError(AbLegacyDataType type) =>
|
||||
$"AbLegacy {type} is not supported as an array element (String, Timer, Counter and Control elements are "
|
||||
+ "structured); author a scalar tag per element instead. The driver skips this tag and it reads BadNodeIdUnknown.";
|
||||
|
||||
/// <summary>
|
||||
/// The element width an array on <paramref name="address"/>'s data file has, as the data type that decodes /
|
||||
/// encodes it: N, B, A and the I/O/S word files are 16-bit words (<see cref="AbLegacyDataType.Int"/>), L is
|
||||
/// 32-bit (<see cref="AbLegacyDataType.Long"/>), F is IEEE float (<see cref="AbLegacyDataType.Float"/>).
|
||||
/// <see langword="null"/> for a file with no flat array layout (ST, T, C, R, function files) or an address that
|
||||
/// does not parse.
|
||||
/// </summary>
|
||||
/// <param name="address">The authored address.</param>
|
||||
/// <returns>The file's element type, or <see langword="null"/>.</returns>
|
||||
public static AbLegacyDataType? ArrayFileElementType(string address) =>
|
||||
AbLegacyAddress.TryParse(address) is { } a ? ArrayFileElementType(a) : null;
|
||||
|
||||
/// <inheritdoc cref="ArrayFileElementType(string)"/>
|
||||
public static AbLegacyDataType? ArrayFileElementType(AbLegacyAddress a) => a.FileLetter switch
|
||||
{
|
||||
"N" or "B" or "A" or "I" or "O" or "S" => AbLegacyDataType.Int,
|
||||
"L" => AbLegacyDataType.Long,
|
||||
"F" => AbLegacyDataType.Float,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Why an ARRAY authored as <paramref name="type"/> on <paramref name="address"/> does not match its data
|
||||
/// file's element width (review 07 r0 I3), or <see langword="null"/> when it does. Int / AnalogInt need a 16-bit
|
||||
/// word file (N/B/A/I/O/S), Long an L file, Float an F file; Bit arrays are bit-packed words and fit any 16-bit
|
||||
/// word file. A mismatch reads the file at the wrong width (garbage) and the driver refuses to WRITE it
|
||||
/// (<c>BadTypeMismatch</c>), because encoding at the authored width would corrupt the neighbouring elements.
|
||||
/// </summary>
|
||||
/// <param name="type">The authored element data type.</param>
|
||||
/// <param name="address">The authored address.</param>
|
||||
/// <returns>The mismatch message, or <see langword="null"/>.</returns>
|
||||
public static string? ArrayFileMismatch(AbLegacyDataType type, string address)
|
||||
{
|
||||
if (!IsArrayElementTypeSupported(type)) return null; // structured arrays have their own refusal
|
||||
// The file LETTER is static even for an indirect address (N7:[N7:0], N[N7:0]:5) — only the numbers move —
|
||||
// so the width rule applies to it too (review 07 r1 N5).
|
||||
if (AbLegacyAddress.TryParse(address) is not { } a) return null;
|
||||
var file = ArrayFileElementType(a);
|
||||
var ok = type switch
|
||||
{
|
||||
AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Bit => file == AbLegacyDataType.Int,
|
||||
_ => file == type,
|
||||
};
|
||||
return ok
|
||||
? null
|
||||
: $"AbLegacy {type} array on '{address}' does not match the {a.FileLetter} file's element width "
|
||||
+ "(Int/AnalogInt/Bit need an N/B/A word file, Long an L file, Float an F file): reads decode the wrong width "
|
||||
+ "and writes are refused BadTypeMismatch.";
|
||||
}
|
||||
|
||||
private static int ReadInt(JsonElement o, string name)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Collections;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OPC UA array-write value into the element-typed CLR array a runtime's
|
||||
/// <see cref="IAbLegacyTagRuntime.EncodeArray"/> takes. Pure: it runs BEFORE any runtime is created, so a refused
|
||||
/// write never costs a wire round trip and never touches driver health.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Elements are converted, not exact-matched.</b> ScadaBridge writes CLR lists such as <c>int[]</c> to
|
||||
/// Int16 nodes, and an OPC UA client may send any numeric array, so each element goes through the same checked
|
||||
/// <see cref="Convert"/> the scalar path uses. A value that does not fit is <c>BadOutOfRange</c>; one that is not
|
||||
/// a number at all is <c>BadTypeMismatch</c>.</para>
|
||||
/// <para><b>The length must equal the tag's element count</b> (<c>BadOutOfRange</c> otherwise): a shorter write
|
||||
/// would have to fill the tail from the last read buffer, which is the stale-elements defect this replaces.</para>
|
||||
/// <para><b>Bit arrays are refused</b> (<c>BadNotSupported</c>): a PCCC bit array is bit-packed into words, so
|
||||
/// encoding it rewrites whole words and would clobber neighbouring bits the PLC owns. String / Timer / Counter /
|
||||
/// Control arrays never reach here — the mapper rejects them.</para>
|
||||
/// </remarks>
|
||||
internal static class AbLegacyArrayWriteEncoder
|
||||
{
|
||||
/// <summary>Converts <paramref name="value"/> to a typed array of exactly <paramref name="count"/> elements.</summary>
|
||||
/// <param name="type">The tag's element data type.</param>
|
||||
/// <param name="value">The value the client wrote.</param>
|
||||
/// <param name="count">The tag's element count.</param>
|
||||
/// <param name="typed">The converted array when the result is Good.</param>
|
||||
/// <returns><see cref="AbLegacyStatusMapper.Good"/>, or the Bad status the write is answered with.</returns>
|
||||
public static uint TryConvert(AbLegacyDataType type, object? value, int count, out Array? typed)
|
||||
{
|
||||
typed = null;
|
||||
if (type is AbLegacyDataType.Bit) return AbLegacyStatusMapper.BadNotSupported;
|
||||
if (type is not (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float))
|
||||
return AbLegacyStatusMapper.BadNotSupported;
|
||||
if (value is null or string || value is not IEnumerable items) return AbLegacyStatusMapper.BadTypeMismatch;
|
||||
|
||||
var elements = items.Cast<object?>().ToList();
|
||||
if (elements.Count != count) return AbLegacyStatusMapper.BadOutOfRange;
|
||||
try
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case AbLegacyDataType.Long:
|
||||
{
|
||||
var a = new int[count];
|
||||
for (var i = 0; i < count; i++) a[i] = Convert.ToInt32(Element(elements[i]));
|
||||
typed = a;
|
||||
break;
|
||||
}
|
||||
case AbLegacyDataType.Float:
|
||||
{
|
||||
var a = new float[count];
|
||||
for (var i = 0; i < count; i++) a[i] = Convert.ToSingle(Element(elements[i]));
|
||||
typed = a;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
{
|
||||
var a = new short[count];
|
||||
for (var i = 0; i < count; i++) a[i] = Convert.ToInt16(Element(elements[i]));
|
||||
typed = a;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return AbLegacyStatusMapper.Good;
|
||||
}
|
||||
catch (OverflowException) { return AbLegacyStatusMapper.BadOutOfRange; }
|
||||
catch (Exception ex) when (ex is FormatException or InvalidCastException) { return AbLegacyStatusMapper.BadTypeMismatch; }
|
||||
}
|
||||
|
||||
// A string element is not a number: Convert.ToInt16("12") would PARSE it, which no OPC UA numeric array carries.
|
||||
private static object Element(object? e) => e switch
|
||||
{
|
||||
null => throw new InvalidCastException("null array element"),
|
||||
string => throw new InvalidCastException("string array element"),
|
||||
bool => throw new InvalidCastException("bool array element"),
|
||||
_ => e,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// Adjacent-element read coalescing (review 07 P7): scalar tags on the same N / B / F / L data file whose
|
||||
/// elements are close together are read as ONE multi-element PCCC read (the same path array tags already use),
|
||||
/// then each tag is decoded from its offset in the block. One round trip instead of one per tag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>Only shapes the single-tag path decodes identically are coalesced</b>: a word tag
|
||||
/// (<c>Int</c>/<c>AnalogInt</c>) or a bit of a word (<c>Bit</c>, with or without <c>/n</c>) on an N or B file; a
|
||||
/// <c>Float</c> on an F file; a <c>Long</c> or a bit of a long on an L file. Indirect addresses, sub-elements
|
||||
/// (<c>T4:0.ACC</c>), arrays, the file-number-less system files (<c>S:</c>, <c>I:</c>, <c>O:</c>) and any tag
|
||||
/// authored with a type that does not match its file's element width keep the single-tag path, so a
|
||||
/// mis-authored tag still gets exactly the answer it got before.</para>
|
||||
/// <para><b>The span is safe to read.</b> A block runs from its lowest to its highest member, so every element
|
||||
/// it reads lies between two authored elements of the same file — inside the file whenever the authored tags
|
||||
/// are. A device REFUSAL of a block (an authored element past the end of the file, a PLC-5 native non-N file)
|
||||
/// — or ANY other status the device returned, incl. libplctag's remote error — falls back to single reads for that
|
||||
/// block's members in the same pass, so one bad tag never blackens its neighbours; a shape refused twice in a row
|
||||
/// is remembered so it is not retried every poll. Only a NO-REPLY failure fails the members together — they
|
||||
/// share the connection — exactly as their single reads would have.</para>
|
||||
/// <para>Turned off with the driver-config key <c>coalesceReads: false</c>.</para>
|
||||
/// </remarks>
|
||||
public sealed partial class AbLegacyDriver
|
||||
{
|
||||
/// <summary>Largest block, in data bytes. Below the 236-byte PCCC typed-read limit, so libplctag never needs
|
||||
/// to fragment a coalesced read (the native runtime would chunk it anyway).</summary>
|
||||
internal const int MaxCoalescedBytes = 200;
|
||||
|
||||
/// <summary>Largest run of UNAUTHORED elements a block may read across to join two members.</summary>
|
||||
internal const int MaxCoalesceGap = 8;
|
||||
|
||||
/// <summary>One unit of a device pass: a single tag, or a coalesced block of several.</summary>
|
||||
private sealed record ReadUnit(ReadItem? Single, CoalescedBlock? Block);
|
||||
|
||||
/// <summary>A coalesced block: <paramref name="Count"/> elements of one file from <paramref name="Start"/>.</summary>
|
||||
private sealed record CoalescedBlock(
|
||||
string FileLetter, int FileNumber, int Start, int Count, AbLegacyDataType ElementType, List<ReadItem> Members)
|
||||
{
|
||||
/// <summary>The runtime-cache key; distinct from any RawPath (RawPaths never contain '|').</summary>
|
||||
public string Key => $"|block|{FileLetter}{FileNumber}:{Start}+{Count}";
|
||||
|
||||
/// <summary>The block's base address in libplctag form.</summary>
|
||||
public string StartName => $"{FileLetter}{FileNumber}:{Start}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The element type a scalar tag is decoded from when it rides a coalesced block, or <see langword="null"/>
|
||||
/// when the tag keeps the single-tag path.
|
||||
/// </summary>
|
||||
private static AbLegacyDataType? CoalescedElementType(ReadItem item)
|
||||
{
|
||||
var a = item.Parsed;
|
||||
if (a.IsIndirect || a.SubElement is not null || a.FileNumber is null || IsArrayTag(item.Def)) return null;
|
||||
return (a.FileLetter, item.Def.DataType) switch
|
||||
{
|
||||
("N" or "B", AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Bit) => AbLegacyDataType.Int,
|
||||
("F", AbLegacyDataType.Float) => AbLegacyDataType.Float,
|
||||
("L", AbLegacyDataType.Long) => AbLegacyDataType.Long,
|
||||
("L", AbLegacyDataType.Bit) when a.BitIndex is not null => AbLegacyDataType.Long,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plans a device pass: coalescable tags grouped per file into runs of nearby elements (a run of two or more
|
||||
/// tags becomes a block), everything else a single unit. Block shapes the device refused before stay single.
|
||||
/// </summary>
|
||||
private List<ReadUnit> PlanReadUnits(DeviceState device, List<ReadItem> items)
|
||||
{
|
||||
if (_options.CoalesceReads == false || items.Count < 2) return [.. items.Select(i => new ReadUnit(i, null))];
|
||||
|
||||
var units = new List<ReadUnit>(items.Count);
|
||||
var groups = new Dictionary<(string Letter, int File, AbLegacyDataType Type), List<ReadItem>>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (CoalescedElementType(item) is not { } type)
|
||||
{
|
||||
units.Add(new ReadUnit(item, null));
|
||||
continue;
|
||||
}
|
||||
var key = (item.Parsed.FileLetter, item.Parsed.FileNumber!.Value, type);
|
||||
if (!groups.TryGetValue(key, out var list)) groups[key] = list = [];
|
||||
list.Add(item);
|
||||
}
|
||||
|
||||
foreach (var ((letter, file, type), members) in groups)
|
||||
{
|
||||
var width = type == AbLegacyDataType.Int ? 2 : 4;
|
||||
var maxElements = MaxCoalescedBytes / width;
|
||||
var ordered = members.OrderBy(m => m.Parsed.WordNumber).ToList();
|
||||
var run = new List<ReadItem> { ordered[0] };
|
||||
for (var i = 1; i <= ordered.Count; i++)
|
||||
{
|
||||
var next = i < ordered.Count ? ordered[i] : null;
|
||||
var start = run[0].Parsed.WordNumber;
|
||||
if (next is not null
|
||||
&& next.Parsed.WordNumber - run[^1].Parsed.WordNumber <= MaxCoalesceGap + 1
|
||||
&& next.Parsed.WordNumber - start + 1 <= maxElements)
|
||||
{
|
||||
run.Add(next);
|
||||
continue;
|
||||
}
|
||||
var count = run[^1].Parsed.WordNumber - start + 1;
|
||||
var block = new CoalescedBlock(letter, file, start, count, type, run);
|
||||
if (run.Count < 2 || device.RefusedBlocks.ContainsKey(block.Key))
|
||||
units.AddRange(run.Select(r => new ReadUnit(r, null)));
|
||||
else
|
||||
units.Add(new ReadUnit(null, block));
|
||||
if (next is not null) run = [next];
|
||||
}
|
||||
}
|
||||
return units;
|
||||
}
|
||||
|
||||
/// <summary>What a block read concluded; <see cref="Fallback"/> = read the members singly instead.</summary>
|
||||
private enum BlockOutcome { Done, Fallback }
|
||||
|
||||
/// <summary>
|
||||
/// Reads one coalesced block and decodes each member from its offset. A device refusal or a local refusal
|
||||
/// returns <see cref="BlockOutcome.Fallback"/> (and remembers the shape) without writing any member's result.
|
||||
/// </summary>
|
||||
private async Task<(BlockOutcome Outcome, OpVerdict Verdict, string? Message)> ReadBlockAsync(
|
||||
DeviceState device, CoalescedBlock block, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
|
||||
{
|
||||
void FailAll(uint status)
|
||||
{
|
||||
foreach (var m in block.Members) results[m.Index] = new DataValueSnapshot(null, status, null, now);
|
||||
}
|
||||
|
||||
(BlockOutcome, OpVerdict, string?) Refused(string why)
|
||||
{
|
||||
// The handle is never kept for a refused shape (review 07 r0 M5): it may never be planned again.
|
||||
EvictRuntime(device, block.Key);
|
||||
// Every refusal falls back to per-member reads THIS pass, so each member gets its own answer and a bad
|
||||
// member can never black out its neighbours. The shape is only REMEMBERED after a repeat — one refusal can
|
||||
// be transient (program mode, a download in progress), two in a row are deterministic.
|
||||
var refusals = device.BlockRefusals.AddOrUpdate(block.Key, 1, (_, n) => n + 1);
|
||||
if (refusals >= 2 && device.RefusedBlocks.TryAdd(block.Key, 0))
|
||||
{
|
||||
device.BlockRefusals.TryRemove(block.Key, out _);
|
||||
_logger.LogInformation(
|
||||
"AbLegacy coalesced read {Block} refused twice ({Reason}); its {Count} tags read singly from now on. Driver={DriverInstanceId} Device={DeviceHostAddress}",
|
||||
block.StartName + "+" + block.Count, why, block.Members.Count, _driverInstanceId, device.Options.HostAddress);
|
||||
}
|
||||
return (BlockOutcome.Fallback, OpVerdict.Neutral, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var createParams = new AbLegacyTagCreateParams(
|
||||
Gateway: device.ParsedAddress.Gateway,
|
||||
Port: device.ParsedAddress.Port,
|
||||
CipPath: device.EffectiveCipPath,
|
||||
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
|
||||
TagName: block.StartName,
|
||||
Timeout: _options.Timeout,
|
||||
ElementCount: block.Count);
|
||||
var runtime = await CreateSingleFlightAsync(
|
||||
device, device.Runtimes, block.Key, () => _tagFactory.Create(createParams), ct).ConfigureAwait(false);
|
||||
|
||||
int status;
|
||||
Array? data = null;
|
||||
var opLock = device.GetRuntimeLock(block.Key);
|
||||
await opLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await runtime.ReadAsync(ct).ConfigureAwait(false);
|
||||
status = runtime.GetStatus();
|
||||
if (status == 0) data = runtime.DecodeArray(block.ElementType, block.Count) as Array;
|
||||
}
|
||||
finally
|
||||
{
|
||||
opLock.Release();
|
||||
}
|
||||
|
||||
if (status != 0)
|
||||
{
|
||||
// ANY status the device returned (a refusal, a remote error, an unmapped reply, Unsupported) falls back
|
||||
// to per-member reads; only a no-reply status fails the block's members together (review 07 r0 I1).
|
||||
if (!IsNoReply((AbStatus)status))
|
||||
return Refused($"device status {(AbStatus)status}");
|
||||
EvictRuntime(device, block.Key);
|
||||
FailAll(AbLegacyStatusMapper.MapStatus(status));
|
||||
return (BlockOutcome.Done, OpVerdict.Fault, $"transport status {status} reading {block.StartName}+{block.Count}");
|
||||
}
|
||||
if (data is null || data.Length < block.Count)
|
||||
return Refused("the block decoded short");
|
||||
|
||||
foreach (var m in block.Members)
|
||||
results[m.Index] = new DataValueSnapshot(DecodeMember(block, m, data), AbLegacyStatusMapper.Good, now, now);
|
||||
device.BlockRefusals.TryRemove(block.Key, out _);
|
||||
device.FirstNonZeroStatusLogged = false;
|
||||
return (BlockOutcome.Done, OpVerdict.Answered, null);
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (ConnectionThrottledException tex)
|
||||
{
|
||||
FailAll(AbLegacyStatusMapper.BadCommunicationError);
|
||||
return (BlockOutcome.Done, OpVerdict.Throttled, tex.Message);
|
||||
}
|
||||
catch (AbTransportException ex) when (!IsNoReply(ex.Status))
|
||||
{
|
||||
return Refused(ex.Message);
|
||||
}
|
||||
catch (NotSupportedException nse)
|
||||
{
|
||||
return Refused(nse.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
EvictRuntime(device, block.Key);
|
||||
FailAll(AbLegacyStatusMapper.BadCommunicationError);
|
||||
return (BlockOutcome.Done, OpVerdict.Fault, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes one member from the block, returning exactly the CLR type and value the single-tag path's
|
||||
/// <see cref="IAbLegacyTagRuntime.DecodeValue"/> would (<c>int</c> for a word or long, <c>float</c>, <c>bool</c>).
|
||||
/// </summary>
|
||||
private static object DecodeMember(CoalescedBlock block, ReadItem m, Array data)
|
||||
{
|
||||
var offset = m.Parsed.WordNumber - block.Start;
|
||||
var bit = m.Parsed.BitIndex;
|
||||
switch (data)
|
||||
{
|
||||
case short[] words:
|
||||
{
|
||||
var w = words[offset];
|
||||
return m.Def.DataType == AbLegacyDataType.Bit
|
||||
? ((ushort)w >> (bit ?? 0) & 1) != 0
|
||||
: (int)w;
|
||||
}
|
||||
case int[] longs:
|
||||
{
|
||||
var l = longs[offset];
|
||||
return m.Def.DataType == AbLegacyDataType.Bit ? ((uint)l >> bit!.Value & 1) != 0 : l;
|
||||
}
|
||||
case float[] floats:
|
||||
return floats[offset];
|
||||
default:
|
||||
throw new InvalidOperationException($"Unexpected block buffer {data.GetType().Name}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -97,6 +97,7 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
|
||||
},
|
||||
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
|
||||
CoalesceReads = dto.CoalesceReads,
|
||||
// Absent ⇒ null ("use the default" = Libplctag, resolved by AbLegacyTransportResolver);
|
||||
// a bad value still fails exactly as any other bad enum field does.
|
||||
Transport = string.IsNullOrWhiteSpace(dto.Transport)
|
||||
@@ -157,6 +158,9 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
/// </summary>
|
||||
public string? Transport { get; init; }
|
||||
|
||||
/// <summary>Gets or sets whether adjacent scalar reads are coalesced; absent means <see langword="true"/>.</summary>
|
||||
public bool? CoalesceReads { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the list of devices to connect to.
|
||||
/// </summary>
|
||||
|
||||
@@ -55,6 +55,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
|
||||
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
|
||||
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
|
||||
|
||||
// The authored "Admin UI probe timeout" (probeTimeoutSeconds) tightens the caller's budget. It was parsed by
|
||||
// nothing before: Test Connect always ran on the caller's budget alone.
|
||||
timeout = EffectiveTimeout(timeout, opts.ProbeTimeoutSeconds);
|
||||
|
||||
var (parsed, firstDevice) = ExtractTarget(opts);
|
||||
if (parsed is null || string.IsNullOrWhiteSpace(parsed.Gateway) || parsed.Port <= 0)
|
||||
return new(false, "Config has no host/port to probe.", null);
|
||||
@@ -169,6 +173,22 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
|
||||
/// <returns>Whether the status proves the controller answered.</returns>
|
||||
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
|
||||
|
||||
/// <summary>
|
||||
/// The deadline the probe runs under: the caller's budget, tightened by the config's
|
||||
/// <see cref="AbLegacyDriverOptions.ProbeTimeoutSeconds"/> when that is positive and shorter (same rule as the
|
||||
/// Galaxy probe). A non-positive caller budget is floored to 1 s first.
|
||||
/// </summary>
|
||||
/// <param name="callerTimeout">The budget the host handed the probe.</param>
|
||||
/// <param name="configuredSeconds">The config's <c>probeTimeoutSeconds</c>; ignored when not positive.</param>
|
||||
/// <returns>The effective probe deadline.</returns>
|
||||
internal static TimeSpan EffectiveTimeout(TimeSpan callerTimeout, int configuredSeconds)
|
||||
{
|
||||
var caller = callerTimeout > TimeSpan.Zero ? callerTimeout : TimeSpan.FromSeconds(1);
|
||||
if (configuredSeconds <= 0) return caller;
|
||||
var configured = TimeSpan.FromSeconds(configuredSeconds);
|
||||
return configured < caller ? configured : caller;
|
||||
}
|
||||
|
||||
private static (AbLegacyHostAddress? parsed, AbLegacyDeviceOptions? device) ExtractTarget(AbLegacyDriverOptions opts)
|
||||
{
|
||||
// Parse the first device's ab:// host address to extract the gateway IP + EIP port.
|
||||
|
||||
@@ -50,6 +50,20 @@ public interface IAbLegacyTagRuntime : IDisposable
|
||||
/// <param name="bitIndex">Optional bit index for bit-level access.</param>
|
||||
/// <param name="value">The value to encode.</param>
|
||||
void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value);
|
||||
|
||||
/// <summary>
|
||||
/// Encodes EVERY element of a multi-element tag for the next <see cref="WriteAsync"/>, at the element width
|
||||
/// of <paramref name="type"/>. <paramref name="values"/> is already element-typed by the driver
|
||||
/// (<c>short[]</c> for Int/AnalogInt, <c>int[]</c> for Long, <c>float[]</c> for Float) and its length must
|
||||
/// equal the runtime's element count (<see cref="AbLegacyTagCreateParams.ElementCount"/>), so no element of
|
||||
/// the write comes from a stale read buffer. Bit and structured element arrays are not encodable.
|
||||
/// </summary>
|
||||
/// <param name="type">The PCCC element data type.</param>
|
||||
/// <param name="values">The typed elements, exactly element-count long.</param>
|
||||
/// <exception cref="ArgumentException">The length differs from the element count, or the array's element type
|
||||
/// does not match <paramref name="type"/>.</exception>
|
||||
/// <exception cref="NotSupportedException"><paramref name="type"/> has no flat array encoding.</exception>
|
||||
void EncodeArray(AbLegacyDataType type, Array values);
|
||||
}
|
||||
|
||||
public interface IAbLegacyTagFactory
|
||||
|
||||
@@ -23,6 +23,15 @@ internal static class LibplctagLegacyStatusTranslation
|
||||
Status.ErrorPartial => AbStatus.Partial,
|
||||
Status.ErrorAbort => AbStatus.Abort,
|
||||
Status.ErrorBadReply => AbStatus.BadReply,
|
||||
// A reply ARRIVED but carries an error libplctag does not break out: it folds EVERY non-zero PCCC STS and CIP
|
||||
// general status into PLCTAG_ERR_REMOTE_ERR / ERR_BAD_STATUS. That covers the PLC refusing (element past the
|
||||
// end of the file) AND a routing hop reporting the end device is gone (CIP 0x01/0x0204 unconnected send timed
|
||||
// out, 0x07 connection lost, PCCC STS 0x30 remote node missing). So it is neither a no-reply comms fault
|
||||
// (review 07 r0 I1 — it must not fall into the BadConnection default, which black-outs coalesced neighbours and
|
||||
// trips fail-fast) nor proof of reachability (review 07 r1 N1). AbStatus has no dedicated "opaque remote error"
|
||||
// value and Driver.Ab.Transport is Lane K's, so this maps to the existing BadReply — "a reply we cannot
|
||||
// classify" — which the driver treats as OPAQUE (AbLegacyDriver.IsOpaque). OPC UA: BadCommunicationError.
|
||||
Status.ErrorRemoteErr or Status.ErrorBadStatus => AbStatus.BadReply,
|
||||
_ => AbStatus.BadConnection,
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
{
|
||||
private readonly Tag _tag;
|
||||
|
||||
/// <summary>The element count the tag was created with (1 for a scalar).</summary>
|
||||
private readonly int _count;
|
||||
|
||||
/// <summary>Why a write to this tag is refused before the wire (<see cref="WriteRefusal"/>), else null.</summary>
|
||||
private readonly string? _writeRefusal;
|
||||
|
||||
@@ -32,6 +35,7 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
|
||||
{
|
||||
_tag = BuildTag(p);
|
||||
_count = Math.Max(1, p.ElementCount);
|
||||
_writeRefusal = WriteRefusal(p);
|
||||
}
|
||||
|
||||
@@ -244,6 +248,32 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EncodeArray(AbLegacyDataType type, Array values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
if (values.Length != _count)
|
||||
throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values));
|
||||
// Same offsets DecodeArray reads. EVERY element is set, so the buffer libplctag sends holds nothing from the
|
||||
// last read (a scalar Set*(0, ...) used to re-send elements 1..n-1 as they were last read).
|
||||
switch (type, values)
|
||||
{
|
||||
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s):
|
||||
for (var i = 0; i < s.Length; i++) _tag.SetInt16(i * 2, s[i]);
|
||||
break;
|
||||
case (AbLegacyDataType.Long, int[] l):
|
||||
for (var i = 0; i < l.Length; i++) _tag.SetInt32(i * 4, l[i]);
|
||||
break;
|
||||
case (AbLegacyDataType.Float, float[] f):
|
||||
for (var i = 0; i < f.Length; i++) _tag.SetFloat32(i * 4, f[i]);
|
||||
break;
|
||||
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _):
|
||||
throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values));
|
||||
default:
|
||||
throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Disposes the tag and releases its resources.</summary>
|
||||
public void Dispose() => _tag.Dispose();
|
||||
|
||||
|
||||
@@ -448,6 +448,32 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void EncodeArray(AbLegacyDataType type, Array values)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
if (values.Length != _count)
|
||||
throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values));
|
||||
// Same layout as DecodeArray: element i at i * elementSize. Every element is overwritten, so nothing the
|
||||
// write sends comes from the last read.
|
||||
switch (type, values)
|
||||
{
|
||||
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s):
|
||||
for (var i = 0; i < s.Length; i++) BinaryPrimitives.WriteInt16LittleEndian(SliceW(i * 2, 2), s[i]);
|
||||
break;
|
||||
case (AbLegacyDataType.Long, int[] l):
|
||||
for (var i = 0; i < l.Length; i++) BinaryPrimitives.WriteInt32LittleEndian(SliceW(i * 4, 4), l[i]);
|
||||
break;
|
||||
case (AbLegacyDataType.Float, float[] f):
|
||||
for (var i = 0; i < f.Length; i++) BinaryPrimitives.WriteSingleLittleEndian(SliceW(i * 4, 4), f[i]);
|
||||
break;
|
||||
case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _):
|
||||
throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values));
|
||||
default:
|
||||
throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the lease on the shared device connection (the last release closes it) without waiting:
|
||||
/// closing does I/O (Forward Close / UnRegisterSession) and the driver disposes under a per-tag lock.
|
||||
|
||||
+12
-1
@@ -28,6 +28,13 @@
|
||||
</InputSelect>
|
||||
<div class="form-text">Which protocol stack talks to the controller. Default omits the setting (libplctag). Native is opt-in: wire-proven only for N-file reads/writes and F8 reads. Takes effect on the next deploy.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch mt-4">
|
||||
<InputCheckbox @bind-Value="_form.CoalesceReads" @bind-Value:after="EmitAsync" class="form-check-input" id="ablegacyCoalesceReads" />
|
||||
<label class="form-check-label" for="ablegacyCoalesceReads">Coalesce adjacent reads</label>
|
||||
</div>
|
||||
<div class="form-text">Read nearby scalar tags on one N/B/F/L file as a single PCCC request. A block the PLC refuses falls back to per-tag reads automatically. Default on.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -97,7 +104,7 @@
|
||||
/// <summary>JSON paths this form edits; everything else in the stored config is preserved.</summary>
|
||||
internal static readonly string[] OwnedPaths =
|
||||
[
|
||||
"transport", "probeTimeoutSeconds",
|
||||
"transport", "probeTimeoutSeconds", "coalesceReads",
|
||||
"probe.enabled", "probe.probeAddress",
|
||||
];
|
||||
|
||||
@@ -163,6 +170,7 @@
|
||||
// PLC-5 / LogixPccc exclusion still applies). Never re-emit a concrete default here — that would pin
|
||||
// every edited driver to it.
|
||||
public AbLegacyTransport? Transport { get; set; }
|
||||
public bool CoalesceReads { get; set; } = true;
|
||||
|
||||
// Probe
|
||||
public bool ProbeEnabled { get; set; } = true;
|
||||
@@ -200,6 +208,7 @@
|
||||
_loadedProbeTimeout = o.Probe.Timeout,
|
||||
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
|
||||
Transport = o.Transport,
|
||||
CoalesceReads = o.CoalesceReads ?? true,
|
||||
ProbeEnabled = o.Probe.Enabled,
|
||||
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
|
||||
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
|
||||
@@ -220,6 +229,8 @@
|
||||
},
|
||||
Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout),
|
||||
Transport = Transport,
|
||||
// On = the default: OMIT the key (never pin a concrete default); off is the only value written.
|
||||
CoalesceReads = CoalesceReads ? null : false,
|
||||
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -87,6 +87,16 @@ public sealed class AbLegacyTagConfigModel
|
||||
if (isArray && arrayLength > AbLegacyArray.MaxElements)
|
||||
return $"AbLegacy array length cannot exceed {AbLegacyArray.MaxElements} (PCCC data-file element limit).";
|
||||
|
||||
// Same rule the reader enforces (review 07 H16): structured elements never form an array. Stated here so
|
||||
// the CSV-import / API paths get the specific reason instead of the parse gate's generic one.
|
||||
if (isArray && arrayLength >= 1 && !AbLegacyTagDefinitionFactory.IsArrayElementTypeSupported(DataType))
|
||||
return AbLegacyTagDefinitionFactory.ArrayElementTypeError(DataType);
|
||||
|
||||
// An array must match its data file's element width (review 07 r0 I3): the driver refuses to write a
|
||||
// mismatched one, and reads it at the wrong width.
|
||||
if (isArray && arrayLength >= 1 && AbLegacyTagDefinitionFactory.ArrayFileMismatch(DataType, Address.Trim()) is { } widthError)
|
||||
return widthError;
|
||||
|
||||
// Parse-gate backstop: the checks above mirror the reader's whole rejection surface today, so this is
|
||||
// expected to be silent for AbLegacy. It is here so a future reader rule cannot become a gap.
|
||||
return TagConfigParseGate.Describe(
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
using CliFx.Infrastructure;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U7: the AbLegacy probe/read/write commands had no <see cref="OperationCanceledException"/> arm, so a
|
||||
/// Ctrl-C printed a stack trace. They now run their driver work through
|
||||
/// <see cref="AbLegacyCommandBase.RunCancellableAsync"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CancellationHandlingTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Operator_cancellation_prints_Cancelled_instead_of_throwing()
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
await Should.NotThrowAsync(async () => await AbLegacyCommandBase.RunCancellableAsync(
|
||||
console, cts.Token, () => Task.FromCanceled(cts.Token)));
|
||||
|
||||
console.ReadErrorString().ShouldContain("Cancelled.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_cancellation_the_operator_did_not_request_still_propagates()
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(async () => await AbLegacyCommandBase.RunCancellableAsync(
|
||||
console, CancellationToken.None, () => throw new OperationCanceledException()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_completed_body_runs_to_completion()
|
||||
{
|
||||
using var console = new FakeInMemoryConsole();
|
||||
var ran = false;
|
||||
|
||||
await AbLegacyCommandBase.RunCancellableAsync(console, CancellationToken.None, () =>
|
||||
{
|
||||
ran = true;
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
ran.ShouldBeTrue(); // positive control
|
||||
console.ReadErrorString().ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U10: an N-file array write (4 elements) round-trips every element on both transports and leaves the
|
||||
/// words either side untouched. Gated by <see cref="IntegrationWriteGate"/>. Doubles as the real-controller
|
||||
/// hardware gate: <c>AB_LEGACY_ARRAY_WRITE_BASE</c> (default <c>N7:1</c>) names the first of the SIX consecutive
|
||||
/// words it uses (base = "before" sentinel, base+1..base+4 = the array, base+5 = "after" sentinel); every one is
|
||||
/// read first and written back in a <c>finally</c>, so a run restores them byte-exact.
|
||||
/// </summary>
|
||||
[Collection(AbLegacyServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "ab_server-PCCC")]
|
||||
public sealed class AbLegacyArrayWriteIntegrationTests(AbLegacyServerFixture sim)
|
||||
{
|
||||
/// <summary>The env var naming the first word of the six the test may overwrite (and restores).</summary>
|
||||
public const string BaseVar = "AB_LEGACY_ARRAY_WRITE_BASE";
|
||||
|
||||
/// <summary>Seeds the neighbours, writes an <c>int[]</c> to a 4-element Int array, reads it all back, restores.</summary>
|
||||
/// <param name="profile">The simulator profile.</param>
|
||||
/// <param name="transport">The transport stack the driver runs on.</param>
|
||||
[AbLegacyTheory]
|
||||
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
|
||||
public async Task N_file_array_write_round_trips_every_element(AbLegacyServerProfile profile, AbLegacyTransport transport)
|
||||
{
|
||||
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
|
||||
IntegrationWriteGate.SkipUnlessWritesAllowed();
|
||||
|
||||
var baseText = Environment.GetEnvironmentVariable(BaseVar) is { Length: > 0 } b ? b : "N7:1";
|
||||
var baseAddr = AbLegacyAddress.TryParse(baseText, profile.Family);
|
||||
if (baseAddr is not { FileLetter: "N", FileNumber: int file, BitIndex: null, SubElement: null } || baseAddr.IsIndirect)
|
||||
throw new InvalidOperationException($"{BaseVar}='{baseText}' must be a plain N-file word such as N7:200.");
|
||||
string At(int offset) => $"N{file}:{baseAddr.WordNumber + offset}";
|
||||
|
||||
var deviceUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
|
||||
var words = Enumerable.Range(0, 6)
|
||||
.Select(i => new AbLegacyTagDefinition($"W{i}", deviceUri, At(i), AbLegacyDataType.Int))
|
||||
.ToArray();
|
||||
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)],
|
||||
RawTags = AbLegacyRawTags.Entries(
|
||||
[.. words, new AbLegacyTagDefinition("Arr", deviceUri, At(1), AbLegacyDataType.Int, ArrayLength: 4)]),
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
Transport = transport,
|
||||
CoalesceReads = false, // read each word on its own, independent of the array path under test
|
||||
}, driverInstanceId: $"ablegacy-arraywrite-{profile.Family}-{transport}");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
string[] wordRefs = [.. words.Select(w => w.Name)];
|
||||
|
||||
if (profile.Family == AbLegacyPlcFamily.Plc5 && transport == AbLegacyTransport.Native)
|
||||
{
|
||||
// Native PLC-5 writes are refused before the wire (no capture of the PLC-5 write frames exists).
|
||||
(await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], ct)).Single()
|
||||
.StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
return;
|
||||
}
|
||||
|
||||
var original = await drv.ReadAsync(wordRefs, ct);
|
||||
original.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good, "the six words must exist before anything is written");
|
||||
try
|
||||
{
|
||||
(await drv.WriteAsync([new WriteRequest("W0", (short)111), new WriteRequest("W5", (short)666)], ct))
|
||||
.ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good);
|
||||
|
||||
// ScadaBridge shape: a CLR int[] to an Int16 array node.
|
||||
(await drv.WriteAsync([new WriteRequest("Arr", new[] { 10, -20, 30, 32767 })], ct)).Single()
|
||||
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
|
||||
var arr = (await drv.ReadAsync(["Arr"], ct)).Single();
|
||||
arr.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
arr.Value.ShouldBe(new short[] { 10, -20, 30, 32767 });
|
||||
var back = await drv.ReadAsync(wordRefs, ct);
|
||||
back.Select(s => Convert.ToInt32(s.Value)).ShouldBe(new[] { 111, 10, -20, 30, 32767, 666 }); // per word, neighbours untouched
|
||||
|
||||
// A length mismatch is refused before the wire and changes nothing.
|
||||
(await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], ct)).Single()
|
||||
.StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
|
||||
(await drv.ReadAsync(["Arr"], ct)).Single().Value.ShouldBe(new short[] { 10, -20, 30, 32767 });
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Restore all six words exactly as found (CancellationToken.None: restore even if the run was cancelled).
|
||||
var restore = words.Select((w, i) => new WriteRequest(w.Name, Convert.ToInt16(original[i].Value))).ToArray();
|
||||
(await drv.WriteAsync(restore, CancellationToken.None))
|
||||
.ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good, "restoring the original words must succeed");
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
using System.Diagnostics;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 P7 (High), wire level: one driver serving a live <c>ab_server</c> device AND a dead device (a
|
||||
/// blackholed address, so every connect waits out the full timeout). Before the fix the dead device's 20 tags
|
||||
/// cost 20 x Timeout per poll and the live device was read once per cycle; now the live device reads Good in
|
||||
/// the same call, the dead device costs at most two timeouts, and the next poll does not touch it at all.
|
||||
/// Read-only.
|
||||
/// </summary>
|
||||
[Collection(AbLegacyServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "ab_server-PCCC")]
|
||||
public sealed class AbLegacyDeadDeviceIsolationTests(AbLegacyServerFixture sim)
|
||||
{
|
||||
// TEST-NET-1 (RFC 5737): never routed, so a connect blackholes until the driver's timeout.
|
||||
private const string DeadUri = "ab://192.0.2.1:44818/1,0";
|
||||
|
||||
/// <summary>The live device is served promptly while a blackholed sibling is throttled.</summary>
|
||||
/// <param name="profile">The simulator profile.</param>
|
||||
/// <param name="transport">The transport stack the driver runs on.</param>
|
||||
[AbLegacyTheory]
|
||||
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
|
||||
public async Task Dead_device_does_not_stall_the_live_one(AbLegacyServerProfile profile, AbLegacyTransport transport)
|
||||
{
|
||||
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
|
||||
|
||||
var liveUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
|
||||
var timeout = TimeSpan.FromSeconds(2);
|
||||
var dead = Enumerable.Range(0, 20)
|
||||
.Select(i => new AbLegacyTagDefinition($"Dead{i}", DeadUri, $"N7:{i}", AbLegacyDataType.Int))
|
||||
.ToArray();
|
||||
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(liveUri, profile.Family), new AbLegacyDeviceOptions(DeadUri, profile.Family)],
|
||||
RawTags = AbLegacyRawTags.Entries(
|
||||
[new AbLegacyTagDefinition("Live", liveUri, "N7:0", AbLegacyDataType.Int), .. dead]),
|
||||
Timeout = timeout,
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
Transport = transport,
|
||||
}, driverInstanceId: $"ablegacy-p7-{profile.Family}-{transport}");
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
string[] refs = ["Live", .. dead.Select(d => d.Name)];
|
||||
|
||||
// Two consecutive comms faults mark the device down. With coalescing on, the dead device's 20 tags are ONE
|
||||
// block (one fault per poll), so the window can open on the second poll rather than the first — either way
|
||||
// at most two timeouts are paid before the device stops being dialled.
|
||||
var total = TimeSpan.Zero;
|
||||
TimeSpan last = default;
|
||||
for (var poll = 0; poll < 3; poll++)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var r = await drv.ReadAsync(refs, ct);
|
||||
sw.Stop();
|
||||
total += last = sw.Elapsed;
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: the live device must be served in the same poll");
|
||||
r.Skip(1).ShouldAllBe(s => s.StatusCode != AbLegacyStatusMapper.Good);
|
||||
}
|
||||
|
||||
total.ShouldBeLessThan(timeout * 2.5, "a dead device may cost at most two timeouts per backoff window");
|
||||
last.ShouldBeLessThan(timeout, "inside the backoff window the dead device is not dialled");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 P7: a coalesced read of nearby N7 words and bits must decode exactly what per-tag reads return,
|
||||
/// on the wire, on both transports. Seeds distinct values first when writes are allowed (simulator only);
|
||||
/// otherwise compares whatever the simulator holds.
|
||||
/// </summary>
|
||||
[Collection(AbLegacyServerCollection.Name)]
|
||||
[Trait("Category", "Integration")]
|
||||
[Trait("Simulator", "ab_server-PCCC")]
|
||||
public sealed class AbLegacyReadCoalescingIntegrationTests(AbLegacyServerFixture sim)
|
||||
{
|
||||
/// <summary>Coalesced and per-tag reads of the same tags agree value for value.</summary>
|
||||
/// <param name="profile">The simulator profile.</param>
|
||||
/// <param name="transport">The transport stack the driver runs on.</param>
|
||||
[AbLegacyTheory]
|
||||
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
|
||||
public async Task Coalesced_reads_match_single_reads(AbLegacyServerProfile profile, AbLegacyTransport transport)
|
||||
{
|
||||
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
|
||||
AbLegacyTagDefinition[] tags =
|
||||
[
|
||||
new("W0", uri, "N7:0", AbLegacyDataType.Int),
|
||||
new("W1", uri, "N7:1", AbLegacyDataType.Int),
|
||||
new("W4", uri, "N7:4", AbLegacyDataType.Int),
|
||||
new("W4b3", uri, "N7:4/3", AbLegacyDataType.Bit),
|
||||
];
|
||||
string[] refs = [.. tags.Select(t => t.Name)];
|
||||
|
||||
AbLegacyDriver Make(bool coalesce) => new(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(uri, profile.Family)],
|
||||
RawTags = AbLegacyRawTags.Entries(tags),
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
Transport = transport,
|
||||
CoalesceReads = coalesce,
|
||||
}, driverInstanceId: $"ablegacy-coalesce-{coalesce}-{profile.Family}-{transport}");
|
||||
|
||||
await using var single = Make(coalesce: false);
|
||||
await using var coalesced = Make(coalesce: true);
|
||||
await single.InitializeAsync("{}", ct);
|
||||
await coalesced.InitializeAsync("{}", ct);
|
||||
|
||||
var writesRefused = profile.Family == AbLegacyPlcFamily.Plc5 && transport == AbLegacyTransport.Native;
|
||||
if (Environment.GetEnvironmentVariable(IntegrationWriteGate.AllowWritesVar) == "1"
|
||||
&& Environment.GetEnvironmentVariable(IntegrationWriteGate.DifferentialEndpointVar) is not { Length: > 0 }
|
||||
&& !writesRefused)
|
||||
{
|
||||
(await single.WriteAsync(
|
||||
[new WriteRequest("W0", (short)101), new WriteRequest("W1", (short)-202), new WriteRequest("W4", (short)0x0008)], ct))
|
||||
.ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good);
|
||||
}
|
||||
|
||||
var a = await single.ReadAsync(refs, ct);
|
||||
var b = await coalesced.ReadAsync(refs, ct);
|
||||
|
||||
a.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
|
||||
b.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
|
||||
b.Select(s => s.Value).ShouldBe(a.Select(s => s.Value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 r0 I1, on the wire: one coalesced member past the END of the file (the fixture's N7 holds 10
|
||||
/// words) makes the PLC refuse the block with a real transport status (libplctag: ERR_REMOTE_ERR). Its
|
||||
/// neighbours must still read Good on every poll, and the device must never be failed fast or degraded.
|
||||
/// </summary>
|
||||
/// <param name="profile">The simulator profile.</param>
|
||||
/// <param name="transport">The transport stack the driver runs on.</param>
|
||||
[AbLegacyTheory]
|
||||
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
|
||||
public async Task A_member_past_the_end_of_the_file_never_blacks_out_its_neighbours(
|
||||
AbLegacyServerProfile profile, AbLegacyTransport transport)
|
||||
{
|
||||
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
|
||||
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(uri, profile.Family)],
|
||||
RawTags = AbLegacyRawTags.Entries(
|
||||
new AbLegacyTagDefinition("A", uri, "N7:7", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", uri, "N7:8", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("PastEnd", uri, "N7:12", AbLegacyDataType.Int)),
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
Transport = transport,
|
||||
}, driverInstanceId: $"ablegacy-pastend-{profile.Family}-{transport}");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
|
||||
for (var poll = 0; poll < 3; poll++)
|
||||
{
|
||||
var r = await drv.ReadAsync(["A", "B", "PastEnd"], ct);
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: neighbour A");
|
||||
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: neighbour B");
|
||||
r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good, $"poll {poll}: the past-the-end tag");
|
||||
}
|
||||
// The refusal (libplctag: an opaque ERR_REMOTE_ERR; native: a mapped STS) is never a comms fault: the
|
||||
// answering neighbours keep the driver Healthy and the device is never failed fast.
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 r1 N1, on the wire: libplctag reports the past-the-end refusal as the OPAQUE ERR_REMOTE_ERR —
|
||||
/// the same code a bridge returns for a powered-off PLC behind it — so a device whose ONLY traffic is that
|
||||
/// reply must not look reachable: Degraded, <c>LastSuccessfulRead</c> frozen (the comms-loss watchdog's
|
||||
/// evidence). The native transport maps the STS itself and is only checked to stay non-Good.
|
||||
/// </summary>
|
||||
/// <param name="profile">The simulator profile.</param>
|
||||
/// <param name="transport">The transport stack the driver runs on.</param>
|
||||
[AbLegacyTheory]
|
||||
[MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
|
||||
public async Task An_opaque_only_device_never_proves_reachability(AbLegacyServerProfile profile, AbLegacyTransport transport)
|
||||
{
|
||||
if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
|
||||
var ct = TestContext.Current.CancellationToken;
|
||||
var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
|
||||
await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(uri, profile.Family)],
|
||||
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("PastEnd", uri, "N7:12", AbLegacyDataType.Int)),
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
Transport = transport,
|
||||
}, driverInstanceId: $"ablegacy-opaque-{profile.Family}-{transport}");
|
||||
await drv.InitializeAsync("{}", ct);
|
||||
var initLastRead = drv.GetHealth().LastSuccessfulRead;
|
||||
await Task.Delay(20, ct);
|
||||
|
||||
for (var poll = 0; poll < 2; poll++)
|
||||
(await drv.ReadAsync(["PastEnd"], ct))[0].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good);
|
||||
|
||||
if (transport == AbLegacyTransport.Libplctag)
|
||||
{
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldBe(initLastRead);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using libplctag;
|
||||
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>
|
||||
/// Pins BOTH sides of the reply-classification line (review 07 r0 I1 + r1 N1).
|
||||
/// <list type="bullet">
|
||||
/// <item><b>No reply</b> (Timeout / BadConnection / Abort / Pending / unknown) — comms fault: evict, Degraded,
|
||||
/// counts toward fail-fast.</item>
|
||||
/// <item><b>Answered</b> (an allow-list: NotFound / NotAllowed / OutOfBounds / TooLarge / TooSmall / TypeMismatch /
|
||||
/// DeviceStateConflict) — proves reachability: per-tag Bad, Healthy, <c>LastSuccessfulRead</c> advances.</item>
|
||||
/// <item><b>Opaque</b> (BadReply / Partial — incl. libplctag's <c>ERR_REMOTE_ERR</c>, which folds a PLC refusal
|
||||
/// and a routing hop's "end device gone" together) — neither: per-tag Bad, never fail-fast, never blacks out
|
||||
/// coalesced neighbours, but NEVER advances <c>LastSuccessfulRead</c>; an opaque-only device degrades so the
|
||||
/// comms-loss watchdog can reach a dead PLC behind a bridge.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyAnsweredStatusTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
/// <summary>What the libplctag runtime's GetStatus() reports for ERR_REMOTE_ERR (through the real translation).</summary>
|
||||
private static readonly int RemoteErr = (int)LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorRemoteErr);
|
||||
|
||||
private static AbLegacyDriver NewDriver(FakeAbLegacyTagFactory factory, bool coalesce, params AbLegacyTagDefinition[] tags) =>
|
||||
new(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(tags),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
CoalesceReads = coalesce,
|
||||
}, "drv-i1", factory);
|
||||
|
||||
// ---- the classification table ----
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbStatus.NotFound)]
|
||||
[InlineData(AbStatus.NotAllowed)]
|
||||
[InlineData(AbStatus.OutOfBounds)]
|
||||
[InlineData(AbStatus.TooLarge)]
|
||||
[InlineData(AbStatus.TooSmall)]
|
||||
[InlineData(AbStatus.TypeMismatch)]
|
||||
[InlineData(AbStatus.DeviceStateConflict)]
|
||||
public void The_answered_class_is_an_allow_list(AbStatus status)
|
||||
{
|
||||
AbLegacyDriver.IsDeviceRefusal(status).ShouldBeTrue();
|
||||
AbLegacyDriver.IsOpaque(status).ShouldBeFalse();
|
||||
AbLegacyDriver.IsNoReply(status).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbStatus.BadReply)]
|
||||
[InlineData(AbStatus.Partial)]
|
||||
public void Unclassifiable_replies_are_opaque_not_answers(AbStatus status)
|
||||
{
|
||||
AbLegacyDriver.IsOpaque(status).ShouldBeTrue();
|
||||
AbLegacyDriver.IsDeviceRefusal(status).ShouldBeFalse();
|
||||
AbLegacyDriver.IsNoReply(status).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbStatus.Timeout)]
|
||||
[InlineData(AbStatus.BadConnection)]
|
||||
[InlineData(AbStatus.Abort)]
|
||||
[InlineData(AbStatus.Pending)]
|
||||
[InlineData((AbStatus)(-99))] // a code this build does not know: conservatively no reply
|
||||
public void Only_no_reply_statuses_are_comms_faults(AbStatus status)
|
||||
{
|
||||
AbLegacyDriver.IsNoReply(status).ShouldBeTrue();
|
||||
AbLegacyDriver.IsDeviceRefusal(status).ShouldBeFalse();
|
||||
AbLegacyDriver.IsOpaque(status).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Every_non_Ok_status_lands_in_at_most_one_class_and_Unsupported_in_none()
|
||||
{
|
||||
foreach (var s in Enum.GetValues<AbStatus>().Where(s => s != AbStatus.Ok))
|
||||
{
|
||||
var classes = new[] { AbLegacyDriver.IsNoReply(s), AbLegacyDriver.IsDeviceRefusal(s), AbLegacyDriver.IsOpaque(s) }
|
||||
.Count(c => c);
|
||||
if (s == AbStatus.Unsupported) classes.ShouldBe(0, "Unsupported is neutral");
|
||||
else classes.ShouldBe(1, s.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void libplctag_remote_errors_translate_to_opaque_not_to_an_answer_or_a_fault()
|
||||
{
|
||||
foreach (var st in new[] { Status.ErrorRemoteErr, Status.ErrorBadStatus })
|
||||
{
|
||||
var s = LibplctagLegacyStatusTranslation.ToAbStatus(st);
|
||||
AbLegacyDriver.IsOpaque(s).ShouldBeTrue(st.ToString());
|
||||
AbLegacyDriver.IsDeviceRefusal(s).ShouldBeFalse(st.ToString());
|
||||
AbLegacyDriver.IsNoReply(s).ShouldBeFalse(st.ToString());
|
||||
}
|
||||
// Positive controls on the other two sides.
|
||||
AbLegacyDriver.IsNoReply(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorTimeout)).ShouldBeTrue();
|
||||
AbLegacyDriver.IsDeviceRefusal(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorNotFound)).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---- behaviour: the I1 side (an opaque member never blacks out its neighbours, never trips fail-fast) ----
|
||||
|
||||
[Fact]
|
||||
public async Task A_remote_error_member_never_blacks_out_its_coalesced_neighbours()
|
||||
{
|
||||
// N7:52 is past the end of the file: the block N7:48+5 and the single N7:52 both draw ERR_REMOTE_ERR.
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => p.ElementCount > 1 || p.TagName == "N7:52"
|
||||
? new FakeAbLegacyTag(p) { Status = RemoteErr }
|
||||
: new FakeAbLegacyTag(p) { Value = 48 },
|
||||
};
|
||||
var drv = NewDriver(factory, coalesce: true,
|
||||
new AbLegacyTagDefinition("A", Host, "N7:48", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", Host, "N7:49", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("C", Host, "N7:52", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
for (var poll = 0; poll < 4; 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.ShouldNotBe(AbLegacyStatusMapper.Good, $"poll {poll}");
|
||||
}
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy); // the neighbours answered
|
||||
drv.GetDeviceState(Host)!.IsThrottled(DateTime.UtcNow).ShouldBeFalse();
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Consecutive_opaque_replies_do_not_trip_fail_fast_on_an_answering_device()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => p.TagName == "N7:9" ? new FakeAbLegacyTag(p) { Value = 9 } : new FakeAbLegacyTag(p) { Status = RemoteErr },
|
||||
};
|
||||
var drv = NewDriver(factory, coalesce: false,
|
||||
new AbLegacyTagDefinition("R1", Host, "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("R2", Host, "N7:3", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("R3", Host, "N7:6", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("Good", Host, "N7:9", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
for (var poll = 0; poll < 3; poll++)
|
||||
{
|
||||
var r = await drv.ReadAsync(["R1", "R2", "R3", "Good"], CancellationToken.None);
|
||||
r[3].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: never failed fast");
|
||||
}
|
||||
factory.Tags["N7:9"].ReadCount.ShouldBe(3);
|
||||
factory.Tags["N7:0"].InitializeCount.ShouldBe(1); // opaque handles are kept, not churned
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// ---- behaviour: the N1 side (an opaque reply never proves reachability) ----
|
||||
|
||||
[Fact]
|
||||
public async Task Remote_error_only_reads_never_advance_LastSuccessfulRead_and_degrade()
|
||||
{
|
||||
// A PLC behind a bridge is powered off: every request comes back fast with ERR_REMOTE_ERR.
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
|
||||
var drv = NewDriver(factory, coalesce: false,
|
||||
new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("Y", Host, "N7:5", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await drv.ReadAsync(["X", "Y"], CancellationToken.None)).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
|
||||
var healthyAt = drv.GetHealth().LastSuccessfulRead; // positive control: Good reads DID advance it
|
||||
healthyAt.ShouldNotBeNull();
|
||||
foreach (var t in factory.Tags.Values) t.Status = RemoteErr;
|
||||
await Task.Delay(5);
|
||||
|
||||
for (var poll = 0; poll < 3; poll++)
|
||||
(await drv.ReadAsync(["X", "Y"], CancellationToken.None)).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError);
|
||||
|
||||
var h = drv.GetHealth();
|
||||
h.LastSuccessfulRead.ShouldBe(healthyAt); // frozen: the comms-loss watchdog can reach this device
|
||||
h.State.ShouldBe(DriverState.Degraded);
|
||||
drv.GetDeviceState(Host)!.IsThrottled(DateTime.UtcNow).ShouldBeFalse(); // but no fail-fast window
|
||||
factory.Tags["N7:0"].ReadCount.ShouldBe(4); // every poll still asked
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class ThrowingTag(AbLegacyTagCreateParams p, AbStatus status) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public override Task ReadAsync(CancellationToken ct) => throw new AbTransportException(status, $"native {status}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(AbStatus.BadReply)]
|
||||
[InlineData(AbStatus.Partial)]
|
||||
public async Task A_native_unclassifiable_reply_is_not_counted_as_an_answer(AbStatus status)
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
|
||||
var drv = NewDriver(factory, coalesce: false, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var healthyAt = drv.GetHealth().LastSuccessfulRead;
|
||||
drv.GetDeviceState(Host)!.Runtimes.TryRemove("X", out _);
|
||||
factory.Customise = p => new ThrowingTag(p, status);
|
||||
await Task.Delay(5);
|
||||
|
||||
var r = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(status));
|
||||
drv.GetHealth().LastSuccessfulRead.ShouldBe(healthyAt);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Positive_control_a_real_device_refusal_alone_still_proves_reachability()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound } };
|
||||
var drv = NewDriver(factory, coalesce: false, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var before = drv.GetHealth().LastSuccessfulRead;
|
||||
await Task.Delay(5);
|
||||
|
||||
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins <see cref="DriverArrayWriteSupport"/>'s AbLegacy row against the REAL array encoder
|
||||
/// (<see cref="AbLegacyArrayWriteEncoder"/>), the same way the Modbus and S7 parity tests do: for every element
|
||||
/// type the server must advertise an array node writable exactly when the driver encodes it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ SKIPPED until follow-up F1 lands: the driver gained an array encoder in this lane (review 07 U10), but
|
||||
/// <see cref="DriverArrayWriteSupport"/> (Core.Abstractions, not owned by the AbLegacy lane) still lists AbLegacy as
|
||||
/// scalar-only, so every AbLegacy array node stays read-only and the encoder is unreachable from OPC UA. When F1
|
||||
/// adds the row, delete the Skip; this test then holds the table and the encoder together.
|
||||
/// </remarks>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyArrayWriteSupportParityTests
|
||||
{
|
||||
public static TheoryData<AbLegacyDataType> AllTypes() => new(Enum.GetValues<AbLegacyDataType>());
|
||||
|
||||
private static string AddressFor(AbLegacyDataType t) => t switch
|
||||
{
|
||||
AbLegacyDataType.Float => "F8:0",
|
||||
AbLegacyDataType.Long => "L9:0",
|
||||
AbLegacyDataType.Bit => "B3:0",
|
||||
AbLegacyDataType.String => "ST9:0",
|
||||
AbLegacyDataType.TimerElement => "T4:0",
|
||||
AbLegacyDataType.CounterElement => "C5:0",
|
||||
AbLegacyDataType.ControlElement => "R6:0",
|
||||
_ => "N7:0",
|
||||
};
|
||||
|
||||
[Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")]
|
||||
[MemberData(nameof(AllTypes))]
|
||||
public void Table_agrees_with_the_array_encoder(AbLegacyDataType dataType)
|
||||
{
|
||||
var json = $$"""{"address":"{{AddressFor(dataType)}}","dataType":"{{dataType}}","isArray":true,"arrayLength":2}""";
|
||||
var encoderAccepts = AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out _)
|
||||
&& AbLegacyArrayWriteEncoder.TryConvert(dataType, new object[] { 1, 1 }, 2, out _) == AbLegacyStatusMapper.Good;
|
||||
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", json).ShouldBe(encoderAccepts, dataType.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The shapes the WRITE PATH refuses although their element type alone looks writable (review 07 r0 M6): a
|
||||
/// <c>/bit</c> or <c>.sub</c> address, an indirect address, a type that does not match its data file (I3), and
|
||||
/// a native PLC-5 device. The F1 row must answer false for every one — keyed on <c>dataType</c> alone it would
|
||||
/// advertise them writable. Skipped with the parity theory until F1 lands.
|
||||
/// </summary>
|
||||
[Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")]
|
||||
[InlineData("""{"address":"N7:0/3","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"T4:0.ACC","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"N7:[N7:0]","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"F8:0","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"N7:0","dataType":"Long","isArray":true,"arrayLength":2}""", "{}")]
|
||||
[InlineData("""{"address":"N7:0","dataType":"Int","isArray":true,"arrayLength":2}""",
|
||||
"""{"transport":"Native","devices":[{"hostAddress":"ab://10.0.0.5/1,0","plcFamily":"Plc5"}]}""")]
|
||||
public void Table_refuses_the_shapes_the_write_path_refuses(string tagConfig, string driverConfig) =>
|
||||
DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", tagConfig, driverConfig).ShouldBeFalse();
|
||||
|
||||
/// <summary>What the encoder accepts today — the row F1 must add (Int, AnalogInt, Long, Float on a matching file).</summary>
|
||||
[Theory]
|
||||
[InlineData(AbLegacyDataType.Int, true)]
|
||||
[InlineData(AbLegacyDataType.AnalogInt, true)]
|
||||
[InlineData(AbLegacyDataType.Long, true)]
|
||||
[InlineData(AbLegacyDataType.Float, true)]
|
||||
[InlineData(AbLegacyDataType.Bit, false)]
|
||||
[InlineData(AbLegacyDataType.String, false)]
|
||||
[InlineData(AbLegacyDataType.TimerElement, false)]
|
||||
public void Encoder_accepts_exactly_the_flat_numeric_element_types(AbLegacyDataType dataType, bool accepts) =>
|
||||
(AbLegacyArrayWriteEncoder.TryConvert(dataType, new object[] { 1, 1 }, 2, out _) == AbLegacyStatusMapper.Good)
|
||||
.ShouldBe(accepts);
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
using System.Buffers.Binary;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 U10 (user decision "implement array encode now"): an array write used to encode a SCALAR — an
|
||||
/// <c>int[]</c> threw <see cref="InvalidCastException"/> into <c>BadTypeMismatch</c> + Degraded, and a scalar to an
|
||||
/// array tag wrote element 0 (native) or re-sent the stale last-read buffer (libplctag). The driver now encodes
|
||||
/// exactly <c>count</c> elements at the element width, converting each element (ScadaBridge writes CLR lists
|
||||
/// such as <c>int[]</c> to Int16 nodes), and refuses a length mismatch with <c>BadOutOfRange</c>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyArrayWriteTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
private sealed class RecordingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public Array? Encoded { get; private set; }
|
||||
public AbLegacyDataType? EncodedType { get; private set; }
|
||||
|
||||
public override void EncodeArray(AbLegacyDataType type, Array values)
|
||||
{
|
||||
EncodedType = type;
|
||||
Encoded = values;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(AbLegacyDriver Drv, FakeAbLegacyTagFactory Factory)> NewAsync(
|
||||
AbLegacyDataType type, string address, int length)
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new RecordingTag(p) };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("Arr", Host, address, type, ArrayLength: length)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-u10", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
return (drv, factory);
|
||||
}
|
||||
|
||||
public static TheoryData<string> IntPayloads => ["int[]", "short[]", "List<int>", "object[]"];
|
||||
|
||||
private static object Payload(string kind) => kind switch
|
||||
{
|
||||
"int[]" => new[] { 1, -2, 3 }, // ScadaBridge: CLR int[] to an Int16 node
|
||||
"short[]" => new short[] { 1, -2, 3 },
|
||||
"List<int>" => new List<int> { 1, -2, 3 },
|
||||
_ => new object[] { 1L, (short)-2, 3.0 },
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(IntPayloads))]
|
||||
public async Task Int_array_write_converts_each_element_and_encodes_count_elements(string kind)
|
||||
{
|
||||
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 3);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", Payload(kind))], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var tag = (RecordingTag)f.Tags["N7:0"];
|
||||
tag.CreationParams.ElementCount.ShouldBe(3);
|
||||
tag.EncodedType.ShouldBe(AbLegacyDataType.Int);
|
||||
tag.Encoded.ShouldBe(new short[] { 1, -2, 3 });
|
||||
tag.WriteCount.ShouldBe(1);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
/// <summary>Review 07 r0 I3: every data file type encodes at ITS element width (one case per file).</summary>
|
||||
[Theory]
|
||||
[InlineData("N7:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
|
||||
[InlineData("N7:0", AbLegacyDataType.AnalogInt, AbLegacyDataType.Int)]
|
||||
[InlineData("B3:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
|
||||
[InlineData("A9:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
|
||||
[InlineData("L9:0", AbLegacyDataType.Long, AbLegacyDataType.Long)]
|
||||
[InlineData("F8:0", AbLegacyDataType.Float, AbLegacyDataType.Float)]
|
||||
public async Task Each_file_type_encodes_at_the_file_element_width(string address, AbLegacyDataType authored, AbLegacyDataType fileWidth)
|
||||
{
|
||||
var (drv, f) = await NewAsync(authored, address, 2);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2 })], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var tag = (RecordingTag)f.Tags[address];
|
||||
tag.EncodedType.ShouldBe(fileWidth);
|
||||
tag.Encoded!.GetType().ShouldBe(fileWidth switch
|
||||
{
|
||||
AbLegacyDataType.Long => typeof(int[]),
|
||||
AbLegacyDataType.Float => typeof(float[]),
|
||||
_ => typeof(short[]),
|
||||
});
|
||||
tag.WriteCount.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 r0 I3: an array whose authored type does not match its data file (Int on F8, Long on N7, Float on
|
||||
/// L9) used to be encoded at the AUTHORED width into the file-typed buffer and sent — garbage in the
|
||||
/// neighbouring elements, answered Good. It is now refused BadTypeMismatch before any runtime exists.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData("F8:0", AbLegacyDataType.Int)]
|
||||
[InlineData("N7:0", AbLegacyDataType.Long)]
|
||||
[InlineData("N7:0", AbLegacyDataType.Float)]
|
||||
[InlineData("L9:0", AbLegacyDataType.Float)]
|
||||
[InlineData("L9:0", AbLegacyDataType.Int)]
|
||||
public async Task A_type_that_does_not_match_its_file_is_refused_and_never_written(string address, AbLegacyDataType authored)
|
||||
{
|
||||
var (drv, f) = await NewAsync(authored, address, 4);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
|
||||
f.Tags.ShouldBeEmpty(); // nothing created, nothing on the wire
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
AbLegacyTagDefinitionFactory.Inspect(
|
||||
$$"""{"address":"{{address}}","dataType":"{{authored}}","isArray":true,"arrayLength":4}""")
|
||||
.ShouldContain(w => w.Contains("element width")); // and the deploy gate names it
|
||||
}
|
||||
|
||||
/// <summary>Review 07 r1 N5: the file letter of an indirect address is static, so the deploy gate checks it too.</summary>
|
||||
[Theory]
|
||||
[InlineData("N7:[N7:0]", "Float", true)]
|
||||
[InlineData("F[N7:0]:2", "Int", true)]
|
||||
[InlineData("N7:[N7:0]", "Int", false)]
|
||||
[InlineData("F[N7:0]:2", "Float", false)]
|
||||
public void An_indirect_array_type_file_mismatch_is_reported_at_the_gate(string address, string dataType, bool mismatch)
|
||||
{
|
||||
var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":2}""";
|
||||
Enum.TryParse<AbLegacyDataType>(dataType, out var type).ShouldBeTrue();
|
||||
|
||||
(AbLegacyTagDefinitionFactory.ArrayFileMismatch(type, address) is not null).ShouldBe(mismatch);
|
||||
AbLegacyTagDefinitionFactory.Inspect(json).Any(w => w.Contains("element width")).ShouldBe(mismatch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Positive_control_a_Float_array_on_an_F_file_is_written()
|
||||
{
|
||||
var (drv, f) = await NewAsync(AbLegacyDataType.Float, "F8:0", 4);
|
||||
(await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], CancellationToken.None))[0]
|
||||
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
((RecordingTag)f.Tags["F8:0"]).Encoded.ShouldBe(new[] { 1f, 2f, 3f, 4f });
|
||||
AbLegacyTagDefinitionFactory.Inspect("""{"address":"F8:0","dataType":"Float","isArray":true,"arrayLength":4}""")
|
||||
.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Float_and_Long_arrays_encode_at_their_element_width()
|
||||
{
|
||||
var (fd, ff) = await NewAsync(AbLegacyDataType.Float, "F8:0", 2);
|
||||
(await fd.WriteAsync([new WriteRequest("Arr", new[] { 1.5, 2 })], CancellationToken.None))[0]
|
||||
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
((RecordingTag)ff.Tags["F8:0"]).Encoded.ShouldBe(new[] { 1.5f, 2f });
|
||||
|
||||
var (ld, lf) = await NewAsync(AbLegacyDataType.Long, "L9:0", 2);
|
||||
(await ld.WriteAsync([new WriteRequest("Arr", new short[] { 7, -8 })], CancellationToken.None))[0]
|
||||
.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
((RecordingTag)lf.Tags["L9:0"]).Encoded.ShouldBe(new[] { 7, -8 });
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(2)]
|
||||
[InlineData(4)]
|
||||
public async Task Length_mismatch_is_BadOutOfRange_and_nothing_is_sent(int supplied)
|
||||
{
|
||||
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 3);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new int[supplied])], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
|
||||
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Element_overflow_is_BadOutOfRange_and_nothing_is_sent()
|
||||
{
|
||||
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 2);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 70000 })], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
|
||||
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(5)]
|
||||
[InlineData("1,2")]
|
||||
public async Task Non_array_payload_or_unconvertible_element_is_BadTypeMismatch(object payload)
|
||||
{
|
||||
var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 2);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", payload)], CancellationToken.None);
|
||||
var e = await drv.WriteAsync([new WriteRequest("Arr", new object[] { 1, "x" })], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
|
||||
e[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
|
||||
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bit_array_write_is_refused_BadNotSupported_before_any_runtime()
|
||||
{
|
||||
// Bit arrays are bit-packed in words: an encode of N bits would rewrite whole words from a stale read.
|
||||
var (drv, f) = await NewAsync(AbLegacyDataType.Bit, "B3:0", 16);
|
||||
|
||||
var r = await drv.WriteAsync([new WriteRequest("Arr", new bool[16])], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
f.Tags.ShouldBeEmpty();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Native_array_encode_writes_every_element_at_its_offset()
|
||||
{
|
||||
var channel = new FakeCipChannel();
|
||||
await using var scheduler = new RequestScheduler(channel, allowPacking: false);
|
||||
var rt = new NativeAbLegacyTagRuntime(
|
||||
new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(2), 3),
|
||||
scheduler, 0xF33D, 0x21504345);
|
||||
channel.Respond = req => Task.FromResult(NativeEcho(req));
|
||||
|
||||
rt.EncodeArray(AbLegacyDataType.Int, new short[] { 10, -1, 300 });
|
||||
await rt.WriteAsync(CancellationToken.None);
|
||||
|
||||
var data = channel.Sent.Single()[23..];
|
||||
data.Length.ShouldBe(6);
|
||||
BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(0)).ShouldBe((short)10);
|
||||
BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(2)).ShouldBe((short)-1);
|
||||
BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(4)).ShouldBe((short)300);
|
||||
rt.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Native_array_encode_refuses_a_length_other_than_the_runtime_count()
|
||||
{
|
||||
var channel = new FakeCipChannel();
|
||||
var scheduler = new RequestScheduler(channel, allowPacking: false);
|
||||
var rt = new NativeAbLegacyTagRuntime(
|
||||
new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", "slc500", "F8:0", TimeSpan.FromSeconds(2), 3),
|
||||
scheduler, 0xF33D, 0x21504345);
|
||||
|
||||
Should.Throw<ArgumentException>(() => rt.EncodeArray(AbLegacyDataType.Float, new float[2]));
|
||||
rt.Dispose();
|
||||
}
|
||||
|
||||
// Execute PCCC reply echoing the request's requestor id and TNS with STS 0 and no data.
|
||||
private static byte[] NativeEcho(byte[] req)
|
||||
{
|
||||
var body = new List<byte>(req[6..13]) { 0x4F, 0x00, req[15], req[16] };
|
||||
return FakeCipChannel.Reply(0x4B, 0x00, [.. body]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 02 P1: AbLegacy ran <see cref="PollGroupEngine"/> but did not implement <see cref="IBatchSubscribable"/>,
|
||||
/// so every changed tag paid its own trip through the actor chain. It now raises one
|
||||
/// <see cref="IBatchSubscribable.OnDataChangeBatch"/> per poll cycle, alongside the per-tag event.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyBatchSubscribeTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
[Fact]
|
||||
public async Task One_batch_per_poll_carries_every_changed_tag_and_per_tag_events_still_fire()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? 1 : 2 } };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(
|
||||
new AbLegacyTagDefinition("A", Host, "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", Host, "N9:0", AbLegacyDataType.Int)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-batch", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
IBatchSubscribable batching = drv; // the capability the actor prefers
|
||||
|
||||
var batches = new System.Collections.Concurrent.ConcurrentQueue<DataChangeBatchEventArgs>();
|
||||
var singles = new System.Collections.Concurrent.ConcurrentQueue<DataChangeEventArgs>();
|
||||
batching.OnDataChangeBatch += (_, e) => batches.Enqueue(e);
|
||||
drv.OnDataChange += (_, e) => singles.Enqueue(e);
|
||||
|
||||
var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (batches.IsEmpty && DateTime.UtcNow < deadline) await Task.Delay(10);
|
||||
|
||||
batches.TryPeek(out var first).ShouldBeTrue();
|
||||
first!.SubscriptionHandle.ShouldBe(handle);
|
||||
first.Items.Select(i => i.FullReference).ShouldBe(new[] { "A", "B" }, ignoreOrder: true);
|
||||
first.Items.Single(i => i.FullReference == "A").Snapshot.Value.ShouldBe(1);
|
||||
singles.Count.ShouldBeGreaterThanOrEqualTo(2); // positive control: the legacy per-tag event still fires
|
||||
|
||||
await drv.UnsubscribeAsync(handle, CancellationToken.None);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -205,7 +205,7 @@ public sealed class AbLegacyBitRmwTests
|
||||
var firstResult = await drv.WriteAsync([new WriteRequest("Bit2", true)], CancellationToken.None);
|
||||
firstResult.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
|
||||
// Teardown — DisposeRuntimes must dispose + clear the RMW lock map.
|
||||
// Teardown — retirement drops the device state (and its RMW lock map) with it.
|
||||
await drv.DisposeAsync();
|
||||
|
||||
// Second session: re-init on a fresh driver instance (mirrors ReinitializeAsync behavior).
|
||||
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 H16: an array of String/Timer/Counter/Control was accepted by the mapper, so every poll created a
|
||||
/// handle (one init read), threw <see cref="NotSupportedException"/> from the decode, disposed the handle and
|
||||
/// latched Degraded. A deterministic refusal is now rejected at the mapper; a runtime
|
||||
/// <see cref="NotSupportedException"/> that still occurs is per-tag <c>BadNotSupported</c>, keeps its handle,
|
||||
/// warns once and leaves driver health alone.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyDeterministicRefusalTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
[Theory]
|
||||
[InlineData("String", "ST9:0")]
|
||||
[InlineData("TimerElement", "T4:0")]
|
||||
[InlineData("CounterElement", "C5:0")]
|
||||
[InlineData("ControlElement", "R6:0")]
|
||||
public void Mapper_rejects_arrays_of_structured_element_types(string dataType, string address)
|
||||
{
|
||||
var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}""";
|
||||
|
||||
AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out _).ShouldBeFalse();
|
||||
AbLegacyTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("not supported as an array"));
|
||||
// Positive control: the same type as a SCALAR is still accepted.
|
||||
AbLegacyTagDefinitionFactory.FromTagConfig(
|
||||
$$"""{"address":"{{address}}","dataType":"{{dataType}}"}""", "p", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Int", "N7:0")]
|
||||
[InlineData("Float", "F8:0")]
|
||||
[InlineData("Long", "L9:0")]
|
||||
[InlineData("Bit", "B3:0")]
|
||||
public void Mapper_keeps_arrays_of_flat_element_types(string dataType, string address)
|
||||
{
|
||||
var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}""";
|
||||
AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out var def).ShouldBeTrue();
|
||||
def.ArrayLength.ShouldBe(4);
|
||||
AbLegacyTagDefinitionFactory.Inspect(json).ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejected_array_tag_is_skipped_with_its_reason_and_creates_no_runtime()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags =
|
||||
[
|
||||
new RawTagEntry("Strs", """{"address":"ST9:0","dataType":"String","isArray":true,"arrayLength":3}""", false, Host),
|
||||
new RawTagEntry("Good", """{"address":"N7:0","dataType":"Int"}""", false, Host),
|
||||
],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-h16", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["Strs", "Good"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control
|
||||
factory.Tags.Keys.ShouldNotContain("ST9:0");
|
||||
var skipped = drv.GetSkippedTags().ShouldNotBeNull();
|
||||
skipped.Sample.ShouldContain(s => s.RawPath == "Strs" && s.Reason.Contains("not supported as an array"));
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class RefusingDecodeTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public override object? DecodeArray(AbLegacyDataType type, int count) =>
|
||||
throw new NotSupportedException("decode refused");
|
||||
|
||||
public override void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) =>
|
||||
throw new NotSupportedException("encode refused");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Runtime_NotSupported_on_read_is_BadNotSupported_keeps_handle_no_health_warns_once()
|
||||
{
|
||||
var logger = new AbLegacyTestLogger();
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => p.TagName == "N7:0" ? new RefusingDecodeTag(p) : new FakeAbLegacyTag(p) { Value = 7 },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(
|
||||
new AbLegacyTagDefinition("Arr", Host, "N7:0", AbLegacyDataType.Int, ArrayLength: 4),
|
||||
new AbLegacyTagDefinition("Good", Host, "N9:0", AbLegacyDataType.Int)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-h16", factory, logger);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var first = await drv.ReadAsync(["Arr", "Good"], CancellationToken.None);
|
||||
var second = await drv.ReadAsync(["Arr", "Good"], CancellationToken.None);
|
||||
|
||||
first[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
second[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
first[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: the refusal is per tag
|
||||
var tag = factory.Tags["N7:0"];
|
||||
tag.InitializeCount.ShouldBe(1); // handle kept, not churned
|
||||
tag.Disposed.ShouldBeFalse();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
logger.Warnings.Count(w => w.Contains("Arr") && w.Contains("decode refused")).ShouldBe(1);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Runtime_NotSupported_on_write_is_BadNotSupported_with_no_health_write()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new RefusingDecodeTag(p) { Value = 1 } };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-h16", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest("X", 5)], CancellationToken.None);
|
||||
|
||||
w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
factory.Tags["N7:0"].Disposed.ShouldBeFalse();
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
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 (High): reads were strictly serial across every device with no per-device backoff, so one dead
|
||||
/// device cost <c>Timeout</c> per tag per poll and starved every healthy device — long enough to trip the
|
||||
/// comms-loss watchdog against a reachable one. Devices are now read in parallel (bounded), each device pass
|
||||
/// stops paying for a dead device after two consecutive comms faults and opens a per-device
|
||||
/// <see cref="ConnectionBackoff"/> window, and cold connects are single-flight per device.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyDeviceIsolationTests
|
||||
{
|
||||
private const string HostA = "ab://10.0.0.5/1,0";
|
||||
private const string HostB = "ab://10.0.0.6/1,0";
|
||||
|
||||
/// <summary>A tag on a dead device: each wire operation burns <paramref name="cost"/> and then fails.</summary>
|
||||
private sealed class DeadTag(AbLegacyTagCreateParams p, TimeSpan cost) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public static int WireOps;
|
||||
|
||||
public override async Task InitializeAsync(CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref WireOps);
|
||||
await Task.Delay(cost, ct);
|
||||
throw new System.Net.Sockets.SocketException(10060);
|
||||
}
|
||||
|
||||
public override async Task ReadAsync(CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref WireOps);
|
||||
await Task.Delay(cost, ct);
|
||||
throw new System.Net.Sockets.SocketException(10060);
|
||||
}
|
||||
}
|
||||
|
||||
private static AbLegacyDriver NewDriver(FakeAbLegacyTagFactory factory, params AbLegacyTagDefinition[] tags) =>
|
||||
new(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(HostA), new AbLegacyDeviceOptions(HostB)],
|
||||
RawTags = AbLegacyRawTags.Entries(tags),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
CoalesceReads = false, // per-tag isolation is under test here; coalescing has its own tests
|
||||
}, "drv-p7", factory);
|
||||
|
||||
private static AbLegacyTagDefinition[] Tags(string prefix, string host, int n, string file = "N7") =>
|
||||
[.. Enumerable.Range(0, n).Select(i => new AbLegacyTagDefinition($"{prefix}{i}", host, $"{file}:{i * 3}", AbLegacyDataType.Int))];
|
||||
|
||||
[Fact]
|
||||
public async Task Dead_device_costs_at_most_two_timeouts_then_fails_fast_and_never_stalls_the_healthy_one()
|
||||
{
|
||||
var cost = TimeSpan.FromMilliseconds(150);
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => p.Gateway == "10.0.0.6" ? new DeadTag(p, cost) : new FakeAbLegacyTag(p) { Value = 7 },
|
||||
};
|
||||
var a = Tags("A", HostA, 5);
|
||||
var b = Tags("B", HostB, 20);
|
||||
var drv = NewDriver(factory, [.. a, .. b]);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var refs = a.Concat(b).Select(t => t.Name).ToList();
|
||||
var before = drv.GetHealth().LastSuccessfulRead;
|
||||
Interlocked.Exchange(ref DeadTag.WireOps, 0);
|
||||
|
||||
var first = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
|
||||
// The proof is the wire-op count, not a stopwatch: serial-with-no-backoff would dial B 20 times.
|
||||
Volatile.Read(ref DeadTag.WireOps).ShouldBeLessThanOrEqualTo(2);
|
||||
first.Take(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); // positive control: A is served
|
||||
first.Skip(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError);
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Degraded);
|
||||
health.LastSuccessfulRead.ShouldNotBeNull();
|
||||
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before ?? DateTime.MinValue); // A's success advances it
|
||||
|
||||
// Inside B's backoff window the next poll touches B's wire not at all.
|
||||
var opsAfterFirst = Volatile.Read(ref DeadTag.WireOps);
|
||||
var second = await drv.ReadAsync(refs, CancellationToken.None);
|
||||
Volatile.Read(ref DeadTag.WireOps).ShouldBe(opsAfterFirst);
|
||||
second.Take(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Devices_are_read_in_parallel()
|
||||
{
|
||||
// Device A's read cannot complete until device B's read has STARTED: a serial walk deadlocks here.
|
||||
var bStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => p.Gateway == "10.0.0.5"
|
||||
? new GatedTag(p, wait: bStarted.Task) { Value = 1 }
|
||||
: new GatedTag(p, signal: bStarted) { Value = 2 },
|
||||
};
|
||||
var drv = NewDriver(factory,
|
||||
new AbLegacyTagDefinition("A", HostA, "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("B", HostB, "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["A", "B"], CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
r[0].Value.ShouldBe(1);
|
||||
r[1].Value.ShouldBe(2);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class GatedTag(AbLegacyTagCreateParams p, Task? wait = null, TaskCompletionSource? signal = null)
|
||||
: FakeAbLegacyTag(p)
|
||||
{
|
||||
public override async Task ReadAsync(CancellationToken ct)
|
||||
{
|
||||
signal?.TrySetResult();
|
||||
if (wait is not null) await wait.WaitAsync(ct);
|
||||
await base.ReadAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_timing_out_tag_does_not_blacken_the_rest_of_its_device()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => p.TagName == "N7:0"
|
||||
? new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new TimeoutException() }
|
||||
: new FakeAbLegacyTag(p) { Value = 5 },
|
||||
};
|
||||
var drv = NewDriver(factory, Tags("A", HostA, 4));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
for (var poll = 0; poll < 3; poll++)
|
||||
{
|
||||
var r = await drv.ReadAsync(["A0", "A1", "A2", "A3"], CancellationToken.None);
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
r.Skip(1).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good, $"poll {poll}");
|
||||
}
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Device_refusal_is_per_tag_Bad_advances_LastSuccessfulRead_and_keeps_the_handle()
|
||||
{
|
||||
var logger = new AbLegacyTestLogger();
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(HostA)],
|
||||
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", HostA, "N7:0", AbLegacyDataType.Int)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-p7", factory, logger);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var initHealth = drv.GetHealth();
|
||||
await Task.Delay(20);
|
||||
|
||||
var r1 = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
var r2 = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
r1[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
r2[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
var health = drv.GetHealth();
|
||||
health.State.ShouldBe(DriverState.Healthy); // the device answered: not a comms fault
|
||||
health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(initHealth.LastSuccessfulRead!.Value);
|
||||
factory.Tags["N7:0"].InitializeCount.ShouldBe(1);
|
||||
factory.Tags["N7:0"].ReadCount.ShouldBe(2); // positive control: both polls really asked the device
|
||||
logger.Warnings.Count(w => w.Contains("Reference=X")).ShouldBe(1);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Cold_connects_on_one_device_are_single_flight()
|
||||
{
|
||||
var inFlight = 0;
|
||||
var maxInFlight = 0;
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => new CountingInitTag(p, () =>
|
||||
{
|
||||
var now = Interlocked.Increment(ref inFlight);
|
||||
int seen;
|
||||
while (now > (seen = Volatile.Read(ref maxInFlight)) && Interlocked.CompareExchange(ref maxInFlight, now, seen) != seen) { }
|
||||
}, () => Interlocked.Decrement(ref inFlight)) { Value = 1 },
|
||||
};
|
||||
var tags = Tags("A", HostA, 6);
|
||||
var drv = NewDriver(factory, tags);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Three concurrent callers (poll loop, server read, write) each hitting different cold tags of one device.
|
||||
await Task.WhenAll(
|
||||
drv.ReadAsync(["A0", "A1"], CancellationToken.None),
|
||||
drv.ReadAsync(["A2", "A3"], CancellationToken.None),
|
||||
drv.WriteAsync([new WriteRequest("A4", 3), new WriteRequest("A5", 4)], CancellationToken.None));
|
||||
|
||||
maxInFlight.ShouldBe(1);
|
||||
factory.Tags.Values.Count(t => t.InitializeCount == 1).ShouldBe(6); // positive control: all six connected
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private sealed class CountingInitTag(AbLegacyTagCreateParams p, Action enter, Action exit) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public override async Task InitializeAsync(CancellationToken ct)
|
||||
{
|
||||
enter();
|
||||
try
|
||||
{
|
||||
await Task.Delay(30, ct);
|
||||
await base.InitializeAsync(ct);
|
||||
}
|
||||
finally { exit(); }
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_recovered_device_is_read_again_once_its_window_expires()
|
||||
{
|
||||
var dead = true;
|
||||
var creates = 0;
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p =>
|
||||
{
|
||||
Interlocked.Increment(ref creates);
|
||||
return dead
|
||||
? new FakeAbLegacyTag(p) { ThrowOnInitialize = true, Exception = new System.Net.Sockets.SocketException(10061) }
|
||||
: new FakeAbLegacyTag(p) { Value = 9 };
|
||||
},
|
||||
};
|
||||
var drv = NewDriver(factory, new AbLegacyTagDefinition("B", HostB, "N7:0", AbLegacyDataType.Int));
|
||||
var now = new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
|
||||
drv.Clock = () => now;
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
// Two consecutive comms faults mark the device down and open its first (1 s) window.
|
||||
(await drv.ReadAsync(["B"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
(await drv.ReadAsync(["B"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
var attempts = Volatile.Read(ref creates);
|
||||
dead = false;
|
||||
// Inside the window: fails fast without a connect attempt, although the device is back.
|
||||
(await drv.ReadAsync(["B"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
Volatile.Read(ref creates).ShouldBe(attempts);
|
||||
now = now.AddMilliseconds(1100); // cross the first (1 s) window on the driver's clock, no sleeping
|
||||
|
||||
var recovered = await drv.ReadAsync(["B"], CancellationToken.None);
|
||||
recovered[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
recovered[0].Value.ShouldBe(9);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,40 @@ public sealed class AbLegacyDriverProbeTests
|
||||
LibplctagLegacyName.Translate(status!).ShouldBe("S2:0");
|
||||
}
|
||||
|
||||
private sealed class StallingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public override Task InitializeAsync(CancellationToken ct) => Task.Delay(Timeout.Infinite, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The authored <c>probeTimeoutSeconds</c> ("Admin UI probe timeout") was parsed by nothing — Test Connect ran
|
||||
/// on the caller's budget alone. It now tightens that budget (never raises it); absent or non-positive keeps
|
||||
/// the caller's budget (the positive control).
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(",\"probeTimeoutSeconds\":1", "1s")]
|
||||
[InlineData(",\"probeTimeoutSeconds\":30", "2s")]
|
||||
[InlineData("", "2s")]
|
||||
[InlineData(",\"probeTimeoutSeconds\":0", "2s")]
|
||||
public async Task Configured_probe_timeout_tightens_the_caller_budget(string key, string expectedBudget)
|
||||
{
|
||||
var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
try
|
||||
{
|
||||
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new StallingTag(p) };
|
||||
var json = $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\"}}]{key}}}";
|
||||
|
||||
var result = await new AbLegacyDriverProbe(factory)
|
||||
.ProbeAsync(json, TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
|
||||
|
||||
result.Ok.ShouldBeFalse();
|
||||
result.Message.ShouldBe($"Probe timed out after {expectedBudget}.");
|
||||
}
|
||||
finally { listener.Stop(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unparseable_probe_address_is_a_clean_failure_not_an_exception()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
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 r0 I2 + M1/M2/M4/M10: any evidence that the device answered — a Good value, a device refusal, a Good
|
||||
/// or refused write — restores Healthy and advances <c>LastSuccessfulRead</c>; a no-reply failure on either path
|
||||
/// degrades; writes respect the device's backoff window; teardown health is epoch-guarded.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyHealthEvidenceTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
private sealed class SwitchableFactory : IAbLegacyTagFactory
|
||||
{
|
||||
public Func<AbLegacyTagCreateParams, FakeAbLegacyTag> Make { get; set; } = p => new FakeAbLegacyTag(p) { Value = 1 };
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, FakeAbLegacyTag> Tags { get; } = new();
|
||||
|
||||
public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p) => Tags[p.TagName] = Make(p);
|
||||
}
|
||||
|
||||
private static FakeAbLegacyTag Dead(AbLegacyTagCreateParams p) =>
|
||||
new(p) { ThrowOnRead = true, ThrowOnWrite = true, Exception = new System.Net.Sockets.SocketException(10060) };
|
||||
|
||||
private static AbLegacyDriver NewDriver(IAbLegacyTagFactory f, params AbLegacyTagDefinition[] tags) =>
|
||||
new(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(tags),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
CoalesceReads = false,
|
||||
}, "drv-health", f);
|
||||
|
||||
private static async Task<AbLegacyDriver> DegradedDriverAsync(SwitchableFactory f, params AbLegacyTagDefinition[] tags)
|
||||
{
|
||||
var drv = NewDriver(f, tags);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
f.Make = Dead;
|
||||
(await drv.ReadAsync([tags[0].Name], CancellationToken.None))[0].StatusCode
|
||||
.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded); // precondition
|
||||
return drv;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refusal_only_read_after_a_fault_restores_Healthy_and_advances_LastSuccessfulRead()
|
||||
{
|
||||
var f = new SwitchableFactory();
|
||||
var drv = await DegradedDriverAsync(f, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
|
||||
var before = drv.GetHealth().LastSuccessfulRead;
|
||||
f.Make = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound };
|
||||
await Task.Delay(5);
|
||||
|
||||
var r = await drv.ReadAsync(["X"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Healthy);
|
||||
h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)] // a Good write
|
||||
[InlineData((int)AbStatus.NotAllowed)] // a write the device refused
|
||||
public async Task A_good_or_refused_write_after_a_fault_restores_Healthy(int writeStatus)
|
||||
{
|
||||
var f = new SwitchableFactory();
|
||||
var drv = await DegradedDriverAsync(f,
|
||||
new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int));
|
||||
var before = drv.GetHealth().LastSuccessfulRead;
|
||||
f.Make = p => new FakeAbLegacyTag(p) { WriteStatusOverride = writeStatus };
|
||||
await Task.Delay(5);
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest("W", 3)], CancellationToken.None);
|
||||
|
||||
w[0].StatusCode.ShouldBe(writeStatus == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.BadNotWritable);
|
||||
f.Tags["N7:5"].WriteCount.ShouldBe(1); // positive control: the write reached the device
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Healthy);
|
||||
h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_no_reply_write_status_degrades_like_the_exception_path_does()
|
||||
{
|
||||
var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { WriteStatusOverride = (int)AbStatus.Timeout } };
|
||||
var drv = NewDriver(f, new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest("W", 3)], CancellationToken.None);
|
||||
|
||||
w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTimeout);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task One_write_call_is_worst_wins_but_still_advances_LastSuccessfulRead()
|
||||
{
|
||||
var f = new SwitchableFactory
|
||||
{
|
||||
Make = p => p.TagName == "N7:6" ? Dead(p) : new FakeAbLegacyTag(p) { Value = 1 },
|
||||
};
|
||||
var drv = NewDriver(f,
|
||||
new AbLegacyTagDefinition("Good", Host, "N7:5", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("Bad", Host, "N7:6", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var before = drv.GetHealth().LastSuccessfulRead;
|
||||
await Task.Delay(5);
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest("Good", 1), new WriteRequest("Bad", 2)], CancellationToken.None);
|
||||
|
||||
w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
w[1].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
var h = drv.GetHealth();
|
||||
h.State.ShouldBe(DriverState.Degraded);
|
||||
h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_write_inside_the_backoff_window_fails_fast_even_with_a_cached_handle()
|
||||
{
|
||||
var now = new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
|
||||
var f = new SwitchableFactory();
|
||||
var drv = NewDriver(f,
|
||||
new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("D1", Host, "N7:6", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("D2", Host, "N7:7", AbLegacyDataType.Int));
|
||||
drv.Clock = () => now;
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await drv.ReadAsync(["W"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // cache W
|
||||
var cached = f.Tags["N7:5"];
|
||||
f.Make = Dead;
|
||||
await drv.ReadAsync(["D1", "D2"], CancellationToken.None); // two consecutive no-reply faults: device down
|
||||
drv.GetDeviceState(Host)!.IsThrottled(now).ShouldBeTrue();
|
||||
|
||||
var w = await drv.WriteAsync([new WriteRequest("W", 9)], CancellationToken.None);
|
||||
|
||||
w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
cached.WriteCount.ShouldBe(0);
|
||||
|
||||
// Positive control: once the window has passed, the same cached handle is written.
|
||||
now = now.AddSeconds(2);
|
||||
(await drv.WriteAsync([new WriteRequest("W", 9)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
cached.WriteCount.ShouldBe(1);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_indirect_bit_write_releases_the_previous_parent_word_handle()
|
||||
{
|
||||
var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? (short)5 : (short)0 } };
|
||||
var drv = NewDriver(f, new AbLegacyTagDefinition("IndBit", Host, "N7:[N7:0]/3", AbLegacyDataType.Bit));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
(await drv.WriteAsync([new WriteRequest("IndBit", true)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var state = drv.GetDeviceState(Host)!;
|
||||
state.ParentRuntimes.ContainsKey("N7:5").ShouldBeTrue(); // positive control: the parent word was RMW'd
|
||||
f.Tags["N7:0"].Value = (short)6;
|
||||
(await drv.WriteAsync([new WriteRequest("IndBit", true)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
|
||||
state.ParentRuntimes.ContainsKey("N7:5").ShouldBeFalse();
|
||||
state.ParentRuntimes.ContainsKey("N7:6").ShouldBeTrue();
|
||||
f.Tags["N7:5"].Disposed.ShouldBeTrue();
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>A probe read that parks ignoring cancellation, so ShutdownAsync is still joining.</summary>
|
||||
private sealed class ParkingTag(AbLegacyTagCreateParams p, Task release, TaskCompletionSource entered) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public override async Task ReadAsync(CancellationToken ct)
|
||||
{
|
||||
entered.TrySetResult();
|
||||
await release.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_slow_teardown_does_not_overwrite_a_newer_initialise_health()
|
||||
{
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var parked = false;
|
||||
var f = new SwitchableFactory
|
||||
{
|
||||
Make = p =>
|
||||
{
|
||||
if (parked) return new FakeAbLegacyTag(p) { Value = 1 };
|
||||
parked = true;
|
||||
return new ParkingTag(p, release.Task, entered);
|
||||
},
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) },
|
||||
}, "drv-m1", f);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await entered.Task;
|
||||
|
||||
var shutdown = drv.ShutdownAsync(CancellationToken.None); // parks joining the wedged probe loop
|
||||
await Task.Delay(50);
|
||||
shutdown.IsCompleted.ShouldBeFalse();
|
||||
await drv.InitializeAsync("{}", CancellationToken.None); // a newer initialise wins
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
release.SetResult();
|
||||
await shutdown;
|
||||
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
// Positive control: an uncontested shutdown still writes Unknown.
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
private sealed class DisposeAwareTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public override Task ReadAsync(CancellationToken ct) =>
|
||||
Disposed ? throw new ObjectDisposedException("tag") : base.ReadAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 r0 M3: a reader that fetched a handle and then waited on its gate while another path evicted and
|
||||
/// disposed it used to read the disposed instance — a spurious comms fault and Degraded. It now re-resolves
|
||||
/// the handle once the gate is held.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_handle_evicted_while_a_reader_waited_for_its_gate_is_re_resolved()
|
||||
{
|
||||
var f = new SwitchableFactory { Make = p => new DisposeAwareTag(p) { Value = 4 } };
|
||||
var drv = NewDriver(f, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var state = drv.GetDeviceState(Host)!;
|
||||
var first = f.Tags["N7:0"];
|
||||
|
||||
var gate = state.GetRuntimeLock("X");
|
||||
await gate.WaitAsync();
|
||||
// Signalled at exactly "handle fetched, about to wait on the gate" — no sleep, so the eviction below always
|
||||
// lands between the reader's fetch and its use (review 07 r1 test nit: the sleep could pass vacuously).
|
||||
var atGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
state.BeforeOperationGate = key => { if (key == "X") atGate.TrySetResult(); };
|
||||
var read = drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await atGate.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
state.Runtimes.TryRemove("X", out var evicted).ShouldBeTrue();
|
||||
evicted!.Dispose();
|
||||
gate.Release();
|
||||
var r = await read;
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
f.Tags["N7:0"].ShouldNotBeSameAs(first); // a fresh handle served it
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 r1 N3: a reader parked on a resolved INDIRECT key's gate while the index sweep evicted that key and
|
||||
/// forgot its gate used to RECONNECT it under the lock — resurrecting a stale resolution under a gate nobody
|
||||
/// else would get. It now answers that one call neutrally and leaves the key gone.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_swept_indirect_resolution_is_never_resurrected_under_a_forgotten_gate()
|
||||
{
|
||||
var creates = new System.Collections.Concurrent.ConcurrentQueue<string>();
|
||||
var f = new SwitchableFactory
|
||||
{
|
||||
Make = p =>
|
||||
{
|
||||
creates.Enqueue(p.TagName);
|
||||
return new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? (short)5 : (short)42 };
|
||||
},
|
||||
};
|
||||
var drv = NewDriver(f, new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await drv.ReadAsync(["Ind"], CancellationToken.None))[0].Value.ShouldBe(42); // positive control
|
||||
var state = drv.GetDeviceState(Host)!;
|
||||
const string key = "Ind=>N7:5";
|
||||
state.Runtimes.ContainsKey(key).ShouldBeTrue();
|
||||
|
||||
var gate = state.GetRuntimeLock(key);
|
||||
await gate.WaitAsync();
|
||||
var atGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
state.BeforeOperationGate = k => { if (k == key) atGate.TrySetResult(); };
|
||||
var read = drv.ReadAsync(["Ind"], CancellationToken.None);
|
||||
await atGate.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
// What the sweep does when the index moves: evict the resolution, then forget its gate.
|
||||
state.Runtimes.TryRemove(key, out var swept).ShouldBeTrue();
|
||||
swept!.Dispose();
|
||||
state.ForgetGates(key);
|
||||
gate.Release();
|
||||
var r = await read;
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
|
||||
state.Runtimes.ContainsKey(key).ShouldBeFalse(); // not resurrected
|
||||
creates.Count(n => n == "N7:5").ShouldBe(1); // no reconnect of the stale resolution
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy); // neutral: no health evidence either way
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Review 07 r1 N4: an index read answered Unsupported is neutral, not a transport fault.</summary>
|
||||
[Fact]
|
||||
public async Task An_index_read_answered_Unsupported_is_neutral()
|
||||
{
|
||||
var f = new SwitchableFactory
|
||||
{
|
||||
Make = p => p.TagName == "N7:0"
|
||||
? new FakeAbLegacyTag(p) { Status = (int)AbStatus.Unsupported }
|
||||
: new FakeAbLegacyTag(p) { Value = 1 },
|
||||
};
|
||||
var drv = NewDriver(f,
|
||||
new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("Plain", Host, "N9:0", AbLegacyDataType.Int));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["Ind", "Plain"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
|
||||
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
drv.GetDeviceState(Host)!.ParentRuntimes.ContainsKey("N7:0").ShouldBeTrue(); // handle kept, not evicted
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Review 07 r1 N2: a persisted mismatched array READS BadTypeMismatch instead of garbage as Good.</summary>
|
||||
[Fact]
|
||||
public async Task A_mismatched_array_reads_BadTypeMismatch_not_garbage()
|
||||
{
|
||||
var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { 1f, 2f } } };
|
||||
var drv = NewDriver(f,
|
||||
new AbLegacyTagDefinition("Bad", Host, "F8:0", AbLegacyDataType.Int, ArrayLength: 2),
|
||||
new AbLegacyTagDefinition("Good", Host, "F9:0", AbLegacyDataType.Float, ArrayLength: 2));
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
|
||||
|
||||
r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
|
||||
f.Tags.ContainsKey("F8:0").ShouldBeFalse(); // nothing read at the wrong width
|
||||
r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control
|
||||
r[1].Value.ShouldBe(new[] { 1f, 2f });
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
+28
@@ -128,4 +128,32 @@ public sealed class AbLegacyIndirectAddressingTests
|
||||
w.StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
|
||||
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 L7: every index value an indirect tag sweeps through used to leave one per-runtime semaphore and
|
||||
/// one creation semaphore behind until shutdown. They are pruned with the stale resolution's handle, so the
|
||||
/// per-key gate tables stay bounded by the authored set however far the index sweeps.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Sweeping_index_does_not_accumulate_per_key_gates()
|
||||
{
|
||||
var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
|
||||
Seed(f, ("N7:0", (short)10));
|
||||
(await drv.ReadAsync(["Ind"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var state = drv.GetDeviceState(Host)!;
|
||||
var baseline = state.GateCount;
|
||||
|
||||
for (short i = 11; i < 60; i++)
|
||||
{
|
||||
f.Tags["N7:0"].Value = i;
|
||||
f.Customise = p => new FakeAbLegacyTag(p) { Value = (short)(p.TagName == "N7:0" ? i : 1000 + i) };
|
||||
var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single();
|
||||
r.StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: every resolution really read
|
||||
r.Value.ShouldBe((short)(1000 + i));
|
||||
}
|
||||
|
||||
state.GateCount.ShouldBe(baseline);
|
||||
// Only the live resolution's handle (plus the index word's parent runtime) remains cached.
|
||||
state.Runtimes.Keys.Count(k => k.StartsWith("Ind=>", StringComparison.Ordinal)).ShouldBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Review 07 H13 + the Lane A runtime contract: the device and tag tables are build-and-swapped (never cleared
|
||||
/// in place under a reader), <c>Dispose</c> may run concurrently with a still-running <c>ShutdownAsync</c>, and
|
||||
/// an abandoned <c>InitializeAsync</c> that overlaps the next <c>ShutdownAsync</c> must not resurrect state.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class AbLegacyLifecycleConcurrencyTests
|
||||
{
|
||||
private const string Host = "ab://10.0.0.5/1,0";
|
||||
|
||||
/// <summary>A probe tag whose read parks until released, ignoring cancellation (a wedged device call).</summary>
|
||||
private sealed class ParkingProbeTag(AbLegacyTagCreateParams p, Task release) : FakeAbLegacyTag(p)
|
||||
{
|
||||
public readonly TaskCompletionSource Entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public override async Task ReadAsync(CancellationToken ct)
|
||||
{
|
||||
Entered.TrySetResult();
|
||||
await release.ConfigureAwait(false);
|
||||
await base.ReadAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition, string what)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTime.UtcNow > deadline) throw new TimeoutException(what);
|
||||
await Task.Delay(10);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-initialising in a loop while other threads read the tables never throws "Collection was modified":
|
||||
/// the readers see either the old or the new table, never one being cleared.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reinit_under_concurrent_readers_never_throws()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host), new AbLegacyDeviceOptions("ab://10.0.0.6/1,0")],
|
||||
RawTags = AbLegacyRawTags.Entries(
|
||||
new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int),
|
||||
new AbLegacyTagDefinition("Y", "ab://10.0.0.6/1,0", "N7:1", AbLegacyDataType.Int)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-swap", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
using var stop = new CancellationTokenSource();
|
||||
var failures = new System.Collections.Concurrent.ConcurrentQueue<Exception>();
|
||||
var reads = 0;
|
||||
var readers = Enumerable.Range(0, 3).Select(n => Task.Run(async () =>
|
||||
{
|
||||
while (!stop.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = drv.GetHostStatuses();
|
||||
_ = drv.ResolveHost("X");
|
||||
_ = await drv.ReadAsync(["X", "Y"], CancellationToken.None);
|
||||
Interlocked.Increment(ref reads);
|
||||
}
|
||||
catch (Exception ex) { failures.Enqueue(ex); }
|
||||
}
|
||||
})).ToArray();
|
||||
|
||||
// Keep re-initialising until the readers have demonstrably overlapped many swaps.
|
||||
var deadline = DateTime.UtcNow.AddSeconds(10);
|
||||
for (var i = 0; i < 100 || Volatile.Read(ref reads) < 200; i++)
|
||||
{
|
||||
if (DateTime.UtcNow > deadline) break;
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
}
|
||||
stop.Cancel();
|
||||
await Task.WhenAll(readers);
|
||||
|
||||
failures.ShouldBeEmpty();
|
||||
reads.ShouldBeGreaterThan(0); // positive control: the readers really ran against the swapping tables
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>A re-init publishes a NEW device state; the old one is retired, not mutated in place.</summary>
|
||||
[Fact]
|
||||
public async Task Reinit_publishes_a_new_table_and_retires_the_old_state()
|
||||
{
|
||||
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-swap", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
var old = drv.GetDeviceState(Host)!;
|
||||
var oldTag = factory.Tags["N7:0"];
|
||||
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
|
||||
var fresh = drv.GetDeviceState(Host)!;
|
||||
fresh.ShouldNotBeSameAs(old);
|
||||
old.IsRetired.ShouldBeTrue();
|
||||
oldTag.Disposed.ShouldBeTrue();
|
||||
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A runtime created by a read that was already in flight when shutdown retired its device state is disposed
|
||||
/// instead of being cached on the retired state (where nothing would ever release it).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Runtime_created_after_retirement_is_disposed_not_cached()
|
||||
{
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
FakeAbLegacyTag? created = null;
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => created = new InitParkingTag(p, entered, release.Task) { Value = 1 },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = false },
|
||||
}, "drv-late", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
var state = drv.GetDeviceState(Host)!;
|
||||
|
||||
var read = drv.ReadAsync(["X"], CancellationToken.None);
|
||||
await entered.Task;
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
release.SetResult();
|
||||
_ = await read;
|
||||
|
||||
created.ShouldNotBeNull();
|
||||
created!.Disposed.ShouldBeTrue();
|
||||
state.Runtimes.ShouldBeEmpty();
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown); // the late read did not overwrite shutdown's health
|
||||
}
|
||||
|
||||
private sealed class InitParkingTag(AbLegacyTagCreateParams p, TaskCompletionSource entered, Task release)
|
||||
: FakeAbLegacyTag(p)
|
||||
{
|
||||
public override async Task InitializeAsync(CancellationToken ct)
|
||||
{
|
||||
entered.TrySetResult();
|
||||
await release.ConfigureAwait(false);
|
||||
await base.InitializeAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lane A's final stop calls ShutdownAsync and then DisposeAsync/Dispose; under the 5 s bound Dispose can run
|
||||
/// while ShutdownAsync is still joining a wedged probe loop. Both complete without throwing and every runtime
|
||||
/// is released.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Dispose_concurrent_with_running_shutdown_is_safe()
|
||||
{
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
ParkingProbeTag? probe = null;
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p => p.TagName == "S:0"
|
||||
? probe = new ParkingProbeTag(p, release.Task)
|
||||
: new FakeAbLegacyTag(p) { Value = 1 },
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host)],
|
||||
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
|
||||
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) },
|
||||
}, "drv-dispose", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
|
||||
await WaitUntilAsync(() => probe is not null, "probe never created");
|
||||
await probe!.Entered.Task;
|
||||
|
||||
var shutdown = drv.ShutdownAsync(CancellationToken.None); // parks joining the wedged probe loop
|
||||
await Task.Delay(50);
|
||||
shutdown.IsCompleted.ShouldBeFalse();
|
||||
|
||||
Should.NotThrow(() => drv.Dispose());
|
||||
await Should.NotThrowAsync(async () => await drv.DisposeAsync());
|
||||
release.SetResult();
|
||||
await shutdown;
|
||||
|
||||
factory.Tags["N7:0"].Disposed.ShouldBeTrue();
|
||||
probe.Disposed.ShouldBeTrue();
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An InitializeAsync the runtime abandoned (it is parked releasing a wedged prior probe loop) overlaps the next
|
||||
/// ShutdownAsync. When it resumes it must NOT publish its tables or start probe loops — that state would sit
|
||||
/// behind a driver the runtime believes is stopped. A later Initialize publishes normally (positive control).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Abandoned_initialize_overlapping_shutdown_does_not_resurrect_state()
|
||||
{
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var probes = new System.Collections.Concurrent.ConcurrentQueue<FakeAbLegacyTag>();
|
||||
ParkingProbeTag? first = null;
|
||||
var factory = new FakeAbLegacyTagFactory
|
||||
{
|
||||
Customise = p =>
|
||||
{
|
||||
FakeAbLegacyTag t = first is null ? first = new ParkingProbeTag(p, release.Task) : new FakeAbLegacyTag(p);
|
||||
probes.Enqueue(t);
|
||||
return t;
|
||||
},
|
||||
};
|
||||
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
|
||||
{
|
||||
Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)],
|
||||
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) },
|
||||
}, "drv-abandon", factory);
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await WaitUntilAsync(() => first is not null, "probe never created");
|
||||
await first!.Entered.Task;
|
||||
|
||||
var abandoned = drv.InitializeAsync("{}", CancellationToken.None); // parks joining the wedged loop
|
||||
await Task.Delay(50);
|
||||
abandoned.IsCompleted.ShouldBeFalse();
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
|
||||
release.SetResult();
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => abandoned);
|
||||
var createdAfter = probes.Count;
|
||||
await Task.Delay(150); // several probe intervals
|
||||
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
probes.Count.ShouldBe(createdAfter);
|
||||
|
||||
// Positive control: a fresh Initialize after the stop publishes and probes as normal.
|
||||
await drv.InitializeAsync("{}", CancellationToken.None);
|
||||
drv.DeviceCount.ShouldBe(1);
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
||||
await WaitUntilAsync(() => probes.Count > createdAfter, "fresh init never probed");
|
||||
await drv.ShutdownAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>Thread-safe capturing logger shared by the remediation tests (poll loops log from pool threads).</summary>
|
||||
internal sealed class AbLegacyTestLogger : ILogger<AbLegacyDriver>
|
||||
{
|
||||
/// <summary>Every entry logged, in order.</summary>
|
||||
public System.Collections.Concurrent.ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new();
|
||||
|
||||
/// <summary>The Warning-level messages logged.</summary>
|
||||
public IReadOnlyList<string> Warnings => [.. Entries.Where(e => e.Level == LogLevel.Warning).Select(e => e.Message)];
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
|
||||
Func<TState, Exception?, string> formatter) => Entries.Enqueue((logLevel, formatter(state, exception)));
|
||||
}
|
||||
@@ -109,6 +109,11 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
|
||||
/// <param name="value">The value to encode.</param>
|
||||
public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value;
|
||||
|
||||
/// <summary>Encodes a whole typed array (array writes); stored as <see cref="ArrayValue"/>.</summary>
|
||||
/// <param name="type">The AbLegacy element type.</param>
|
||||
/// <param name="values">The typed elements.</param>
|
||||
public virtual void EncodeArray(AbLegacyDataType type, Array values) => ArrayValue = values;
|
||||
|
||||
/// <summary>Disposes the tag.</summary>
|
||||
public virtual void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
+21
@@ -256,6 +256,27 @@ public sealed class AbLegacyDriverPageFormSerializationTests
|
||||
bound.Probe.ProbeAddress.ShouldBe("S:1");
|
||||
}
|
||||
|
||||
/// <summary>Review 07 P7: the coalesceReads switch is authored here and consumed by the driver factory.</summary>
|
||||
[Theory]
|
||||
[InlineData("""{"devices":[]}""", true, false)]
|
||||
[InlineData("""{"coalesceReads":false,"devices":[]}""", false, true)]
|
||||
public void CoalesceReads_round_trips_to_the_factory_bound_options(string original, bool loadedValue, bool edited)
|
||||
{
|
||||
var loaded = AbLegacyDriverForm.TryDeserialize(original)!;
|
||||
var form = AbLegacyDriverForm.FormModel.FromJson(original, loaded);
|
||||
form.CoalesceReads.ShouldBe(loadedValue);
|
||||
form.CoalesceReads = edited;
|
||||
|
||||
var saved = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices), original);
|
||||
|
||||
(ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.AbLegacyDriverFactoryExtensions.ParseOptions("t", saved)
|
||||
.CoalesceReads != false).ShouldBe(edited);
|
||||
// Review 07 r0 M9: ON is the default and is OMITTED, never pinned; only an explicit off is written.
|
||||
var root = System.Text.Json.Nodes.JsonNode.Parse(saved)!.AsObject();
|
||||
if (edited) root.ContainsKey("coalesceReads").ShouldBeFalse();
|
||||
else root["coalesceReads"]!.GetValue<bool>().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_legacy_TimeSpan_string_key_is_loaded_then_migrated_to_ms_and_removed()
|
||||
{
|
||||
|
||||
@@ -122,6 +122,38 @@ public sealed class AbLegacyTagConfigModelTests
|
||||
m.Validate().ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Review 07 H16: an array of a structured element type is refused with the reader's own reason.</summary>
|
||||
[Theory]
|
||||
[InlineData("String", "ST9:0")]
|
||||
[InlineData("TimerElement", "T4:0")]
|
||||
public void Validate_refuses_an_array_of_a_structured_element_type(string dataType, string address)
|
||||
{
|
||||
var m = AbLegacyTagConfigModel.FromJson(
|
||||
$$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}""");
|
||||
|
||||
m.Validate().ShouldNotBeNull().ShouldContain("not supported as an array");
|
||||
// Positive control: the same type as a scalar validates.
|
||||
AbLegacyTagConfigModel.FromJson($$"""{"address":"{{address}}","dataType":"{{dataType}}"}""").Validate().ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>Review 07 r0 I3: an array must match its data file's element width.</summary>
|
||||
[Theory]
|
||||
[InlineData("F8:0", "Int", false)]
|
||||
[InlineData("N7:0", "Long", false)]
|
||||
[InlineData("N7:0", "Int", true)]
|
||||
[InlineData("L9:0", "Long", true)]
|
||||
[InlineData("F8:0", "Float", true)]
|
||||
public void Validate_refuses_an_array_whose_type_does_not_match_its_file(string address, string dataType, bool valid)
|
||||
{
|
||||
var m = AbLegacyTagConfigModel.FromJson(
|
||||
$$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}""");
|
||||
|
||||
if (valid) m.Validate().ShouldBeNull();
|
||||
else m.Validate().ShouldNotBeNull().ShouldContain("element width");
|
||||
// A SCALAR of the same shape is not this rule's business.
|
||||
AbLegacyTagConfigModel.FromJson($$"""{"address":"{{address}}","dataType":"{{dataType}}"}""").Validate().ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_returns_null_for_scalar_tag_regardless_of_no_array_length()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user