merge: #553 — CSV typed-column merge preserves the cell's own tokens (no pre-gate laundering); export reads effective values and strips out-ranking aliases
This commit is contained in:
@@ -27,6 +27,15 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
||||
/// it is never silently skipped. This check is for the operator's benefit (it names the row before
|
||||
/// commit); the guarantee lives in <see cref="IRawTreeService.ImportTagsAsync"/>, which re-runs it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// For that gate to mean anything the merge must hand it the row <b>as written</b>, which it did not
|
||||
/// (Gitea #553): the model-backed maps round-tripped the free-form cell through a driver model's lenient
|
||||
/// <c>FromJson</c>/<c>ToJson</c>, so a typo'd value under a key the model owns was rewritten to that model's
|
||||
/// default before any gate ran — the row passed, and the tag the operator actually described was gone.
|
||||
/// <see cref="CsvDriverTagMap.ApplyTypedColumns"/> now preserves the cell's own tokens for every key no
|
||||
/// non-blank typed column overrode, and clears a column's out-ranking aliases when one does. The verdict on
|
||||
/// those tokens is still the gate's alone — nothing about which values are legal is restated here.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class RawTagCsvMapper
|
||||
{
|
||||
@@ -162,7 +171,13 @@ public static class RawTagCsvMapper
|
||||
if (row.TryGetValue(col.Header, out var cv) && !string.IsNullOrWhiteSpace(cv))
|
||||
{
|
||||
typedUsed.Add(col.Header);
|
||||
if (baseKeys.Contains(col.JsonKey))
|
||||
|
||||
// Every spelling the column speaks for, not just the one it writes: an MTConnect cell
|
||||
// carrying "driverDataType" is overridden by the MtDataType column exactly as a
|
||||
// "dataType" cell is, and the operator must see that in the grid (Gitea #553). This
|
||||
// report is also what makes accepting the override — rather than erroring on the
|
||||
// contradiction — an open decision instead of a silent one.
|
||||
if (col.OwnedJsonKeys.Any(baseKeys.Contains))
|
||||
{
|
||||
overrodeFallback.Add(col.Header);
|
||||
}
|
||||
|
||||
@@ -19,9 +19,35 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
|
||||
/// a header directly onto a JSON key.
|
||||
/// </summary>
|
||||
/// <param name="Header">The CSV column header (PascalCase, e.g. <c>ModbusDataType</c>).</param>
|
||||
/// <param name="JsonKey">The root TagConfig JSON key this column reads/writes (camelCase, e.g. <c>dataType</c>).</param>
|
||||
/// <param name="JsonKey">The root TagConfig JSON key this column <b>writes</b> (camelCase, e.g. <c>dataType</c>).</param>
|
||||
/// <param name="ModelProperty">The backing model property name, or <c>null</c> for a model-less map.</param>
|
||||
public sealed record CsvTypedColumn(string Header, string JsonKey, string? ModelProperty);
|
||||
/// <param name="ReadKeys">
|
||||
/// The full ordered set of root JSON keys the <b>runtime</b> reads for this column's value, most-preferred
|
||||
/// first, or <c>null</c> when the column owns only <paramref name="JsonKey"/>. Supplied for the drivers
|
||||
/// whose blob accepts more than one spelling of a field (MTConnect's <c>driverDataType</c>/<c>dataType</c>
|
||||
/// and <c>fullName</c>/<c>dataItemId</c>/<c>address</c>) — see <see cref="OwnedJsonKeys"/> for why the CSV
|
||||
/// path has to know the whole set and not just the one it writes.
|
||||
/// </param>
|
||||
public sealed record CsvTypedColumn(
|
||||
string Header,
|
||||
string JsonKey,
|
||||
string? ModelProperty,
|
||||
IReadOnlyList<string>? ReadKeys = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Every root JSON key this column speaks for, in the <b>runtime's</b> read precedence — i.e.
|
||||
/// <see cref="ReadKeys"/> when supplied, else just <see cref="JsonKey"/>.
|
||||
/// <para>
|
||||
/// A column that writes <c>dataType</c> while the driver prefers a <c>driverDataType</c> alias does
|
||||
/// not actually control the tag's type: the alias, carried through in the free-form
|
||||
/// <c>TagConfigJson</c> cell, out-ranks it at the driver, so an operator's edit of the typed column
|
||||
/// is accepted and then silently ignored (Gitea #553). Knowing the whole set is what lets the
|
||||
/// export residual drop the alias, the export cell show the <i>effective</i> value, and a firing
|
||||
/// column clear the alias so it genuinely wins.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> OwnedJsonKeys { get; } = ReadKeys is { Count: > 0 } ? ReadKeys : [JsonKey];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-driver mapping between a CSV row's <b>typed</b> columns and a tag's driver-specific
|
||||
@@ -46,8 +72,50 @@ public abstract class CsvDriverTagMap
|
||||
/// <summary>The typed column headers, in order.</summary>
|
||||
public IReadOnlyList<string> Headers => Columns.Select(c => c.Header).ToArray();
|
||||
|
||||
/// <summary>The root TagConfig JSON keys this map owns (used to compute the export residual).</summary>
|
||||
public IReadOnlyList<string> JsonKeys => Columns.Select(c => c.JsonKey).ToArray();
|
||||
/// <summary>The root TagConfig JSON keys this map owns (used to compute the export residual) — every
|
||||
/// <see cref="CsvTypedColumn.OwnedJsonKeys"/> entry, aliases included, so the residual can never carry a
|
||||
/// spelling that out-ranks the typed column beside it (Gitea #553).</summary>
|
||||
public IReadOnlyList<string> JsonKeys => Columns.SelectMany(c => c.OwnedJsonKeys).Distinct(StringComparer.Ordinal).ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// Applies one typed column's already-parsed effect to <paramref name="target"/>: removes every
|
||||
/// <b>other</b> spelling the runtime would read for that column, so the value the column supplied is
|
||||
/// the one the driver actually sees. Callers write the column's own <see cref="CsvTypedColumn.JsonKey"/>
|
||||
/// themselves (model-backed maps do it through the model). Gitea #553.
|
||||
/// </summary>
|
||||
/// <param name="target">The merged TagConfig object being assembled.</param>
|
||||
/// <param name="column">The typed column that supplied a value on this row.</param>
|
||||
protected static void ClearOutrankingAliases(JsonObject target, CsvTypedColumn column)
|
||||
{
|
||||
foreach (var key in column.OwnedJsonKeys)
|
||||
{
|
||||
if (!string.Equals(key, column.JsonKey, StringComparison.Ordinal))
|
||||
{
|
||||
target.Remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads one typed column's <b>effective</b> cell value out of a stored TagConfig for export: the
|
||||
/// first non-blank string under <see cref="CsvTypedColumn.OwnedJsonKeys"/>, in the runtime's own
|
||||
/// precedence. Exporting the canonical key alone would print the spelling the driver is <i>ignoring</i>
|
||||
/// and leave the winning alias in the residual, which is how the re-import trap in Gitea #553 got set.
|
||||
/// </summary>
|
||||
/// <param name="source">The stored TagConfig object.</param>
|
||||
/// <param name="column">The typed column to project.</param>
|
||||
/// <returns>The effective raw node, or <c>null</c> when no spelling carries a value.</returns>
|
||||
protected static JsonNode? ReadEffective(JsonObject source, CsvTypedColumn column)
|
||||
{
|
||||
foreach (var key in column.OwnedJsonKeys)
|
||||
{
|
||||
if (!source.TryGetPropertyValue(key, out var node) || node is null) { continue; }
|
||||
if (node is JsonValue sv && sv.TryGetValue<string>(out var s) && string.IsNullOrWhiteSpace(s)) { continue; }
|
||||
return node;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Folds the typed columns present (non-blank) in <paramref name="row"/> onto
|
||||
@@ -122,18 +190,58 @@ public sealed class ModelBackedCsvDriverTagMap<TModel> : CsvDriverTagMap
|
||||
public override IReadOnlyList<CsvTypedColumn> Columns { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// <b>The base cell's own tokens are restored verbatim over the model's serialisation (Gitea #553).</b>
|
||||
/// The model round-trip is what merges the typed columns and stamps a default for a key the row
|
||||
/// declares nowhere — but <c>FromJson</c> is deliberately lenient (it has to LOAD a poisoned blob so an
|
||||
/// operator can fix it), so a <c>"dataType":"Intt16"</c> the row carried in its free-form
|
||||
/// <c>TagConfigJson</c> cell came back out of <c>ToJson</c> as the model's <i>default</i> — the
|
||||
/// operator's typo replaced by a valid-looking value before the import gate, or any later gate, could
|
||||
/// ever see it. Only keys the model does not own survived to be judged.
|
||||
/// <para>
|
||||
/// So every key the base cell actually carried is written back afterwards, unless a non-blank
|
||||
/// typed column overrode it. Nothing here decides whether a token is <i>good</i>: that stays with
|
||||
/// <see cref="TagConfigValidator"/> on the assembled blob (and with
|
||||
/// <c>IRawTreeService.ImportTagsAsync</c> behind it), which asks each driver model's own
|
||||
/// <c>Validate()</c> — one authority. This method's only job is to stop destroying the evidence
|
||||
/// before that authority is consulted.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A key a non-blank column DID override is <b>not</b> restored, and its out-ranking aliases are
|
||||
/// cleared: the typed column winning over a conflicting cell key is this map's documented contract,
|
||||
/// the review grid reports each such override by name, and refusing the row instead would leave an
|
||||
/// operator no way to repair a poisoned cell through the very columns that exist for it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public override string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary<string, string> row)
|
||||
{
|
||||
var baseObj = TagConfigJson.ParseOrNew(baseJson);
|
||||
var model = _fromJson(baseJson);
|
||||
var overridden = new HashSet<string>(StringComparer.Ordinal);
|
||||
var fired = new List<CsvTypedColumn>();
|
||||
|
||||
foreach (var col in _columns)
|
||||
{
|
||||
if (row.TryGetValue(col.Meta.Header, out var raw) && !string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
col.Set(model, raw.Trim());
|
||||
fired.Add(col.Meta);
|
||||
foreach (var key in col.Meta.OwnedJsonKeys) { overridden.Add(key); }
|
||||
}
|
||||
}
|
||||
|
||||
return _toJson(model);
|
||||
var merged = TagConfigJson.ParseOrNew(_toJson(model));
|
||||
foreach (var (key, node) in baseObj)
|
||||
{
|
||||
if (!overridden.Contains(key))
|
||||
{
|
||||
merged[key] = node?.DeepClone();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var col in fired) { ClearOutrankingAliases(merged, col); }
|
||||
|
||||
return TagConfigJson.Serialize(merged);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -214,6 +322,10 @@ public sealed class RawKeyCsvDriverTagMap : CsvDriverTagMap
|
||||
CsvRawKeyKind.Int => JsonValue.Create(CsvValues.ParseInt(col.Meta.Header, trimmed)),
|
||||
_ => JsonValue.Create(trimmed),
|
||||
};
|
||||
|
||||
// Without this the cell's alias survives beside the key just written and OUT-RANKS it at the
|
||||
// driver, so the operator's column edit is accepted and then ignored (Gitea #553).
|
||||
ClearOutrankingAliases(o, col.Meta);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +339,7 @@ public sealed class RawKeyCsvDriverTagMap : CsvDriverTagMap
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var col in _columns)
|
||||
{
|
||||
if (o.TryGetPropertyValue(col.Meta.JsonKey, out var node) && node is JsonValue v)
|
||||
if (ReadEffective(o, col.Meta) is JsonValue v)
|
||||
{
|
||||
result[col.Meta.Header] = col.Kind switch
|
||||
{
|
||||
@@ -480,10 +592,18 @@ public static class CsvColumnMap
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("MqttDeviceId", "deviceId", null), CsvRawKeyKind.String),
|
||||
}),
|
||||
|
||||
// MTConnect is the one driver whose blob accepts several spellings per field, so its two identity
|
||||
// columns declare the runtime's whole read precedence rather than just the key they write —
|
||||
// mirroring MTConnectTagDefinitionFactory.TryFromTagConfig's FirstNonBlank(...) pairs, and the
|
||||
// MTConnectTagConfigModel load that already mirrors them for the editor (Gitea #550/#553).
|
||||
new RawKeyCsvDriverTagMap(DriverTypeNames.MTConnect, new[]
|
||||
{
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("DataItemId", "fullName", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("MtDataType", "dataType", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(
|
||||
new CsvTypedColumn("DataItemId", "fullName", null, ["fullName", "dataItemId", "address"]),
|
||||
CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(
|
||||
new CsvTypedColumn("MtDataType", "dataType", null, ["driverDataType", "dataType"]),
|
||||
CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("MtCategory", "mtCategory", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("MtType", "mtType", null), CsvRawKeyKind.String),
|
||||
new CsvRawKeyColumn(new CsvTypedColumn("MtSubType", "mtSubType", null), CsvRawKeyKind.String),
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
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.Configuration.Enums;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||
using EditorTagConfigJson = ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors.TagConfigJson;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #553 — the CSV typed-column merge must hand the import gate the row <b>as the operator wrote it</b>.
|
||||
/// <para>
|
||||
/// <see cref="CsvDriverTagMap.ApplyTypedColumns"/> folded the free-form <c>TagConfigJson</c> cell through a
|
||||
/// driver model's <c>FromJson</c>/<c>ToJson</c>, and that load is deliberately lenient — it must be, so a
|
||||
/// poisoned blob can be opened and repaired. The consequence on this path was that a typo'd value under a key
|
||||
/// the model owns (<c>"dataType":"Intt16"</c>) came back out as the model's <i>default</i>, before the Gitea
|
||||
/// #551 import gate — or any later gate — could judge it. The row passed; the tag the operator described was
|
||||
/// gone. Only keys the model did not own survived to be judged, which is exactly why the sibling test
|
||||
/// <c>RawTagConfigGateTests</c> looked like it covered this and did not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// MTConnect carried the same defect from the other end. It is the one driver whose blob accepts several
|
||||
/// spellings per field, and the runtime prefers the <c>driverDataType</c> alias over the <c>dataType</c> key
|
||||
/// the <c>MtDataType</c> column writes. The export residual carried that alias (the map did not own it), so a
|
||||
/// re-imported row let it out-rank the very column the operator had just edited — the change accepted, then
|
||||
/// silently ignored at the driver.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What is asserted here is evidence preservation, never a rule about which values are legal.</b> Every
|
||||
/// verdict below is <see cref="TagConfigValidator"/>'s, reached through each driver model's own
|
||||
/// <c>Validate()</c>; this suite only proves the merge stops destroying the token before that authority is
|
||||
/// consulted, and that <see cref="IRawTreeService.ImportTagsAsync"/> behind it still reaches the same verdict
|
||||
/// on the blob that would have been committed. Falsification is documented on
|
||||
/// <see cref="Neutering_the_preservation_reproduces_the_defect_and_leaves_the_service_gate_green"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class CsvMergeEvidenceTests
|
||||
{
|
||||
private static string Csv(params string[] lines) => string.Join("\r\n", lines);
|
||||
|
||||
private static RawCsvReviewRow SingleRow(string driverType, params string[] lines)
|
||||
{
|
||||
var result = RawTagCsvMapper.Parse(driverType, Csv(lines));
|
||||
result.FatalError.ShouldBeNull();
|
||||
return result.Rows.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
private static JsonObject Blob(RawCsvReviewRow row)
|
||||
{
|
||||
row.Error.ShouldBeNull();
|
||||
row.ImportRow.ShouldNotBeNull();
|
||||
return EditorTagConfigJson.ParseOrNew(row.ImportRow!.Tag.TagConfig);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ R1: the laundering is closed
|
||||
|
||||
// The defect proper, across the three shapes a driver's dataType verdict can take: Modbus and TwinCAT
|
||||
// judge the ORIGINAL key bag through TagConfigJson.DescribeInvalidEnum (the runtime's strict-enum read);
|
||||
// S7 does the same and additionally rejects an ABSENT key; MTConnect asks the driver's own
|
||||
// IsAcceptedDataTypeName predicate. All three used to launder identically because the laundering happened
|
||||
// upstream of every one of them — in the merge, not in the verdict.
|
||||
[Theory]
|
||||
[InlineData(DriverTypeNames.Modbus, "Name,DataType,TagConfigJson", "t1,Int16,")]
|
||||
[InlineData(DriverTypeNames.S7, "Name,DataType,Address,TagConfigJson", "t1,Int16,DB1.DBW0,")]
|
||||
[InlineData(DriverTypeNames.TwinCAT, "Name,DataType,SymbolPath,TagConfigJson", "t1,Int16,MAIN.x,")]
|
||||
public void A_typod_value_in_the_cell_that_no_typed_column_overrides_is_an_error_not_a_rewrite(
|
||||
string driverType, string header, string rowPrefix)
|
||||
{
|
||||
var row = SingleRow(driverType, header, rowPrefix + "\"{\"\"dataType\"\":\"\"Intt16\"\"}\"");
|
||||
|
||||
// An ERROR, never a skip and never a silent substitution (Gitea #553 / #551 item 1).
|
||||
row.Error.ShouldNotBeNull();
|
||||
row.Error.ShouldContain("Intt16", Case.Sensitive,
|
||||
"the operator's own token must appear in the message — a default substituted for it is the defect");
|
||||
row.Error.ShouldContain("dataType");
|
||||
row.ImportRow.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_mtconnect_cell_typo_is_an_error_too_and_names_the_spelling_that_carried_it()
|
||||
{
|
||||
var row = SingleRow(
|
||||
DriverTypeNames.MTConnect,
|
||||
"Name,DataType,DataItemId,MtDataType,TagConfigJson",
|
||||
"t1,Double,di1,,\"{\"\"dataType\"\":\"\"Intt16\"\"}\"");
|
||||
|
||||
row.Error.ShouldNotBeNull();
|
||||
row.Error.ShouldContain("Intt16");
|
||||
row.Error.ShouldContain("dataType");
|
||||
}
|
||||
|
||||
// The merge is not allowed to invent a verdict of its own: the blob it produces for a poisoned row must be
|
||||
// one the driver model's OWN validator rejects, reached through the same TagConfigValidator the service
|
||||
// gate calls. This is what makes the error above a preserved token rather than a restated rule.
|
||||
[Fact]
|
||||
public void The_preserved_blob_is_rejected_by_the_drivers_own_validator_not_by_a_rule_stated_in_the_mapper()
|
||||
{
|
||||
var map = CsvColumnMap.Resolve(DriverTypeNames.Modbus).ShouldNotBeNull();
|
||||
|
||||
var merged = map.ApplyTypedColumns(
|
||||
"{\"region\":\"HoldingRegisters\",\"address\":1,\"dataType\":\"Intt16\"}",
|
||||
new Dictionary<string, string>(StringComparer.Ordinal));
|
||||
|
||||
EditorTagConfigJson.ParseOrNew(merged)["dataType"]!.GetValue<string>().ShouldBe("Intt16");
|
||||
TagConfigValidator.Validate(DriverTypeNames.Modbus, merged).ShouldNotBeNull();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ R2: fragment composition is intact
|
||||
|
||||
// The acceptance control for the whole change. The TagConfigJson cell is legitimately a FRAGMENT — the
|
||||
// typed columns supply address/dataType/etc. — so nothing here may impose a completeness demand on the
|
||||
// bare cell. (Running the full model validator over the pre-merge cell, the shape the issue itself
|
||||
// proposed, would false-reject every one of these rows and break typed columns outright.)
|
||||
[Fact]
|
||||
public void A_blank_cell_plus_typed_columns_still_composes_and_still_stamps_the_model_defaults()
|
||||
{
|
||||
var row = SingleRow(
|
||||
DriverTypeNames.Modbus,
|
||||
"Name,DataType,Region,Address,ModbusDataType,TagConfigJson",
|
||||
"t1,Int16,HoldingRegisters,7,Int32,");
|
||||
|
||||
var blob = Blob(row);
|
||||
blob["region"]!.GetValue<string>().ShouldBe("HoldingRegisters");
|
||||
blob["address"]!.GetValue<int>().ShouldBe(7);
|
||||
blob["dataType"]!.GetValue<string>().ShouldBe("Int32");
|
||||
|
||||
// Keys neither the cell nor a column declared still get the model's default, exactly as before —
|
||||
// preservation restores what the cell CARRIED, it does not suppress the model's contribution.
|
||||
blob["byteOrder"]!.GetValue<string>().ShouldBe("BigEndian");
|
||||
blob["stringLength"]!.GetValue<int>().ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_S7_row_whose_type_lives_only_in_the_typed_column_is_accepted()
|
||||
{
|
||||
// S7 is the one driver that rejects an absent dataType key (Gitea #551), so it is the sharpest
|
||||
// fragment control: the cell declares none, and the row must still pass on the column alone.
|
||||
var row = SingleRow(
|
||||
DriverTypeNames.S7,
|
||||
"Name,DataType,Address,S7DataType,TagConfigJson",
|
||||
"t1,Int16,DB1.DBW0,Int32,\"{\"\"pollHint\"\":\"\"fast\"\"}\"");
|
||||
|
||||
var blob = Blob(row);
|
||||
blob["dataType"]!.GetValue<string>().ShouldBe("Int32");
|
||||
blob["pollHint"]!.GetValue<string>().ShouldBe("fast", "an unowned cell key must still survive the merge");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_valid_cell_value_under_an_owned_key_survives_unchanged()
|
||||
{
|
||||
var row = SingleRow(
|
||||
DriverTypeNames.Modbus,
|
||||
"Name,DataType,TagConfigJson",
|
||||
"t1,Int32,\"{\"\"region\"\":\"\"Coils\"\",\"\"address\"\":42,\"\"dataType\"\":\"\"Bool\"\"}\"");
|
||||
|
||||
var blob = Blob(row);
|
||||
blob["region"]!.GetValue<string>().ShouldBe("Coils");
|
||||
blob["address"]!.GetValue<int>().ShouldBe(42);
|
||||
blob["dataType"]!.GetValue<string>().ShouldBe("Bool");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ The override-contradiction semantics
|
||||
|
||||
/// <summary>
|
||||
/// A cell token that a NON-BLANK typed column overrides is <b>accepted, and the column wins</b> —
|
||||
/// deliberately, not by omission.
|
||||
/// <list type="number">
|
||||
/// <item>
|
||||
/// Typed-column-wins is this map's documented contract, and the review grid reports each such
|
||||
/// override by name (<see cref="RawCsvReviewRow.TypedOverrodeFallback"/>), so the operator
|
||||
/// SEES it. The #549 doctrine is against <i>silent</i> rewrites; this one is announced.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// Erroring would remove the only way to repair a poisoned tag through the CSV surface: the
|
||||
/// operator would have to hand-edit the JSON cell, which is the thing typed columns exist to
|
||||
/// replace. An exported legacy row is exactly where a bad cell value meets a corrected column.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// The overridden token never reaches the stored blob, so no evidence about what the runtime
|
||||
/// will do is lost. The line the fix draws is therefore clean: <b>every token that reaches the
|
||||
/// blob is judged; a token the operator overrode is discarded openly and reported.</b>
|
||||
/// </item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void A_cell_typo_that_a_non_blank_column_overrides_is_accepted_with_the_column_winning_and_reported()
|
||||
{
|
||||
var row = SingleRow(
|
||||
DriverTypeNames.Modbus,
|
||||
"Name,DataType,Region,Address,ModbusDataType,TagConfigJson",
|
||||
"t1,Int32,HoldingRegisters,1,Int32,\"{\"\"dataType\"\":\"\"Intt16\"\"}\"");
|
||||
|
||||
var blob = Blob(row);
|
||||
blob["dataType"]!.GetValue<string>().ShouldBe("Int32");
|
||||
row.TypedOverrodeFallback.ShouldContain("ModbusDataType");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_mtconnect_alias_the_column_overrides_is_reported_as_an_override_too()
|
||||
{
|
||||
// The provenance report reads the column's whole owned key set, so an overridden "driverDataType" is
|
||||
// announced exactly as an overridden "dataType" would be — otherwise the accept-and-win decision
|
||||
// above would be silent for the one driver where it matters most.
|
||||
var row = SingleRow(
|
||||
DriverTypeNames.MTConnect,
|
||||
"Name,DataType,DataItemId,MtDataType,TagConfigJson",
|
||||
"t1,Double,di1,Int32,\"{\"\"driverDataType\"\":\"\"Float64\"\"}\"");
|
||||
|
||||
row.Error.ShouldBeNull();
|
||||
row.TypedOverrodeFallback.ShouldContain("MtDataType");
|
||||
}
|
||||
|
||||
// ---------------------------------------------- MTConnect export -> reimport, through the real runtime
|
||||
|
||||
[Fact]
|
||||
public void Export_surfaces_the_effective_type_in_the_typed_cell_and_leaves_no_alias_in_the_residual()
|
||||
{
|
||||
// The trap was set here, not at import: the residual carried the alias the driver PREFERS while the
|
||||
// MtDataType cell printed the key it ignores (or nothing at all).
|
||||
var rows = ParseCsv(RawTagCsvMapper.Export(DriverTypeNames.MTConnect, new[]
|
||||
{
|
||||
new RawCsvExportTag(null, "aliasOnly", "Double", TagAccessLevel.Read, false, null,
|
||||
"{\"fullName\":\"di1\",\"driverDataType\":\"Float64\"}"),
|
||||
new RawCsvExportTag(null, "bothSpellings", "Double", TagAccessLevel.Read, false, null,
|
||||
"{\"fullName\":\"di2\",\"driverDataType\":\"Float64\",\"dataType\":\"String\"}"),
|
||||
new RawCsvExportTag(null, "legacyIdAlias", "Double", TagAccessLevel.Read, false, null,
|
||||
"{\"address\":\"di3\",\"dataType\":\"Int32\"}"),
|
||||
}));
|
||||
|
||||
// The cell shows what the DRIVER reads, in every spelling combination…
|
||||
rows["aliasOnly"]["MtDataType"].ShouldBe("Float64");
|
||||
rows["bothSpellings"]["MtDataType"].ShouldBe("Float64");
|
||||
rows["legacyIdAlias"]["DataItemId"].ShouldBe("di3");
|
||||
|
||||
// …and the residual is empty, so a re-import cannot smuggle an out-ranking spelling back in.
|
||||
rows["aliasOnly"]["TagConfigJson"].ShouldBeEmpty();
|
||||
rows["bothSpellings"]["TagConfigJson"].ShouldBeEmpty();
|
||||
rows["legacyIdAlias"]["TagConfigJson"].ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_edited_column_beats_a_residual_alias_all_the_way_into_the_runtime_factory()
|
||||
{
|
||||
// (i) The issue's headline round-trip: an operator edits MtDataType in an exported CSV whose residual
|
||||
// still carries driverDataType (the shape produced before the export fix, and the shape a
|
||||
// hand-authored or driver-shaped blob has anyway). The edit must reach the driver.
|
||||
var row = SingleRow(
|
||||
DriverTypeNames.MTConnect,
|
||||
"Name,DataType,DataItemId,MtDataType,TagConfigJson",
|
||||
"t1,Double,di1,Int32,\"{\"\"driverDataType\"\":\"\"Float64\"\"}\"");
|
||||
|
||||
var blob = Blob(row);
|
||||
blob.ContainsKey("driverDataType").ShouldBeFalse("the out-ranking alias must be cleared, not left beside the key");
|
||||
blob["dataType"]!.GetValue<string>().ShouldBe("Int32");
|
||||
|
||||
// Asked of the real runtime parser, which is the only thing whose opinion decides the tag's type.
|
||||
MTConnectTagDefinitionFactory
|
||||
.TryFromTagConfig(row.ImportRow!.Tag.TagConfig, out var dataItemId, out var authored)
|
||||
.ShouldBeTrue();
|
||||
dataItemId.ShouldBe("di1");
|
||||
authored!.DriverDataType.ShouldBe(DriverDataType.Int32);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_empty_column_leaves_a_valid_alias_carrying_the_same_type_it_always_did()
|
||||
{
|
||||
// (ii) Nothing is overridden, so nothing is rewritten — and the type the runtime reads is unchanged.
|
||||
var row = SingleRow(
|
||||
DriverTypeNames.MTConnect,
|
||||
"Name,DataType,DataItemId,MtDataType,TagConfigJson",
|
||||
"t1,Double,di1,,\"{\"\"driverDataType\"\":\"\"Float64\"\"}\"");
|
||||
|
||||
row.Error.ShouldBeNull();
|
||||
MTConnectTagDefinitionFactory
|
||||
.TryFromTagConfig(row.ImportRow!.Tag.TagConfig, out _, out var authored)
|
||||
.ShouldBeTrue();
|
||||
authored!.DriverDataType.ShouldBe(DriverDataType.Float64);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_invalid_alias_that_no_column_overrides_is_an_error()
|
||||
{
|
||||
// (iii) The alias is read at the runtime's precedence by the model's Validate(), so the row is named
|
||||
// before commit rather than deploying a tag the driver refuses.
|
||||
var row = SingleRow(
|
||||
DriverTypeNames.MTConnect,
|
||||
"Name,DataType,DataItemId,MtDataType,TagConfigJson",
|
||||
"t1,Double,di1,,\"{\"\"driverDataType\"\":\"\"Flt64\"\"}\"");
|
||||
|
||||
row.Error.ShouldNotBeNull();
|
||||
row.Error.ShouldContain("Flt64");
|
||||
row.Error.ShouldContain("driverDataType", Case.Sensitive,
|
||||
"the message must name the spelling the operator wrote, not the canonical key");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------- Falsification note
|
||||
|
||||
/// <summary>
|
||||
/// <b>Falsification evidence (recorded, not executed).</b> Deleting the base-token restore loop from
|
||||
/// <c>ModelBackedCsvDriverTagMap.ApplyTypedColumns</c> and the
|
||||
/// <c>ClearOutrankingAliases</c>/<c>ReadEffective</c> calls turns exactly the tests in this class red
|
||||
/// and nothing else: the six laundering/alias assertions fail, while the acceptance controls above,
|
||||
/// the whole <c>RawTagCsvRoundTripTests</c> property suite, and the Gitea #551 service-gate tests in
|
||||
/// <c>RawTagConfigGateTests</c> all stay green — which is the point. This layer preserves evidence;
|
||||
/// the gate behind it, in <see cref="IRawTreeService.ImportTagsAsync"/>, still owns the verdict and is
|
||||
/// unchanged. A fix that had instead moved the verdict here would have taken those service-gate tests
|
||||
/// with it.
|
||||
/// <para>
|
||||
/// The live assertion of that layering is below: the exact blob the mapper stages is put to
|
||||
/// <see cref="TagConfigValidator"/> — the same call <c>ImportTagsAsync</c> makes — and rejected
|
||||
/// there too, so the two layers cannot drift into agreeing only by accident.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Neutering_the_preservation_reproduces_the_defect_and_leaves_the_service_gate_green()
|
||||
{
|
||||
var map = CsvColumnMap.Resolve(DriverTypeNames.MTConnect).ShouldNotBeNull();
|
||||
var empty = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
// With no column firing, the merge is a pass-through and the gate — not the merge — refuses it.
|
||||
var preserved = map.ApplyTypedColumns("{\"fullName\":\"di1\",\"driverDataType\":\"Flt64\"}", empty);
|
||||
TagConfigValidator.Validate(DriverTypeNames.MTConnect, preserved).ShouldNotBeNull();
|
||||
|
||||
// With the column firing, the merge clears the alias and the gate accepts the operator's choice.
|
||||
var overridden = map.ApplyTypedColumns(
|
||||
"{\"fullName\":\"di1\",\"driverDataType\":\"Flt64\"}",
|
||||
new Dictionary<string, string>(StringComparer.Ordinal) { ["MtDataType"] = "Int32" });
|
||||
TagConfigValidator.Validate(DriverTypeNames.MTConnect, overridden).ShouldBeNull();
|
||||
MTConnectTagDefinitionFactory.TryFromTagConfig(overridden, out _, out var authored).ShouldBeTrue();
|
||||
authored!.DriverDataType.ShouldBe(DriverDataType.Int32);
|
||||
}
|
||||
|
||||
// Re-reads an exported document into header→cell maps keyed by the row's Name column.
|
||||
private static Dictionary<string, Dictionary<string, string>> ParseCsv(string csv)
|
||||
{
|
||||
var raw = ZB.MOM.WW.OtOpcUa.Commons.Csv.CsvParser.Parse(csv);
|
||||
var headers = raw[0];
|
||||
var byName = new Dictionary<string, Dictionary<string, string>>(StringComparer.Ordinal);
|
||||
foreach (var cells in raw.Skip(1))
|
||||
{
|
||||
if (cells.Length == 1 && cells[0].Length == 0) { continue; }
|
||||
|
||||
var map = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < headers.Length; i++) { map[headers[i]] = i < cells.Length ? cells[i] : ""; }
|
||||
|
||||
byName[map["Name"]] = map;
|
||||
}
|
||||
|
||||
return byName;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user