Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CsvMergeEvidenceTests.cs
T
Joseph Doherty 87a7414df7 fix(s7): refuse unservable tag shapes per tag at every gate (R07:H3, R07:U4)
The init-time tag guards THREW: one mis-authored tag (DB1.DBZ0, DB1.DBW0 typed
Float64, T5 typed Int32, a writable Bool array) faulted the whole driver and the
actor retried the same init every 10 s forever, darkening every tag on the PLC.
Neither the editor nor the deploy gate checked any of it.

- S7AddressParser moves to S7.Contracts (same namespace) and a new
  S7TagShapeRules there is the single authority on servable shapes.
- S7TagDefinitionFactory.FromTagConfig applies it, so the AdminUI editor, the
  deploy gate (Inspect, with the specific reason) and the driver init refuse
  the same tags. Init now skips a refused tag (ISkippedTagReporter) instead of
  throwing; the throwing RejectUnsupportedTagConfigs is gone.
- New rules on top of the old guards: a narrow type at the wrong access width
  (every read failed) and a Bool array not starting at bit 0 (U4: DB1.DBX0.3
  silently read bit 0 of bytes 0..n).
- AdminUI test fixtures that used addresses the driver could never serve
  (DB1.DBS0, a browse "SomeAddress", Int32/Float32 on DBW) now use real ones.

Refs #742
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 15:04:19 -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.DBD0,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;
}
}