89961af6e6
MAJOR-1: a deploy that changes a driver's config respawns it, and the fresh instance's empty told record re-sent Raise for every still-active alarm, un-acking acknowledged conditions and writing a duplicate "Activated" alerts row. NativeAlarmProjector now treats Raise on an already-active condition as a re-statement (keeps Acked; ForwardNativeAlarm skips the alerts row); only Retrigger (or inactive->active) un-acks. Sources whose upstream ack state is authoritative send Retrigger for an unacked re-statement: OpcUaClient on first sight (no prior told), Galaxy for an unacked active snapshot entry. Runtime E2E drives a FRESH FOCAS driver after a redeploy and asserts the ack survives. MAJOR-2: a failed FOCAS alarm read annotates each subscribed condition BadNoCommunication once per outage (#626 quality-only path) with one Warning (MINOR-6), lifted to Good on recovery; failed alarm reads degrade driver health with LastSuccessfulRead frozen (successful ones advance it), so an alarm-only driver trips the #527 comms-loss watchdog. MINOR-3 CLAUDE.md #630 bullet; MINOR-4 split line; MINOR-5 a non-empty cnc_rdalmmsg2 payload that fits neither stride throws; MINOR-7 value-tag + parsed-address tables are built and swapped, never cleared in place under the retained poll engine. Docs corrected (FOCAS.md, AlarmTracking.md, CLAUDE.md). Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
161 lines
7.2 KiB
C#
161 lines
7.2 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
|
|
|
/// <summary>Gitea #647 — the <c>ALARM:</c> address grammar, shared by the editor, the deploy gate and the driver.</summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class FocasAlarmSelectorTests
|
|
{
|
|
[Theory]
|
|
[InlineData("ALARM:*", "ALARM:*")]
|
|
[InlineData("alarm:other", "ALARM:OTHER")]
|
|
[InlineData("ALARM:servo", "ALARM:Servo")]
|
|
[InlineData("ALARM:Servo/401", "ALARM:Servo/401")]
|
|
[InlineData("ALARM:SERVO/401-453", "ALARM:Servo/401-453")]
|
|
[InlineData("ALARM:Servo/401-401", "ALARM:Servo/401")]
|
|
[InlineData("ALARM:T9", "ALARM:T9")]
|
|
[InlineData("ALARM:t9/750-758", "ALARM:T9/750-758")]
|
|
[InlineData(" ALARM:MacroAlarm/3000 ", "ALARM:MacroAlarm/3000")]
|
|
public void Valid_selectors_parse_to_their_canonical_form(string address, string canonical)
|
|
{
|
|
var parsed = FocasAddress.TryParse(address);
|
|
parsed.ShouldNotBeNull();
|
|
parsed.Kind.ShouldBe(FocasAreaKind.Alarm);
|
|
parsed.Canonical.ShouldBe(canonical);
|
|
FocasAddress.TryParse(parsed.Canonical)!.Canonical.ShouldBe(canonical, "canonical must round-trip");
|
|
FocasAddress.DescribeAlarmAddressError(address).ShouldBeNull();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("ALARM:")]
|
|
[InlineData("ALARM:Bogus")]
|
|
[InlineData("ALARM:Servo/")]
|
|
[InlineData("ALARM:Servo/x")]
|
|
[InlineData("ALARM:Servo/-5")]
|
|
[InlineData("ALARM:Servo/10-5")]
|
|
[InlineData("ALARM:Servo/1-")]
|
|
[InlineData("ALARM:T")]
|
|
[InlineData("ALARM:T-1")]
|
|
[InlineData("ALARM:T99999")]
|
|
[InlineData("ALARM:Servo /401")]
|
|
[InlineData("ALARM:*/401")]
|
|
public void Malformed_selectors_are_refused_with_a_reason(string address)
|
|
{
|
|
FocasAddress.TryParse(address).ShouldBeNull();
|
|
FocasAddress.DescribeAlarmAddressError(address).ShouldNotBeNullOrWhiteSpace();
|
|
}
|
|
|
|
/// <summary>"A" is a PMC letter — an ALARM: address must be owned by the alarm rule, never mis-read as PMC.</summary>
|
|
[Fact]
|
|
public void Alarm_prefix_is_not_read_as_a_PMC_A_address()
|
|
{
|
|
FocasAddress.TryParse("A10")!.Kind.ShouldBe(FocasAreaKind.Pmc);
|
|
FocasAddress.TryParse("ALARM:*")!.Kind.ShouldBe(FocasAreaKind.Alarm);
|
|
FocasAddress.DescribeAlarmAddressError("A10").ShouldBeNull("not an ALARM: address at all");
|
|
}
|
|
|
|
[Fact]
|
|
public void Type_selector_matches_on_type_and_inclusive_number_range()
|
|
{
|
|
var range = FocasAlarmSelector.TryParse("Servo/401-453")!;
|
|
range.Matches(FocasAlarmType.Servo, 401).ShouldBeTrue();
|
|
range.Matches(FocasAlarmType.Servo, 453).ShouldBeTrue();
|
|
range.Matches(FocasAlarmType.Servo, 454).ShouldBeFalse();
|
|
range.Matches(FocasAlarmType.Overtravel, 401).ShouldBeFalse("(type, number) is the identity");
|
|
|
|
var any = FocasAlarmSelector.TryParse("*")!;
|
|
any.Matches(FocasAlarmType.Overheat, 1).ShouldBeTrue();
|
|
|
|
// OTHER depends on the device's other selectors, so it never matches on its own terms.
|
|
FocasAlarmSelector.TryParse("OTHER")!.Matches(FocasAlarmType.Servo, 401).ShouldBeFalse();
|
|
|
|
// A raw code selects exactly that code — the series-independent form.
|
|
FocasAlarmSelector.TryParse("T4")!.Matches(FocasAlarmType.Servo, 1).ShouldBeTrue();
|
|
FocasAlarmSelector.TryParse("T9")!.Matches(FocasAlarmType.Servo, 1).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Every_named_type_resolves_through_the_one_table()
|
|
{
|
|
foreach (var name in FocasAlarmType.Names)
|
|
{
|
|
FocasAlarmType.TryResolve(name.ToUpperInvariant(), out var code).ShouldBeTrue(name);
|
|
FocasAlarmType.NameOf(code).ShouldBe(name);
|
|
}
|
|
FocasAlarmType.NameOf(9).ShouldBeNull("codes outside the 16i-era table are not invented");
|
|
}
|
|
|
|
[Fact]
|
|
public void Capability_matrix_accepts_an_alarm_address_on_every_series()
|
|
{
|
|
var address = FocasAddress.TryParse("ALARM:Servo/401")!;
|
|
foreach (var series in Enum.GetValues<FocasCncSeries>())
|
|
FocasCapabilityMatrix.Validate(series, address).ShouldBeNull(series.ToString());
|
|
}
|
|
|
|
// ---- the deploy-time inspector (the gate CSV import / API authoring meets) ----
|
|
|
|
[Fact]
|
|
public void Inspect_is_clean_for_an_alarm_selector_with_its_alarm_object()
|
|
{
|
|
FocasTagDefinitionFactory.Inspect("""{"address":"ALARM:Servo/401-453","dataType":"Int32","alarm":{"severity":800}}""")
|
|
.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Inspect_advises_when_an_alarm_selector_has_no_alarm_object_or_an_alarm_object_has_no_selector()
|
|
{
|
|
FocasTagDefinitionFactory.Inspect("""{"address":"ALARM:*","dataType":"Int32"}""")
|
|
.ShouldHaveSingleItem().ShouldContain("no 'alarm' object");
|
|
FocasTagDefinitionFactory.Inspect("""{"address":"R100","dataType":"Byte","alarm":{}}""")
|
|
.ShouldHaveSingleItem().ShouldContain("not an ALARM: selector");
|
|
}
|
|
|
|
[Fact]
|
|
public void Inspect_names_the_selector_fault_for_a_malformed_alarm_address()
|
|
{
|
|
var warning = FocasTagDefinitionFactory.Inspect("""{"address":"ALARM:Servo/10-5","alarm":{}}""").ShouldHaveSingleItem();
|
|
warning.ShouldContain("reversed");
|
|
warning.ShouldContain(FocasAddress.DescribeAlarmAddressError("ALARM:Servo/10-5")!,
|
|
customMessage: "the gate and the editor must say the same thing about the same string");
|
|
}
|
|
|
|
[Fact]
|
|
public void The_mapper_reads_the_alarm_object_and_its_optional_integer_severity()
|
|
{
|
|
FocasTagDefinitionFactory.FromTagConfig("""{"address":"ALARM:*","alarm":{"severity":650}}""", "p", out var a).ShouldBeTrue();
|
|
a.Alarm.ShouldBe(new FocasTagAlarm(650));
|
|
FocasTagDefinitionFactory.FromTagConfig("""{"address":"ALARM:*","alarm":{"severity":"high"}}""", "p", out var b).ShouldBeTrue();
|
|
b.Alarm.ShouldBe(new FocasTagAlarm(null));
|
|
FocasTagDefinitionFactory.FromTagConfig("""{"address":"R100"}""", "p", out var c).ShouldBeTrue();
|
|
c.Alarm.ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gitea #647 review MINOR-5 — a non-empty alarm payload that fits neither record stride is malformed, not
|
|
/// "no alarms" (which would mass-clear every condition on the device). Empty stays empty.
|
|
/// </summary>
|
|
[Fact]
|
|
public void A_malformed_alarm_payload_throws_while_an_empty_one_is_no_alarms()
|
|
{
|
|
FocasWireClient.ParseAlarms([], 32).ShouldBeEmpty();
|
|
FocasWireClient.ParseAlarms(new byte[76], 32).Count.ShouldBe(1);
|
|
FocasWireClient.ParseAlarms(new byte[160], 32).Count.ShouldBe(2);
|
|
Should.Throw<FocasWireException>(() => FocasWireClient.ParseAlarms(new byte[77], 32));
|
|
Should.Throw<FocasWireException>(() => FocasWireClient.ParseAlarms(new byte[12], 32));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gitea #647 defect 4 — a read that FAILED must not look like "no alarms": the projection would diff it
|
|
/// to a mass clear and re-raise everything un-acknowledged on recovery. A disconnected wire client throws.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Wire_client_ReadAlarms_throws_when_not_connected_rather_than_returning_empty()
|
|
{
|
|
using var client = new WireFocasClient();
|
|
await Should.ThrowAsync<InvalidOperationException>(() => client.ReadAlarmsAsync(CancellationToken.None));
|
|
}
|
|
}
|