refactor(mqtt): accept a blank jsonPath; guide with a seeded "$" instead

Review follow-up. Dropped the hard Validate rejection of a blank jsonPath under
a Json payload: the runtime defaults it to the document root, which is the real
"publisher puts a bare JSON scalar on the topic" case, so rejecting it made the
editor refuse what the driver accepts — inverting "authoring surface accepts <=>
publish accepts" — and blocked whole CSV-import batches, since this validator
also gates RawManualTagEntryModal's review grid.

Replaced with guidance: an UNAUTHORED tag (no topic AND no jsonPath) seeds the
field with "$", so a fresh tag emits an explicit "jsonPath":"$" while an
existing path-less tag keeps the key ABSENT and the driver defaults it. The
wildcard-topic rule stays strict — a wildcard has no legitimate single-Tag
interpretation.

Also: omit a blank "topic" rather than writing "topic":"", and record why the
_lastConfigJson guard diverges from the Modbus template — the landmine is a
DERIVED, NON-PERSISTED UI FIELD INFERRED FROM BLOB CONTENT (Mode), which Task
24's Sparkplug field group will be tempted to add more of. Named the host-side
dependency (RawTagModal only mutates through this callback) that keeps the
staleness trade benign today.

Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
Joseph Doherty
2026-07-24 17:37:13 -04:00
parent a13ae926a8
commit ff62b62a55
3 changed files with 109 additions and 26 deletions
@@ -30,7 +30,9 @@ public sealed class MqttTagConfigModelTests
m.Mode.ShouldBe(MqttMode.Plain);
m.Topic.ShouldBe("");
m.PayloadFormat.ShouldBe(MqttPayloadFormat.Json);
m.JsonPath.ShouldBe("");
// An UNAUTHORED tag seeds the document-root path so the common "no extraction needed" case is
// visible and one click away — it is a guide, not a requirement (blank is accepted, see below).
m.JsonPath.ShouldBe("$");
m.DataType.ShouldBe(DriverDataType.String);
m.Qos.ShouldBeNull();
m.RetainSeed.ShouldBeNull();
@@ -107,6 +109,15 @@ public sealed class MqttTagConfigModelTests
json.ShouldNotContain("jsonPath");
}
[Fact]
public void ToJson_omits_a_blank_topic()
{
// Toggling the shape dropdown on an untouched tag must not leave a stray "topic":"" behind.
var json = new MqttTagConfigModel { PayloadFormat = MqttPayloadFormat.Raw }.ToJson();
json.ShouldNotContain("topic");
}
[Fact]
public void FromJson_then_ToJson_preserves_unknown_keys()
{
@@ -171,13 +182,38 @@ public sealed class MqttTagConfigModelTests
=> MqttTagConfigModel.FromJson($$"""{"topic":"{{topic}}","payloadFormat":"Raw","dataType":"String"}""")
.Validate().ShouldNotBeNullOrWhiteSpace();
// A blank jsonPath under a Json payload is ACCEPTED — the runtime defaults it to the document root
// ("$"), which is the real "publisher puts a bare JSON scalar on the topic" case. Rejecting it would
// make the editor refuse what the driver accepts, and would block CSV-import batches (this validator
// also gates RawManualTagEntryModal's review grid) over a sane default.
[Fact]
public void Validate_JsonWithoutPath_Fails()
public void Validate_JsonWithoutPath_IsAccepted()
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Float64"}""")
.Validate().ShouldNotBeNullOrWhiteSpace();
.Validate().ShouldBeNull();
// …and the key stays ABSENT through a load→save, so the driver applies its own default rather than
// this editor freezing "$" into an already-deployed blob.
[Fact]
public void ToJson_leaves_an_existing_blank_jsonPath_absent()
{
var json = MqttTagConfigModel
.FromJson("""{"topic":"a/b","payloadFormat":"Json","dataType":"Float64"}""")
.ToJson();
json.ShouldNotContain("jsonPath");
}
// The seed applies ONLY to an unauthored tag (no topic AND no jsonPath) — a brand-new tag emits an
// explicit "$" once the operator touches anything, which is the guided half of the deal.
[Fact]
public void ToJson_seeds_the_root_jsonPath_on_a_fresh_tag()
{
var m = MqttTagConfigModel.FromJson(null);
m.Topic = "a/b";
m.ToJson().ShouldContain("\"jsonPath\":\"$\"");
}
// Falsifiability control for the test above: the SAME blob with a jsonPath must validate clean, so
// the failure above is provably the missing jsonPath and not the topic or the dataType.
[Fact]
public void Validate_JsonWithPath_ReturnsNull()
=> MqttTagConfigModel.FromJson("""{"topic":"a/b","payloadFormat":"Json","jsonPath":"$","dataType":"Float64"}""")