298ad04fba
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.
420 lines
18 KiB
C#
420 lines
18 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.Text.Json.Serialization;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests;
|
|
|
|
/// <summary>
|
|
/// Unit tests for the plain-C# core of <see cref="MTConnectDriverForm"/> — the pure form model and
|
|
/// its <c>DriverConfig</c> JSON projection. There is no bUnit in this project, so every decision the
|
|
/// form makes lives here (in code a test can reach) rather than in razor markup.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The JSON shape asserted below is the one <c>MTConnectDriver.ParseOptions</c> binds
|
|
/// (<c>agentUri</c> / <c>deviceName</c> / <c>requestTimeoutMs</c> / <c>sampleIntervalMs</c> /
|
|
/// <c>sampleCount</c> / <c>heartbeatMs</c> / <c>probe</c> / <c>reconnect</c>). It is deliberately
|
|
/// NOT <c>MTConnectDriverOptions</c>: that type carries <c>TimeSpan</c> probe knobs, which serialise
|
|
/// as <c>"00:00:05"</c> and would not bind to the driver's <c>intervalMs</c> integers.
|
|
/// </remarks>
|
|
public sealed class MTConnectDriverFormModelTests
|
|
{
|
|
// Mirrors MTConnectDriverForm._jsonOpts for the read side of the assertions.
|
|
private static readonly JsonSerializerOptions _opts = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
PropertyNameCaseInsensitive = true,
|
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
|
|
WriteIndented = false,
|
|
Converters = { new JsonStringEnumConverter() },
|
|
};
|
|
|
|
private static JsonObject Emit(MTConnectDriverForm.MTConnectFormModel form, string? existingJson = null)
|
|
=> JsonNode.Parse(MTConnectDriverForm.BuildConfigJson(form, existingJson))!.AsObject();
|
|
|
|
// ---- default shape -------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void Default_form_emits_a_config_the_driver_can_parse()
|
|
{
|
|
var o = Emit(new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000" });
|
|
|
|
o["agentUri"]!.GetValue<string>().ShouldBe("http://agent:5000");
|
|
o["requestTimeoutMs"]!.GetValue<int>().ShouldBe(5000);
|
|
o["sampleIntervalMs"]!.GetValue<int>().ShouldBe(1000);
|
|
o["sampleCount"]!.GetValue<int>().ShouldBe(1000);
|
|
o["heartbeatMs"]!.GetValue<int>().ShouldBe(10000);
|
|
o["probe"]!["enabled"]!.GetValue<bool>().ShouldBeTrue();
|
|
o["probe"]!["intervalMs"]!.GetValue<int>().ShouldBe(5000);
|
|
o["probe"]!["timeoutMs"]!.GetValue<int>().ShouldBe(2000);
|
|
o["reconnect"]!["maxBackoffMs"]!.GetValue<int>().ShouldBe(30000);
|
|
o["reconnect"]!["backoffMultiplier"]!.GetValue<double>().ShouldBe(2.0);
|
|
}
|
|
|
|
[Fact]
|
|
public void Blank_agent_uri_is_omitted_rather_than_written_as_an_empty_string()
|
|
{
|
|
// An empty "agentUri" and an absent one both fail ParseOptions, but omitting it keeps the
|
|
// driver's own "missing required AgentUri" message accurate.
|
|
var o = Emit(new MTConnectDriverForm.MTConnectFormModel { AgentUri = " " });
|
|
o.ContainsKey("agentUri").ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Agent_uri_and_device_name_are_trimmed_and_blank_device_name_is_omitted()
|
|
{
|
|
var o = Emit(new MTConnectDriverForm.MTConnectFormModel
|
|
{
|
|
AgentUri = " http://agent:5000 ",
|
|
DeviceName = " ",
|
|
});
|
|
|
|
o["agentUri"]!.GetValue<string>().ShouldBe("http://agent:5000");
|
|
o.ContainsKey("deviceName").ShouldBeFalse();
|
|
}
|
|
|
|
// ---- arch-review 01/S-6: a zero must never reach the driver -------------------------------
|
|
|
|
[Theory]
|
|
[InlineData("requestTimeoutMs")]
|
|
[InlineData("sampleIntervalMs")]
|
|
[InlineData("sampleCount")]
|
|
[InlineData("heartbeatMs")]
|
|
public void Non_positive_timing_knobs_are_omitted_so_the_driver_applies_its_own_default(string key)
|
|
{
|
|
var form = new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000" };
|
|
switch (key)
|
|
{
|
|
case "requestTimeoutMs": form.RequestTimeoutMs = 0; break;
|
|
case "sampleIntervalMs": form.SampleIntervalMs = -1; break;
|
|
case "sampleCount": form.SampleCount = 0; break;
|
|
case "heartbeatMs": form.HeartbeatMs = 0; break;
|
|
}
|
|
|
|
// MTConnectDriver.StartCore calls RequirePositive on all four and faults the driver at
|
|
// Initialize; omitting the key means ParseOptions substitutes the positive default instead.
|
|
Emit(form).ContainsKey(key).ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Non_positive_probe_knobs_are_omitted_so_the_driver_applies_its_own_default()
|
|
{
|
|
var o = Emit(new MTConnectDriverForm.MTConnectFormModel
|
|
{
|
|
AgentUri = "http://agent:5000",
|
|
ProbeIntervalMs = 0,
|
|
ProbeTimeoutMs = -5,
|
|
});
|
|
|
|
var probe = o["probe"]!.AsObject();
|
|
probe["enabled"]!.GetValue<bool>().ShouldBeTrue();
|
|
probe.ContainsKey("intervalMs").ShouldBeFalse();
|
|
probe.ContainsKey("timeoutMs").ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void Zero_reconnect_min_backoff_is_preserved_because_the_driver_allows_it()
|
|
{
|
|
// MinBackoffMs = 0 means "retry immediately" and is the driver's own default — it must NOT be
|
|
// swept up by the positive-only rule that guards the four RequirePositive knobs.
|
|
var o = Emit(new MTConnectDriverForm.MTConnectFormModel
|
|
{
|
|
AgentUri = "http://agent:5000",
|
|
ReconnectMinBackoffMs = 0,
|
|
});
|
|
|
|
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]
|
|
public void Validate_rejects_a_blank_agent_uri()
|
|
=> new MTConnectDriverForm.MTConnectFormModel { AgentUri = "" }
|
|
.Validate().ShouldNotBeNullOrEmpty();
|
|
|
|
[Fact]
|
|
public void Validate_rejects_a_non_positive_timing_knob()
|
|
=> new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000", SampleCount = 0 }
|
|
.Validate().ShouldNotBeNullOrEmpty();
|
|
|
|
[Fact]
|
|
public void Validate_ignores_probe_knobs_when_probing_is_off()
|
|
=> new MTConnectDriverForm.MTConnectFormModel
|
|
{
|
|
AgentUri = "http://agent:5000",
|
|
ProbeEnabled = false,
|
|
ProbeIntervalMs = 0,
|
|
ProbeTimeoutMs = 0,
|
|
}.Validate().ShouldBeNull();
|
|
|
|
[Fact]
|
|
public void Validate_accepts_a_fully_populated_form()
|
|
=> new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000" }
|
|
.Validate().ShouldBeNull();
|
|
|
|
// ---- round trip ----------------------------------------------------------------------------
|
|
|
|
[Fact]
|
|
public void Round_trip_preserves_every_authored_value()
|
|
{
|
|
var original = new MTConnectDriverForm.MTConnectFormModel
|
|
{
|
|
AgentUri = "http://cell-a:5000",
|
|
DeviceName = "VMC-1",
|
|
RequestTimeoutMs = 7000,
|
|
SampleIntervalMs = 250,
|
|
SampleCount = 500,
|
|
HeartbeatMs = 15000,
|
|
ProbeEnabled = false,
|
|
ProbeIntervalMs = 9000,
|
|
ProbeTimeoutMs = 3000,
|
|
ReconnectMinBackoffMs = 100,
|
|
ReconnectMaxBackoffMs = 60000,
|
|
ReconnectBackoffMultiplier = 1.5,
|
|
};
|
|
|
|
var json = MTConnectDriverForm.BuildConfigJson(original, null);
|
|
var back = MTConnectDriverForm.MTConnectFormModel.FromJson(json);
|
|
|
|
back.AgentUri.ShouldBe("http://cell-a:5000");
|
|
back.DeviceName.ShouldBe("VMC-1");
|
|
back.RequestTimeoutMs.ShouldBe(7000);
|
|
back.SampleIntervalMs.ShouldBe(250);
|
|
back.SampleCount.ShouldBe(500);
|
|
back.HeartbeatMs.ShouldBe(15000);
|
|
back.ProbeEnabled.ShouldBeFalse();
|
|
back.ProbeIntervalMs.ShouldBe(9000);
|
|
back.ProbeTimeoutMs.ShouldBe(3000);
|
|
back.ReconnectMinBackoffMs.ShouldBe(100);
|
|
back.ReconnectMaxBackoffMs.ShouldBe(60000);
|
|
back.ReconnectBackoffMultiplier.ShouldBe(1.5);
|
|
}
|
|
|
|
[Fact]
|
|
public void FromJson_reads_a_pascal_case_seeded_config()
|
|
{
|
|
// Seed SQL / hand-authored configs are PascalCase; the driver reads them case-insensitively,
|
|
// so the form must too or it silently shows defaults over a real config.
|
|
const string seeded = """
|
|
{
|
|
"AgentUri": "http://10.100.0.35:5000",
|
|
"DeviceName": "Mill-01",
|
|
"RequestTimeoutMs": 8000,
|
|
"Probe": { "Enabled": false, "IntervalMs": 7000, "TimeoutMs": 4000 },
|
|
"Reconnect": { "MinBackoffMs": 250, "MaxBackoffMs": 45000, "BackoffMultiplier": 3.0 }
|
|
}
|
|
""";
|
|
|
|
var form = MTConnectDriverForm.MTConnectFormModel.FromJson(seeded);
|
|
|
|
form.AgentUri.ShouldBe("http://10.100.0.35:5000");
|
|
form.DeviceName.ShouldBe("Mill-01");
|
|
form.RequestTimeoutMs.ShouldBe(8000);
|
|
form.ProbeEnabled.ShouldBeFalse();
|
|
form.ProbeIntervalMs.ShouldBe(7000);
|
|
form.ProbeTimeoutMs.ShouldBe(4000);
|
|
form.ReconnectMinBackoffMs.ShouldBe(250);
|
|
form.ReconnectMaxBackoffMs.ShouldBe(45000);
|
|
form.ReconnectBackoffMultiplier.ShouldBe(3.0);
|
|
}
|
|
|
|
[Fact]
|
|
public void FromJson_falls_back_to_defaults_for_an_unusable_document()
|
|
{
|
|
foreach (var bad in new[] { null, "", " ", "{", "\"scalar\"" })
|
|
{
|
|
var form = MTConnectDriverForm.MTConnectFormModel.FromJson(bad);
|
|
form.RequestTimeoutMs.ShouldBe(5000);
|
|
form.SampleCount.ShouldBe(1000);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void Unknown_top_level_keys_survive_a_load_save()
|
|
{
|
|
// The driver also accepts a hand-authored tags[] surface; opening the form must not delete it.
|
|
const string existing = """{"agentUri":"http://old:5000","tags":[{"fullName":"x1"}]}""";
|
|
|
|
var form = MTConnectDriverForm.MTConnectFormModel.FromJson(existing);
|
|
form.AgentUri = "http://new:5000";
|
|
|
|
var o = Emit(form, existing);
|
|
o["agentUri"]!.GetValue<string>().ShouldBe("http://new:5000");
|
|
o["tags"]!.AsArray().Count.ShouldBe(1);
|
|
}
|
|
|
|
[Fact]
|
|
public void A_previously_written_key_is_removed_when_its_value_becomes_non_positive()
|
|
{
|
|
const string existing = """{"agentUri":"http://a:5000","requestTimeoutMs":9000}""";
|
|
|
|
var form = MTConnectDriverForm.MTConnectFormModel.FromJson(existing);
|
|
form.RequestTimeoutMs = 0;
|
|
|
|
// Not merely "not written" — the stale 9000 must not survive either, or the UI would show 0
|
|
// while the stored config still said 9000.
|
|
Emit(form, existing).ContainsKey("requestTimeoutMs").ShouldBeFalse();
|
|
}
|
|
|
|
// ---- the shape the driver actually binds ---------------------------------------------------
|
|
|
|
[Fact]
|
|
public void Emitted_config_binds_to_the_drivers_wire_dto_shape()
|
|
{
|
|
var json = MTConnectDriverForm.BuildConfigJson(
|
|
new MTConnectDriverForm.MTConnectFormModel
|
|
{
|
|
AgentUri = "http://agent:5000",
|
|
DeviceName = "VMC-1",
|
|
RequestTimeoutMs = 7000,
|
|
},
|
|
null);
|
|
|
|
// Mirror of MTConnectDriver.MTConnectDriverConfigDto (private there), asserting the emitted
|
|
// document binds field-for-field — a rename on either side breaks this.
|
|
var dto = JsonSerializer.Deserialize<WireDto>(json, _opts);
|
|
|
|
dto.ShouldNotBeNull();
|
|
dto!.AgentUri.ShouldBe("http://agent:5000");
|
|
dto.DeviceName.ShouldBe("VMC-1");
|
|
dto.RequestTimeoutMs.ShouldBe(7000);
|
|
dto.SampleIntervalMs.ShouldBe(1000);
|
|
dto.SampleCount.ShouldBe(1000);
|
|
dto.HeartbeatMs.ShouldBe(10000);
|
|
dto.Probe.ShouldNotBeNull();
|
|
dto.Probe!.Enabled.ShouldBe(true);
|
|
dto.Probe.IntervalMs.ShouldBe(5000);
|
|
dto.Probe.TimeoutMs.ShouldBe(2000);
|
|
dto.Reconnect.ShouldNotBeNull();
|
|
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
|
|
{
|
|
public string? AgentUri { get; init; }
|
|
public string? DeviceName { get; init; }
|
|
public int? RequestTimeoutMs { get; init; }
|
|
public int? SampleIntervalMs { get; init; }
|
|
public int? SampleCount { get; init; }
|
|
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
|
|
{
|
|
public bool? Enabled { get; init; }
|
|
public int? IntervalMs { get; init; }
|
|
public int? TimeoutMs { get; init; }
|
|
}
|
|
|
|
private sealed class WireReconnect
|
|
{
|
|
public int? MinBackoffMs { get; init; }
|
|
public int? MaxBackoffMs { get; init; }
|
|
public double? BackoffMultiplier { get; init; }
|
|
}
|
|
}
|