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.ControlPlane.AdminOperations;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
///
/// Review r3 N3 (#741, fix round 4): one authority for a Modbus tag's address. Every persisted
/// TagConfig shape goes through EVERY consumer that decides, displays or rewrites the address. They
/// must all agree with the driver, under every family:
///
/// - the driver's tag reader (ModbusTagDefinitionFactory.FromTagConfig with the driver's
/// family, the exact call ModbusDriver makes at initialize);
/// - the deploy gate (EquipmentTagConfigInspector.Inspect);
/// - the editor model's load (what its fields claim), save (incl. a Writable toggle) and
/// Validate;
/// - the CSV export → import round trip through the real RawTagCsvMapper (the export residual,
/// not the stored blob, is the import base).
///
/// The expected verdict per family is pinned in the table, so the authority itself cannot drift either.
/// Three rounds converged on the rule one consumer at a time; this matrix is what makes a new divergence
/// fail in one place.
///
public sealed class ModbusAddressAuthorityAgreementTests
{
private static readonly (ModbusFamily Family, MelsecFamily Sub, string Label)[] Families =
[
(ModbusFamily.Generic, MelsecFamily.Q_L_iQR, "Generic"),
(ModbusFamily.DL205, MelsecFamily.Q_L_iQR, "DL205"),
(ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, "MELSEC-Q"),
(ModbusFamily.MELSEC, MelsecFamily.F_iQF, "MELSEC-F"),
];
///
/// Shape name, persisted blob, and the expected driver verdict per family in the order of
/// (IR5 = InputRegisters[5], - = refused).
///
public static TheoryData Shapes => new()
{
{ "keys only", """{"region":"InputRegisters","address":5}""", "IR5|IR5|IR5|IR5" },
{ "pre-lane keys + string", """{"region":"InputRegisters","address":5,"dataType":"Int16","addressString":"40011:F"}""", "IR5|IR5|IR5|IR5" },
{ "pre-lane stamped HR/0 + string",
"""{"region":"HoldingRegisters","address":0,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0,"addressString":"D100"}""",
"HR0|HR0|HR0|HR0" },
{ "string only", """{"addressString":"40011:F"}""", "HR10|HR10|HR10|HR10" },
{ "string only, family-native", """{"addressString":"D100"}""", "-|-|HR100|HR100" },
{ "string + marker + keys", """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16"}""", "HR100|HR100|HR100|HR100" },
{ "string + marker, no keys", """{"addressString":"40011:F","addressResolved":true}""", "HR10|HR10|HR10|HR10" },
{ "string + marker, no keys, family-native", """{"addressString":"D100","addressResolved":true}""", "-|-|HR100|HR100" },
{ "malformed string", """{"addressString":"ZZ9"}""", "-|-|-|-" },
{ "malformed string + marker", """{"addressString":"ZZ9","addressResolved":true}""", "-|-|-|-" },
{ "malformed string + keys", """{"region":"InputRegisters","address":5,"addressString":"ZZ9"}""", "IR5|IR5|IR5|IR5" },
{ "non-string addressString", """{"addressString":5}""", "-|-|-|-" },
{ "string + one key", """{"addressString":"40011:F","region":"InputRegisters"}""", "HR10|HR10|HR10|HR10" },
{ "no address", """{"writable":true}""", "-|-|-|-" },
{ "bit region, legacy Int16", """{"region":"Coils","address":3,"dataType":"Int16"}""", "CO3|CO3|CO3|CO3" },
};
[Theory]
[MemberData(nameof(Shapes))]
public void Every_consumer_agrees_with_the_driver_on_every_persisted_shape(string shape, string blob, string expected)
{
var want = expected.Split('|');
var anyServed = want.Any(v => v != "-");
// 1. The driver (the authority) — pinned, so the authority cannot drift unnoticed either.
for (var i = 0; i < Families.Length; i++)
{
Verdict(blob, i).ShouldBe(want[i], $"{shape}: driver / {Families[i].Label}");
}
// 2. The deploy gate: clean exactly when some family's driver serves the tag.
(EquipmentTagConfigInspector.Inspect(DriverTypeNames.Modbus, blob).Count == 0)
.ShouldBe(anyServed, $"{shape}: deploy gate accepts ⇔ some driver serves it");
// 3. Editor load: when the model presents its fields as the address, they ARE the driver's address.
var loaded = ModbusTagConfigModel.FromJson(blob);
if (!loaded.IsStringOnly)
{
for (var i = 0; i < Families.Length; i++)
{
if (want[i] == "-") continue;
FieldsVerdict(loaded).ShouldBe(want[i], $"{shape}: editor fields / {Families[i].Label}");
}
}
// 4. Editor save, untouched and after a Writable toggle (the round trip that remapped N1 / N3).
var untouched = ModbusTagConfigModel.FromJson(blob).ToJson();
var toggled = ModbusTagConfigModel.FromJson(blob);
toggled.Writable = false;
var toggledJson = toggled.ToJson();
for (var i = 0; i < Families.Length; i++)
{
Verdict(untouched, i).ShouldBe(want[i], $"{shape}: editor save / {Families[i].Label}");
Verdict(toggledJson, i).ShouldBe(want[i], $"{shape}: editor save after Writable toggle / {Families[i].Label}");
}
// 5. Validate: accepts exactly what some family's driver serves.
(ModbusTagConfigModel.FromJson(blob).Validate() is null).ShouldBe(anyServed, $"{shape}: Validate");
// 6. CSV export → import through the production mapper (the residual is the import base).
var csv = RawTagCsvMapper.Export(DriverTypeNames.Modbus,
[new RawCsvExportTag(null, "t", "Int16", TagAccessLevel.ReadWrite, false, null, blob)]);
var parsed = RawTagCsvMapper.Parse(DriverTypeNames.Modbus, csv);
parsed.FatalError.ShouldBeNull();
var row = parsed.Rows.ShouldHaveSingleItem();
if (!row.Ok)
{
anyServed.ShouldBeFalse($"{shape}: CSV import refused a tag the driver serves ({row.Error})");
}
else
{
for (var i = 0; i < Families.Length; i++)
{
Verdict(row.ImportRow!.Tag.TagConfig, i).ShouldBe(want[i], $"{shape}: CSV export → import / {Families[i].Label}");
}
}
}
private static string Verdict(string json, int familyIndex)
{
var (family, sub, _) = Families[familyIndex];
return ModbusTagDefinitionFactory.FromTagConfig(json, "T", family, sub, out var def)
? Short(def.Region) + def.Address
: "-";
}
private static string FieldsVerdict(ModbusTagConfigModel m) => Short(m.Region) + m.Address;
private static string Short(ModbusRegion r) => r switch
{
ModbusRegion.HoldingRegisters => "HR",
ModbusRegion.InputRegisters => "IR",
ModbusRegion.Coils => "CO",
ModbusRegion.DiscreteInputs => "DI",
_ => r.ToString(),
};
// ------------------------------------------------------------------ CSV metadata (review r3 m1 / m3)
private static RawCsvReviewRow RoundTrip(string blob, Func? editCsv = null)
{
var csv = RawTagCsvMapper.Export(DriverTypeNames.Modbus,
[new RawCsvExportTag(null, "t", "Int16", TagAccessLevel.ReadWrite, false, null, blob)]);
if (editCsv is not null) csv = editCsv(csv);
return RawTagCsvMapper.Parse(DriverTypeNames.Modbus, csv).Rows.ShouldHaveSingleItem();
}
/// m1: an UNCHANGED export → import of a pinned row keeps its address string and marker.
[Fact]
public void An_unchanged_csv_round_trip_of_a_pinned_row_keeps_its_address_string_and_marker()
{
const string pinned = """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""";
var row = RoundTrip(pinned);
row.Ok.ShouldBeTrue(row.Error);
var json = row.ImportRow!.Tag.TagConfig;
json.ShouldContain("\"addressString\":\"D100\"");
json.ShouldContain("\"addressResolved\":true");
row.TypedOverrodeFallback.ShouldBeEmpty("nothing was overridden");
ModbusTagConfigModel.FromJson(json).IsPinned.ShouldBeTrue();
}
/// m1 positive control: an address cell that CONTRADICTS the string drops it, and the grid says so.
[Fact]
public void A_csv_address_cell_that_contradicts_the_string_drops_it_and_the_review_grid_reports_it()
{
const string pinned = """{"addressString":"D100","addressResolved":true,"region":"HoldingRegisters","address":100,"dataType":"Int16","byteOrder":"BigEndian","bitIndex":0,"stringLength":0}""";
// Edit the exported Address cell 100 → 101.
var row = RoundTrip(pinned, csv => csv.Replace(",HoldingRegisters,100,", ",HoldingRegisters,101,", StringComparison.Ordinal));
row.Ok.ShouldBeTrue(row.Error);
var json = row.ImportRow!.Tag.TagConfig;
json.ShouldNotContain("addressString");
json.ShouldNotContain("addressResolved");
row.TypedOverrodeFallback.ShouldNotBeEmpty("the dropped string is reported, not silent");
ModbusTagDefinitionFactory.FromTagConfig(json, "T", ModbusFamily.MELSEC, MelsecFamily.Q_L_iQR, out var def).ShouldBeTrue();
def.Address.ShouldBe((ushort)101);
}
/// m3: the n5 case on an UNMARKED string-only row — typed address cells are applied and drop the string.
[Fact]
public void A_typed_csv_address_cell_on_an_unmarked_string_only_row_is_applied_and_drops_the_string()
{
var map = CsvColumnMap.Resolve(DriverTypeNames.Modbus).ShouldNotBeNull();
var merged = map.ApplyTypedColumns("""{"addressString":"40011:F"}""",
new Dictionary(StringComparer.Ordinal) { ["Region"] = "InputRegisters", ["Address"] = "7" });
merged.ShouldNotContain("addressString");
foreach (var (family, sub, label) in Families)
{
ModbusTagDefinitionFactory.FromTagConfig(merged, "T", family, sub, out var def).ShouldBeTrue(label);
def.Region.ShouldBe(ModbusRegion.InputRegisters, label);
def.Address.ShouldBe((ushort)7, label);
}
}
/// N3, by name: the marker without keys is string-only for the editor, its save and its CSV export.
[Fact]
public void A_marker_without_keys_is_string_only_and_never_saved_as_HR0()
{
var m = ModbusTagConfigModel.FromJson("""{"addressString":"40011:F","addressResolved":true}""");
m.IsStringOnly.ShouldBeTrue();
m.IsPinned.ShouldBeFalse("a marker without keys pins nothing");
m.Writable = false;
var saved = m.ToJson();
saved.ShouldNotContain("\"address\":");
saved.ShouldNotContain("\"region\":");
CsvColumnMap.Resolve(DriverTypeNames.Modbus)!.ReadTypedColumns(saved).ShouldNotContainKey("Address");
}
/// The editor never writes default keys it did not resolve: a Writable toggle on an address-less blob
/// does not author HoldingRegisters[0]; setting a field does.
[Fact]
public void An_address_less_blob_gains_keys_only_when_a_field_is_authored()
{
var m = ModbusTagConfigModel.FromJson("{}");
m.Writable = false;
m.ToJson().ShouldNotContain("\"region\":");
m.HasKeyAddress.ShouldBeFalse();
m.Address = 0; // an explicit authoring of HR[0] is honoured
m.HasKeyAddress.ShouldBeTrue();
ModbusTagDefinitionFactory.FromTagConfig(m.ToJson(), "T", out var def).ShouldBeTrue();
def.Address.ShouldBe((ushort)0);
}
}