Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmLayoutTests.cs
T
Joseph Doherty fd64fed217 fix(focas): a stalled device fails fast for the rest of its batch; generation-stamp health (#740 review round 1)
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
2026-10-22 14:44:42 -04:00

115 lines
4.9 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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&lt;&lt;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));
}
}