fd64fed217
I1: Task.WhenAll waits for the slowest device, so concurrency alone still let a device that accepts TCP and then stalls cost (its tag count x call timeout) per batch. After the first timeout, transport failure or comms-class status on a device, its remaining tags in the batch now fail BadCommunicationError immediately, the same as the coalescer's FailMembers, which now also stops after a failed range. FIXED: tags are still served from the cache. A malformed address is BadNodeIdUnknown and does not count as a link failure. Test: a 10-tag stalled device beside a healthy one completes in under 3 call timeouts with the healthy value Good. The P9 test now also asserts per-client read counts and that two reads overlapped across devices. Minors: - 1: a superseded init releases its table only if it was never published (flag). - 2: a fixed-tree refusal credits LastSuccessfulRead; a value-path refusal after a comms fault restores Healthy. - 5: alarm/fixed/refusal health is reset inside the publish lock, after the generation check. - 6: FocasFixedTreeHealthTests wait on served-failure/refusal counters, not wall-clock. - 8: data-path health writes (reads, coalescer, writes, poll errors, refusals) carry the generation they started under and are dropped once a lifecycle call has moved on. Each write is a check-and-set under the lifecycle lock. Also adds a unit case where 20 vendor records with type 0 and empty messages stay vendor. Refs #740 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
115 lines
4.9 KiB
C#
115 lines
4.9 KiB
C#
using System.Buffers.Binary;
|
||
using System.Text;
|
||
using Shouldly;
|
||
using Xunit;
|
||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
||
|
||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||
|
||
/// <summary>
|
||
/// #740 / R07:H20 — <c>ParseAlarms</c> picked the 76-byte vendor layout whenever <c>Length % 76 == 0</c>, so a
|
||
/// legacy 80-byte reply of exactly 19 records (1,520 B = 20 × 76) decoded as 20 garbage vendor records. The
|
||
/// response block carries no record count, so the layout is now chosen from the two things that are known: the
|
||
/// count the REQUEST asked for (a reply never holds more) and each layout's own field invariants.
|
||
/// </summary>
|
||
[Trait("Category", "Unit")]
|
||
public sealed class FocasAlarmLayoutTests
|
||
{
|
||
private static byte[] Legacy(params (int no, int type, int axis, string msg)[] records)
|
||
{
|
||
var buf = new byte[records.Length * 80];
|
||
for (var i = 0; i < records.Length; i++)
|
||
{
|
||
var o = i * 80;
|
||
var (no, type, axis, msg) = records[i];
|
||
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o, 4), no);
|
||
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 4, 4), type);
|
||
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 8, 4), axis);
|
||
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 12, 4), msg.Length);
|
||
Encoding.ASCII.GetBytes(msg).CopyTo(buf.AsSpan(o + 16));
|
||
}
|
||
return buf;
|
||
}
|
||
|
||
private static byte[] Vendor(params (int no, short type, short axis, string msg)[] records)
|
||
{
|
||
var buf = new byte[records.Length * 76];
|
||
for (var i = 0; i < records.Length; i++)
|
||
{
|
||
var o = i * 76;
|
||
var (no, type, axis, msg) = records[i];
|
||
BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o, 4), no);
|
||
BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 4, 2), type);
|
||
BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 6, 2), axis);
|
||
BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 10, 2), (short)msg.Length);
|
||
Encoding.ASCII.GetBytes(msg).CopyTo(buf.AsSpan(o + 12));
|
||
}
|
||
return buf;
|
||
}
|
||
|
||
private static (int, int, int, string)[] LegacySet(int n) =>
|
||
[.. Enumerable.Range(0, n).Select(i => (400 + i, 2 + (i % 3), 1 + (i % 4), $"SERVO ALARM {400 + i}"))];
|
||
|
||
private static (int, short, short, string)[] VendorSet(int n) =>
|
||
[.. Enumerable.Range(0, n).Select(i => (1000 + i, (short)(4 + (i % 3)), (short)(i % 2), $"OVERTRAVEL +{i}"))];
|
||
|
||
/// <summary>The ambiguous length: 19 legacy records decode as 19 legacy records, not 20 vendor ones.</summary>
|
||
[Fact]
|
||
public void Nineteen_legacy_records_decode_as_legacy()
|
||
{
|
||
var payload = Legacy(LegacySet(19));
|
||
payload.Length.ShouldBe(1520);
|
||
(payload.Length % 76).ShouldBe(0, "the length really is ambiguous");
|
||
|
||
var alarms = FocasWireClient.ParseAlarms(payload, 32);
|
||
|
||
alarms.Count.ShouldBe(19);
|
||
alarms[0].AlarmNumber.ShouldBe(400);
|
||
alarms[0].Message.ShouldBe("SERVO ALARM 400");
|
||
alarms[18].AlarmNumber.ShouldBe(418);
|
||
alarms[18].Axis.ShouldBe((short)(1 + (18 % 4)));
|
||
}
|
||
|
||
/// <summary>Positive control at the same length: 20 vendor records still decode as vendor.</summary>
|
||
[Fact]
|
||
public void Twenty_vendor_records_decode_as_vendor()
|
||
{
|
||
var payload = Vendor(VendorSet(20));
|
||
payload.Length.ShouldBe(1520);
|
||
|
||
var alarms = FocasWireClient.ParseAlarms(payload, 32);
|
||
|
||
alarms.Count.ShouldBe(20);
|
||
alarms[0].AlarmNumber.ShouldBe(1000);
|
||
alarms[19].Message.ShouldBe("OVERTRAVEL +19");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Review I2 edge: vendor records with type 0 and EMPTY messages also satisfy the legacy invariants (the
|
||
/// int32 read of <c>type<<16 | axis</c> is just the axis, and the "length" int32 is the empty message's
|
||
/// zero bytes) — both readings are plausible, so the vendor default must hold and the numbers decode.
|
||
/// </summary>
|
||
[Fact]
|
||
public void Twenty_vendor_records_with_type_zero_and_empty_messages_stay_vendor()
|
||
{
|
||
var payload = Vendor([.. Enumerable.Range(0, 20).Select(i => (2000 + i, (short)0, (short)(i % 3), ""))]);
|
||
payload.Length.ShouldBe(1520);
|
||
|
||
var alarms = FocasWireClient.ParseAlarms(payload, 32);
|
||
|
||
alarms.Count.ShouldBe(20);
|
||
alarms.Select(a => a.AlarmNumber).ShouldBe(Enumerable.Range(2000, 20));
|
||
alarms.ShouldAllBe(a => a.Type == 0 && a.Message == "");
|
||
}
|
||
|
||
/// <summary>The unambiguous lengths are unchanged.</summary>
|
||
[Fact]
|
||
public void Unambiguous_lengths_are_unchanged()
|
||
{
|
||
FocasWireClient.ParseAlarms(Vendor(VendorSet(3)), 32).Count.ShouldBe(3);
|
||
FocasWireClient.ParseAlarms(Legacy(LegacySet(3)), 32).Count.ShouldBe(3);
|
||
FocasWireClient.ParseAlarms([], 32).ShouldBeEmpty();
|
||
Should.Throw<FocasWireException>(() => FocasWireClient.ParseAlarms(new byte[77], 32));
|
||
}
|
||
}
|