feat(uns): S7/AbCip/AbLegacy/TwinCAT/Focas typed tag-config editors (F-uns-1 T4-T8)

This commit is contained in:
Joseph Doherty
2026-06-09 09:42:40 -04:00
parent 5990b673cc
commit 75021fa2c9
15 changed files with 860 additions and 0 deletions
@@ -0,0 +1,42 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Driver.AbCip
<div class="row g-2">
<div class="col-md-4"><label class="form-label">Device host</label>
<input class="form-control form-control-sm mono" value="@_m.DeviceHostAddress" placeholder="ab://gateway[:port]/cip-path" @onchange="@(e => Update(() => _m.DeviceHostAddress = e.Value?.ToString() ?? string.Empty))" /></div>
<div class="col-md-5"><label class="form-label">Tag path</label>
<input class="form-control form-control-sm mono" value="@_m.TagPath" placeholder="e.g. Motor1.Speed" @onchange="@(e => Update(() => _m.TagPath = e.Value?.ToString() ?? string.Empty))" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, AbCipDataType.DInt)))">
@foreach (var v in Enum.GetValues<AbCipDataType>()) { <option value="@v">@v</option> }
</select></div>
</div>
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback<string> 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<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
private async Task Update(Action apply)
{
apply();
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
}
}
@@ -0,0 +1,42 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy
<div class="row g-2">
<div class="col-md-4"><label class="form-label">Device host</label>
<input class="form-control form-control-sm mono" value="@_m.DeviceHostAddress" placeholder="ab://host" @onchange="@(e => Update(() => _m.DeviceHostAddress = e.Value?.ToString() ?? string.Empty))" /></div>
<div class="col-md-5"><label class="form-label">Address</label>
<input class="form-control form-control-sm mono" value="@_m.Address" placeholder="e.g. N7:0, B3:0/0" @onchange="@(e => Update(() => _m.Address = e.Value?.ToString() ?? string.Empty))" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, AbLegacyDataType.Int)))">
@foreach (var v in Enum.GetValues<AbLegacyDataType>()) { <option value="@v">@v</option> }
</select></div>
</div>
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback<string> 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<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
private async Task Update(Action apply)
{
apply();
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
}
}
@@ -0,0 +1,42 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Driver.FOCAS
<div class="row g-2">
<div class="col-md-4"><label class="form-label">Device host</label>
<input class="form-control form-control-sm mono" value="@_m.DeviceHostAddress" placeholder="host[:port]" @onchange="@(e => Update(() => _m.DeviceHostAddress = e.Value?.ToString() ?? string.Empty))" /></div>
<div class="col-md-5"><label class="form-label">Address</label>
<input class="form-control form-control-sm mono" value="@_m.Address" placeholder="e.g. X0.0, R100, PARAM:1815/0, MACRO:500" @onchange="@(e => Update(() => _m.Address = e.Value?.ToString() ?? string.Empty))" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, FocasDataType.Int32)))">
@foreach (var v in Enum.GetValues<FocasDataType>()) { <option value="@v">@v</option> }
</select></div>
</div>
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback<string> 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<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
private async Task Update(Action apply)
{
apply();
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
}
}
@@ -0,0 +1,42 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Driver.S7
<div class="row g-2">
<div class="col-md-5"><label class="form-label">Address</label>
<input class="form-control form-control-sm mono" value="@_m.Address" placeholder="e.g. DB1.DBW0, M0.0, I0.0, QD4" @onchange="@(e => Update(() => _m.Address = e.Value?.ToString() ?? string.Empty))" /></div>
<div class="col-md-4"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, S7DataType.Int16)))">
@foreach (var v in Enum.GetValues<S7DataType>()) { <option value="@v">@v</option> }
</select></div>
<div class="col-md-3"><label class="form-label">String len</label>
<input type="number" class="form-control form-control-sm" value="@_m.StringLength" @onchange="@(e => Update(() => _m.StringLength = ParseInt(e.Value)))" /></div>
</div>
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback<string> 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<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
private async Task Update(Action apply)
{
apply();
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
}
}
@@ -0,0 +1,42 @@
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
@using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT
<div class="row g-2">
<div class="col-md-4"><label class="form-label">Device host</label>
<input class="form-control form-control-sm mono" value="@_m.DeviceHostAddress" placeholder="AmsNetId[:port]" @onchange="@(e => Update(() => _m.DeviceHostAddress = e.Value?.ToString() ?? string.Empty))" /></div>
<div class="col-md-5"><label class="form-label">Symbol path</label>
<input class="form-control form-control-sm mono" value="@_m.SymbolPath" placeholder="e.g. MAIN.bStart, GVL.Counter" @onchange="@(e => Update(() => _m.SymbolPath = e.Value?.ToString() ?? string.Empty))" /></div>
<div class="col-md-3"><label class="form-label">Data type</label>
<select class="form-select form-select-sm" value="@_m.DataType" @onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, TwinCATDataType.DInt)))">
@foreach (var v in Enum.GetValues<TwinCATDataType>()) { <option value="@v">@v</option> }
</select></div>
</div>
@code {
[Parameter] public string? ConfigJson { get; set; }
[Parameter] public EventCallback<string> 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<TEnum>(object? v, TEnum fallback) where TEnum : struct, Enum
=> Enum.TryParse<TEnum>(v?.ToString(), out var r) ? r : fallback;
private async Task Update(Action apply)
{
apply();
await ConfigJsonChanged.InvokeAsync(_m.ToJson());
}
}
@@ -0,0 +1,48 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>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.</summary>
public sealed class AbCipTagConfigModel
{
/// <summary>Which device (<c>AbCipDeviceOptions.HostAddress</c>) this tag lives on. Optional.</summary>
public string DeviceHostAddress { get; set; } = "";
/// <summary>Logix symbolic path (controller or program scope). Required.</summary>
public string TagPath { get; set; } = "";
/// <summary>Logix atomic type, or <c>AbCipDataType.Structure</c> for UDT-typed tags.</summary>
public AbCipDataType DataType { get; set; } = AbCipDataType.DInt;
private JsonObject _bag = new();
/// <summary>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).</summary>
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,
};
}
/// <summary>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.</summary>
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);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
public string? Validate()
=> string.IsNullOrWhiteSpace(TagPath) ? "Tag path is required." : null;
}
@@ -0,0 +1,48 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>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.</summary>
public sealed class AbLegacyTagConfigModel
{
/// <summary>Which device (<c>AbLegacyDeviceOptions.HostAddress</c>) this tag lives on. Optional.</summary>
public string DeviceHostAddress { get; set; } = "";
/// <summary>Canonical PCCC file-address string, e.g. <c>N7:0</c>, <c>B3:0/0</c>. Required.</summary>
public string Address { get; set; } = "";
/// <summary>PCCC data type that maps onto SLC / MicroLogix / PLC-5 files.</summary>
public AbLegacyDataType DataType { get; set; } = AbLegacyDataType.Int;
private JsonObject _bag = new();
/// <summary>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).</summary>
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,
};
}
/// <summary>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.</summary>
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);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
public string? Validate()
=> string.IsNullOrWhiteSpace(Address) ? "Address is required." : null;
}
@@ -0,0 +1,48 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>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.</summary>
public sealed class FocasTagConfigModel
{
/// <summary>Which device (<c>FocasDeviceOptions.HostAddress</c>) this tag lives on. Optional.</summary>
public string DeviceHostAddress { get; set; } = "";
/// <summary>Canonical FOCAS address string, e.g. <c>X0.0</c>, <c>R100</c>, <c>PARAM:1815/0</c>, <c>MACRO:500</c>. Required.</summary>
public string Address { get; set; } = "";
/// <summary>FOCAS atomic data type.</summary>
public FocasDataType DataType { get; set; } = FocasDataType.Int32;
private JsonObject _bag = new();
/// <summary>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).</summary>
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,
};
}
/// <summary>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.</summary>
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);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
public string? Validate()
=> string.IsNullOrWhiteSpace(Address) ? "Address is required." : null;
}
@@ -0,0 +1,48 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.S7;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>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.</summary>
public sealed class S7TagConfigModel
{
/// <summary>S7 address string, e.g. <c>DB1.DBW0</c>, <c>M0.0</c>, <c>I0.0</c>, <c>QD4</c>. Required.</summary>
public string Address { get; set; } = "";
/// <summary>Logical data type — drives the underlying S7.Net read/write width.</summary>
public S7DataType DataType { get; set; } = S7DataType.Int16;
/// <summary>For <c>DataType = String</c>: S7-string max length (max 254).</summary>
public int StringLength { get; set; }
private JsonObject _bag = new();
/// <summary>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).</summary>
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,
};
}
/// <summary>Serialises this model back to a TagConfig JSON string, writing the exposed fields
/// (enum as its name string) over the preserved key bag.</summary>
public string ToJson()
{
TagConfigJson.Set(_bag, "address", Address.Trim());
TagConfigJson.Set(_bag, "dataType", DataType);
TagConfigJson.Set(_bag, "stringLength", StringLength);
return TagConfigJson.Serialize(_bag);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
public string? Validate()
=> string.IsNullOrWhiteSpace(Address) ? "Address is required." : null;
}
@@ -0,0 +1,48 @@
using System.Text.Json.Nodes;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
/// <summary>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.</summary>
public sealed class TwinCATTagConfigModel
{
/// <summary>Which device (<c>TwinCATDeviceOptions.HostAddress</c>) this tag lives on. Optional.</summary>
public string DeviceHostAddress { get; set; } = "";
/// <summary>Full TwinCAT symbolic name, e.g. <c>MAIN.bStart</c>, <c>GVL.Counter</c>. Required.</summary>
public string SymbolPath { get; set; } = "";
/// <summary>TwinCAT / IEC 61131-3 atomic data type.</summary>
public TwinCATDataType DataType { get; set; } = TwinCATDataType.DInt;
private JsonObject _bag = new();
/// <summary>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).</summary>
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,
};
}
/// <summary>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.</summary>
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);
}
/// <summary>Validation hook; returns an error message or null when the model is valid.</summary>
public string? Validate()
=> string.IsNullOrWhiteSpace(SymbolPath) ? "Symbol path is required." : null;
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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();
}
}