fix(tagconfig): close the four #551 residuals — accept-gate-first Inspect, three authoring rules, CSV/service gate, doc sweep

Gitea #551.

1. CSV import ran no driver-typed config gate. Its typed columns were always
   strictly parsed, but the free-form TagConfigJson column reached the database
   unread, so a CSV could author a tag the driver rejects — one that deploys
   green and reads BadNodeIdUnknown forever. RawTagCsvMapper.Parse now names the
   offending row in the staged review grid, and RawTreeService enforces the same
   gate on CreateTag/UpdateTag/ImportTags: a form cannot be the guarantee (the
   API and any future caller reach none of them, and this repo has no bUnit).
   A rejected import row is an ERROR — the batch inserts nothing and the row is
   named — never a skip.

   Note the brief's premise that manual entry rides ImportTagsAsync is wrong:
   RawManualTagEntryModal commits row-by-row via CreateTagAsync, which is why
   the gate had to land on all three write paths.

2/3. The seven pre-#549 Inspect methods ran a hand-list of DescribeInvalidEnum
   checks, so every rejection cause not on the list was invisible. They are now
   ACCEPT-GATE-FIRST (the MTConnect/Sql/OpcUaClient/Calculation shape): ask the
   driver's own reader for the verdict, then explain the refusal, with a
   catch-all so "the parse rejects but Inspect said nothing" is unreachable.
   That closes S7's absent dataType, AbCip members[], Modbus stringByteOrder and
   the type-dependent guards, plus causes the issue did not name (absent
   region/address/tagPath/symbolPath/topic, TwinCAT Structure, MQTT's Sparkplug
   tuple). FOCAS and MQTT keep advisories about ACCEPTED blobs, so they
   diagnose rather than return empty.

   Authoring half: ModbusTagConfigModel gains stringByteOrder;
   AbCipTagConfigModel asks the new AbCipTagDefinitionFactory.DescribeInvalidMember;
   S7TagConfigModel captures whether the SOURCE blob declared dataType and asks
   S7TagDefinitionFactory.DataTypeKey/DeclaresDataType. One authority per rule.

4. Doc sweep: the six factory Inspect summaries, EquipmentTagConfigInspector's
   class remarks and AdminOperationsActor's comment all claimed invalid enums are
   "silently defaulted by the lenient runtime" — false since R2-11 Phase C made
   the runtime reads strict. They describe rejections now.
This commit is contained in:
Joseph Doherty
2026-08-10 00:08:06 -04:00
parent b0d2fa30e3
commit 05b424788f
24 changed files with 1846 additions and 100 deletions
@@ -34,6 +34,19 @@ public sealed class RawTreeServiceWaveBTests
private static RawTagInput Tag(string name, string dataType = "Float", string tagConfig = "{}") =>
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.
/// </summary>
/// <param name="name">The tag name.</param>
/// <returns>The import-ready tag input.</returns>
private static RawTagInput S7Tag(string name) =>
Tag(name, tagConfig: """{"address":"DB1.DBW0","dataType":"Int16"}""");
// -------------------------------------------------------------------------------- CreateTag (WP4)
[Fact]
@@ -181,9 +194,9 @@ public sealed class RawTreeServiceWaveBTests
var rows = new[]
{
new RawTagImportRow("Line/A", Tag("t1")),
new RawTagImportRow("Line/A", Tag("t2")), // reuses Line + Line/A
new RawTagImportRow("Line/B", Tag("t3")), // reuses Line, new B
new RawTagImportRow("Line/A", S7Tag("t1")),
new RawTagImportRow("Line/A", S7Tag("t2")), // reuses Line + Line/A
new RawTagImportRow("Line/B", S7Tag("t3")), // reuses Line, new B
};
var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows);
@@ -207,7 +220,7 @@ public sealed class RawTreeServiceWaveBTests
var (service, dbName) = Seeded();
var result = await service.ImportTagsAsync(
RawTreeTestDb.RootDeviceId, new[] { new RawTagImportRow(null, Tag("rootTag")) });
RawTreeTestDb.RootDeviceId, new[] { new RawTagImportRow(null, S7Tag("rootTag")) });
result.Inserted.ShouldBe(1);
using var db = RawTreeTestDb.CreateNamed(dbName);
@@ -221,9 +234,9 @@ public sealed class RawTreeServiceWaveBTests
var rows = new[]
{
new RawTagImportRow("Grp", Tag("good1")),
new RawTagImportRow("Grp", S7Tag("good1")),
new RawTagImportRow("Grp", Tag("bad", tagConfig: "{oops")), // malformed JSON ⇒ error
new RawTagImportRow("Grp", Tag("good2")),
new RawTagImportRow("Grp", S7Tag("good2")),
};
var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows);
@@ -245,8 +258,8 @@ public sealed class RawTreeServiceWaveBTests
var rows = new[]
{
new RawTagImportRow("Grp", Tag("dup")),
new RawTagImportRow("Grp", Tag("dup")), // same path + name within the batch
new RawTagImportRow("Grp", S7Tag("dup")),
new RawTagImportRow("Grp", S7Tag("dup")), // same path + name within the batch
};
var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows);
@@ -262,7 +275,7 @@ public sealed class RawTreeServiceWaveBTests
// "temperature" already sits at the device root of RootDevice.
var result = await service.ImportTagsAsync(
RawTreeTestDb.RootDeviceId, new[] { new RawTagImportRow(null, Tag("temperature")) });
RawTreeTestDb.RootDeviceId, new[] { new RawTagImportRow(null, S7Tag("temperature")) });
result.Inserted.ShouldBe(0);
result.Errors.ShouldContain(e => e.Contains("already exists"));