diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor new file mode 100644 index 00000000..a845ebba --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbCipTagConfigEditor.razor @@ -0,0 +1,42 @@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors +@using ZB.MOM.WW.OtOpcUa.Driver.AbCip + +
+
+
+
+
+
+
+
+ +@code { + [Parameter] public string? ConfigJson { get; set; } + [Parameter] public EventCallback ConfigJsonChanged { get; set; } + + private AbCipTagConfigModel _m = new(); + private string? _lastConfigJson; + + // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render + // (Blazor Server live-status pushes do this) can't reset the user's in-progress edits. + protected override void OnParametersSet() + { + if (ConfigJson == _lastConfigJson) { return; } + _lastConfigJson = ConfigJson; + _m = AbCipTagConfigModel.FromJson(ConfigJson); + } + + private static int ParseInt(object? v, int fallback = 0) => int.TryParse(v?.ToString(), out var i) ? i : fallback; + + // TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back. + private static TEnum ParseEnum(object? v, TEnum fallback) where TEnum : struct, Enum + => Enum.TryParse(v?.ToString(), out var r) ? r : fallback; + + private async Task Update(Action apply) + { + apply(); + await ConfigJsonChanged.InvokeAsync(_m.ToJson()); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor new file mode 100644 index 00000000..66526cf0 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/AbLegacyTagConfigEditor.razor @@ -0,0 +1,42 @@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors +@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy + +
+
+
+
+
+
+
+
+ +@code { + [Parameter] public string? ConfigJson { get; set; } + [Parameter] public EventCallback ConfigJsonChanged { get; set; } + + private AbLegacyTagConfigModel _m = new(); + private string? _lastConfigJson; + + // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render + // (Blazor Server live-status pushes do this) can't reset the user's in-progress edits. + protected override void OnParametersSet() + { + if (ConfigJson == _lastConfigJson) { return; } + _lastConfigJson = ConfigJson; + _m = AbLegacyTagConfigModel.FromJson(ConfigJson); + } + + private static int ParseInt(object? v, int fallback = 0) => int.TryParse(v?.ToString(), out var i) ? i : fallback; + + // TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back. + private static TEnum ParseEnum(object? v, TEnum fallback) where TEnum : struct, Enum + => Enum.TryParse(v?.ToString(), out var r) ? r : fallback; + + private async Task Update(Action apply) + { + apply(); + await ConfigJsonChanged.InvokeAsync(_m.ToJson()); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor new file mode 100644 index 00000000..cff7e878 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/FocasTagConfigEditor.razor @@ -0,0 +1,42 @@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors +@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS + +
+
+
+
+
+
+
+
+ +@code { + [Parameter] public string? ConfigJson { get; set; } + [Parameter] public EventCallback ConfigJsonChanged { get; set; } + + private FocasTagConfigModel _m = new(); + private string? _lastConfigJson; + + // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render + // (Blazor Server live-status pushes do this) can't reset the user's in-progress edits. + protected override void OnParametersSet() + { + if (ConfigJson == _lastConfigJson) { return; } + _lastConfigJson = ConfigJson; + _m = FocasTagConfigModel.FromJson(ConfigJson); + } + + private static int ParseInt(object? v, int fallback = 0) => int.TryParse(v?.ToString(), out var i) ? i : fallback; + + // TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back. + private static TEnum ParseEnum(object? v, TEnum fallback) where TEnum : struct, Enum + => Enum.TryParse(v?.ToString(), out var r) ? r : fallback; + + private async Task Update(Action apply) + { + apply(); + await ConfigJsonChanged.InvokeAsync(_m.ToJson()); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor new file mode 100644 index 00000000..8f8120fd --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/S7TagConfigEditor.razor @@ -0,0 +1,42 @@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors +@using ZB.MOM.WW.OtOpcUa.Driver.S7 + +
+
+
+
+
+
+
+
+ +@code { + [Parameter] public string? ConfigJson { get; set; } + [Parameter] public EventCallback ConfigJsonChanged { get; set; } + + private S7TagConfigModel _m = new(); + private string? _lastConfigJson; + + // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render + // (Blazor Server live-status pushes do this) can't reset the user's in-progress edits. + protected override void OnParametersSet() + { + if (ConfigJson == _lastConfigJson) { return; } + _lastConfigJson = ConfigJson; + _m = S7TagConfigModel.FromJson(ConfigJson); + } + + private static int ParseInt(object? v, int fallback = 0) => int.TryParse(v?.ToString(), out var i) ? i : fallback; + + // TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back. + private static TEnum ParseEnum(object? v, TEnum fallback) where TEnum : struct, Enum + => Enum.TryParse(v?.ToString(), out var r) ? r : fallback; + + private async Task Update(Action apply) + { + apply(); + await ConfigJsonChanged.InvokeAsync(_m.ToJson()); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor new file mode 100644 index 00000000..b8371ab6 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/TwinCATTagConfigEditor.razor @@ -0,0 +1,42 @@ +@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors +@using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT + +
+
+
+
+
+
+
+
+ +@code { + [Parameter] public string? ConfigJson { get; set; } + [Parameter] public EventCallback ConfigJsonChanged { get; set; } + + private TwinCATTagConfigModel _m = new(); + private string? _lastConfigJson; + + // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render + // (Blazor Server live-status pushes do this) can't reset the user's in-progress edits. + protected override void OnParametersSet() + { + if (ConfigJson == _lastConfigJson) { return; } + _lastConfigJson = ConfigJson; + _m = TwinCATTagConfigModel.FromJson(ConfigJson); + } + + private static int ParseInt(object? v, int fallback = 0) => int.TryParse(v?.ToString(), out var i) ? i : fallback; + + // TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back. + private static TEnum ParseEnum(object? v, TEnum fallback) where TEnum : struct, Enum + => Enum.TryParse(v?.ToString(), out var r) ? r : fallback; + + private async Task Update(Action apply) + { + apply(); + await ConfigJsonChanged.InvokeAsync(_m.ToJson()); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbCipTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbCipTagConfigModel.cs new file mode 100644 index 00000000..449d38da --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbCipTagConfigModel.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// Typed working model for an AB CIP / EtherNet-IP tag's TagConfig JSON (the driver-specific addressing/encoding +/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save. +public sealed class AbCipTagConfigModel +{ + /// Which device (AbCipDeviceOptions.HostAddress) this tag lives on. Optional. + public string DeviceHostAddress { get; set; } = ""; + + /// Logix symbolic path (controller or program scope). Required. + public string TagPath { get; set; } = ""; + + /// Logix atomic type, or AbCipDataType.Structure for UDT-typed tags. + public AbCipDataType DataType { get; set; } = AbCipDataType.DInt; + + private JsonObject _bag = new(); + + /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining + /// every original key (so fields this editor doesn't expose survive a load→save). + public static AbCipTagConfigModel FromJson(string? json) + { + var o = TagConfigJson.ParseOrNew(json); + return new AbCipTagConfigModel + { + DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "", + TagPath = TagConfigJson.GetString(o, "tagPath") ?? "", + DataType = TagConfigJson.GetEnum(o, "dataType", AbCipDataType.DInt), + _bag = o, + }; + } + + /// Serialises this model back to a TagConfig JSON string, writing the exposed fields + /// (enum as its name string; the optional host address omitted when blank) over the preserved key bag. + public string ToJson() + { + TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim()); + TagConfigJson.Set(_bag, "tagPath", TagPath.Trim()); + TagConfigJson.Set(_bag, "dataType", DataType); + return TagConfigJson.Serialize(_bag); + } + + /// Validation hook; returns an error message or null when the model is valid. + public string? Validate() + => string.IsNullOrWhiteSpace(TagPath) ? "Tag path is required." : null; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs new file mode 100644 index 00000000..d35fb320 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// Typed working model for an AB Legacy (PCCC) tag's TagConfig JSON (the driver-specific addressing/encoding +/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save. +public sealed class AbLegacyTagConfigModel +{ + /// Which device (AbLegacyDeviceOptions.HostAddress) this tag lives on. Optional. + public string DeviceHostAddress { get; set; } = ""; + + /// Canonical PCCC file-address string, e.g. N7:0, B3:0/0. Required. + public string Address { get; set; } = ""; + + /// PCCC data type that maps onto SLC / MicroLogix / PLC-5 files. + public AbLegacyDataType DataType { get; set; } = AbLegacyDataType.Int; + + private JsonObject _bag = new(); + + /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining + /// every original key (so fields this editor doesn't expose survive a load→save). + public static AbLegacyTagConfigModel FromJson(string? json) + { + var o = TagConfigJson.ParseOrNew(json); + return new AbLegacyTagConfigModel + { + DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "", + Address = TagConfigJson.GetString(o, "address") ?? "", + DataType = TagConfigJson.GetEnum(o, "dataType", AbLegacyDataType.Int), + _bag = o, + }; + } + + /// Serialises this model back to a TagConfig JSON string, writing the exposed fields + /// (enum as its name string; the optional host address omitted when blank) over the preserved key bag. + public string ToJson() + { + TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim()); + TagConfigJson.Set(_bag, "address", Address.Trim()); + TagConfigJson.Set(_bag, "dataType", DataType); + return TagConfigJson.Serialize(_bag); + } + + /// Validation hook; returns an error message or null when the model is valid. + public string? Validate() + => string.IsNullOrWhiteSpace(Address) ? "Address is required." : null; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/FocasTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/FocasTagConfigModel.cs new file mode 100644 index 00000000..1cd44d8e --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/FocasTagConfigModel.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// Typed working model for a FOCAS (FANUC CNC) tag's TagConfig JSON (the driver-specific addressing/encoding +/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save. +public sealed class FocasTagConfigModel +{ + /// Which device (FocasDeviceOptions.HostAddress) this tag lives on. Optional. + public string DeviceHostAddress { get; set; } = ""; + + /// Canonical FOCAS address string, e.g. X0.0, R100, PARAM:1815/0, MACRO:500. Required. + public string Address { get; set; } = ""; + + /// FOCAS atomic data type. + public FocasDataType DataType { get; set; } = FocasDataType.Int32; + + private JsonObject _bag = new(); + + /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining + /// every original key (so fields this editor doesn't expose survive a load→save). + public static FocasTagConfigModel FromJson(string? json) + { + var o = TagConfigJson.ParseOrNew(json); + return new FocasTagConfigModel + { + DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "", + Address = TagConfigJson.GetString(o, "address") ?? "", + DataType = TagConfigJson.GetEnum(o, "dataType", FocasDataType.Int32), + _bag = o, + }; + } + + /// Serialises this model back to a TagConfig JSON string, writing the exposed fields + /// (enum as its name string; the optional host address omitted when blank) over the preserved key bag. + public string ToJson() + { + TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim()); + TagConfigJson.Set(_bag, "address", Address.Trim()); + TagConfigJson.Set(_bag, "dataType", DataType); + return TagConfigJson.Serialize(_bag); + } + + /// Validation hook; returns an error message or null when the model is valid. + public string? Validate() + => string.IsNullOrWhiteSpace(Address) ? "Address is required." : null; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs new file mode 100644 index 00000000..611aa4df --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.Driver.S7; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// Typed working model for a Siemens S7 tag's TagConfig JSON (the driver-specific addressing/encoding +/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save. +public sealed class S7TagConfigModel +{ + /// S7 address string, e.g. DB1.DBW0, M0.0, I0.0, QD4. Required. + public string Address { get; set; } = ""; + + /// Logical data type — drives the underlying S7.Net read/write width. + public S7DataType DataType { get; set; } = S7DataType.Int16; + + /// For DataType = String: S7-string max length (max 254). + public int StringLength { get; set; } + + private JsonObject _bag = new(); + + /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining + /// every original key (so fields this editor doesn't expose survive a load→save). + public static S7TagConfigModel FromJson(string? json) + { + var o = TagConfigJson.ParseOrNew(json); + return new S7TagConfigModel + { + Address = TagConfigJson.GetString(o, "address") ?? "", + DataType = TagConfigJson.GetEnum(o, "dataType", S7DataType.Int16), + StringLength = TagConfigJson.GetInt(o, "stringLength"), + _bag = o, + }; + } + + /// Serialises this model back to a TagConfig JSON string, writing the exposed fields + /// (enum as its name string) over the preserved key bag. + public string ToJson() + { + TagConfigJson.Set(_bag, "address", Address.Trim()); + TagConfigJson.Set(_bag, "dataType", DataType); + TagConfigJson.Set(_bag, "stringLength", StringLength); + return TagConfigJson.Serialize(_bag); + } + + /// Validation hook; returns an error message or null when the model is valid. + public string? Validate() + => string.IsNullOrWhiteSpace(Address) ? "Address is required." : null; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TwinCATTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TwinCATTagConfigModel.cs new file mode 100644 index 00000000..69507a31 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TwinCATTagConfigModel.cs @@ -0,0 +1,48 @@ +using System.Text.Json.Nodes; +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; + +/// Typed working model for a TwinCAT ADS tag's TagConfig JSON (the driver-specific addressing/encoding +/// fields; name/writable live on the Tag entity). Preserves unrecognised JSON keys across a load→save. +public sealed class TwinCATTagConfigModel +{ + /// Which device (TwinCATDeviceOptions.HostAddress) this tag lives on. Optional. + public string DeviceHostAddress { get; set; } = ""; + + /// Full TwinCAT symbolic name, e.g. MAIN.bStart, GVL.Counter. Required. + public string SymbolPath { get; set; } = ""; + + /// TwinCAT / IEC 61131-3 atomic data type. + public TwinCATDataType DataType { get; set; } = TwinCATDataType.DInt; + + private JsonObject _bag = new(); + + /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining + /// every original key (so fields this editor doesn't expose survive a load→save). + public static TwinCATTagConfigModel FromJson(string? json) + { + var o = TagConfigJson.ParseOrNew(json); + return new TwinCATTagConfigModel + { + DeviceHostAddress = TagConfigJson.GetString(o, "deviceHostAddress") ?? "", + SymbolPath = TagConfigJson.GetString(o, "symbolPath") ?? "", + DataType = TagConfigJson.GetEnum(o, "dataType", TwinCATDataType.DInt), + _bag = o, + }; + } + + /// Serialises this model back to a TagConfig JSON string, writing the exposed fields + /// (enum as its name string; the optional host address omitted when blank) over the preserved key bag. + public string ToJson() + { + TagConfigJson.Set(_bag, "deviceHostAddress", string.IsNullOrWhiteSpace(DeviceHostAddress) ? null : DeviceHostAddress.Trim()); + TagConfigJson.Set(_bag, "symbolPath", SymbolPath.Trim()); + TagConfigJson.Set(_bag, "dataType", DataType); + return TagConfigJson.Serialize(_bag); + } + + /// Validation hook; returns an error message or null when the model is valid. + public string? Validate() + => string.IsNullOrWhiteSpace(SymbolPath) ? "Symbol path is required." : null; +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbCipTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbCipTagConfigModelTests.cs new file mode 100644 index 00000000..ea5a8405 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbCipTagConfigModelTests.cs @@ -0,0 +1,82 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +public sealed class AbCipTagConfigModelTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("{}")] + public void FromJson_returns_defaults_for_empty_input(string? json) + { + var m = AbCipTagConfigModel.FromJson(json); + + m.DeviceHostAddress.ShouldBe(""); + m.TagPath.ShouldBe(""); + m.DataType.ShouldBe(AbCipDataType.DInt); + } + + [Fact] + public void Round_trip_preserves_all_fields() + { + var m = new AbCipTagConfigModel + { + DeviceHostAddress = "ab://gw/1,0", + TagPath = "Program:Main.Speed", + DataType = AbCipDataType.Real, + }; + + var json = m.ToJson(); + var m2 = AbCipTagConfigModel.FromJson(json); + + m2.DeviceHostAddress.ShouldBe("ab://gw/1,0"); + m2.TagPath.ShouldBe("Program:Main.Speed"); + m2.DataType.ShouldBe(AbCipDataType.Real); + } + + [Fact] + public void ToJson_emits_camelCase_keys_with_enum_names() + { + var m = new AbCipTagConfigModel + { + DeviceHostAddress = "ab://gw/1,0", + TagPath = "Motor1.Status", + DataType = AbCipDataType.DInt, + }; + + var json = m.ToJson(); + + json.ShouldContain("\"deviceHostAddress\":\"ab://gw/1,0\""); + json.ShouldContain("\"tagPath\":\"Motor1.Status\""); + json.ShouldContain("\"dataType\":\"DInt\""); + } + + [Fact] + public void FromJson_then_ToJson_preserves_unknown_keys() + { + var json = AbCipTagConfigModel + .FromJson("""{"tagPath":"Motor1.Status","extraKey":"keepme"}""") + .ToJson(); + + json.ShouldContain("extraKey"); + json.ShouldContain("keepme"); + json.ShouldContain("\"tagPath\":\"Motor1.Status\""); + } + + [Fact] + public void Validate_returns_error_when_tagPath_blank() + { + new AbCipTagConfigModel { TagPath = "" }.Validate().ShouldNotBeNull(); + } + + [Fact] + public void Validate_returns_null_when_tagPath_filled() + { + new AbCipTagConfigModel { TagPath = "Motor1.Status" }.Validate().ShouldBeNull(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyTagConfigModelTests.cs new file mode 100644 index 00000000..f900a834 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyTagConfigModelTests.cs @@ -0,0 +1,82 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +public sealed class AbLegacyTagConfigModelTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("{}")] + public void FromJson_returns_defaults_for_empty_input(string? json) + { + var m = AbLegacyTagConfigModel.FromJson(json); + + m.DeviceHostAddress.ShouldBe(""); + m.Address.ShouldBe(""); + m.DataType.ShouldBe(AbLegacyDataType.Int); + } + + [Fact] + public void Round_trip_preserves_all_fields() + { + var m = new AbLegacyTagConfigModel + { + DeviceHostAddress = "ab://10.0.0.5", + Address = "N7:0", + DataType = AbLegacyDataType.Float, + }; + + var json = m.ToJson(); + var m2 = AbLegacyTagConfigModel.FromJson(json); + + m2.DeviceHostAddress.ShouldBe("ab://10.0.0.5"); + m2.Address.ShouldBe("N7:0"); + m2.DataType.ShouldBe(AbLegacyDataType.Float); + } + + [Fact] + public void ToJson_emits_camelCase_keys_with_enum_names() + { + var m = new AbLegacyTagConfigModel + { + DeviceHostAddress = "ab://10.0.0.5", + Address = "N7:0", + DataType = AbLegacyDataType.Int, + }; + + var json = m.ToJson(); + + json.ShouldContain("\"deviceHostAddress\":\"ab://10.0.0.5\""); + json.ShouldContain("\"address\":\"N7:0\""); + json.ShouldContain("\"dataType\":\"Int\""); + } + + [Fact] + public void FromJson_then_ToJson_preserves_unknown_keys() + { + var json = AbLegacyTagConfigModel + .FromJson("""{"address":"N7:0","extraKey":"keepme"}""") + .ToJson(); + + json.ShouldContain("extraKey"); + json.ShouldContain("keepme"); + json.ShouldContain("\"address\":\"N7:0\""); + } + + [Fact] + public void Validate_returns_error_when_address_blank() + { + new AbLegacyTagConfigModel { Address = "" }.Validate().ShouldNotBeNull(); + } + + [Fact] + public void Validate_returns_null_when_address_filled() + { + new AbLegacyTagConfigModel { Address = "N7:0" }.Validate().ShouldBeNull(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/FocasTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/FocasTagConfigModelTests.cs new file mode 100644 index 00000000..03a1ff28 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/FocasTagConfigModelTests.cs @@ -0,0 +1,82 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Driver.FOCAS; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +public sealed class FocasTagConfigModelTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("{}")] + public void FromJson_returns_defaults_for_empty_input(string? json) + { + var m = FocasTagConfigModel.FromJson(json); + + m.DeviceHostAddress.ShouldBe(""); + m.Address.ShouldBe(""); + m.DataType.ShouldBe(FocasDataType.Int32); + } + + [Fact] + public void Round_trip_preserves_all_fields() + { + var m = new FocasTagConfigModel + { + DeviceHostAddress = "192.168.0.1:8193", + Address = "MACRO:500", + DataType = FocasDataType.Float64, + }; + + var json = m.ToJson(); + var m2 = FocasTagConfigModel.FromJson(json); + + m2.DeviceHostAddress.ShouldBe("192.168.0.1:8193"); + m2.Address.ShouldBe("MACRO:500"); + m2.DataType.ShouldBe(FocasDataType.Float64); + } + + [Fact] + public void ToJson_emits_camelCase_keys_with_enum_names() + { + var m = new FocasTagConfigModel + { + DeviceHostAddress = "192.168.0.1:8193", + Address = "R100", + DataType = FocasDataType.Int32, + }; + + var json = m.ToJson(); + + json.ShouldContain("\"deviceHostAddress\":\"192.168.0.1:8193\""); + json.ShouldContain("\"address\":\"R100\""); + json.ShouldContain("\"dataType\":\"Int32\""); + } + + [Fact] + public void FromJson_then_ToJson_preserves_unknown_keys() + { + var json = FocasTagConfigModel + .FromJson("""{"address":"R100","extraKey":"keepme"}""") + .ToJson(); + + json.ShouldContain("extraKey"); + json.ShouldContain("keepme"); + json.ShouldContain("\"address\":\"R100\""); + } + + [Fact] + public void Validate_returns_error_when_address_blank() + { + new FocasTagConfigModel { Address = "" }.Validate().ShouldNotBeNull(); + } + + [Fact] + public void Validate_returns_null_when_address_filled() + { + new FocasTagConfigModel { Address = "R100" }.Validate().ShouldBeNull(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/S7TagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/S7TagConfigModelTests.cs new file mode 100644 index 00000000..d56af92f --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/S7TagConfigModelTests.cs @@ -0,0 +1,82 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Driver.S7; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +public sealed class S7TagConfigModelTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("{}")] + public void FromJson_returns_defaults_for_empty_input(string? json) + { + var m = S7TagConfigModel.FromJson(json); + + m.Address.ShouldBe(""); + m.DataType.ShouldBe(S7DataType.Int16); + m.StringLength.ShouldBe(0); + } + + [Fact] + public void Round_trip_preserves_all_fields() + { + var m = new S7TagConfigModel + { + Address = "DB1.DBW0", + DataType = S7DataType.Float32, + StringLength = 32, + }; + + var json = m.ToJson(); + var m2 = S7TagConfigModel.FromJson(json); + + m2.Address.ShouldBe("DB1.DBW0"); + m2.DataType.ShouldBe(S7DataType.Float32); + m2.StringLength.ShouldBe(32); + } + + [Fact] + public void ToJson_emits_camelCase_keys_with_enum_names() + { + var m = new S7TagConfigModel + { + Address = "M0.0", + DataType = S7DataType.Bool, + StringLength = 0, + }; + + var json = m.ToJson(); + + json.ShouldContain("\"address\":\"M0.0\""); + json.ShouldContain("\"dataType\":\"Bool\""); + json.ShouldContain("\"stringLength\":0"); + } + + [Fact] + public void FromJson_then_ToJson_preserves_unknown_keys() + { + var json = S7TagConfigModel + .FromJson("""{"address":"DB1.DBW0","extraKey":"keepme"}""") + .ToJson(); + + json.ShouldContain("extraKey"); + json.ShouldContain("keepme"); + json.ShouldContain("\"address\":\"DB1.DBW0\""); + } + + [Fact] + public void Validate_returns_error_when_address_blank() + { + new S7TagConfigModel { Address = "" }.Validate().ShouldNotBeNull(); + } + + [Fact] + public void Validate_returns_null_when_address_filled() + { + new S7TagConfigModel { Address = "DB1.DBW0" }.Validate().ShouldBeNull(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TwinCATTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TwinCATTagConfigModelTests.cs new file mode 100644 index 00000000..435edc89 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TwinCATTagConfigModelTests.cs @@ -0,0 +1,82 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +public sealed class TwinCATTagConfigModelTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("{}")] + public void FromJson_returns_defaults_for_empty_input(string? json) + { + var m = TwinCATTagConfigModel.FromJson(json); + + m.DeviceHostAddress.ShouldBe(""); + m.SymbolPath.ShouldBe(""); + m.DataType.ShouldBe(TwinCATDataType.DInt); + } + + [Fact] + public void Round_trip_preserves_all_fields() + { + var m = new TwinCATTagConfigModel + { + DeviceHostAddress = "5.1.2.3.1.1:851", + SymbolPath = "MAIN.bStart", + DataType = TwinCATDataType.Real, + }; + + var json = m.ToJson(); + var m2 = TwinCATTagConfigModel.FromJson(json); + + m2.DeviceHostAddress.ShouldBe("5.1.2.3.1.1:851"); + m2.SymbolPath.ShouldBe("MAIN.bStart"); + m2.DataType.ShouldBe(TwinCATDataType.Real); + } + + [Fact] + public void ToJson_emits_camelCase_keys_with_enum_names() + { + var m = new TwinCATTagConfigModel + { + DeviceHostAddress = "5.1.2.3.1.1:851", + SymbolPath = "GVL.Counter", + DataType = TwinCATDataType.DInt, + }; + + var json = m.ToJson(); + + json.ShouldContain("\"deviceHostAddress\":\"5.1.2.3.1.1:851\""); + json.ShouldContain("\"symbolPath\":\"GVL.Counter\""); + json.ShouldContain("\"dataType\":\"DInt\""); + } + + [Fact] + public void FromJson_then_ToJson_preserves_unknown_keys() + { + var json = TwinCATTagConfigModel + .FromJson("""{"symbolPath":"GVL.Counter","extraKey":"keepme"}""") + .ToJson(); + + json.ShouldContain("extraKey"); + json.ShouldContain("keepme"); + json.ShouldContain("\"symbolPath\":\"GVL.Counter\""); + } + + [Fact] + public void Validate_returns_error_when_symbolPath_blank() + { + new TwinCATTagConfigModel { SymbolPath = "" }.Validate().ShouldNotBeNull(); + } + + [Fact] + public void Validate_returns_null_when_symbolPath_filled() + { + new TwinCATTagConfigModel { SymbolPath = "MAIN.bStart" }.Validate().ShouldBeNull(); + } +}