Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CsvMergeEvidenceTests.cs
T
Joseph Doherty cf2af8a5a2 fix(csv): stop the typed-column merge laundering the TagConfigJson cell (#553)
ModelBackedCsvDriverTagMap.ApplyTypedColumns round-tripped the row's free-form
TagConfigJson cell through a driver model's FromJson/ToJson to fold the typed
columns in. That load is deliberately lenient — it has to be, so a poisoned blob
can be opened and repaired — so a typo'd value under a key the model owns
("dataType":"Intt16") came back out as the model's DEFAULT, before the #551
import gate or any later gate could judge it. The row imported clean and the tag
the operator described was gone. Only keys the model did not own survived to be
judged, which is why RawTagConfigGateTests looked like it covered this.

MTConnect carried the same defect from the export end. It is the one driver whose
blob accepts several spellings per field, and the runtime PREFERS driverDataType
over the dataType key the MtDataType column writes. The export residual carried
that alias (the map owned only the canonical key), so a re-imported row let it
out-rank the very column the operator had just edited — accepted, then silently
ignored at the driver — while the exported cell printed the spelling the driver
was ignoring, or nothing at all.

The fix preserves evidence; it does not move the verdict:

- ApplyTypedColumns writes the base cell's own tokens back over the model's
  serialisation for every key no non-blank typed column overrode. The model still
  stamps defaults for keys the row declares nowhere, so fragment cells compose
  exactly as before.
- CsvTypedColumn gains ReadKeys / OwnedJsonKeys: the full ordered set of spellings
  the RUNTIME reads for that column, mirroring TryFromTagConfig's FirstNonBlank
  pairs. A firing column clears the out-ranking aliases; the export residual drops
  them; the export cell shows the EFFECTIVE value.
- The review grid's override provenance reads the whole owned set, so an
  overridden driverDataType is announced as an overridden dataType would be.

Nothing here decides which values are legal. The verdict stays with
TagConfigValidator on the assembled blob and with IRawTreeService.ImportTagsAsync
behind it — the deliberate reason NOT to follow the issue's own "run the validator
over the pre-merge cell" direction, which would have imposed completeness on a
cell that is legitimately a fragment and false-rejected every typed-column row.

Semantics chosen for the override contradiction: a cell token that a non-blank
column overrides is ACCEPTED with the column winning, and reported by name in
TypedOverrodeFallback. Typed-column-wins is the map's documented contract, the
override is announced rather than silent, and erroring would remove the only way
to repair a poisoned tag through the CSV surface. Every token that reaches the
stored blob is judged; a token the operator overrode is discarded openly.

Falsification: neutering the restore loop + alias handling turns exactly 7 of the
15 new tests red, while RawTagConfigGateTests (#551), RawTagCsvRoundTripTests and
CsvColumnMapReflectionTests all stay green — layering, not replacement.

AdminUI 1248, MTConnect 534, Modbus 373, S7 285, TwinCAT 205 — all green.
2026-08-10 01:18:55 -04:00

356 lines
18 KiB
C#

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