286 lines
11 KiB
C#
286 lines
11 KiB
C#
using Bunit;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Design;
|
|
|
|
/// <summary>
|
|
/// M9-T30: <c>ParameterValueForm</c> renders typed nested inputs for object and
|
|
/// list parameters (replacing the raw-JSON textarea fallback), driven by the
|
|
/// declared schema (with <c>{"$ref":"lib:Name"}</c> resolved via
|
|
/// <see cref="ISchemaLibraryQueryService"/>), with per-field validation, and
|
|
/// emits the SAME canonical JSON value shape the form has always produced
|
|
/// (scalars as bool/long/double/string, objects as
|
|
/// <c>Dictionary<string,object?></c>, lists as <c>List<object?></c>).
|
|
/// </summary>
|
|
public class ParameterValueFormTests : BunitContext
|
|
{
|
|
private readonly ISchemaLibraryQueryService _library = Substitute.For<ISchemaLibraryQueryService>();
|
|
|
|
public ParameterValueFormTests()
|
|
{
|
|
// Default: empty library (no $ref entries). Individual tests override.
|
|
_library.GetSchemaMapAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new Dictionary<string, string>());
|
|
Services.AddSingleton(_library);
|
|
}
|
|
|
|
private const string ObjectParamSchema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"order": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": { "type": "integer" },
|
|
"name": { "type": "string" }
|
|
},
|
|
"required": ["id"]
|
|
}
|
|
},
|
|
"required": ["order"]
|
|
}
|
|
""";
|
|
|
|
private const string ListParamSchema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"tags": {
|
|
"type": "array",
|
|
"items": { "type": "string" }
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
[Fact]
|
|
public void ObjectParameter_RendersPerFieldInputs_NotRawTextarea()
|
|
{
|
|
var cut = Render<ParameterValueForm>(p => p
|
|
.Add(x => x.ParameterDefinitions, ObjectParamSchema)
|
|
.Add(x => x.Values, new Dictionary<string, object?>()));
|
|
|
|
// No raw-JSON textarea fallback for the object parameter.
|
|
Assert.Empty(cut.FindAll("textarea"));
|
|
|
|
// Nested fields are rendered as their own typed inputs: an Integer (number)
|
|
// for order.id and a String (text) for order.name.
|
|
var numberInputs = cut.FindAll("input[type=number]");
|
|
var textInputs = cut.FindAll("input[type=text]");
|
|
Assert.NotEmpty(numberInputs);
|
|
Assert.NotEmpty(textInputs);
|
|
|
|
// The nested field labels surface.
|
|
Assert.Contains("id", cut.Markup);
|
|
Assert.Contains("name", cut.Markup);
|
|
}
|
|
|
|
[Fact]
|
|
public void ObjectParameter_RoundTripsToCanonicalNestedJsonShape()
|
|
{
|
|
Dictionary<string, object?>? emitted = null;
|
|
var cut = Render<ParameterValueForm>(p => p
|
|
.Add(x => x.ParameterDefinitions, ObjectParamSchema)
|
|
.Add(x => x.Values, new Dictionary<string, object?>())
|
|
.Add(x => x.ValuesChanged, v => emitted = v));
|
|
|
|
// Enter the nested integer field (order.id).
|
|
var idInput = cut.FindAll("input[type=number]")[0];
|
|
idInput.Input("42");
|
|
|
|
// Enter the nested string field (order.name).
|
|
var nameInput = cut.FindAll("input[type=text]")[0];
|
|
nameInput.Input("widget");
|
|
|
|
Assert.NotNull(emitted);
|
|
var order = Assert.IsType<Dictionary<string, object?>>(emitted!["order"]);
|
|
Assert.Equal(42L, order["id"]);
|
|
Assert.Equal("widget", order["name"]);
|
|
}
|
|
|
|
[Fact]
|
|
public void ListParameter_RendersAddRemoveTypedItemRows()
|
|
{
|
|
Dictionary<string, object?>? emitted = null;
|
|
var cut = Render<ParameterValueForm>(p => p
|
|
.Add(x => x.ParameterDefinitions, ListParamSchema)
|
|
.Add(x => x.Values, new Dictionary<string, object?>())
|
|
.Add(x => x.ValuesChanged, v => emitted = v));
|
|
|
|
// No raw-JSON textarea fallback for the list parameter.
|
|
Assert.Empty(cut.FindAll("textarea"));
|
|
|
|
// An "add item" button exists; clicking it appends a typed item editor.
|
|
var addButton = cut.Find("button.param-list-add");
|
|
addButton.Click();
|
|
|
|
var itemInput = cut.Find("input.param-list-item");
|
|
itemInput.Input("alpha");
|
|
|
|
Assert.NotNull(emitted);
|
|
var tags = Assert.IsType<List<object?>>(emitted!["tags"]);
|
|
Assert.Single(tags);
|
|
Assert.Equal("alpha", tags[0]);
|
|
|
|
// Removing the row drops it back to an empty list.
|
|
cut.Find("button.param-list-remove").Click();
|
|
var tagsAfter = Assert.IsType<List<object?>>(emitted!["tags"]);
|
|
Assert.Empty(tagsAfter);
|
|
}
|
|
|
|
[Fact]
|
|
public void RefBearingParameter_RendersResolvedShape()
|
|
{
|
|
// The library entry "Address" resolves to an object schema with two string
|
|
// fields; the parameter references it via {"$ref":"lib:Address"}.
|
|
const string libSchema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"street": { "type": "string" },
|
|
"city": { "type": "string" }
|
|
}
|
|
}
|
|
""";
|
|
_library.GetSchemaMapAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new Dictionary<string, string> { ["Address"] = libSchema });
|
|
|
|
const string paramSchema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"shipTo": { "$ref": "lib:Address" }
|
|
}
|
|
}
|
|
""";
|
|
|
|
var cut = Render<ParameterValueForm>(p => p
|
|
.Add(x => x.ParameterDefinitions, paramSchema)
|
|
.Add(x => x.Values, new Dictionary<string, object?>()));
|
|
|
|
// The resolved shape renders its fields (no raw textarea, no unresolved-ref message).
|
|
Assert.Empty(cut.FindAll("textarea"));
|
|
Assert.Contains("street", cut.Markup);
|
|
Assert.Contains("city", cut.Markup);
|
|
// Two string inputs for the resolved object's two fields.
|
|
Assert.Equal(2, cut.FindAll("input[type=text]").Count);
|
|
}
|
|
|
|
[Fact]
|
|
public void PerFieldValidationError_SurfacesInline()
|
|
{
|
|
// A required nested field (order.id is an integer) left non-numeric must
|
|
// surface a per-field error inline.
|
|
var cut = Render<ParameterValueForm>(p => p
|
|
.Add(x => x.ParameterDefinitions, ObjectParamSchema)
|
|
.Add(x => x.Values, new Dictionary<string, object?>()));
|
|
|
|
var idInput = cut.FindAll("input[type=number]")[0];
|
|
idInput.Input("not-a-number");
|
|
|
|
// A field-level error class is shown.
|
|
Assert.NotEmpty(cut.FindAll(".text-danger"));
|
|
}
|
|
|
|
// M1 — top-level $ref: empty-state guard must key off resolved fields, not _shapes
|
|
[Fact]
|
|
public void TopLevelRef_ResolvesToObject_RendersFields_NotEmptyState()
|
|
{
|
|
// Library entry "SomeObject" resolves to an object with two fields.
|
|
const string libSchema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"code": { "type": "integer" },
|
|
"label": { "type": "string" }
|
|
},
|
|
"required": ["code"]
|
|
}
|
|
""";
|
|
_library.GetSchemaMapAsync(Arg.Any<CancellationToken>())
|
|
.Returns(new Dictionary<string, string> { ["SomeObject"] = libSchema });
|
|
|
|
// The parameter definitions IS a top-level $ref — no "properties" key at the
|
|
// root level, so _shapes (JsonSchemaShapeParser) stays empty.
|
|
const string paramSchema = """{"$ref":"lib:SomeObject"}""";
|
|
|
|
var cut = Render<ParameterValueForm>(p => p
|
|
.Add(x => x.ParameterDefinitions, paramSchema)
|
|
.Add(x => x.Values, new Dictionary<string, object?>()));
|
|
|
|
// Must NOT show the empty-state message.
|
|
Assert.DoesNotContain("No parameters declared", cut.Markup);
|
|
|
|
// Must render the resolved object's two fields.
|
|
Assert.Contains("code", cut.Markup);
|
|
Assert.Contains("label", cut.Markup);
|
|
// Integer and String inputs are present.
|
|
Assert.NotEmpty(cut.FindAll("input[type=number]"));
|
|
Assert.NotEmpty(cut.FindAll("input[type=text]"));
|
|
}
|
|
|
|
// I1 — list item removal: in-progress raw text of later items must SHIFT, not wipe.
|
|
// Uses a list-of-objects schema so sub-field raw-text entries (e.g. "rows[1].qty")
|
|
// persist in the rendered list (the LIST SLOT stays; only the nested field value
|
|
// is cleared on invalid input, not the containing dict). This is the scenario the
|
|
// ShiftDict fix is designed for: "rows[1].qty" → "rows[0].qty" when rows[0] is
|
|
// removed, so the error/raw-text follows the item rather than being wiped.
|
|
[Fact]
|
|
public void ListItemRemoval_ShiftsNestedRawTextOfLaterItems_NotWiped()
|
|
{
|
|
// List of objects, each with an integer "qty" field.
|
|
const string schema = """
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"rows": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"qty": { "type": "integer" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""";
|
|
|
|
var cut = Render<ParameterValueForm>(p => p
|
|
.Add(x => x.ParameterDefinitions, schema)
|
|
.Add(x => x.Values, new Dictionary<string, object?>())
|
|
.Add(x => x.ValuesChanged, _ => { }));
|
|
|
|
// Add two object items.
|
|
var addButton = cut.Find("button.param-list-add");
|
|
addButton.Click(); // rows[0]
|
|
addButton.Click(); // rows[1]
|
|
|
|
// Commit a valid qty in rows[0].
|
|
var numberInputs = cut.FindAll("input[type=number]");
|
|
numberInputs[0].Input("5");
|
|
|
|
// Enter an INVALID qty in rows[1] — parks raw text "abc" in _rawText["rows[1].qty"]
|
|
// and _parseErrors["rows[1].qty"]. The LIST SLOT rows[1] is preserved (only the
|
|
// nested field value is cleared, not the containing dict).
|
|
numberInputs = cut.FindAll("input[type=number]");
|
|
numberInputs[1].Input("abc");
|
|
|
|
// The error for rows[1].qty is visible before removal.
|
|
Assert.NotEmpty(cut.FindAll(".text-danger"));
|
|
|
|
// Remove rows[0]. With the fix, "rows[1].qty" raw-text/error entries shift to
|
|
// "rows[0].qty", following the item. Without the fix they are wiped and the
|
|
// item renders as if freshly added (no error).
|
|
cut.Find("button.param-list-remove").Click();
|
|
|
|
// After removal, the remaining item (formerly rows[1]) must still show the
|
|
// invalid-field error. Two remove buttons collapsed to one; one item renders.
|
|
Assert.Single(cut.FindAll("button.param-list-remove"));
|
|
Assert.NotEmpty(cut.FindAll(".text-danger"));
|
|
}
|
|
}
|