Associate ScadaBridge controls, guidance and editor errors

This commit is contained in:
Joseph Doherty
2026-11-13 17:59:00 -05:00
parent 20885b6161
commit 915a8a5914
93 changed files with 24126 additions and 476 deletions
@@ -297,10 +297,10 @@ public class FlowDebuggerPageTests : BunitContext
// The method pane is the shared ParameterValueForm (typed inputs by field id), and
// Run is gated on its validity — MachineCode is required, so Run stays off until it is set.
Assert.NotEmpty(cut.FindAll("[data-test='flow-method-params']"));
Assert.NotNull(cut.Find("#param-MachineCode"));
Assert.NotNull(cut.Find("#param-Qty"));
Assert.NotNull(cut.Find("[data-param-path='MachineCode']"));
Assert.NotNull(cut.Find("[data-param-path='Qty']"));
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
cut.Find("#param-MachineCode").Input("Z28061");
cut.Find("[data-param-path='MachineCode']").Input("Z28061");
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
}
@@ -316,8 +316,8 @@ public class FlowDebuggerPageTests : BunitContext
cut.Find("select[data-test='flow-method-select']").Change("4");
FillRequiredMethodInput(cut);
cut.Find("#param-MachineCode").Input("Z28061");
cut.Find("#param-Qty").Input("3");
cut.Find("[data-param-path='MachineCode']").Input("Z28061");
cut.Find("[data-param-path='Qty']").Input("3");
cut.Find("button[data-test='flow-run']").Click();
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
@@ -791,7 +791,7 @@ public class FlowDebuggerPageTests : BunitContext
Assert.NotEmpty(cut.FindAll("[data-test='flow-script-params']"));
Assert.Empty(cut.FindAll("input[data-test='flow-input-value']"));
cut.Find("#param-n").Input("3");
cut.Find("[data-param-path='n']").Input("3");
cut.Find("button[data-test='flow-run']").Click();
cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim()));
_ = _flow.Received(1).RunScriptAsync(7, "Manual", Arg.Is<JsonElement?>(p => p != null && p.Value.GetProperty("n").GetInt64() == 3), null, Arg.Any<CancellationToken>());
@@ -851,7 +851,7 @@ public class FlowDebuggerPageTests : BunitContext
cut.Find("select[data-test='flow-site-select']").Change("1");
PickInstance(cut, 7);
cut.Find("select[data-test='flow-script-select']").Change("Manual");
cut.Find("#param-n").Input("abc"); // not an integer: the form parks a parse error
cut.Find("[data-param-path='n']").Input("abc"); // not an integer: the form parks a parse error
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
return cut;
}
@@ -868,10 +868,10 @@ public class FlowDebuggerPageTests : BunitContext
// A fresh form: no echo of the previous script's text, and no stale parse error keeping
// Run disabled invisibly — Run is disabled only because the required field is unset.
Assert.Equal(string.Empty, cut.Find("#param-n").GetAttribute("value"));
Assert.Equal(string.Empty, cut.Find("[data-param-path='n']").GetAttribute("value"));
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
cut.Find("#param-n").Input("5");
cut.Find("[data-param-path='n']").Input("5");
Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
@@ -1298,7 +1298,7 @@ public class FlowDebuggerPageTests : BunitContext
/// </summary>
private static void FillRequiredMethodInput(IRenderedComponent<FlowDebuggerPage> cut)
{
cut.WaitForElement("#param-MachineCode").Input("Z28061");
cut.WaitForElement("[data-param-path='MachineCode']").Input("Z28061");
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
}
@@ -1314,16 +1314,16 @@ public class FlowDebuggerPageTests : BunitContext
.Returns(GraphOutcome(withSite: true, withChildId: true));
var cut = Render<FlowDebuggerPage>();
cut.Find("select[data-test='flow-method-select']").Change("5");
cut.WaitForElement("#param-SAPID");
cut.WaitForElement("[data-param-path='SAPID']");
// Required array unset → Run off. Add two work orders through the list editor.
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
cut.Find("#param-SAPID").Input("103559_A");
cut.Find("[data-param-path='SAPID']").Input("103559_A");
cut.Find("button.param-list-add").Click();
cut.Find("button.param-list-add").Click();
cut.Find("[id='param-WorkOrders[0].WorkOrderNumber']").Input("WO-1");
cut.Find("[id='param-WorkOrders[0].PartNumber']").Input("P-1");
cut.Find("[id='param-WorkOrders[1].WorkOrderNumber']").Input("WO-2");
cut.Find("[data-param-path='WorkOrders[0].WorkOrderNumber']").Input("WO-1");
cut.Find("[data-param-path='WorkOrders[0].PartNumber']").Input("P-1");
cut.Find("[data-param-path='WorkOrders[1].WorkOrderNumber']").Input("WO-2");
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
cut.Find("button[data-test='flow-run']").Click();
@@ -1346,14 +1346,14 @@ public class FlowDebuggerPageTests : BunitContext
RegisterServices();
var cut = Render<FlowDebuggerPage>();
cut.Find("select[data-test='flow-method-select']").Change("5");
cut.WaitForElement("#param-SAPID");
cut.Find("#param-SAPID").Input("103559_A");
cut.WaitForElement("[data-param-path='SAPID']");
cut.Find("[data-param-path='SAPID']").Input("103559_A");
cut.Find("button.param-list-add").Click();
// An item exists but its required WorkOrderNumber is unset: still invalid.
cut.Find("[id='param-WorkOrders[0].PartNumber']").Input("P-1");
cut.Find("[data-param-path='WorkOrders[0].PartNumber']").Input("P-1");
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
cut.Find("[id='param-WorkOrders[0].WorkOrderNumber']").Input("WO-1");
cut.Find("[data-param-path='WorkOrders[0].WorkOrderNumber']").Input("WO-1");
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
}
@@ -1401,7 +1401,7 @@ public class FlowDebuggerPageTests : BunitContext
.Returns(GraphOutcome(withSite: true, withChildId: true));
var cut = Render<FlowDebuggerPage>();
cut.Find("select[data-test='flow-method-select']").Change("5");
cut.WaitForElement("#param-SAPID");
cut.WaitForElement("[data-param-path='SAPID']");
cut.Find("button[data-test='flow-input-mode-raw']").Click();
var editor = cut.FindComponent<MonacoEditor>();
@@ -1412,9 +1412,9 @@ public class FlowDebuggerPageTests : BunitContext
// Back to the form: the parsed tree is what the item editors now show.
cut.Find("button[data-test='flow-input-mode-form']").Click();
cut.WaitForElement("[id='param-WorkOrders[0].WorkOrderNumber']");
Assert.Equal("WO-1", cut.Find("[id='param-WorkOrders[0].WorkOrderNumber']").GetAttribute("value"));
Assert.Equal("103559_A", cut.Find("#param-SAPID").GetAttribute("value"));
cut.WaitForElement("[data-param-path='WorkOrders[0].WorkOrderNumber']");
Assert.Equal("WO-1", cut.Find("[data-param-path='WorkOrders[0].WorkOrderNumber']").GetAttribute("value"));
Assert.Equal("103559_A", cut.Find("[data-param-path='SAPID']").GetAttribute("value"));
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
cut.Find("button[data-test='flow-run']").Click();
@@ -1436,9 +1436,9 @@ public class FlowDebuggerPageTests : BunitContext
cut.Find("select[data-test='flow-method-select']").Change("5");
cut.WaitForElement("#param-SAPID");
Assert.Empty(cut.FindAll("#param-MachineCode"));
Assert.Equal("", cut.Find("#param-SAPID").GetAttribute("value"));
cut.WaitForElement("[data-param-path='SAPID']");
Assert.Empty(cut.FindAll("[data-param-path='MachineCode']"));
Assert.Equal("", cut.Find("[data-param-path='SAPID']").GetAttribute("value"));
Assert.Empty(cut.FindAll(".param-list-row"));
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
@@ -1453,8 +1453,8 @@ public class FlowDebuggerPageTests : BunitContext
var cut = Render<FlowDebuggerPage>();
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForAssertion(() => Assert.Equal("Z9", cut.Find("#param-MachineCode").GetAttribute("value")));
Assert.Equal("7", cut.Find("#param-Qty").GetAttribute("value"));
cut.WaitForAssertion(() => Assert.Equal("Z9", cut.Find("[data-param-path='MachineCode']").GetAttribute("value")));
Assert.Equal("7", cut.Find("[data-param-path='Qty']").GetAttribute("value"));
cut.WaitForAssertion(() => Assert.False(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")));
}
@@ -1470,8 +1470,8 @@ public class FlowDebuggerPageTests : BunitContext
var cut = Render<FlowDebuggerPage>();
cut.Find("select[data-test='flow-method-select']").Change("4");
cut.WaitForElement("#param-MachineCode");
Assert.Equal("", cut.Find("#param-MachineCode").GetAttribute("value"));
cut.WaitForElement("[data-param-path='MachineCode']");
Assert.Equal("", cut.Find("[data-param-path='MachineCode']").GetAttribute("value"));
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
}
}
@@ -34,6 +34,27 @@ public class ParameterValueFormMonacoSchemaTests : BunitContext
Services.AddSingleton(_library);
}
/// <summary>Raw JSON errors describe the named composite and disappear after correction.</summary>
[Fact]
public async Task EscapeHatch_ParseErrorIsLinkedToNamedGroupUntilCorrection()
{
const string schema = """{"type":"object","properties":{"payload":{"$ref":"lib:Missing"}}}""";
var cut = Render<ParameterValueForm>(p => p.Add(c => c.ParameterDefinitions, schema));
var editor = cut.FindComponent<MonacoEditor>();
Assert.Equal("Parameter payload JSON", editor.Instance.AriaLabel);
await cut.InvokeAsync(() => editor.Instance.ValueChanged.InvokeAsync("{bad"));
var group = cut.Find(".param-json-editor");
Assert.Equal("group", group.GetAttribute("role"));
Assert.Equal("Parameter payload JSON", group.GetAttribute("aria-label"));
Assert.Equal("true", group.GetAttribute("aria-invalid"));
Assert.Contains("JSON parse error", cut.Find("#" + group.GetAttribute("aria-describedby")).TextContent);
await cut.InvokeAsync(() => editor.Instance.ValueChanged.InvokeAsync("{}"));
group = cut.Find(".param-json-editor");
Assert.Equal("false", group.GetAttribute("aria-invalid"));
Assert.Null(group.GetAttribute("aria-describedby"));
Assert.Equal("Parameter payload JSON", cut.FindComponent<MonacoEditor>().Instance.AriaLabel);
}
/// <summary>Verifies the case: escape Hatch, renders Monaco Json Editor, not Plain Textarea.</summary>
[Fact]
public void EscapeHatch_RendersMonacoJsonEditor_NotPlainTextarea()
@@ -28,6 +28,23 @@ public class ParameterValueFormTests : BunitContext
Services.AddSingleton(_library);
}
/// <summary>Repeated forms have distinct IDs and parse errors stay linked until correction.</summary>
[Fact]
public void RepeatedForms_HaveUniqueIdsAndLinkedParseErrors()
{
const string schema = """{"type":"object","properties":{"n":{"type":"integer"}}}""";
var first = Render<ParameterValueForm>(p => p.Add(c => c.ParameterDefinitions, schema));
var second = Render<ParameterValueForm>(p => p.Add(c => c.ParameterDefinitions, schema));
var input = first.Find("input");
Assert.NotEqual(input.Id, second.Find("input").Id);
input.Input("abc");
Assert.Equal("true", input.GetAttribute("aria-invalid"));
Assert.Contains("Not a valid integer", first.Find("#" + input.GetAttribute("aria-describedby")).TextContent);
input.Input("5");
Assert.Equal("false", input.GetAttribute("aria-invalid"));
Assert.Null(input.GetAttribute("aria-describedby"));
}
private const string ObjectParamSchema = """
{
"type": "object",
@@ -300,7 +317,7 @@ public class ParameterValueFormTests : BunitContext
.Add(x => x.ParameterDefinitions, """{"type":"object","properties":{"n":{"type":"integer"}}}""")
.Add(x => x.ValidChanged, v => valid.Add(v)));
var input = cut.Find("#param-n");
var input = cut.Find("input[type=number]");
input.Input("abc");
Assert.False(valid.Last());
input.Input("5");
@@ -22,6 +22,21 @@ public class OpcUaEndpointEditorTests : BunitContext
Services.AddSingleton<ICertManagementService>(Substitute.For<ICertManagementService>());
}
/// <summary>Existing endpoint validation is linked to its control and clears on correction.</summary>
[Fact]
public void EndpointError_IsDescribedAndInvalidUntilValidationClears()
{
var errors = ValidationResult.FromErrors(new ValidationEntry { EntityName = "EndpointUrl", Message = "Endpoint is required" });
var cut = Render<OpcUaEndpointEditor>(p => p.Add(c => c.Config, new OpcUaEndpointConfig()).Add(c => c.Errors, errors));
var input = cut.Find("input[placeholder='opc.tcp://host:4840']");
Assert.Equal("true", input.GetAttribute("aria-invalid"));
var errorId = input.GetAttribute("aria-describedby");
Assert.Equal("Endpoint is required", cut.Find("#" + errorId).TextContent);
cut.Render(p => p.Add(c => c.Errors, (ValidationResult?)null));
Assert.Equal("false", cut.Find("input[placeholder='opc.tcp://host:4840']").GetAttribute("aria-invalid"));
Assert.Null(cut.Find("input[placeholder='opc.tcp://host:4840']").GetAttribute("aria-describedby"));
}
/// <summary>Verifies that the editor renders all four section labels.</summary>
[Fact]
public void Renders_All_Four_Section_Labels()
@@ -305,7 +305,12 @@ public class TransportExportPageTests : BunitContext
var passphraseInput = cut.Find("#passphrase");
passphraseInput.Input("hunter2hunter2");
var confirmInput = cut.Find("#passphrase-confirm");
confirmInput.Input("different");
Assert.Equal("true", confirmInput.GetAttribute("aria-invalid"));
Assert.Contains("do not match", cut.Find("#" + confirmInput.GetAttribute("aria-describedby")).TextContent);
confirmInput.Input("hunter2hunter2");
Assert.Equal("false", confirmInput.GetAttribute("aria-invalid"));
Assert.Null(confirmInput.GetAttribute("aria-describedby"));
// Click "Export" — the only enabled button labeled "Export" at this step.
await cut.FindAll("button").First(b => b.TextContent.Trim() == "Export").ClickAsync(new());
@@ -0,0 +1,23 @@
using Bunit;
using Microsoft.AspNetCore.Components;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// <summary>Control names remain associated as search and selection values change.</summary>
public class ControlLabelTests : BunitContext
{
/// <summary>Visible DataTable search label targets its instance-specific input.</summary>
[Fact]
public void TableSearch_HasAssociatedLabelAfterTyping()
{
var cut = Render<DataTable<string>>(p => p.Add(c => c.Items, new[] { "one", "two" })
.Add(c => c.ShowSearch, true).Add(c => c.HeaderContent, (RenderFragment)(b => b.AddContent(0, "Value")))
.Add(c => c.RowContent, (RenderFragment<string>)(v => b => b.AddContent(0, v))));
var input = cut.Find("input");
Assert.False(string.IsNullOrEmpty(input.Id));
Assert.Equal("Search", cut.Find("label[for='" + input.Id + "']").TextContent.Trim());
input.Input("one");
Assert.NotNull(cut.Find("label[for='" + cut.Find("input").Id + "']"));
}
}
@@ -15,6 +15,17 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Shared;
/// </summary>
public class MonacoEditorLoggingTests : BunitContext
{
/// <summary>Editor creation supplies a real editor accessibility name.</summary>
[Fact]
public void CreateEditor_SuppliesAccessibleName()
{
Services.AddLogging();
JSInterop.Mode = JSRuntimeMode.Loose;
Render<MonacoEditor>();
var options = JSInterop.Invocations.Single(x => x.Identifier == "MonacoBlazor.createEditor").Arguments[2]!;
Assert.Equal("Code editor", options.GetType().GetProperty("ariaLabel")?.GetValue(options));
}
/// <summary>Captures log entries so the test can assert on them.</summary>
private sealed class CapturingLoggerProvider : ILoggerProvider
{
@@ -62,6 +62,23 @@ public class TreeViewMultiSelectTests : BunitContext
});
}
/// <summary>Each checkbox names its visible node, with IDs unique across repeated trees.</summary>
[Fact]
public void CheckboxNames_ReferenceVisibleNodeAndRemainUniqueAcrossTrees()
{
var first = RenderCheckboxTree();
var second = RenderCheckboxTree();
var ids = new HashSet<string>();
foreach (var tree in new[] { first, second })
foreach (var box in tree.FindAll("input.tv-checkbox"))
{
var id = box.GetAttribute("aria-labelledby");
Assert.False(string.IsNullOrWhiteSpace(id));
Assert.True(ids.Add(id!));
Assert.Contains(tree.Find("#" + id).TextContent.Trim(), new[] { "Folder1", "Folder2", "Leaf1a", "Leaf1b", "Leaf2a" });
}
}
/// <summary>Verifies that checkbox mode renders a checkbox input for each node.</summary>
[Fact]
public void Checkbox_mode_renders_input_checkbox_per_node()