feat(adminui): author MTConnect write-back on the driver form (Driver.MTConnect-004)

IWritable shipped behind write.enabled and docs/drivers/MTConnect.md told the operator to
set it, but MTConnectDriverForm — the only editor DriverFormMaps maps for this type, with
no raw-JSON escape hatch and no driver-instance API — exposed no write section, so the
documented enablement path was a hand edit of DriverInstance.DriverConfig in the config DB.

- Write-back section: switch + form text stating it is observation injection, that the
  Agent's AllowPut is an independent second gate, and that it does not by itself make a
  node writable over OPC UA (Tag.AccessLevel still composes CurrentWrite).
- Write added to the mirrored MTConnectConfigDto and to the form model, written in BOTH
  states — an omitted block would leave a previously-authored true standing, so the switch
  could never turn writing off again.
- The false 'MTConnect is read-only' header comment is gone; the doc names the new section.

No bUnit here, so the model/serialization seam is unit-tested both sides: the AdminUI test
pins the emitted bytes, and MTConnectFactoryTests parses that same document and asserts
MTConnectDriverOptions.Write.Enabled. Live page verification remains a rig step.
This commit is contained in:
Joseph Doherty
2026-08-10 08:07:36 -04:00
parent 2d7dffcc42
commit 298ad04fba
4 changed files with 182 additions and 4 deletions
@@ -128,6 +128,95 @@ public sealed class MTConnectDriverFormModelTests
o["reconnect"]!["minBackoffMs"]!.GetValue<int>().ShouldBe(0);
}
// ---- write-back (Driver.MTConnect-004) -----------------------------------------------------
[Fact]
public void Write_back_is_off_by_default_and_is_written_explicitly()
{
// The capability shipped 2026-08-05 behind write.enabled and docs/drivers/MTConnect.md tells
// the operator to set it — but the form authored no write block at all, so the documented
// enablement path existed only as a hand edit of DriverInstance.DriverConfig in the config DB.
//
// The default is written rather than omitted: the driver reads an absent block as disabled
// either way, and an explicit "enabled": false is what makes a config an operator opened and
// saved say what it does. It is also what lets the form TURN THE FLAG OFF again — an omitted
// key would leave a previously-authored true standing.
var o = Emit(new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000" });
o["write"]!["enabled"]!.GetValue<bool>().ShouldBeFalse();
}
[Fact]
public void Write_back_enabled_reaches_the_key_the_driver_binds()
{
var o = Emit(new MTConnectDriverForm.MTConnectFormModel
{
AgentUri = "http://agent:5000",
WriteEnabled = true,
});
// MTConnectDriver.ParseOptions binds write.enabled into MTConnectDriverOptions.Write.Enabled
// (pinned driver-side by MTConnectFactoryTests). This is the AdminUI half of that contract.
o["write"]!["enabled"]!.GetValue<bool>().ShouldBeTrue();
}
[Fact]
public void Write_back_round_trips_and_a_hand_authored_block_loads_into_the_form()
{
// The mitigating property the finding recorded — BuildConfigJson preserves unknown TOP-LEVEL
// keys, so a hand-authored write block survived an edit through the form — is not enough on
// its own: unless the form READS it, opening the modal shows the switch off over a config
// that has writing on, and the operator's next save is the one that finally clears it.
const string handAuthored = """{"agentUri":"http://agent:5000","write":{"enabled":true}}""";
var form = MTConnectDriverForm.MTConnectFormModel.FromJson(handAuthored);
form.WriteEnabled.ShouldBeTrue();
var again = MTConnectDriverForm.MTConnectFormModel.FromJson(
MTConnectDriverForm.BuildConfigJson(form, handAuthored));
again.WriteEnabled.ShouldBeTrue();
}
[Fact]
public void Turning_write_back_off_clears_a_previously_authored_true()
{
// The falsification control for the "always write the block" decision above: a form that
// omitted the key when false would leave the stored true in place, so the switch would appear
// to turn writing off while the deployed driver went on accepting writes.
const string existing = """{"agentUri":"http://agent:5000","write":{"enabled":true}}""";
var form = MTConnectDriverForm.MTConnectFormModel.FromJson(existing);
form.WriteEnabled = false;
Emit(form, existing)["write"]!["enabled"]!.GetValue<bool>().ShouldBeFalse();
}
[Fact]
public void FromJson_reads_a_pascal_case_write_block()
{
var form = MTConnectDriverForm.MTConnectFormModel.FromJson(
"""{"AgentUri":"http://agent:5000","Write":{"Enabled":true}}""");
form.WriteEnabled.ShouldBeTrue();
}
[Fact]
public void The_write_enabled_document_is_the_literal_the_driver_test_parses()
{
// The AdminUI cannot be referenced from a driver test project, so the driver-side leg of this
// contract (MTConnectFactoryTests.The_document_the_AdminUI_form_emits_arms_write_back) parses a
// literal copy of this form's output. Asserting the bytes here is what stops the two drifting:
// a rename or a reordering on this side fails HERE, naming the literal that must be updated.
MTConnectDriverForm.BuildConfigJson(
new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000", WriteEnabled = true },
null)
.ShouldBe(WriteEnabledDocument);
}
/// <summary>The form's output for a write-enabled instance, mirrored in the driver test suite.</summary>
private const string WriteEnabledDocument =
"""{"agentUri":"http://agent:5000","requestTimeoutMs":5000,"sampleIntervalMs":1000,"sampleCount":1000,"heartbeatMs":10000,"probe":{"enabled":true,"intervalMs":5000,"timeoutMs":2000},"reconnect":{"minBackoffMs":0,"maxBackoffMs":30000,"backoffMultiplier":2},"write":{"enabled":true}}""";
// ---- Validate ------------------------------------------------------------------------------
[Fact]
@@ -292,6 +381,8 @@ public sealed class MTConnectDriverFormModelTests
dto.Reconnect!.MinBackoffMs.ShouldBe(0);
dto.Reconnect.MaxBackoffMs.ShouldBe(30000);
dto.Reconnect.BackoffMultiplier.ShouldBe(2.0);
dto.Write.ShouldNotBeNull();
dto.Write!.Enabled.ShouldBe(false);
}
private sealed class WireDto
@@ -304,6 +395,12 @@ public sealed class MTConnectDriverFormModelTests
public int? HeartbeatMs { get; init; }
public WireProbe? Probe { get; init; }
public WireReconnect? Reconnect { get; init; }
public WireWrite? Write { get; init; }
}
private sealed class WireWrite
{
public bool? Enabled { get; init; }
}
private sealed class WireProbe