using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire; namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests; /// Gitea #647 — the ALARM: address grammar, shared by the editor, the deploy gate and the driver. [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(); } /// "A" is a PMC letter — an ALARM: address must be owned by the alarm rule, never mis-read as PMC. [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()) 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(); } /// /// 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. /// [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(() => FocasWireClient.ParseAlarms(new byte[77], 32)); Should.Throw(() => FocasWireClient.ParseAlarms(new byte[12], 32)); } /// /// 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. /// [Fact] public async Task Wire_client_ReadAlarms_throws_when_not_connected_rather_than_returning_empty() { using var client = new WireFocasClient(); await Should.ThrowAsync(() => client.ReadAlarmsAsync(CancellationToken.None)); } }