feat(mtconnect): typed AdminUI tag-config editor + browse round-trip (Task 18 Part A)
Registers the MTConnect typed tag editor in TagConfigEditorMap /
TagConfigValidator (keyed off DriverTypeNames.MTConnect, never a literal) and
adds the razor shell over Task 17's MTConnectTagConfigModel.
The shell stays trivial: the /probe-sourced mtCategory/mtType/mtSubType/units
row is rendered disabled + readonly with NO binding of any kind, and no
inferred-type hint is shown — MTConnectDataTypeInference.Infer() also keys on
the DataItem `representation`, which the tag-config model does not carry, so
calling it here would agree with the browse picker for ordinary items and
silently disagree for exactly the DATA_SET/TABLE/TIME_SERIES ones.
Also fixes the picker -> editor round trip, which was broken in both
directions:
- RawBrowseCommitMapper.BuildTagConfig had no MTConnect branch, so a committed
tag landed as the generic {"address": "<dataItemId>"} while the model read
only `fullName`. The data plane kept working (the driver accepts all three id
spellings), so the only symptom was an empty DataItem-id field in the editor
— and saving from that state stamped fullName:"" over the tag.
- MTConnectTagConfigModel.FromJson now accepts dataItemId/address as fallbacks
(fixing tags committed before this change) and ToJson normalises onto
`fullName`, dropping the aliases so the two spellings cannot drift.
This commit is contained in:
+118
@@ -0,0 +1,118 @@
|
|||||||
|
@* Typed tag-config editor for an MTConnect Agent tag. A thin shell over MTConnectTagConfigModel — every
|
||||||
|
rule (defaults, unknown-key preservation, the numeric-dataType trap) lives in that model, not here.
|
||||||
|
|
||||||
|
MTConnect is READ-ONLY (the driver is deliberately not IWritable), so unlike Modbus there is no
|
||||||
|
"Writable" toggle to author.
|
||||||
|
|
||||||
|
The mtCategory/mtType/mtSubType/units row is /probe-sourced device-model metadata: rendered disabled +
|
||||||
|
readonly with NO binding of any kind, so nothing an operator can do in this UI writes to it. The model
|
||||||
|
still round-trips the values so a browse-committed stamp survives an edit of the data type.
|
||||||
|
|
||||||
|
NOTE: no inferred-type hint is shown. MTConnectDataTypeInference.Infer() also keys on the DataItem's
|
||||||
|
`representation` (DATA_SET/TABLE demote to String even on a SAMPLE; TIME_SERIES is honoured only on a
|
||||||
|
SAMPLE), and the tag-config model does not carry `representation`. Calling Infer() from here would
|
||||||
|
therefore agree with the browse picker for ordinary items and silently DISAGREE for exactly the
|
||||||
|
representation-driven ones — the failure that never shows up in the obvious test. The picker already
|
||||||
|
stamped the inferred type into `dataType` on commit; this editor is the override surface for it. *@
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
|
||||||
|
@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
|
||||||
|
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label class="form-label" for="mtc-fullname">DataItem id</label>
|
||||||
|
<input id="mtc-fullname" type="text" class="form-control form-control-sm mono"
|
||||||
|
value="@_m.FullName" @onchange="@(e => Update(() => _m.FullName = e.Value?.ToString() ?? ""))"
|
||||||
|
placeholder="Xact" />
|
||||||
|
<div class="form-text">The Agent DataItem's <span class="mono">id</span> attribute — the driver's read/subscribe key.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label" for="mtc-datatype">Data type</label>
|
||||||
|
<select id="mtc-datatype" class="form-select form-select-sm" value="@_m.DataType"
|
||||||
|
@onchange="@(e => Update(() => _m.DataType = ParseEnum(e.Value, DriverDataType.String)))">
|
||||||
|
@foreach (var v in Enum.GetValues<DriverDataType>()) { <option value="@v">@v</option> }
|
||||||
|
</select>
|
||||||
|
<div class="form-text">Overrides the type the browse picker inferred. MTConnect is text on the wire — a wrong numeric type reads as Bad quality.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Probe-sourced device-model metadata — display only. *@
|
||||||
|
<div class="row g-2 mt-1">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label" for="mtc-category">Category</label>
|
||||||
|
<input id="mtc-category" type="text" class="form-control form-control-sm mono"
|
||||||
|
value="@Display(_m.MtCategory)" disabled readonly />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label" for="mtc-type">Type</label>
|
||||||
|
<input id="mtc-type" type="text" class="form-control form-control-sm mono"
|
||||||
|
value="@Display(_m.MtType)" disabled readonly />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label" for="mtc-subtype">Sub-type</label>
|
||||||
|
<input id="mtc-subtype" type="text" class="form-control form-control-sm mono"
|
||||||
|
value="@Display(_m.MtSubType)" disabled readonly />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label" for="mtc-units">Units</label>
|
||||||
|
<input id="mtc-units" type="text" class="form-control form-control-sm mono"
|
||||||
|
value="@Display(_m.Units)" disabled readonly />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-text">From the Agent's <span class="mono">/probe</span> device model — not editable here. Re-pick the tag in the browser to refresh it.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* Author's own notes — not consumed by the driver or the runtime. *@
|
||||||
|
<div class="row g-2 mt-1">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="mtc-device">Device (note)</label>
|
||||||
|
<input id="mtc-device" type="text" class="form-control form-control-sm"
|
||||||
|
value="@_m.MtDevice" @onchange="@(e => Update(() => _m.MtDevice = e.Value?.ToString()))" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="mtc-component">Component (note)</label>
|
||||||
|
<input id="mtc-component" type="text" class="form-control form-control-sm"
|
||||||
|
value="@_m.MtComponent" @onchange="@(e => Update(() => _m.MtComponent = e.Value?.ToString()))" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_m.Validate() is { } error)
|
||||||
|
{
|
||||||
|
<div class="text-danger small mt-2">@error</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>DriverType of the selected driver — supplied by the hosting modal for browse-capable pickers.</summary>
|
||||||
|
[Parameter] public string DriverType { get; set; } = "";
|
||||||
|
/// <summary>Live accessor for the selected driver's DriverConfig JSON (browse connect config).</summary>
|
||||||
|
[Parameter] public Func<string> GetDriverConfigJson { get; set; } = () => "{}";
|
||||||
|
|
||||||
|
private MTConnectTagConfigModel _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 = MTConnectTagConfigModel.FromJson(ConfigJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder for absent probe metadata: an empty read-only box reads as "the field is broken".
|
||||||
|
private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "—" : value;
|
||||||
|
|
||||||
|
// 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,6 +115,11 @@ public static class RawBrowseCommitMapper
|
|||||||
return new FocasTagConfigModel { Address = address }.ToJson();
|
return new FocasTagConfigModel { Address = address }.ToJson();
|
||||||
if (Is(driverType, DriverTypeNames.S7))
|
if (Is(driverType, DriverTypeNames.S7))
|
||||||
return new S7TagConfigModel { Address = address }.ToJson();
|
return new S7TagConfigModel { Address = address }.ToJson();
|
||||||
|
// MTConnect: the DataItem id. The driver also accepts "address"/"dataItemId", but the typed
|
||||||
|
// editor's canonical spelling is fullName — committing under the generic key below would open
|
||||||
|
// in that editor with an EMPTY id field and blank it on save.
|
||||||
|
if (Is(driverType, DriverTypeNames.MTConnect))
|
||||||
|
return new MTConnectTagConfigModel { FullName = address }.ToJson();
|
||||||
if (Is(driverType, DriverTypeNames.Galaxy))
|
if (Is(driverType, DriverTypeNames.Galaxy))
|
||||||
return WriteSingleKey("attributeRef", address);
|
return WriteSingleKey("attributeRef", address);
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class MTConnectTagConfigModel
|
public sealed class MTConnectTagConfigModel
|
||||||
{
|
{
|
||||||
/// <summary>The MTConnect DataItem's <c>id</c> attribute — the driver's read/subscribe key. Required.</summary>
|
/// <summary>The MTConnect DataItem's <c>id</c> attribute — the driver's read/subscribe key. Required.
|
||||||
|
/// Loaded from <c>fullName</c>, or from the driver's alternate spellings <c>dataItemId</c> /
|
||||||
|
/// <c>address</c> (see <see cref="IdKeys"/>); always saved as <c>fullName</c>.</summary>
|
||||||
public string FullName { get; set; } = "";
|
public string FullName { get; set; } = "";
|
||||||
|
|
||||||
/// <summary>Logical data type override for the DataItem's value. Defaults to <see cref="DriverDataType.String"/>
|
/// <summary>Logical data type override for the DataItem's value. Defaults to <see cref="DriverDataType.String"/>
|
||||||
@@ -65,6 +67,18 @@ public sealed class MTConnectTagConfigModel
|
|||||||
|
|
||||||
private JsonObject _bag = new();
|
private JsonObject _bag = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The accepted spellings of the DataItem id, in precedence order, mirroring the driver's own
|
||||||
|
/// <c>MTConnectRawTagConfigDto</c>: <c>fullName</c> (this model's canonical key),
|
||||||
|
/// <c>dataItemId</c> (what an operator hand-authoring the blob reaches for), and <c>address</c>
|
||||||
|
/// (what <c>RawBrowseCommitMapper</c> wrote for MTConnect before it grew a typed-editor branch).
|
||||||
|
/// Reading only <c>fullName</c> made every already-committed tag open with an EMPTY id field and
|
||||||
|
/// get blanked on save, while the data plane kept working — the symptom looked cosmetic.
|
||||||
|
/// <see cref="ToJson"/> normalises onto <c>fullName</c> and drops the aliases, so the two
|
||||||
|
/// spellings can never drift apart on a later edit.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly string[] IdKeys = ["fullName", "dataItemId", "address"];
|
||||||
|
|
||||||
/// <summary>Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
|
/// <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>
|
/// every original key (so fields this editor doesn't expose survive a load→save).</summary>
|
||||||
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
|
/// <param name="json">The raw TagConfig JSON string, or <c>null</c> for a new/empty config.</param>
|
||||||
@@ -74,7 +88,7 @@ public sealed class MTConnectTagConfigModel
|
|||||||
var o = TagConfigJson.ParseOrNew(json);
|
var o = TagConfigJson.ParseOrNew(json);
|
||||||
return new MTConnectTagConfigModel
|
return new MTConnectTagConfigModel
|
||||||
{
|
{
|
||||||
FullName = TagConfigJson.GetString(o, "fullName") ?? "",
|
FullName = ReadId(o),
|
||||||
DataType = TagConfigJson.GetEnum(o, "dataType", DriverDataType.String),
|
DataType = TagConfigJson.GetEnum(o, "dataType", DriverDataType.String),
|
||||||
MtCategory = TagConfigJson.GetString(o, "mtCategory"),
|
MtCategory = TagConfigJson.GetString(o, "mtCategory"),
|
||||||
MtType = TagConfigJson.GetString(o, "mtType"),
|
MtType = TagConfigJson.GetString(o, "mtType"),
|
||||||
@@ -94,6 +108,10 @@ public sealed class MTConnectTagConfigModel
|
|||||||
/// <returns>The serialised TagConfig JSON string.</returns>
|
/// <returns>The serialised TagConfig JSON string.</returns>
|
||||||
public string ToJson()
|
public string ToJson()
|
||||||
{
|
{
|
||||||
|
// Normalise onto the canonical key: a blob loaded via an alias must not keep the alias, or the
|
||||||
|
// two spellings drift on the next edit and the driver silently reads the stale one.
|
||||||
|
TagConfigJson.Set(_bag, "dataItemId", null);
|
||||||
|
TagConfigJson.Set(_bag, "address", null);
|
||||||
TagConfigJson.Set(_bag, "fullName", FullName.Trim());
|
TagConfigJson.Set(_bag, "fullName", FullName.Trim());
|
||||||
TagConfigJson.Set(_bag, "dataType", DataType);
|
TagConfigJson.Set(_bag, "dataType", DataType);
|
||||||
TagConfigJson.Set(_bag, "mtCategory", Blank(MtCategory));
|
TagConfigJson.Set(_bag, "mtCategory", Blank(MtCategory));
|
||||||
@@ -127,4 +145,19 @@ public sealed class MTConnectTagConfigModel
|
|||||||
// Normalises a blank/whitespace-only string to null so TagConfigJson.Set omits the key rather than
|
// Normalises a blank/whitespace-only string to null so TagConfigJson.Set omits the key rather than
|
||||||
// persisting an empty string.
|
// persisting an empty string.
|
||||||
private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
|
private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
|
||||||
|
|
||||||
|
// First non-blank id spelling wins (see IdKeys). A present-but-blank "fullName" must not shadow a
|
||||||
|
// populated alias — that is exactly the state a save from the broken editor left behind.
|
||||||
|
private static string ReadId(JsonObject o)
|
||||||
|
{
|
||||||
|
foreach (var key in IdKeys)
|
||||||
|
{
|
||||||
|
if (Blank(TagConfigJson.GetString(o, key)) is { } id)
|
||||||
|
{
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public static class TagConfigEditorMap
|
|||||||
// constant is deliberately absent until Task 11 wires the factory (DriverTypeNamesGuardTests
|
// constant is deliberately absent until Task 11 wires the factory (DriverTypeNamesGuardTests
|
||||||
// asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql.
|
// asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql.
|
||||||
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
|
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
|
||||||
|
[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
|
/// <summary>Returns the editor component type for a driver type, or null if none is registered.</summary>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public static class TagConfigValidator
|
|||||||
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
|
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
|
||||||
// Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap.
|
// Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap.
|
||||||
[SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(),
|
[SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(),
|
||||||
|
[DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins the browse-picker → typed-editor round trip for MTConnect, the driver's <b>primary</b>
|
||||||
|
/// authoring path (a tag is committed from the universal browser, then confirmed/overridden in the
|
||||||
|
/// typed editor).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The defect this guards: <see cref="RawBrowseCommitMapper.BuildTagConfig"/> had no MTConnect
|
||||||
|
/// branch, so a committed tag landed as the generic <c>{"address": "<dataItemId>"}</c>
|
||||||
|
/// while <see cref="MTConnectTagConfigModel"/> read only <c>fullName</c>. The data plane kept
|
||||||
|
/// working (the driver accepts all three id spellings), so the ONLY symptom was an empty
|
||||||
|
/// DataItem-id field in the editor — and saving from that state stamped <c>fullName: ""</c>
|
||||||
|
/// over the tag. Razor is exactly what this repo cannot unit-test, so both halves of the fix
|
||||||
|
/// are pinned here in plain C#.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Both halves are asserted: the mapper now writes the canonical <c>fullName</c> (fixes new
|
||||||
|
/// commits), and the model accepts <c>dataItemId</c>/<c>address</c> as fallbacks (fixes tags
|
||||||
|
/// already committed before the fix).
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class MTConnectBrowseCommitRoundTripTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void BuildTagConfig_writes_the_canonical_fullName_key_for_MTConnect()
|
||||||
|
{
|
||||||
|
var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.MTConnect, "avail");
|
||||||
|
|
||||||
|
var o = JsonNode.Parse(json)!.AsObject();
|
||||||
|
o["fullName"]!.GetValue<string>().ShouldBe("avail");
|
||||||
|
o.ContainsKey("address").ShouldBeFalse(
|
||||||
|
"the generic fallback key must not be used now that MTConnect has a typed editor");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_browse_committed_tag_round_trips_through_the_typed_editor_model()
|
||||||
|
{
|
||||||
|
var committed = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.MTConnect, "Xact");
|
||||||
|
|
||||||
|
var model = MTConnectTagConfigModel.FromJson(committed);
|
||||||
|
|
||||||
|
model.FullName.ShouldBe("Xact");
|
||||||
|
model.Validate().ShouldBeNull("a browse-committed tag must open in the editor already valid");
|
||||||
|
MTConnectTagConfigModel.FromJson(model.ToJson()).FullName.ShouldBe("Xact");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- legacy blobs committed before the mapper fix -------------------------------------------
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("address")]
|
||||||
|
[InlineData("dataItemId")]
|
||||||
|
public void FromJson_accepts_the_drivers_alternate_id_spellings(string key)
|
||||||
|
{
|
||||||
|
var model = MTConnectTagConfigModel.FromJson($$"""{"{{key}}":"Ypos"}""");
|
||||||
|
|
||||||
|
model.FullName.ShouldBe("Ypos");
|
||||||
|
model.Validate().ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FullName_wins_over_dataItemId_which_wins_over_address()
|
||||||
|
{
|
||||||
|
MTConnectTagConfigModel
|
||||||
|
.FromJson("""{"fullName":"a","dataItemId":"b","address":"c"}""")
|
||||||
|
.FullName.ShouldBe("a");
|
||||||
|
|
||||||
|
MTConnectTagConfigModel
|
||||||
|
.FromJson("""{"dataItemId":"b","address":"c"}""")
|
||||||
|
.FullName.ShouldBe("b");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_blank_alias_does_not_shadow_a_populated_one()
|
||||||
|
=> MTConnectTagConfigModel
|
||||||
|
.FromJson("""{"fullName":" ","address":"c"}""")
|
||||||
|
.FullName.ShouldBe("c");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Saving_a_legacy_blob_normalises_it_onto_fullName_and_drops_the_aliases()
|
||||||
|
{
|
||||||
|
var model = MTConnectTagConfigModel.FromJson("""{"address":"Zpos","units":"MILLIMETER"}""");
|
||||||
|
|
||||||
|
var o = JsonNode.Parse(model.ToJson())!.AsObject();
|
||||||
|
|
||||||
|
o["fullName"]!.GetValue<string>().ShouldBe("Zpos");
|
||||||
|
o.ContainsKey("address").ShouldBeFalse(
|
||||||
|
"leaving a second id spelling behind lets the two drift apart on the next edit");
|
||||||
|
o.ContainsKey("dataItemId").ShouldBeFalse();
|
||||||
|
// Unrelated keys are still preserved — normalisation is not a purge.
|
||||||
|
o["units"]!.GetValue<string>().ShouldBe("MILLIMETER");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ public sealed class TagConfigDriverTypeNameGuardTests
|
|||||||
DriverTypeNames.FOCAS,
|
DriverTypeNames.FOCAS,
|
||||||
DriverTypeNames.OpcUaClient,
|
DriverTypeNames.OpcUaClient,
|
||||||
DriverTypeNames.Calculation,
|
DriverTypeNames.Calculation,
|
||||||
|
DriverTypeNames.MTConnect,
|
||||||
];
|
];
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
@@ -46,6 +47,7 @@ public sealed class TagConfigDriverTypeNameGuardTests
|
|||||||
DriverTypeNames.TwinCAT,
|
DriverTypeNames.TwinCAT,
|
||||||
DriverTypeNames.FOCAS,
|
DriverTypeNames.FOCAS,
|
||||||
DriverTypeNames.OpcUaClient,
|
DriverTypeNames.OpcUaClient,
|
||||||
|
DriverTypeNames.MTConnect,
|
||||||
];
|
];
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|||||||
Reference in New Issue
Block a user