482bafdfac
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
100 lines
4.6 KiB
C#
100 lines
4.6 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
|
|
|
|
/// <summary>
|
|
/// Gitea #540. The previous edition of this file asserted
|
|
/// <c>Build("axis", 5) == "axis:5"</c> for four "parameter groups" that are not FOCAS
|
|
/// addressing spaces — it pinned the exact string the driver could not parse, which is why
|
|
/// a fully-green suite coexisted with a picker whose default value was unusable. Every case
|
|
/// here therefore asserts the address ROUND-TRIPS through the driver's parser, not merely
|
|
/// that it has the expected shape.
|
|
/// </summary>
|
|
public sealed class FocasAddressBuilderTests
|
|
{
|
|
[Theory]
|
|
[InlineData(FocasManualAddressKind.Pmc, "R", 100, null, "R100")]
|
|
[InlineData(FocasManualAddressKind.Pmc, "X", 0, 0, "X0.0")]
|
|
[InlineData(FocasManualAddressKind.Pmc, "F", 20, 3, "F20.3")]
|
|
[InlineData(FocasManualAddressKind.Pmc, "r", 100, null, "R100")] // letter is normalised
|
|
[InlineData(FocasManualAddressKind.Parameter, null, 1020, null, "PARAM:1020")]
|
|
[InlineData(FocasManualAddressKind.Parameter, null, 1815, 0, "PARAM:1815/0")]
|
|
[InlineData(FocasManualAddressKind.Macro, null, 500, null, "MACRO:500")]
|
|
[InlineData(FocasManualAddressKind.Macro, null, 500, 3, "MACRO:500")] // macros have no bit form
|
|
public void Build_produces_an_address_the_driver_can_parse(
|
|
FocasManualAddressKind kind, string? letter, int number, int? bit, string expected)
|
|
{
|
|
var built = FocasAddressBuilder.Build(kind, letter, number, bit);
|
|
|
|
built.ShouldBe(expected);
|
|
FocasAddress.TryParse(built).ShouldNotBeNull();
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(FocasManualAddressKind.Pmc, "Z", 10, null)] // not a PMC area letter
|
|
[InlineData(FocasManualAddressKind.Pmc, "", 10, null)] // no area chosen
|
|
[InlineData(FocasManualAddressKind.Pmc, "R", 10, 8)] // PMC bits are 0-7
|
|
[InlineData(FocasManualAddressKind.Pmc, "R", -1, null)] // negative byte
|
|
[InlineData(FocasManualAddressKind.Parameter, null, 1815, 32)] // parameter bits are 0-31
|
|
[InlineData(FocasManualAddressKind.Parameter, null, -5, null)]
|
|
public void Build_returns_empty_rather_than_an_unusable_address(
|
|
FocasManualAddressKind kind, string? letter, int number, int? bit)
|
|
=> FocasAddressBuilder.Build(kind, letter, number, bit).ShouldBeEmpty();
|
|
|
|
/// <summary>
|
|
/// The contract that matters, stated over the whole offered surface rather than a sample:
|
|
/// nothing the picker can compose is unreadable. Every PMC area the picker lists, at a
|
|
/// whole-word address and at each legal bit.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Every_offered_pmc_area_composes_a_parseable_address()
|
|
{
|
|
foreach (var letter in FocasAddressBuilder.PmcLetters)
|
|
{
|
|
foreach (int? bit in new int?[] { null, 0, 7 })
|
|
{
|
|
var built = FocasAddressBuilder.Build(FocasManualAddressKind.Pmc, letter, 12, bit);
|
|
|
|
built.ShouldNotBeEmpty($"PMC area '{letter}' bit '{bit}' composed nothing");
|
|
FocasAddress.TryParse(built).ShouldNotBeNull($"'{built}' does not parse");
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Gitea #647: ALARM: selectors ----
|
|
|
|
[Theory]
|
|
[InlineData("*", null, null, "ALARM:*")]
|
|
[InlineData("*", 401, null, "ALARM:*")] // numbers apply to a type scope only
|
|
[InlineData("OTHER", null, null, "ALARM:OTHER")]
|
|
[InlineData("Servo", null, null, "ALARM:Servo")]
|
|
[InlineData("Servo", 401, null, "ALARM:Servo/401")]
|
|
[InlineData("Servo", 401, 453, "ALARM:Servo/401-453")]
|
|
[InlineData("Servo", 401, 401, "ALARM:Servo/401")]
|
|
[InlineData("T9", 750, 758, "ALARM:T9/750-758")]
|
|
public void BuildAlarm_produces_a_selector_the_driver_can_parse(string scope, int? lo, int? hi, string expected)
|
|
{
|
|
var built = FocasAddressBuilder.BuildAlarm(scope, lo, hi);
|
|
|
|
built.ShouldBe(expected);
|
|
FocasAddress.TryParse(built)!.Kind.ShouldBe(FocasAreaKind.Alarm);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("Servo", 453, 401)] // reversed range
|
|
[InlineData("Bogus", null, null)] // not a type
|
|
[InlineData("", null, null)]
|
|
public void BuildAlarm_returns_empty_rather_than_an_unusable_selector(string scope, int? lo, int? hi)
|
|
=> FocasAddressBuilder.BuildAlarm(scope, lo, hi).ShouldBeEmpty();
|
|
|
|
[Fact]
|
|
public void Every_offered_alarm_scope_composes_a_parseable_selector()
|
|
{
|
|
foreach (var scope in FocasAddressBuilder.AlarmScopes)
|
|
FocasAddress.TryParse(FocasAddressBuilder.BuildAlarm(scope, 1, 2)).ShouldNotBeNull(scope);
|
|
}
|
|
}
|