Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmSelectorTests.cs
T
Joseph Doherty 482bafdfac feat(focas): CNC alarms reach Part 9 conditions via ALARM: tags (#647)
An ALARM: address (*, <Type>, <Type>/<n>, <Type>/<lo>-<hi>, T<code>, OTHER) on a raw tag that
also carries the cross-driver alarm object is ONE condition with ConditionId == RawPath and a
Kind (Raise / Retrigger / Clear), so DriverHostActor.ForwardNativeAlarm routes it and
NativeAlarmProjector activates it. Fixes all seven triage defects:

1. SubscribeAlarmsAsync treated the host's RawPaths as device hosts -> nothing polled.
2. Decorated "{host}#{Type}:{Number}" ConditionId -> every event dropped.
3. No Kind -> projector kept prior state.
4. WireFocasClient.ReadAlarmsAsync answered [] on failure -> mass clear + un-acked re-raise.
   It now throws; a failed device read keeps every condition's state.
5. Acks lost on reconnect -> told-record dedup (MTConnect #626 pattern) held for the driver's
   lifetime; same-set re-subscribe / re-init re-raise nothing; changed set prunes leavers only.
6. Subscribe threw while AlarmProjection.Enabled=false -> the tag is the opt-in; flag is a no-op.
7. Alarm tags could not be authored -> ALARM: grammar (FocasAlarmSelector, Contracts) backs the
   editor, the address picker, the deploy-time inspector and driver init.

Severity: authored alarm.severity -> exact OpcUaSeverity; else the 16i-era type bucket. The type
table stays the only one (moved to Contracts, documented 16i-era; 30i codes not invented; raw
T<code> selector + raw code in every message). Read-only: ack is server-side. Also clears the
value table on a re-initialise (#630 gap that let a removed/moved tag keep reading).

E2E: Runtime.Tests FocasNativeAlarmEndToEndTests drives the real driver through the real
DriverHostActor and asserts the condition goes Active then inactive.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 19:46:48 -04:00

147 lines
6.5 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 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));
}
}