diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs index 72b7e9d7..95059ebb 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawTagCsvMapper.cs @@ -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 , which re-runs it. /// +/// +/// For that gate to mean anything the merge must hand it the row as written, which it did not +/// (Gitea #553): the model-backed maps round-tripped the free-form cell through a driver model's lenient +/// FromJson/ToJson, 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. +/// 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. +/// /// 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); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs index 74ba100f..6df020d3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/CsvColumnMap.cs @@ -19,9 +19,35 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; /// a header directly onto a JSON key. /// /// The CSV column header (PascalCase, e.g. ModbusDataType). -/// The root TagConfig JSON key this column reads/writes (camelCase, e.g. dataType). +/// The root TagConfig JSON key this column writes (camelCase, e.g. dataType). /// The backing model property name, or null for a model-less map. -public sealed record CsvTypedColumn(string Header, string JsonKey, string? ModelProperty); +/// +/// The full ordered set of root JSON keys the runtime reads for this column's value, most-preferred +/// first, or null when the column owns only . Supplied for the drivers +/// whose blob accepts more than one spelling of a field (MTConnect's driverDataType/dataType +/// and fullName/dataItemId/address) — see for why the CSV +/// path has to know the whole set and not just the one it writes. +/// +public sealed record CsvTypedColumn( + string Header, + string JsonKey, + string? ModelProperty, + IReadOnlyList? ReadKeys = null) +{ + /// + /// Every root JSON key this column speaks for, in the runtime's read precedence — i.e. + /// when supplied, else just . + /// + /// A column that writes dataType while the driver prefers a driverDataType alias does + /// not actually control the tag's type: the alias, carried through in the free-form + /// TagConfigJson 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 effective value, and a firing + /// column clear the alias so it genuinely wins. + /// + /// + public IReadOnlyList OwnedJsonKeys { get; } = ReadKeys is { Count: > 0 } ? ReadKeys : [JsonKey]; +} /// /// Per-driver mapping between a CSV row's typed columns and a tag's driver-specific @@ -46,8 +72,50 @@ public abstract class CsvDriverTagMap /// The typed column headers, in order. public IReadOnlyList Headers => Columns.Select(c => c.Header).ToArray(); - /// The root TagConfig JSON keys this map owns (used to compute the export residual). - public IReadOnlyList JsonKeys => Columns.Select(c => c.JsonKey).ToArray(); + /// The root TagConfig JSON keys this map owns (used to compute the export residual) — every + /// entry, aliases included, so the residual can never carry a + /// spelling that out-ranks the typed column beside it (Gitea #553). + public IReadOnlyList JsonKeys => Columns.SelectMany(c => c.OwnedJsonKeys).Distinct(StringComparer.Ordinal).ToArray(); + + /// + /// Applies one typed column's already-parsed effect to : removes every + /// other 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 + /// themselves (model-backed maps do it through the model). Gitea #553. + /// + /// The merged TagConfig object being assembled. + /// The typed column that supplied a value on this row. + 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); + } + } + } + + /// + /// Reads one typed column's effective cell value out of a stored TagConfig for export: the + /// first non-blank string under , in the runtime's own + /// precedence. Exporting the canonical key alone would print the spelling the driver is ignoring + /// and leave the winning alias in the residual, which is how the re-import trap in Gitea #553 got set. + /// + /// The stored TagConfig object. + /// The typed column to project. + /// The effective raw node, or null when no spelling carries a value. + 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(out var s) && string.IsNullOrWhiteSpace(s)) { continue; } + return node; + } + + return null; + } /// /// Folds the typed columns present (non-blank) in onto @@ -122,18 +190,58 @@ public sealed class ModelBackedCsvDriverTagMap : CsvDriverTagMap public override IReadOnlyList Columns { get; } /// + /// + /// The base cell's own tokens are restored verbatim over the model's serialisation (Gitea #553). + /// The model round-trip is what merges the typed columns and stamps a default for a key the row + /// declares nowhere — but FromJson is deliberately lenient (it has to LOAD a poisoned blob so an + /// operator can fix it), so a "dataType":"Intt16" the row carried in its free-form + /// TagConfigJson cell came back out of ToJson as the model's default — 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. + /// + /// 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 good: that stays with + /// on the assembled blob (and with + /// IRawTreeService.ImportTagsAsync behind it), which asks each driver model's own + /// Validate() — one authority. This method's only job is to stop destroying the evidence + /// before that authority is consulted. + /// + /// + /// A key a non-blank column DID override is not 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. + /// + /// public override string ApplyTypedColumns(string? baseJson, IReadOnlyDictionary row) { + var baseObj = TagConfigJson.ParseOrNew(baseJson); var model = _fromJson(baseJson); + var overridden = new HashSet(StringComparer.Ordinal); + var fired = new List(); + 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); } /// @@ -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(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), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CsvMergeEvidenceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CsvMergeEvidenceTests.cs new file mode 100644 index 00000000..337c6a34 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CsvMergeEvidenceTests.cs @@ -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; + +/// +/// Gitea #553 — the CSV typed-column merge must hand the import gate the row as the operator wrote it. +/// +/// folded the free-form TagConfigJson cell through a +/// driver model's FromJson/ToJson, 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 ("dataType":"Intt16") came back out as the model's default, 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 +/// RawTagConfigGateTests looked like it covered this and did not. +/// +/// +/// 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 driverDataType alias over the dataType key +/// the MtDataType 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. +/// +/// +/// What is asserted here is evidence preservation, never a rule about which values are legal. Every +/// verdict below is 's, reached through each driver model's own +/// Validate(); this suite only proves the merge stops destroying the token before that authority is +/// consulted, and that behind it still reaches the same verdict +/// on the blob that would have been committed. Falsification is documented on +/// . +/// +/// +[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(StringComparer.Ordinal)); + + EditorTagConfigJson.ParseOrNew(merged)["dataType"]!.GetValue().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().ShouldBe("HoldingRegisters"); + blob["address"]!.GetValue().ShouldBe(7); + blob["dataType"]!.GetValue().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().ShouldBe("BigEndian"); + blob["stringLength"]!.GetValue().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().ShouldBe("Int32"); + blob["pollHint"]!.GetValue().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().ShouldBe("Coils"); + blob["address"]!.GetValue().ShouldBe(42); + blob["dataType"]!.GetValue().ShouldBe("Bool"); + } + + // ------------------------------------------------------------ The override-contradiction semantics + + /// + /// A cell token that a NON-BLANK typed column overrides is accepted, and the column wins — + /// deliberately, not by omission. + /// + /// + /// Typed-column-wins is this map's documented contract, and the review grid reports each such + /// override by name (), so the operator + /// SEES it. The #549 doctrine is against silent rewrites; this one is announced. + /// + /// + /// 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. + /// + /// + /// 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: every token that reaches the + /// blob is judged; a token the operator overrode is discarded openly and reported. + /// + /// + /// + [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().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().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 + + /// + /// Falsification evidence (recorded, not executed). Deleting the base-token restore loop from + /// ModelBackedCsvDriverTagMap.ApplyTypedColumns and the + /// ClearOutrankingAliases/ReadEffective 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 RawTagCsvRoundTripTests property suite, and the Gitea #551 service-gate tests in + /// RawTagConfigGateTests all stay green — which is the point. This layer preserves evidence; + /// the gate behind it, in , 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. + /// + /// The live assertion of that layering is below: the exact blob the mapper stages is put to + /// — the same call ImportTagsAsync makes — and rejected + /// there too, so the two layers cannot drift into agreeing only by accident. + /// + /// + [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(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(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> ParseCsv(string csv) + { + var raw = ZB.MOM.WW.OtOpcUa.Commons.Csv.CsvParser.Parse(csv); + var headers = raw[0]; + var byName = new Dictionary>(StringComparer.Ordinal); + foreach (var cells in raw.Skip(1)) + { + if (cells.Length == 1 && cells[0].Length == 0) { continue; } + + var map = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < headers.Length; i++) { map[headers[i]] = i < cells.Length ? cells[i] : ""; } + + byName[map["Name"]] = map; + } + + return byName; + } +}