Files
lmxopcua/tests/Core/ZB.MOM.WW.OtOpcUa.Configuration.Tests/DraftValidatorHeidenhainTests.cs
T
Joseph Doherty 2b0eed1a8f feat(heidenhain): controller alarms as native Part 9 conditions; rediscovery on identity change (#831)
- ALARM: selectors (HeidenhainAlarmSelector, Contracts): ALARM:*, ALARM:OTHER,
  ALARM:CLASS=<r>[/GROUP=<r>][/NUMBER=<r>] (<r> = n | lo..hi; negative numbers allowed).
  An ALARM: address and the cross-driver alarm object only work together — the factory
  refuses either alone, so editor, save path, CSV and deploy inspector agree. Data type
  fixed Boolean; never a value (ReadAsync BadNotSupported), never in a data subscription.
- HeidenhainDriver implements IAlarmSource: one condition per ALARM: tag at its RawPath,
  driven by the SAME allowlisted R_RI 27/28 walk on the control's single loop, on the new
  Polling.AlarmMs cadence (default 2000 ms, only for controls carrying an ALARM: tag).
  FOCAS #647 transition rules (Raise / Retrigger / Clear), identity = class+group+number+text,
  deterministic display order, told-record on the driver instance, recorded only while a
  handler listens. A failed walk (refusal, overflow, comms loss) is quality-only, never a clear.
- Acknowledge is LOCAL-ONLY: AcknowledgeAsync is a no-op success (FOCAS/MTConnect posture);
  Lsv2ReadOnlyPolicy is unchanged — no ack or write request exists.
- Opt-in per tag, no driver flag (FOCAS #647 / MTConnect #626 precedent); Alarms/* arrays unchanged.
- IRediscoverable: a reconnect whose R_VR identity (model / NC / PLC / options) differs from
  the previous session raises OnRediscoveryNeeded once (advisory re-browse chip). No extra traffic.
- AdminUI: Heidenhain joins NativeAlarmModel's alarm drivers; the typed editor fixes ALARM:
  tags at Boolean and seeds/drops the alarm object with the selector; driver form gains
  "Alarm conditions (ms)". (HeidenhainDriverFormModel also carries the #830 credential-notice change.)

Tests: HeidenhainAlarmTests (grammar, pairing, rules, simulator e2e with the captured iTNC 530
alarm record, local-only ack = zero bytes sent, quality-only outage/refusal, rediscovery),
Runtime.Tests HeidenhainNativeAlarmEndToEndTests (real DriverHostActor condition), a
DraftValidator case, and the NativeAlarmDriverSetGuard now scans the Heidenhain driver.
2026-11-11 01:37:41 -05:00

115 lines
5.5 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Configuration.Validation;
namespace ZB.MOM.WW.OtOpcUa.Configuration.Tests;
/// <summary>lmxopcua#803: the Heidenhain read-only, credential and endpoint deploy gates.</summary>
[Trait("Category", "Unit")]
public sealed class DraftValidatorHeidenhainTests
{
private static DriverInstance Driver(string config = "{}", string type = "Heidenhain") => new()
{
DriverInstanceId = "hh", ClusterId = "c", Name = "hh", DriverType = type, DriverConfig = config,
};
private static Device Dev(string config = """{"Host":"10.0.0.5"}""") => new()
{
DeviceId = "d1", DriverInstanceId = "hh", Name = "dev1", DeviceConfig = config,
};
private static Tag T(TagAccessLevel access) => new()
{
TagId = "t1", DeviceId = "d1", Name = "ExecState", DataType = "Int32", AccessLevel = access,
TagConfig = """{"address":"Status/ExecutionState"}""",
};
private static IReadOnlyList<ValidationError> Validate(DriverInstance d, Device dev, params Tag[] tags) =>
DraftValidator.Validate(new DraftSnapshot
{
GenerationId = 1, ClusterId = "c", DriverInstances = [d], Devices = [dev], Tags = tags,
});
[Fact]
public void A_read_only_tag_on_a_configured_control_is_valid()
=> Validate(Driver(), Dev(), T(TagAccessLevel.Read)).ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal));
[Fact]
public void A_ReadWrite_tag_is_refused_so_no_node_is_ever_writable()
{
var e = Validate(Driver(), Dev(), T(TagAccessLevel.ReadWrite)).Single(x => x.Code == HeidenhainDeployChecks.WritableCode);
e.Context.ShouldBe("t1");
e.Message.ShouldContain("read-only");
}
[Theory]
[InlineData("""{"Access":{"InspectPassword":"hunter2"}}""", "{\"Host\":\"10.0.0.5\"}")]
[InlineData("{}", """{"Host":"10.0.0.5","DncPassword":"hunter2"}""")]
public void A_literal_password_is_refused_without_echoing_it(string driverConfig, string deviceConfig)
{
var errors = Validate(Driver(driverConfig), Dev(deviceConfig), T(TagAccessLevel.Read));
errors.ShouldContain(e => e.Code == HeidenhainDeployChecks.CredentialCode);
errors.ShouldAllBe(e => !e.Message.Contains("hunter2"));
}
[Fact]
public void Secret_and_env_references_are_accepted()
=> Validate(Driver("""{"Access":{"InspectPassword":"secret:heidenhain/inspect","DncPassword":"env:OTOPCUA_HEIDENHAIN_DNC"}}"""), Dev(), T(TagAccessLevel.Read))
.ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal));
[Fact]
public void A_device_with_tags_but_no_endpoint_is_refused_while_an_empty_device_is_not()
{
Validate(Driver(), Dev("{}"), T(TagAccessLevel.Read)).ShouldContain(e => e.Code == HeidenhainDeployChecks.ConfigCode && e.Context == "d1");
Validate(Driver(), Dev("{}")).ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal));
}
[Fact]
public void A_tag_data_type_that_disagrees_with_the_published_type_is_refused()
{
var tag = T(TagAccessLevel.Read);
tag.DataType = "Float";
var e = Validate(Driver(), Dev(), tag).Single(x => x.Code == HeidenhainDeployChecks.DataTypeCode);
e.Context.ShouldBe("t1");
e.Message.ShouldContain("Int32");
var binding = T(TagAccessLevel.Read);
binding.TagConfig = """{"address":"PLC:DWORD:0","dataType":"Int64"}""";
binding.DataType = "Int64";
Validate(Driver(), Dev(), binding).ShouldNotContain(x => x.Code == HeidenhainDeployChecks.DataTypeCode);
}
[Theory]
[InlineData("env:ConnectionStrings__ConfigDb")]
[InlineData("secret:galaxy/inst1/apikey")]
public void An_out_of_namespace_reference_is_refused(string reference)
=> Validate(Driver("{\"Access\":{\"InspectPassword\":\"" + reference + "\"}}"), Dev(), T(TagAccessLevel.Read))
.ShouldContain(e => e.Code == HeidenhainDeployChecks.CredentialCode);
[Fact]
public void Out_of_range_settings_are_refused()
=> Validate(Driver("""{"Polling":{"MaxAlarms":999}}"""), Dev(), T(TagAccessLevel.Read))
.ShouldContain(e => e.Code == HeidenhainDeployChecks.ConfigCode);
[Fact]
public void An_alarm_condition_tag_deploys_as_Boolean_and_any_other_data_type_is_refused()
{
// lmxopcua#831: an ALARM: selector + the alarm object is a Part 9 condition; its Tag.DataType is fixed.
var tag = T(TagAccessLevel.Read);
tag.TagConfig = """{"address":"ALARM:CLASS=7","dataType":"Boolean","alarm":{"alarmType":"AlarmCondition","severity":800}}""";
tag.DataType = "Boolean";
Validate(Driver(), Dev(), tag).ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal));
tag.DataType = "Int32";
Validate(Driver(), Dev(), tag).ShouldContain(e => e.Code == HeidenhainDeployChecks.DataTypeCode && e.Message.Contains("Boolean"));
tag.DataType = "Boolean";
tag.AccessLevel = TagAccessLevel.ReadWrite;
Validate(Driver(), Dev(), tag).ShouldContain(e => e.Code == HeidenhainDeployChecks.WritableCode);
}
[Fact]
public void Other_drivers_are_unaffected()
=> Validate(Driver("""{"Access":{"InspectPassword":"x"}}""", type: "Modbus"), Dev(), T(TagAccessLevel.ReadWrite))
.ShouldNotContain(e => e.Code.StartsWith("Heidenhain", StringComparison.Ordinal));
}