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; } }