Merge b640-642: typed native-alarm editor in RawTagModal (#642)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
+10
@@ -68,6 +68,16 @@ removed later.
|
|||||||
auto-creates nested groups; typed columns win over `TagConfigJson` on conflict (flagged in
|
auto-creates nested groups; typed columns win over `TagConfigJson` on conflict (flagged in
|
||||||
the review grid). **Import is all-or-nothing** — any invalid row blocks the whole commit.
|
the review grid). **Import is all-or-nothing** — any invalid row blocks the whole commit.
|
||||||
Export round-trips the same shape.
|
Export round-trips the same shape.
|
||||||
|
- **Native alarm section (Gitea #642)** — the Edit-tag modal carries a shared `NativeAlarmEditor`
|
||||||
|
below the per-driver editor / JSON textarea: a "Native alarm (Part 9 condition)" switch plus
|
||||||
|
`alarmType`, `severity` (1..1000) and `historizeToAveva` (default/yes/no), writing the
|
||||||
|
driver-agnostic `alarm` object of `TagConfig`. Offered only for `IAlarmSource` drivers (MTConnect,
|
||||||
|
AbCip, FOCAS, OpcUaClient, Galaxy — `NativeAlarmModel.SupportsNativeAlarms`); on any other driver an
|
||||||
|
existing `alarm` object is shown with a "no effect" note and a Remove button, never silently
|
||||||
|
dropped. Switching it off REMOVES the `alarm` key. Validation is advisory only (warnings) — the
|
||||||
|
deploy gate has no `alarm` rule and the server clamps severity, so the editor is never stricter.
|
||||||
|
Every edit re-parses the latest `ConfigJson` (`NativeAlarmModel.Edit`), so it composes with the
|
||||||
|
driver editor in either order. Not yet on the manual-entry grid (use JSON/CSV there).
|
||||||
- **Browse device…** — enabled per the two-tier resolution (bespoke `IDriverBrowser` →
|
- **Browse device…** — enabled per the two-tier resolution (bespoke `IDriverBrowser` →
|
||||||
universal `DiscoveryDriverBrowser` when the driver's `ITagDiscovery.SupportsOnlineDiscovery`
|
universal `DiscoveryDriverBrowser` when the driver's `ITagDiscovery.SupportsOnlineDiscovery`
|
||||||
is true → grayed out otherwise). Multi-selected leaves become raw `Tag` rows under the
|
is true → grayed out otherwise). Multi-selected leaves become raw `Tag` rows under the
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Forms
|
@using Microsoft.AspNetCore.Components.Forms
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns
|
||||||
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Uns.TagEditors
|
||||||
@using ZB.MOM.WW.OtOpcUa.Commons.Types
|
@using ZB.MOM.WW.OtOpcUa.Commons.Types
|
||||||
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
@using ZB.MOM.WW.OtOpcUa.Configuration.Enums
|
||||||
@inject IRawTreeService Svc
|
@inject IRawTreeService Svc
|
||||||
@@ -90,6 +91,9 @@
|
|||||||
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
|
<div class="form-text">Schemaless per driver type. Validated server-side at deploy.</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
<NativeAlarmEditor ConfigJson="@_form.TagConfig"
|
||||||
|
ConfigJsonChanged="@(v => _form.TagConfig = v)"
|
||||||
|
DriverType="@DriverType" />
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(_error))
|
@if (!string.IsNullOrWhiteSpace(_error))
|
||||||
|
|||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
@* Shared native-alarm section (Gitea #642): authors the driver-agnostic TagConfig `alarm` object
|
||||||
|
(alarmType / severity / historizeToAveva) for a raw tag. A thin shell over NativeAlarmModel — all rules
|
||||||
|
live there. It sits OUTSIDE the per-driver typed editor (Galaxy has none) and writes the same ConfigJson.
|
||||||
|
|
||||||
|
Order-safety: every change goes through NativeAlarmModel.Edit(ConfigJson, ...), which re-parses the
|
||||||
|
LATEST json, so it can never clobber a key the driver editor just wrote; the driver models in turn
|
||||||
|
preserve the `alarm` key (pinned by NativeAlarmEditorModelTests).
|
||||||
|
|
||||||
|
Plain input/select + @onchange (no InputNumber/EditForm binding — see the AdminUI binding pitfalls). *@
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||||
|
|
||||||
|
@if (_m.IsAlarm || _supported)
|
||||||
|
{
|
||||||
|
<div class="mb-3 border rounded p-2" data-testid="native-alarm-editor">
|
||||||
|
@if (!_supported)
|
||||||
|
{
|
||||||
|
@* Never hide an authored alarm silently: show it and offer removal rather than dropping it. *@
|
||||||
|
<div class="text-warning small">
|
||||||
|
This tag carries an <span class="mono">alarm</span> object, but driver type
|
||||||
|
<span class="mono">@DriverType</span> does not raise native alarms, so it has no effect.
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary mt-1" id="native-alarm-remove"
|
||||||
|
@onclick="@(() => Update(m => m.IsAlarm = false))">Remove alarm object</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input id="native-alarm-enabled" type="checkbox" class="form-check-input"
|
||||||
|
checked="@_m.IsAlarm"
|
||||||
|
@onchange="@(e => Update(m => m.IsAlarm = e.Value is true))" />
|
||||||
|
<label class="form-check-label" for="native-alarm-enabled">Native alarm (Part 9 condition)</label>
|
||||||
|
</div>
|
||||||
|
@if (_m.IsAlarm)
|
||||||
|
{
|
||||||
|
<div class="row g-2 mt-1">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="native-alarm-type">Alarm type</label>
|
||||||
|
<select id="native-alarm-type" class="form-select form-select-sm" value="@_m.AlarmType"
|
||||||
|
@onchange="@(e => Update(m => m.AlarmType = e.Value?.ToString() ?? "AlarmCondition"))">
|
||||||
|
@foreach (var t in NativeAlarmModel.KnownAlarmTypes) { <option value="@t">@t</option> }
|
||||||
|
@if (!NativeAlarmModel.KnownAlarmTypes.Contains(_m.AlarmType))
|
||||||
|
{
|
||||||
|
<option value="@_m.AlarmType">@_m.AlarmType (custom)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="native-alarm-severity">Severity (1..1000)</label>
|
||||||
|
<input id="native-alarm-severity" type="number" min="1" max="1000" class="form-control form-control-sm"
|
||||||
|
value="@_m.Severity"
|
||||||
|
@onchange="@(e => Update(m => m.Severity = int.TryParse(e.Value?.ToString(), out var s) ? s : 500))" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="native-alarm-historize">Historize to AVEVA</label>
|
||||||
|
<select id="native-alarm-historize" class="form-select form-select-sm"
|
||||||
|
value="@HistorizeValue(_m.HistorizeToAveva)"
|
||||||
|
@onchange="@(e => Update(m => m.HistorizeToAveva = ParseHistorize(e.Value?.ToString())))">
|
||||||
|
<option value="default">Default (yes)</option>
|
||||||
|
<option value="yes">Yes</option>
|
||||||
|
<option value="no">No (live alerts only)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@foreach (var w in _m.Warnings())
|
||||||
|
{
|
||||||
|
<div class="text-warning small mt-1">@w</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
/// <summary>The tag's raw TagConfig JSON (supports <c>@bind-ConfigJson</c>).</summary>
|
||||||
|
[Parameter] public string? ConfigJson { get; set; }
|
||||||
|
/// <summary>Two-way config callback.</summary>
|
||||||
|
[Parameter] public EventCallback<string> ConfigJsonChanged { get; set; }
|
||||||
|
/// <summary>Driver type of the owning device; gates whether the editor is offered.</summary>
|
||||||
|
[Parameter] public string? DriverType { get; set; }
|
||||||
|
|
||||||
|
private NativeAlarmModel _m = new();
|
||||||
|
private bool _supported;
|
||||||
|
private string? _lastConfigJson;
|
||||||
|
|
||||||
|
protected override void OnParametersSet()
|
||||||
|
{
|
||||||
|
_supported = NativeAlarmModel.SupportsNativeAlarms(DriverType);
|
||||||
|
if (ConfigJson == _lastConfigJson) { return; }
|
||||||
|
_lastConfigJson = ConfigJson;
|
||||||
|
_m = NativeAlarmModel.FromJson(ConfigJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string HistorizeValue(bool? v) => v is null ? "default" : v.Value ? "yes" : "no";
|
||||||
|
|
||||||
|
private static bool? ParseHistorize(string? v) => v switch { "yes" => true, "no" => false, _ => null };
|
||||||
|
|
||||||
|
private async Task Update(Action<NativeAlarmModel> apply)
|
||||||
|
{
|
||||||
|
var json = NativeAlarmModel.Edit(ConfigJson, apply);
|
||||||
|
_lastConfigJson = json;
|
||||||
|
_m = NativeAlarmModel.FromJson(json);
|
||||||
|
await ConfigJsonChanged.InvokeAsync(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,14 +90,72 @@ public sealed class NativeAlarmModel
|
|||||||
// Re-attach (no-op when it was already the same node; required when we created a fresh one).
|
// Re-attach (no-op when it was already the same node; required when we created a fresh one).
|
||||||
_root["alarm"] = alarm;
|
_root["alarm"] = alarm;
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Toggled off (#642): the alarm object must go, or a "disabled" tag would still deploy as a condition.
|
||||||
|
_root.Remove("alarm");
|
||||||
|
}
|
||||||
|
|
||||||
return TagConfigJson.Serialize(_root);
|
return TagConfigJson.Serialize(_root);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
|
||||||
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
|
/// <returns>An error message describing the validation failure, or null when the model is valid.</returns>
|
||||||
|
/// <remarks>Deliberately never errors: the deploy gate has no <c>alarm</c> rule and
|
||||||
|
/// <c>TagConfigIntent.ParseAlarm</c> accepts any alarmType string and any severity (clamped 1..1000 at
|
||||||
|
/// publish), so rejecting here would be stricter than the gate. Out-of-range input is advisory —
|
||||||
|
/// see <see cref="Warnings"/>.</remarks>
|
||||||
public string? Validate() => null;
|
public string? Validate() => null;
|
||||||
|
|
||||||
|
/// <summary>Advisory messages for values the server will accept but silently normalise.</summary>
|
||||||
|
/// <returns>Zero or more warning strings.</returns>
|
||||||
|
public IEnumerable<string> Warnings()
|
||||||
|
{
|
||||||
|
if (!IsAlarm) { yield break; }
|
||||||
|
if (Severity is < 1 or > 1000)
|
||||||
|
{
|
||||||
|
yield return $"Severity {Severity} is outside 1..1000; the server clamps it.";
|
||||||
|
}
|
||||||
|
if (!KnownAlarmTypes.Contains(AlarmType))
|
||||||
|
{
|
||||||
|
yield return $"Alarm type '{AlarmType}' is not one of {string.Join("/", KnownAlarmTypes)}; the server treats it as AlarmCondition.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The Part 9 condition subtypes the server distinguishes (OffNormal/Discrete; the rest map to the base condition).</summary>
|
||||||
|
public static readonly IReadOnlyList<string> KnownAlarmTypes =
|
||||||
|
["AlarmCondition", "OffNormalAlarm", "DiscreteAlarm", "LimitAlarm"];
|
||||||
|
|
||||||
|
/// <summary>Driver types that implement <c>IAlarmSource</c> (so a TagConfig <c>alarm</c> object is
|
||||||
|
/// meaningful). The alarm object itself is driver-agnostic; this only gates the editor's visibility.</summary>
|
||||||
|
private static readonly HashSet<string> AlarmDriverTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
Core.Abstractions.DriverTypeNames.MTConnect, Core.Abstractions.DriverTypeNames.AbCip,
|
||||||
|
Core.Abstractions.DriverTypeNames.FOCAS, Core.Abstractions.DriverTypeNames.OpcUaClient,
|
||||||
|
Core.Abstractions.DriverTypeNames.Galaxy,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>True when the driver type can emit native alarms.</summary>
|
||||||
|
/// <param name="driverType">Driver type name.</param>
|
||||||
|
/// <returns>Whether the native-alarm editor applies.</returns>
|
||||||
|
public static bool SupportsNativeAlarms(string? driverType)
|
||||||
|
=> driverType is not null && AlarmDriverTypes.Contains(driverType);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Order-safe edit: re-parses the LATEST <paramref name="json"/> (so a keystroke in the per-driver
|
||||||
|
/// editor is never overwritten by a stale cached copy), applies <paramref name="mutate"/>, serialises.
|
||||||
|
/// Only the <c>alarm</c> key is touched; every other key is preserved verbatim.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="json">Current TagConfig JSON.</param>
|
||||||
|
/// <param name="mutate">Edit to apply to the freshly-parsed model.</param>
|
||||||
|
/// <returns>The new TagConfig JSON.</returns>
|
||||||
|
public static string Edit(string? json, Action<NativeAlarmModel> mutate)
|
||||||
|
{
|
||||||
|
var m = FromJson(json);
|
||||||
|
mutate(m);
|
||||||
|
return m.ToJson();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Default <c>alarmType</c> seeded when an alarm-bearing attribute is picked but the
|
/// <summary>Default <c>alarmType</c> seeded when an alarm-bearing attribute is picked but the
|
||||||
/// TagConfig has no <c>alarm</c> object yet. Galaxy's <c>AlarmExtension</c> is a boolean
|
/// TagConfig has no <c>alarm</c> object yet. Galaxy's <c>AlarmExtension</c> is a boolean
|
||||||
/// off-normal condition, so <c>OffNormalAlarm</c> is the natural Part 9 subtype.</summary>
|
/// off-normal condition, so <c>OffNormalAlarm</c> is the natural Part 9 subtype.</summary>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
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 guard: <see cref="NativeAlarmModel.SupportsNativeAlarms"/> is a hand-maintained set; it must equal
|
||||||
|
/// the drivers whose concrete types implement <see cref="IAlarmSource"/>. Discovery is by reflection over the
|
||||||
|
/// driver assemblies deployed beside this test; an assembly's driver type is the <see cref="DriverTypeNames"/>
|
||||||
|
/// constant whose FIELD NAME equals the assembly suffix (Driver.Galaxy -> Galaxy = "GalaxyMxGateway").
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NativeAlarmDriverSetGuardTests
|
||||||
|
{
|
||||||
|
private static readonly string[] NonDriverSuffixes = [".Contracts", ".Addressing", ".Cli", ".Historian.Gateway"];
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SupportsNativeAlarms_matches_the_drivers_implementing_IAlarmSource()
|
||||||
|
{
|
||||||
|
var binDir = Path.GetDirectoryName(typeof(NativeAlarmDriverSetGuardTests).Assembly.Location)!;
|
||||||
|
var constants = typeof(DriverTypeNames).GetFields(BindingFlags.Public | BindingFlags.Static)
|
||||||
|
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
|
||||||
|
.ToDictionary(f => f.Name, f => (string)f.GetRawConstantValue()!, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
const string prefix = "ZB.MOM.WW.OtOpcUa.Driver.";
|
||||||
|
var implementers = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var scanned = 0;
|
||||||
|
foreach (var dll in Directory.GetFiles(binDir, prefix + "*.dll"))
|
||||||
|
{
|
||||||
|
var name = Path.GetFileNameWithoutExtension(dll);
|
||||||
|
if (NonDriverSuffixes.Any(s => name.EndsWith(s, StringComparison.Ordinal))) { continue; }
|
||||||
|
Type[] types;
|
||||||
|
try { types = Assembly.LoadFrom(dll).GetTypes(); }
|
||||||
|
catch (ReflectionTypeLoadException ex) { types = ex.Types.Where(t => t is not null).ToArray()!; }
|
||||||
|
scanned++;
|
||||||
|
if (!types.Any(t => t.IsClass && !t.IsAbstract && typeof(IAlarmSource).IsAssignableFrom(t))) { continue; }
|
||||||
|
var suffix = name[prefix.Length..];
|
||||||
|
constants.TryGetValue(suffix, out var typeName).ShouldBeTrue($"no DriverTypeNames constant named '{suffix}' for {name}");
|
||||||
|
implementers.Add(typeName!);
|
||||||
|
}
|
||||||
|
|
||||||
|
scanned.ShouldBeGreaterThan(5, "driver assemblies were not deployed to the test output; the guard would be vacuous");
|
||||||
|
implementers.Count.ShouldBeGreaterThan(0);
|
||||||
|
|
||||||
|
foreach (var typeName in constants.Values)
|
||||||
|
{
|
||||||
|
NativeAlarmModel.SupportsNativeAlarms(typeName).ShouldBe(implementers.Contains(typeName),
|
||||||
|
$"NativeAlarmModel.AlarmDriverTypes disagrees with IAlarmSource for driver type '{typeName}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -40,6 +40,10 @@
|
|||||||
<!-- #576: the AB form tests deserialise the saved JSON through the drivers' own factory parse. -->
|
<!-- #576: the AB form tests deserialise the saved JSON through the drivers' own factory parse. -->
|
||||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbCip\ZB.MOM.WW.OtOpcUa.Driver.AbCip.csproj"/>
|
||||||
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy\ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.FOCAS\ZB.MOM.WW.OtOpcUa.Driver.FOCAS.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj"/>
|
||||||
|
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.csproj"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user