@@ -0,0 +1,570 @@
using Shouldly ;
using Xunit ;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors ;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions ;
using ZB.MOM.WW.OtOpcUa.Driver.AbCip ;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy ;
using ZB.MOM.WW.OtOpcUa.Driver.Calculation ;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS ;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus ;
using ZB.MOM.WW.OtOpcUa.Driver.Mqtt ;
using ZB.MOM.WW.OtOpcUa.Driver.MTConnect ;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient ;
using ZB.MOM.WW.OtOpcUa.Driver.S7 ;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts ;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT ;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns ;
/// <summary>
/// Gitea #554 — the NON-ENUM half of the authoring gate. Gitea #549/#551 made every typed editor judge
/// its driver's enum KEYS against the runtime's own reader, and stopped there: every other reason a
/// driver's reader refuses a tag — an absent Modbus <c>region</c>, an <c>"address":"notanumber"</c>, an
/// S7 <c>stringLength</c> of 300, a TwinCAT <c>dataType:"Structure"</c>, a Sql blob the parser cannot
/// recognise as Sql at all — met no gate until deploy, or until the driver skipped the tag. Each model
/// now consults its driver's own parse (see <c>TagConfigParseGate</c>).
/// <para>
/// <b>Every rejection below is paired with its nearest VALID sibling</b>, so a gate that simply
/// refuses everything cannot pass this suite — the same discipline <c>RawTagConfigGateTests</c> uses.
/// </para>
/// <para>
/// ⚠️ <b>The two directions are not symmetric, and the asymmetry is the design.</b>
/// Authoring-accepts ⇒ runtime-accepts is an ABSOLUTE rule: a blob the modal lets an operator save
/// and the driver then skips is the defect this issue names. Runtime-accepts ⇒ authoring-accepts
/// holds only where the editor is not deliberately stricter — several are, for reasons that are
/// load-bearing and documented on the models themselves. Those are exempted BY NAME below, never by
/// weakening the theory.
/// </para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class TagConfigNonEnumParityTests
{
// ---------------------------------------------------------------------------------------------
// The runtime side of the parity claim.
//
// This is a second dispatch map, and deliberately so: EquipmentTagConfigInspector cannot stand in for
// it, because three inspectors (FOCAS, MQTT, Calculation) emit ADVISORIES for blobs their parse
// accepts — "Inspect is empty" is therefore a strictly stronger statement than "the parse accepts",
// and using it here would quietly assert the very over-strictness caution this suite exists to guard.
// The map is covered by RuntimeParses_covers_every_mapped_driver below, so it cannot silently drift.
// ---------------------------------------------------------------------------------------------
private const string P = "(test)" ;
private static readonly IReadOnlyDictionary < string , Func < string , bool > > RuntimeParses =
new Dictionary < string , Func < string , bool > > ( StringComparer . OrdinalIgnoreCase )
{
[DriverTypeNames.Modbus] = j = > ModbusTagDefinitionFactory . FromTagConfig ( j , P , out _ ) ,
[DriverTypeNames.S7] = j = > S7TagDefinitionFactory . FromTagConfig ( j , P , out _ ) ,
[DriverTypeNames.AbCip] = j = > AbCipTagDefinitionFactory . FromTagConfig ( j , P , out _ ) ,
[DriverTypeNames.AbLegacy] = j = > AbLegacyTagDefinitionFactory . FromTagConfig ( j , P , out _ ) ,
[DriverTypeNames.TwinCAT] = j = > TwinCATTagDefinitionFactory . FromTagConfig ( j , P , out _ ) ,
[DriverTypeNames.FOCAS] = j = > FocasTagDefinitionFactory . FromTagConfig ( j , P , out _ ) ,
// The union, matching MqttTagDefinitionFactory.Inspect's own accept gate: a blob is served if
// EITHER shape reads it.
[DriverTypeNames.Mqtt] = j = > MqttTagDefinitionFactory . FromTagConfig ( j , P , out _ )
| | MqttTagDefinitionFactory . FromSparkplugTagConfig ( j , P , out _ ) ,
[DriverTypeNames.Sql] = j = > SqlEquipmentTagParser . TryParse ( j , P , out _ ) ,
[DriverTypeNames.MTConnect] = j = > MTConnectTagDefinitionFactory . TryFromTagConfig ( j , out _ , out _ ) ,
[DriverTypeNames.Calculation] = j = > CalculationTagDefinitionFactory . TryReadTagConfig ( j , out _ ) ,
[DriverTypeNames.OpcUaClient] = j = > OpcUaClientTagDefinitionFactory . TryReadNodeId ( j , out _ ) ,
} ;
private static bool RuntimeAccepts ( string driverType , string blob ) = > RuntimeParses [ driverType ] ( blob ) ;
private static bool AuthoringAccepts ( string driverType , string blob )
= > TagConfigValidator . Validate ( driverType , blob ) is null ;
/// <summary>The map above must name every driver the authoring gate dispatches on, or a driver could
/// drop out of the fleet theories without a single test going red.</summary>
[Fact]
public void RuntimeParses_covers_every_mapped_driver ( )
{
string [ ] mapped =
[
DriverTypeNames.Modbus, DriverTypeNames.S7, DriverTypeNames.AbCip, DriverTypeNames.AbLegacy,
DriverTypeNames.TwinCAT, DriverTypeNames.FOCAS, DriverTypeNames.Mqtt, DriverTypeNames.Sql,
DriverTypeNames.MTConnect, DriverTypeNames.Calculation, DriverTypeNames.OpcUaClient,
] ;
foreach ( var driverType in mapped )
{
TagConfigValidator . Validate ( driverType , "{}" ) . ShouldNotBeNull (
$"{driverType} is expected to have a typed authoring validator that refuses an empty blob "
+ "(every mapped driver's reader does)." ) ;
RuntimeParses . ShouldContainKey ( driverType ) ;
}
RuntimeParses . Count . ShouldBe ( mapped . Length ) ;
}
// =============================================================================================
// 1. Per-driver: the formerly-invisible rejections, each with its nearest valid sibling.
// =============================================================================================
/// <summary>
/// Modbus is where the issue's own example lives (<c>"address":"notanumber"</c>), and it was the one
/// mapped editor with NO required field at all — <c>{}</c>, which is exactly what the modal persists
/// for a tag whose config the operator never touched, validated clean and then read
/// <c>BadNodeIdUnknown</c> forever.
/// </summary>
[Theory]
[InlineData("{}", "an empty blob names neither a region nor an address")]
[InlineData("""{"address":1}""", "no 'region' key — the one Modbus enum that is not defaulted from absent")]
[InlineData("""{"region":"HoldingRegisters"}""", "no 'address'")]
[InlineData("""{"region":"HoldingRegisters","address":"notanumber"}""", "the issue's own example")]
[InlineData("""{"region":"HoldingRegisters","address":-1}""", "below the 0– 65535 register range")]
[InlineData("""{"region":"HoldingRegisters","address":70000}""", "above the 0– 65535 register range")]
[InlineData("""{"region":"HoldingRegisters","address":1.5}""", "not a whole register address")]
[InlineData("""{"region":"HoldingRegisters","address":1,"dataType":"String","stringLength":0}""",
"a zero-length string reads zero registers — an illegal Modbus request")]
[InlineData("""{"region":"HoldingRegisters","address":1,"dataType":"BitInRegister","bitIndex":16}""",
"a bit index above 15 overflows the 16-bit register mask")]
public void Modbus_refuses_what_its_reader_refuses ( string blob , string why )
{
RuntimeAccepts ( DriverTypeNames . Modbus , blob ) . ShouldBeFalse (
$"the reader must refuse this blob ({why}), or the authoring assertion below proves nothing." ) ;
TagConfigValidator . Validate ( DriverTypeNames . Modbus , blob ) . ShouldNotBeNullOrEmpty (
$"authoring accepted a Modbus blob the driver skips: {why}." ) ;
}
/// <summary>The paired acceptance controls — including two the narrowing casts make legal.</summary>
[Theory]
[InlineData("""{"region":"HoldingRegisters","address":1}""")]
[InlineData("""{"region":"HoldingRegisters","address":0}""")]
[InlineData("""{"region":"Coils","address":65535}""")]
[InlineData("""{"region":"HoldingRegisters","address":1,"dataType":"String","stringLength":16}""")]
[InlineData("""{"region":"HoldingRegisters","address":1,"dataType":"BitInRegister","bitIndex":15}""")]
// ⚠️ THE NARROWING CAST IS PART OF THE RULE, not an accident of the mapper: it reads `(byte)bitIndex`,
// so 256 narrows to bit 0 and the driver SERVES the tag. Authoring must accept it too — refusing the
// un-narrowed number here would make the modal stricter than the runtime.
[InlineData("""{"region":"HoldingRegisters","address":1,"dataType":"BitInRegister","bitIndex":256}""")]
// Same for stringLength, which narrows to ushort: 65537 becomes 1, which is >= 1 and is served.
[InlineData("""{"region":"HoldingRegisters","address":1,"dataType":"String","stringLength":65537}""")]
// A stringLength of 0 only matters for a String tag; on an Int16 tag the reader ignores it.
[InlineData("""{"region":"HoldingRegisters","address":1,"dataType":"Int16","stringLength":0}""")]
public void Modbus_accepts_what_its_reader_accepts ( string blob )
{
RuntimeAccepts ( DriverTypeNames . Modbus , blob ) . ShouldBeTrue (
"the reader must accept this blob, or this control is testing the wrong thing." ) ;
TagConfigValidator . Validate ( DriverTypeNames . Modbus , blob ) . ShouldBeNull (
"authoring refused a Modbus blob the driver serves." ) ;
}
/// <summary>S7's remaining non-enum refusal: a <c>String</c> tag whose <c>stringLength</c> falls outside
/// 0– 254. The reader REJECTS it (it does not clamp), so the tag would never resolve.</summary>
[Theory]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":255}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":300}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":-1}""")]
public void S7_refuses_a_string_length_its_reader_refuses ( string blob )
{
RuntimeAccepts ( DriverTypeNames . S7 , blob ) . ShouldBeFalse (
"the reader must refuse this blob, or the authoring assertion below proves nothing." ) ;
TagConfigValidator . Validate ( DriverTypeNames . S7 , blob ) . ShouldNotBeNullOrEmpty ( ) ;
}
/// <summary>The paired controls: an in-range String tag, the boundary value, and — the shape that
/// stops this becoming an over-rejection — an out-of-range <c>stringLength</c> on a tag that is not a
/// String at all, which the reader ignores entirely.</summary>
[Theory]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":10}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":254}""")]
[InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":0}""")]
[InlineData("""{"address":"DB1.DBW0","dataType":"Int16","stringLength":300}""")]
public void S7_accepts_a_string_length_its_reader_accepts ( string blob )
{
RuntimeAccepts ( DriverTypeNames . S7 , blob ) . ShouldBeTrue (
"the reader must accept this blob, or this control is testing the wrong thing." ) ;
TagConfigValidator . Validate ( DriverTypeNames . S7 , blob ) . ShouldBeNull ( ) ;
}
/// <summary>
/// TwinCAT's <c>Structure</c> is the sharpest case in the whole survey: it is a perfectly VALID
/// <see cref="TwinCATDataType"/> name, so every spelling gate — including the #549 enum check —
/// waves it through, and the reader refuses it anyway because the driver does not serve UDT /
/// FB-instance tags. Nothing but asking the reader could have caught it.
/// </summary>
[Fact]
public void TwinCAT_refuses_a_Structure_tag_its_reader_refuses ( )
{
const string blob = "" "{" symbolPath ":" MAIN . stMotor "," dataType ":" Structure "}" "" ;
Enum . TryParse < TwinCATDataType > ( "Structure" , out _ ) . ShouldBeTrue (
"if 'Structure' stopped being a valid enum name the #549 check would catch it and this test "
+ "would no longer be about #554 at all." ) ;
RuntimeAccepts ( DriverTypeNames . TwinCAT , blob ) . ShouldBeFalse ( ) ;
TagConfigValidator . Validate ( DriverTypeNames . TwinCAT , blob ) . ShouldNotBeNullOrEmpty ( ) ;
}
/// <summary>The paired acceptance: the same symbol with an atomic type.</summary>
[Fact]
public void TwinCAT_accepts_an_atomic_tag_its_reader_accepts ( )
{
const string blob = "" "{" symbolPath ":" MAIN . stMotor "," dataType ":" DInt "}" "" ;
RuntimeAccepts ( DriverTypeNames . TwinCAT , blob ) . ShouldBeTrue ( ) ;
TagConfigValidator . Validate ( DriverTypeNames . TwinCAT , blob ) . ShouldBeNull ( ) ;
}
/// <summary>
/// Sql's RECOGNITION rule — <c>SqlEquipmentTagParser</c> refuses a blob that carries neither a
/// <c>model</c> key nor <c>"driver":"Sql"</c>, because it cannot tell a Sql tag from any other
/// driver's config. Every field below is otherwise complete and correct, which is what made this
/// invisible: nothing the model checks is wrong with it.
/// </summary>
[Fact]
public void Sql_refuses_a_blob_its_parser_does_not_recognise_as_Sql ( )
{
const string blob = "" "{" table ":" dbo . TagValues "," keyColumn ":" TagName "," valueColumn ":" Value "," keyValue ":" L1 "}" "" ;
RuntimeAccepts ( DriverTypeNames . Sql , blob ) . ShouldBeFalse ( ) ;
TagConfigValidator . Validate ( DriverTypeNames . Sql , blob ) . ShouldNotBeNullOrEmpty ( ) ;
}
/// <summary>The paired acceptances: either recognition key rescues the identical blob.</summary>
[Theory]
[InlineData("""{"model":"KeyValue","table":"dbo.TagValues","keyColumn":"TagName","valueColumn":"Value","keyValue":"L1"}""")]
[InlineData("""{"driver":"Sql","table":"dbo.TagValues","keyColumn":"TagName","valueColumn":"Value","keyValue":"L1"}""")]
public void Sql_accepts_a_recognised_blob ( string blob )
{
RuntimeAccepts ( DriverTypeNames . Sql , blob ) . ShouldBeTrue ( ) ;
TagConfigValidator . Validate ( DriverTypeNames . Sql , blob ) . ShouldBeNull ( ) ;
}
/// <summary>MQTT's <c>qos</c>: the reader takes a JSON NUMBER in 0– 2 and refuses anything else outright
/// (it does not clamp). A JSON <c>null</c> is the shape the model's own QoS check read as "absent".</summary>
[Theory]
[InlineData("""{"topic":"otopcua/fixture/a","qos":null}""")]
[InlineData("""{"topic":"otopcua/fixture/a","qos":3}""")]
[InlineData("""{"topic":"otopcua/fixture/a","qos":"1"}""")]
public void Mqtt_refuses_a_qos_its_reader_refuses ( string blob )
{
RuntimeAccepts ( DriverTypeNames . Mqtt , blob ) . ShouldBeFalse ( ) ;
TagConfigValidator . Validate ( DriverTypeNames . Mqtt , blob ) . ShouldNotBeNullOrEmpty ( ) ;
}
/// <summary>The paired acceptances: a legal QoS, and an absent one.</summary>
[Theory]
[InlineData("""{"topic":"otopcua/fixture/a","qos":1}""")]
[InlineData("""{"topic":"otopcua/fixture/a","qos":0}""")]
[InlineData("""{"topic":"otopcua/fixture/a","qos":2}""")]
[InlineData("""{"topic":"otopcua/fixture/a"}""")]
public void Mqtt_accepts_a_qos_its_reader_accepts ( string blob )
{
RuntimeAccepts ( DriverTypeNames . Mqtt , blob ) . ShouldBeTrue ( ) ;
TagConfigValidator . Validate ( DriverTypeNames . Mqtt , blob ) . ShouldBeNull ( ) ;
}
// =============================================================================================
// 2. The advisory pin — the caution that shapes the whole implementation.
// =============================================================================================
/// <summary>
/// ⚠️ <b>An inspector's advisory must never become an authoring error.</b> FOCAS's <c>Inspect</c>
/// emits warnings for blobs its reader ACCEPTS — an authored <c>writable:true</c> on a read-only
/// wire is one — so a gate built on "the inspection is non-empty" would refuse configs the driver
/// serves and leave rows nobody can save (the #520/#535 uneditable-rows trap). The gate is the PARSE
/// verdict; the inspector is consulted only to word a refusal that has already happened.
/// <para>
/// Both halves are asserted, so this cannot pass vacuously: the advisory really is emitted, and
/// authoring really does accept anyway.
/// </para>
/// </summary>
[Fact]
public void A_FOCAS_blob_that_draws_an_advisory_but_parses_clean_is_accepted_at_authoring ( )
{
const string blob = "" "{" address ":" R100 "," dataType ":" Int32 "," writable ":true}" "" ;
FocasTagDefinitionFactory . Inspect ( blob ) . ShouldNotBeEmpty (
"this blob must actually draw an advisory, or the pin below proves nothing." ) ;
RuntimeAccepts ( DriverTypeNames . FOCAS , blob ) . ShouldBeTrue (
"the FOCAS reader accepts an authored writable:true — it is the WIRE that refuses the write." ) ;
TagConfigValidator . Validate ( DriverTypeNames . FOCAS , blob ) . ShouldBeNull (
"authoring refused a FOCAS blob the driver serves — an advisory was promoted to an error." ) ;
}
/// <summary>The same shape at the other driver whose inspector advises on accepted blobs: a
/// Calculation tag with a <c>timerIntervalMs</c> the reader silently discards.</summary>
[Fact]
public void A_Calculation_blob_that_draws_an_advisory_but_parses_clean_is_accepted_at_authoring ( )
{
const string blob = "" "{" scriptId ":" S1 "," changeTriggered ":true," timerIntervalMs ":" 500 "}" "" ;
CalculationTagDefinitionFactory . Inspect ( blob ) . ShouldNotBeEmpty (
"this blob must actually draw an advisory, or the pin below proves nothing." ) ;
RuntimeAccepts ( DriverTypeNames . Calculation , blob ) . ShouldBeTrue ( ) ;
TagConfigValidator . Validate ( DriverTypeNames . Calculation , blob ) . ShouldBeNull ( ) ;
}
// =============================================================================================
// 3. The fleet theories, over one shared corpus.
// =============================================================================================
/// <summary>
/// Representative blobs spanning all eleven mapped drivers, both readable and unreadable shapes.
/// One corpus feeds both directions below, so a blob added for one is checked by the other.
/// </summary>
public static TheoryData < string , string > Corpus ( )
{
var data = new TheoryData < string , string > ( ) ;
foreach ( var ( driverType , blob ) in Blobs ) { data . Add ( driverType , blob ) ; }
return data ;
}
private static readonly ( string DriverType , string Blob ) [ ] Blobs =
[
(DriverTypeNames.Modbus, "{}"),
(DriverTypeNames.Modbus, """{"region":"HoldingRegisters","address":1}"""),
(DriverTypeNames.Modbus, """{"region":"HoldingRegisters","address":"notanumber"}"""),
(DriverTypeNames.Modbus, """{"region":"HoldingRegisters","address":1,"dataType":"String","stringLength":8}"""),
(DriverTypeNames.Modbus, """{"region":"HoldingRegisters","address":1,"dataType":"String"}"""),
(DriverTypeNames.Modbus, """{"region":"HoldingRegisterz","address":1}"""),
(DriverTypeNames.S7, "{}"),
(DriverTypeNames.S7, """{"address":"DB1.DBW0","dataType":"Int16"}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBW0"}"""),
(DriverTypeNames.S7, """{"address":" ","dataType":"Int16"}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBS0","dataType":"String","stringLength":300}"""),
(DriverTypeNames.S7, """{"address":"DB1.DBS0","dataType":"String","stringLength":24}"""),
(DriverTypeNames.AbCip, "{}"),
(DriverTypeNames.AbCip, """{"tagPath":"Program:Main.Speed"}"""),
(DriverTypeNames.AbCip, """{"tagPath":"Program:Main.Speed","dataType":"Structure","members":[{"name":"Run","dataType":"Bool"}] } "" "),
( DriverTypeNames . AbCip , "" "{" tagPath ":" Motor "," members ":[{" dataType ":" Bool "}]}" "" ) ,
( DriverTypeNames . AbCip , "" "{" tagPath ":" Motor "," members ":[]}" "" ) ,
( DriverTypeNames . AbCip , "" "{" tagPath ":1}" "" ) ,
( DriverTypeNames . AbLegacy , "{}" ) ,
( DriverTypeNames . AbLegacy , "" "{" address ":" N7 : 0 "," dataType ":" Int "}" "" ) ,
( DriverTypeNames . AbLegacy , "" "{" address ":" N7 : 0 "," isArray ":true," arrayLength ":10}" "" ) ,
( DriverTypeNames . TwinCAT , "{}" ) ,
( DriverTypeNames . TwinCAT , "" "{" symbolPath ":" MAIN . bStart "," dataType ":" Bool "}" "" ) ,
( DriverTypeNames . TwinCAT , "" "{" symbolPath ":" MAIN . stM "," dataType ":" Structure "}" "" ) ,
( DriverTypeNames . TwinCAT , "" "{" symbolPath ":" MAIN . x "," isArray ":true," arrayLength ":0}" "" ) ,
( DriverTypeNames . FOCAS , "{}" ) ,
( DriverTypeNames . FOCAS , "" "{" address ":" R100 "," dataType ":" Int32 "}" "" ) ,
( DriverTypeNames . FOCAS , "" "{" address ":" PARAM : 1815 / 0 "}" "" ) ,
( DriverTypeNames . FOCAS , "" "{" address ":" R100 "," writable ":true}" "" ) ,
( DriverTypeNames . Mqtt , "{}" ) ,
( DriverTypeNames . Mqtt , "" "{" topic ":" otopcua / fixture / a "," dataType ":" Float64 "}" "" ) ,
( DriverTypeNames . Mqtt , "" "{" topic ":" otopcua / fixture / a "," qos ":3}" "" ) ,
( DriverTypeNames . Mqtt , "" "{" topic ":" otopcua / fixture / a "," qos ":2}" "" ) ,
( DriverTypeNames . Mqtt , "" "{" groupId ":" OtOpcUaSim "," edgeNodeId ":" EdgeA "," metricName ":" Filler / Speed "}" "" ) ,
( DriverTypeNames . Mqtt , "" "{" groupId ":" OtOpcUaSim "," edgeNodeId ":" EdgeA "}" "" ) ,
( DriverTypeNames . Sql , "{}" ) ,
( DriverTypeNames . Sql , "" "{" model ":" KeyValue "," table ":" dbo . T "," keyColumn ":" k "," valueColumn ":" v "," keyValue ":" x "}" "" ) ,
( DriverTypeNames . Sql , "" "{" table ":" dbo . T "," keyColumn ":" k "," valueColumn ":" v "," keyValue ":" x "}" "" ) ,
( DriverTypeNames . Sql , "" "{" model ":" Query "," table ":" dbo . T "}" "" ) ,
( DriverTypeNames . Sql , "" "{" model ":" WideRow "," table ":" dbo . T "," columnName ":" Speed "," rowSelector ":{" whereColumn ":" LineId "," whereValue ":" L1 "}}" "" ) ,
( DriverTypeNames . Sql , "" "{" model ":" WideRow "," table ":" dbo . T "," columnName ":" Speed "}" "" ) ,
( DriverTypeNames . MTConnect , "{}" ) ,
( DriverTypeNames . MTConnect , "" "{" fullName ":" Xact "}" "" ) ,
( DriverTypeNames . MTConnect , "" "{" fullName ":" Xact "," dataType ":" Float64 "}" "" ) ,
( DriverTypeNames . MTConnect , "" "{" dataItemId ":" Xact "}" "" ) ,
( DriverTypeNames . MTConnect , "" "{" fullName ":" Xact "," driverDataType ":" Strng "}" "" ) ,
( DriverTypeNames . Calculation , "{}" ) ,
( DriverTypeNames . Calculation , "" "{" scriptId ":" S1 "}" "" ) ,
( DriverTypeNames . Calculation , "" "{" scriptId ":" S1 "," changeTriggered ":true," timerIntervalMs ":5000}" "" ) ,
( DriverTypeNames . Calculation , "" "{" scriptId ":" "}" "" ) ,
( DriverTypeNames . OpcUaClient , "{}" ) ,
( DriverTypeNames . OpcUaClient , "" "{" nodeId ":" nsu = urn : plc : sim ; s = Line1 . Speed "}" "" ) ,
( DriverTypeNames . OpcUaClient , "" "{" nodeId ":" "}" "" ) ,
( DriverTypeNames . OpcUaClient , "" "{" nodeId ":5}" "" ) ,
] ;
/// <summary>
/// ⚠️ <b>The anti-vacuity guard for both fleet theories.</b> Each of them early-returns on the half
/// of the corpus it does not apply to, so a corpus that happened to be all-rejected (or all-accepted)
/// for some driver would let that driver pass with nothing asserted about it at all. Every mapped
/// driver must therefore contribute at least one blob its reader SERVES and one it REFUSES.
/// </summary>
[Fact]
public void The_corpus_exercises_both_verdicts_for_every_mapped_driver ( )
{
foreach ( var driverType in RuntimeParses . Keys )
{
var mine = Blobs . Where ( b = > b . DriverType = = driverType ) . ToList ( ) ;
mine . ShouldNotBeEmpty ( $"{driverType} contributes no blob to the corpus." ) ;
mine . ShouldContain (
b = > RuntimeAccepts ( b . DriverType , b . Blob ) ,
$"{driverType}: every corpus blob is REFUSED by its reader, so "
+ "Whatever_the_runtime_parse_accepts_is_authorable passes vacuously for it." ) ;
mine . ShouldContain (
b = > ! RuntimeAccepts ( b . DriverType , b . Blob ) ,
$"{driverType}: every corpus blob is SERVED by its reader, so "
+ "Whatever_authoring_accepts_the_runtime_parse_accepts never exercises a refusal for it." ) ;
}
}
/// <summary>
/// <b>The absolute direction.</b> Anything the modal lets an operator save must be something the
/// driver's reader will actually read — otherwise the tag deploys green and reads
/// <c>BadNodeIdUnknown</c> forever, which is the whole of Gitea #554. There are no exemptions from
/// this one, and there must never be.
/// </summary>
[Theory]
[MemberData(nameof(Corpus))]
public void Whatever_authoring_accepts_the_runtime_parse_accepts ( string driverType , string blob )
{
if ( ! AuthoringAccepts ( driverType , blob ) ) { return ; }
RuntimeAccepts ( driverType , blob ) . ShouldBeTrue (
$"{driverType}: authoring accepted a blob its driver's reader refuses — the tag would deploy "
+ $"clean and read BadNodeIdUnknown forever. Blob: {blob}" ) ;
}
/// <summary>
/// <b>The conditional direction.</b> A blob the driver SERVES should normally be authorable, or the
/// modal blocks configuration the runtime would have honoured. It is conditional because four
/// editors are deliberately stricter than their reader for reasons that are load-bearing — see
/// <see cref="DeliberatelyStricter"/>, where each is exempted by name with its reason.
/// </summary>
[Theory]
[MemberData(nameof(Corpus))]
public void Whatever_the_runtime_parse_accepts_is_authorable_unless_the_editor_is_deliberately_stricter (
string driverType , string blob )
{
if ( ! RuntimeAccepts ( driverType , blob ) ) { return ; }
if ( DeliberatelyStricter . ContainsKey ( ( driverType , blob ) ) ) { return ; }
AuthoringAccepts ( driverType , blob ) . ShouldBeTrue (
$"{driverType}: authoring refused a blob the driver serves. If that is intended, add it to "
+ $"DeliberatelyStricter with its reason rather than weakening this theory. Blob: {blob}" ) ;
}
/// <summary>
/// Every place an editor refuses a config its driver would serve, with the reason. These are NOT
/// #554 gaps — #554 closes the opposite direction — and none was removed while closing it. Each is
/// documented on the model itself; this table is the fleet-level record of them.
/// </summary>
private static readonly IReadOnlyDictionary < ( string DriverType , string Blob ) , string > DeliberatelyStricter =
new Dictionary < ( string , string ) , string >
{
// The reader CLAMPS a length above 256 (Math.Min) and serves the tag, but the materialiser sets
// OPC UA ArrayDimensions from the RAW authored number — so the served array is narrower than the
// node advertises, and the authored value is silently not honoured.
[(DriverTypeNames.AbLegacy, """{"address":"N7:0","isArray":true,"arrayLength":300}""")] =
"runtime clamps to 256; the un-clamped length still sets ArrayDimensions (Part 6 §5.3.2 mismatch)" ,
// Gitea #540: the reader accepts any non-blank address and the DRIVER then skips the tag at
// startup. 20 tags carrying a browse full reference deployed green this way.
[(DriverTypeNames.FOCAS, """{"address":"10.201.31.5:8193/Identity/SeriesNumber"}""")] =
"an address the FOCAS parser cannot read is a tag the driver skips at startup (Gitea #540)" ,
// One Tag holds one value; '+'/'#' would feed it from many source topics.
[(DriverTypeNames.Mqtt, """{"topic":"otopcua/fixture/+"}""")] =
"a wildcard topic has no legitimate single-Tag interpretation" ,
// A decoded incoming id can never contain '/', '+' or '#', so such a binding is permanently dead.
[(DriverTypeNames.Mqtt, """{"groupId":"Ot/Sim","edgeNodeId":"EdgeA","metricName":"M"}""")] =
"a Sparkplug id segment carrying '/', '+' or '#' could never bind" ,
// The runtime serves a trigger-less calc tag; it simply never computes.
[(DriverTypeNames.Calculation, """{"scriptId":"S1","changeTriggered":false}""")] =
"a calc tag with no trigger can never fire — the editor refuses it, the runtime tolerates it" ,
// The runtime honours a 1 ms cadence.
[(DriverTypeNames.Calculation, """{"scriptId":"S1","timerIntervalMs":10}""")] =
"sub-50 ms timer: deliberate editor floor, honoured verbatim by the runtime" ,
// The reader still falls back to a legacy PascalCase NodeId when the v3 camelCase key is absent.
// Pre-dates #554 and is left alone: re-entering the reference rewrites it to the v3 key.
[(DriverTypeNames.OpcUaClient, """{"NodeId":"nsu=urn:plc:sim;s=Line1.Speed"}""")] =
"legacy PascalCase key: the reader still honours it, this editor reads only camelCase 'nodeId'" ,
} ;
/// <summary>
/// The exemption table must not rot into a list of shapes nobody checks. Every entry is asserted to
/// still BE an exemption: the runtime accepts it and authoring refuses it. An entry that stops being
/// either — because the editor was aligned, or the runtime tightened — fails here rather than
/// silently excusing a blob from the theory above.
/// </summary>
[Fact]
public void Every_deliberately_stricter_entry_is_still_exactly_that ( )
{
foreach ( var ( ( driverType , blob ) , reason ) in DeliberatelyStricter )
{
RuntimeAccepts ( driverType , blob ) . ShouldBeTrue (
$"{driverType}: the runtime no longer serves this blob, so it is not an over-strictness "
+ $"exemption any more — remove the entry. ({reason}) Blob: {blob}" ) ;
AuthoringAccepts ( driverType , blob ) . ShouldBeFalse (
$"{driverType}: authoring now accepts this blob, so the exemption is stale — remove the "
+ $"entry so the parity theory covers it. ({reason}) Blob: {blob}" ) ;
}
}
// =============================================================================================
// 4. The editor-flow constraint: a freshly authored config must sail through.
// =============================================================================================
/// <summary>
/// Every mapped driver's editor, given minimal valid operator input, must produce a blob its own
/// reader accepts and its own save gate accepts. This is the guard against the failure mode the
/// #554 design cautions about most: a parse consult that refuses configs the editor itself writes,
/// which would leave the modal unable to save anything at all.
/// <para>
/// The configs come from the models' own <c>ToJson()</c>, never hand-written — a hand-written
/// blob is a guess at what the editor writes, and several editors normalise keys on save.
/// </para>
/// </summary>
public static TheoryData < string , string > FreshlyAuthored ( )
{
var data = new TheoryData < string , string > ( ) ;
data . Add ( DriverTypeNames . Modbus , new ModbusTagConfigModel { Address = 1 } . ToJson ( ) ) ;
data . Add ( DriverTypeNames . S7 , new S7TagConfigModel { Address = "DB1.DBW0" } . ToJson ( ) ) ;
data . Add ( DriverTypeNames . S7 ,
new S7TagConfigModel { Address = "DB1.DBS0" , DataType = S7DataType . String , StringLength = 24 } . ToJson ( ) ) ;
data . Add ( DriverTypeNames . AbCip , new AbCipTagConfigModel { TagPath = "Program:Main.Speed" } . ToJson ( ) ) ;
data . Add ( DriverTypeNames . AbLegacy , new AbLegacyTagConfigModel { Address = "N7:0" } . ToJson ( ) ) ;
data . Add ( DriverTypeNames . TwinCAT , new TwinCATTagConfigModel { SymbolPath = "MAIN.bStart" } . ToJson ( ) ) ;
data . Add ( DriverTypeNames . FOCAS , new FocasTagConfigModel { Address = "R100" } . ToJson ( ) ) ;
data . Add ( DriverTypeNames . OpcUaClient ,
new OpcUaClientTagConfigModel { NodeId = "nsu=urn:plc:sim;s=Line1.Speed" } . ToJson ( ) ) ;
data . Add ( DriverTypeNames . Calculation ,
new CalculationTagConfigModel { ScriptId = "S1" , ChangeTriggered = true } . ToJson ( ) ) ;
data . Add ( DriverTypeNames . MTConnect ,
new MTConnectTagConfigModel { FullName = "Xact" , DataType = DriverDataType . Float64 } . ToJson ( ) ) ;
data . Add ( DriverTypeNames . Mqtt ,
new MqttTagConfigModel { Mode = MqttMode . Plain , Topic = "otopcua/fixture/a" } . ToJson ( ) ) ;
data . Add ( DriverTypeNames . Mqtt , new MqttTagConfigModel
{
Mode = MqttMode . SparkplugB ,
GroupId = "OtOpcUaSim" ,
EdgeNodeId = "EdgeA" ,
MetricName = "Filler/Speed" ,
} . ToJson ( ) ) ;
data . Add ( DriverTypeNames . Sql , new SqlTagConfigModel
{
Model = SqlTagModel . KeyValue ,
Table = "dbo.TagValues" ,
KeyColumn = "TagName" ,
KeyValue = "Line1.Speed" ,
ValueColumn = "Value" ,
} . ToJson ( ) ) ;
data . Add ( DriverTypeNames . Sql , new SqlTagConfigModel
{
Model = SqlTagModel . WideRow ,
Table = "dbo.LineSnapshot" ,
ColumnName = "Speed" ,
RowSelector = new SqlRowSelectorModel { WhereColumn = "LineId" , WhereValue = "L1" } ,
} . ToJson ( ) ) ;
return data ;
}
[Theory]
[MemberData(nameof(FreshlyAuthored))]
public void A_freshly_authored_config_is_read_by_its_driver_and_accepted_by_its_save_gate (
string driverType , string blob )
{
RuntimeAccepts ( driverType , blob ) . ShouldBeTrue (
$"{driverType}: the editor writes a blob its own driver's reader refuses. Blob: {blob}" ) ;
TagConfigValidator . Validate ( driverType , blob ) . ShouldBeNull (
$"{driverType}: the save gate refuses a config its own editor just produced — the modal could "
+ $"not save anything. Blob: {blob}" ) ;
}
}