fix(adminui): authoring models consult the driver's own parse for non-enum TagConfig rules (#554)

#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 reader refuses a tag met no gate
until deploy, or until the driver silently skipped the tag: an absent Modbus `region`,
an `"address":"notanumber"`, an S7 `stringLength` of 300, a TwinCAT `dataType:
"Structure"` (a VALID enum name the reader refuses), a Sql blob the parser cannot
recognise as Sql at all, an MQTT `"qos":null`.

Rather than hand-mirroring N more rules into N models — the drift #551 closed on the
deploy side — each `<Driver>TagConfigModel.Validate()` now asks its driver's own parse
whether it would read the AS-LOADED blob, via the new `TagConfigParseGate`. One
authority for the verdict AND the wording.

Two cautions are load-bearing and are pinned by tests:

- The gate is the PARSE VERDICT, never "the inspector said something". FOCAS, MQTT and
  Calculation inspectors emit advisories for blobs their reader ACCEPTS, so a
  non-empty-inspection gate would make the modal stricter than the runtime and leave
  rows nobody can save (#520/#535). Inspect is consulted only to word a refusal that
  already happened.
- It judges the blob the model was LOADED FROM, not the typed fields — the lenient load
  ("address":"notanumber" ⇒ 0) is what hides the defect. Same capture-the-source shape
  as S7's `_dataTypeMissingInSource`.

Deliberately-stricter editor rules were surveyed and KEPT, not aligned: AbLegacy's
arrayLength > 256 (reader clamps, but the un-clamped value still sets ArrayDimensions),
FOCAS's unparseable address (#540), MQTT wildcard topics + Sparkplug segment chars, and
Calculation's trigger/50 ms floors. They are now recorded fleet-wide in a table the
tests assert is still accurate.

Behaviour change: Modbus `{}` is now refused. Modbus was the one mapped editor with no
required field, and `{}` is exactly what the modal persists for a config nobody touched
— the driver skips it and it reads BadNodeIdUnknown forever with the deploy green. Its
pinned test asserted the old, weaker behaviour and is rewritten with the rationale.

Falsification: neutering only the Modbus consult turns 14 tests red, all Modbus-scoped;
every #549 enum test stays green.
This commit is contained in:
Joseph Doherty
2026-08-10 01:54:17 -04:00
parent 36c82b60f7
commit 5ca4f8041d
16 changed files with 940 additions and 45 deletions
@@ -31,16 +31,23 @@ public sealed class RawTreeServiceWaveBTests
return selector(db);
}
private static RawTagInput Tag(string name, string dataType = "Float", string tagConfig = "{}") =>
/// <summary>
/// A <c>TagConfig</c> the <b>Modbus</b> driver would actually read — <c>FolderedDeviceId</c> hangs off
/// the seed's Modbus driver, and the service runs the driver-typed <c>TagConfigValidator</c> on every
/// tag it writes. Gitea #554 made the Modbus editor ask the driver's own reader, which requires a
/// <c>region</c> and an in-range <c>address</c>, so the bare <c>{}</c> this helper used to default to
/// is a config the driver skips (<c>BadNodeIdUnknown</c>) and the service now refuses. None of the
/// tests below is about the config blob — they are tree-structure and concurrency tests — so they
/// take the minimal readable one.
/// </summary>
private const string ModbusConfig = """{"region":"HoldingRegisters","address":1}""";
private static RawTagInput Tag(string name, string dataType = "Float", string tagConfig = ModbusConfig) =>
new(name, dataType, TagAccessLevel.ReadWrite, WriteIdempotent: false, PollGroupId: null, tagConfig);
/// <summary>
/// A tag input whose <c>TagConfig</c> the <b>S7</b> driver would actually read — <c>RootDeviceId</c>
/// hangs off the seed's S7 driver, and since Gitea #551 the service runs the driver-typed
/// <c>TagConfigValidator</c> on every tag it writes. S7 requires BOTH an <c>address</c> and a
/// present <c>dataType</c> key, so the bare <c>{}</c> the sibling helper defaults to is a config the
/// driver rejects and the service now refuses. The Modbus-device tests keep <c>{}</c>: Modbus reads
/// only enums out of the blob and accepts an empty one.
/// The same, for the <b>S7</b> device — <c>RootDeviceId</c> hangs off the seed's S7 driver, which
/// requires BOTH an <c>address</c> and a present <c>dataType</c> key (Gitea #551).
/// </summary>
/// <param name="name">The tag name.</param>
/// <returns>The import-ready tag input.</returns>
@@ -74,7 +81,7 @@ public sealed class RawTreeServiceWaveBTests
var result = await service.CreateTagAsync(
RawTreeTestDb.FolderedDeviceId, null,
new RawTagInput("flow", "Float", TagAccessLevel.Read, false, " ", "{}"));
new RawTagInput("flow", "Float", TagAccessLevel.Read, false, " ", ModbusConfig));
result.Ok.ShouldBeTrue();
using var db = RawTreeTestDb.CreateNamed(dbName);
@@ -126,7 +133,12 @@ public sealed class RawTreeServiceWaveBTests
var result = await service.UpdateTagAsync(
RawTreeTestDb.PlainTagId,
new RawTagInput("running", "Boolean", TagAccessLevel.ReadWrite, true, "pg1", "{\"scale\":2}"),
// The unknown "scale" key rides a readable Modbus config: this test is about field edits and
// unknown-key survival, and since Gitea #554 the service's driver-typed gate refuses a blob the
// Modbus reader would skip.
new RawTagInput(
"running", "Boolean", TagAccessLevel.ReadWrite, true, "pg1",
"""{"region":"HoldingRegisters","address":1,"scale":2}"""),
rv);
result.Ok.ShouldBeTrue();