feat(adminui): typed native-alarm section in the raw tag modal (#642)

NativeAlarmModel: IsAlarm=false now removes the alarm key; advisory Warnings, driver gating, order-safe Edit.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 16:52:55 -04:00
parent a8b07ae12d
commit 6951faa08f
6 changed files with 305 additions and 0 deletions
@@ -0,0 +1,106 @@
using System.Text.Json.Nodes;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>Gitea #642 — the shared NativeAlarmEditor's pure-model behaviour: remove-on-off, composition
/// with the per-driver editors' models, validation parity with the deploy gate, driver-type gating.</summary>
public sealed class NativeAlarmEditorModelTests
{
[Fact]
public void ToJson_with_IsAlarm_false_removes_the_alarm_key_and_keeps_the_rest()
{
var m = NativeAlarmModel.FromJson("""{"FullName":"X.Y","alarm":{"alarmType":"LimitAlarm","severity":300,"extra":1}}""");
m.IsAlarm = false;
var o = JsonNode.Parse(m.ToJson())!.AsObject();
o.ContainsKey("alarm").ShouldBeFalse();
o["FullName"]!.GetValue<string>().ShouldBe("X.Y");
}
[Fact]
public void Toggle_off_then_on_again_starts_from_defaults()
{
var off = NativeAlarmModel.Edit("""{"alarm":{"severity":300}}""", m => m.IsAlarm = false);
var on = NativeAlarmModel.Edit(off, m => m.IsAlarm = true);
var back = NativeAlarmModel.FromJson(on);
back.IsAlarm.ShouldBeTrue();
back.Severity.ShouldBe(500);
back.AlarmType.ShouldBe("AlarmCondition");
}
[Fact]
public void Edit_preserves_unknown_keys_at_both_levels()
{
var json = NativeAlarmModel.Edit("""{"FullName":"A","zz":1,"alarm":{"alarmType":"OffNormalAlarm","custom":"k"}}""",
m => m.Severity = 900);
var o = JsonNode.Parse(json)!.AsObject();
o["zz"]!.GetValue<int>().ShouldBe(1);
o["alarm"]!["custom"]!.GetValue<string>().ShouldBe("k");
o["alarm"]!["severity"]!.GetValue<int>().ShouldBe(900);
}
[Fact]
public void Alarm_and_mtconnect_driver_editor_compose_in_either_order()
{
const string start = """{"fullName":"Xact"}""";
// alarm first, then driver editor (driver model re-parses the alarm-bearing JSON)
var a1 = NativeAlarmModel.Edit(start, m => { m.IsAlarm = true; m.Severity = 800; });
var d1 = MTConnectTagConfigModel.FromJson(a1);
d1.FullName = "Yact";
var r1 = d1.ToJson();
// driver editor first, then alarm
var d2 = MTConnectTagConfigModel.FromJson(start);
d2.FullName = "Yact";
var r2 = NativeAlarmModel.Edit(d2.ToJson(), m => { m.IsAlarm = true; m.Severity = 800; });
foreach (var r in new[] { r1, r2 })
{
var o = JsonNode.Parse(r)!.AsObject();
o["fullName"]!.GetValue<string>().ShouldBe("Yact");
o["alarm"]!["severity"]!.GetValue<int>().ShouldBe(800);
}
}
[Theory]
[InlineData("Modbus")]
[InlineData("S7")]
[InlineData("AbCip")]
[InlineData("AbLegacy")]
[InlineData("TwinCAT")]
[InlineData("FOCAS")]
[InlineData("OpcUaClient")]
[InlineData("Sql")]
[InlineData("Mqtt")]
[InlineData("MTConnect")]
public void Every_typed_driver_model_preserves_an_alarm_object(string driverType)
{
const string json = """{"alarm":{"alarmType":"LimitAlarm","severity":123}}""";
var round = TagConfigRoundTrip.Through(driverType, json);
NativeAlarmModel.FromJson(round).Severity.ShouldBe(123);
}
[Fact]
public void Validate_is_not_stricter_than_the_deploy_gate()
{
// The gate/parser accepts any severity (clamped 1..1000 at publish) and any alarmType string.
var m = NativeAlarmModel.FromJson("""{"alarm":{"alarmType":"Whatever","severity":5000}}""");
m.Validate().ShouldBeNull();
m.Warnings().ShouldNotBeEmpty(); // advisory only
}
[Fact]
public void SupportsNativeAlarms_covers_exactly_the_IAlarmSource_drivers()
{
foreach (var t in new[] { DriverTypeNames.MTConnect, DriverTypeNames.AbCip, DriverTypeNames.FOCAS,
DriverTypeNames.OpcUaClient, DriverTypeNames.Galaxy })
NativeAlarmModel.SupportsNativeAlarms(t).ShouldBeTrue(t);
foreach (var t in new[] { DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.Mqtt, null })
NativeAlarmModel.SupportsNativeAlarms(t).ShouldBeFalse(t);
}
}
@@ -0,0 +1,23 @@
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>Test helper: FromJson→ToJson through the driver's typed TagConfig model.</summary>
internal static class TagConfigRoundTrip
{
public static string Through(string driverType, string json) => driverType switch
{
DriverTypeNames.Modbus => ModbusTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.S7 => S7TagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.AbCip => AbCipTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.AbLegacy => AbLegacyTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.TwinCAT => TwinCATTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.FOCAS => FocasTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.OpcUaClient => OpcUaClientTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.Sql => SqlTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.Mqtt => MqttTagConfigModel.FromJson(json).ToJson(),
DriverTypeNames.MTConnect => MTConnectTagConfigModel.FromJson(json).ToJson(),
_ => throw new ArgumentOutOfRangeException(nameof(driverType)),
};
}