feat(m9/T30): schema-driven nested object/list value-entry forms (+ $ref resolution)

This commit is contained in:
Joseph Doherty
2026-06-18 12:40:22 -04:00
parent 991d90a875
commit 10c08dd309
2 changed files with 668 additions and 76 deletions
@@ -1,35 +1,42 @@
@using ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi
@using System.Text.Json
@inject ISchemaLibraryQueryService SchemaLibrary
@*
Renders an input row per declared parameter so the user can supply values
for a script test run. Primitive types get typed inputs (text / number /
checkbox); Object and List fall back to a JSON textarea with inline parse
errors. The companion SchemaBuilder edits the schema; this edits values.
for a script test run, driven by the declared schema. Primitive types get
typed inputs (text / number / checkbox); OBJECT parameters render a labeled
group of per-field inputs (recursing for nested objects/lists) and ARRAY
parameters render an add/remove list of typed item editors. A
{"$ref":"lib:Name"} node resolves through ISchemaLibraryQueryService and
renders the referenced shape. Per-field validation surfaces inline via
InboundApiSchema.Validate. The emitted value shape is unchanged: scalars as
bool/long/double/string, objects as Dictionary<string,object?>, lists as
List<object?>. The companion SchemaBuilder edits the schema; this edits
values.
*@
@if (Shapes.Count == 0)
@if (_shapes.Count == 0)
{
<div class="text-muted small fst-italic">No parameters declared.</div>
}
else
{
<div class="d-flex flex-column gap-2">
@foreach (var shape in Shapes)
@foreach (var field in _topLevelFields)
{
<div class="row g-2 align-items-center">
var shape = _shapes.FirstOrDefault(s => s.Name == field.Name);
<div class="row g-2 @(IsScalar(field.Schema) ? "align-items-center" : "align-items-start")">
<div class="col-sm-4">
<label class="form-label small mb-0" for="@FieldId(shape)">
<code>@shape.Name</code>
<span class="text-muted ms-1">@shape.Type@(shape.Required ? "" : "?")</span>
<label class="form-label small mb-0" for="@FieldId(field.Name)">
<code>@field.Name</code>
<span class="text-muted ms-1">@(shape?.Type ?? DisplayType(field.Schema))@(field.Required ? "" : "?")</span>
</label>
</div>
<div class="col-sm-8">
@RenderInput(shape)
@if (_parseErrors.TryGetValue(shape.Name, out var err))
{
<div class="text-danger small mt-1">@err</div>
}
@RenderNode(field.Schema, field.Name, FieldId(field.Name))
</div>
</div>
}
@@ -41,127 +48,525 @@ else
[Parameter] public Dictionary<string, object?> Values { get; set; } = new();
[Parameter] public EventCallback<Dictionary<string, object?>> ValuesChanged { get; set; }
private IReadOnlyList<ParameterShape> Shapes =>
ScriptParameterNames.ParseShapes(ParameterDefinitions);
private IReadOnlyList<ParameterShape> _shapes = Array.Empty<ParameterShape>();
private IReadOnlyList<InboundApiSchemaField> _topLevelFields = Array.Empty<InboundApiSchemaField>();
private InboundApiSchema? _rootSchema;
private string? _parsedFor;
// Raw text and per-field parse/validation errors keyed by canonical PATH
// (e.g. "order.id", "tags[0]") so nested inputs each carry their own state.
private readonly Dictionary<string, string> _rawText = new();
private readonly Dictionary<string, string> _parseErrors = new();
private static string FieldId(ParameterShape shape) => $"param-{shape.Name}";
private RenderFragment RenderInput(ParameterShape shape) => __builder =>
protected override async Task OnParametersSetAsync()
{
switch (shape.Type)
// Re-parse only when the declared schema text changes.
if (_parsedFor == ParameterDefinitions)
{
case "Boolean":
return;
}
_parsedFor = ParameterDefinitions;
_shapes = ScriptParameterNames.ParseShapes(ParameterDefinitions);
_rootSchema = await ParseRootAsync();
_topLevelFields = _rootSchema?.Type == "object" ? _rootSchema.Fields : Array.Empty<InboundApiSchemaField>();
}
/// <summary>
/// Parses the declared parameter definitions into an object schema, resolving
/// any {"$ref":"lib:Name"} nodes through the schema library so a referenced
/// shape renders its resolved fields. Library load is skipped entirely when
/// the definition contains no $ref token. Malformed schemas yield null (the
/// form then shows nothing, consistent with the lenient shape parser).
/// </summary>
private async Task<InboundApiSchema?> ParseRootAsync()
{
if (string.IsNullOrWhiteSpace(ParameterDefinitions))
{
return null;
}
Func<string, string?>? resolver = null;
if (InboundApiSchema.MightContainRef(ParameterDefinitions))
{
var map = await SchemaLibrary.GetSchemaMapAsync();
resolver = name => map.TryGetValue(name, out var json) ? json : null;
}
try
{
// Collect (not throw on) unresolved refs so a dangling reference still
// renders the rest of the form; the unresolved node falls through to
// the raw-JSON escape hatch.
return InboundApiSchema.ParseWithRefs(ParameterDefinitions, resolver).Schema;
}
catch (JsonException)
{
return null;
}
}
private static string FieldId(string path) => $"param-{path}";
private static bool IsScalar(InboundApiSchema schema) =>
schema.Type is "boolean" or "integer" or "number" or "string";
private static string DisplayType(InboundApiSchema schema) => schema.Type switch
{
"boolean" => "Boolean",
"integer" => "Integer",
"number" => "Float",
"string" => "String",
"object" => "Object",
"array" => schema.Items is null ? "List" : $"List<{DisplayType(schema.Items)}>",
_ => schema.Type,
};
// ---- Recursive node rendering ------------------------------------------
private RenderFragment RenderNode(InboundApiSchema schema, string path, string id) => __builder =>
{
switch (schema.Type)
{
case "boolean":
<div class="form-check">
<input class="form-check-input" type="checkbox" id="@FieldId(shape)"
checked="@AsBool(shape.Name)"
@onchange="e => SetBool(shape.Name, (bool)(e.Value ?? false))" />
<input class="form-check-input" type="checkbox" id="@id"
checked="@AsBool(path)"
@onchange="e => SetBool(path, (bool)(e.Value ?? false))" />
</div>
break;
case "Integer":
<input class="form-control form-control-sm" type="number" step="1" id="@FieldId(shape)"
value="@AsRaw(shape.Name)"
@oninput="e => SetNumeric(shape.Name, (string?)e.Value, integerOnly: true)" />
case "integer":
<input class="form-control form-control-sm" type="number" step="1" id="@id"
value="@AsRaw(path)"
@oninput="e => SetNumeric(path, (string?)e.Value, integerOnly: true)" />
@RenderError(path)
break;
case "Float":
<input class="form-control form-control-sm" type="number" step="any" id="@FieldId(shape)"
value="@AsRaw(shape.Name)"
@oninput="e => SetNumeric(shape.Name, (string?)e.Value, integerOnly: false)" />
case "number":
<input class="form-control form-control-sm" type="number" step="any" id="@id"
value="@AsRaw(path)"
@oninput="e => SetNumeric(path, (string?)e.Value, integerOnly: false)" />
@RenderError(path)
break;
case "String":
<input class="form-control form-control-sm" type="text" id="@FieldId(shape)"
value="@AsRaw(shape.Name)"
@oninput="e => SetString(shape.Name, (string?)e.Value)" />
case "string":
<input class="form-control form-control-sm" type="text" id="@id"
value="@AsRaw(path)"
@oninput="e => SetString(path, (string?)e.Value)" />
@RenderError(path)
break;
default: // Object, List, List<...>, unknown
<textarea class="form-control form-control-sm font-monospace" rows="3" id="@FieldId(shape)"
placeholder='@($"JSON {shape.Type.ToLowerInvariant()}")'
@oninput="e => SetJson(shape.Name, (string?)e.Value)">@AsRaw(shape.Name)</textarea>
case "object":
@RenderObject(schema, path)
break;
case "array":
@RenderArray(schema, path)
break;
default: // unresolved $ref / unknown — raw-JSON escape hatch.
<textarea class="form-control form-control-sm font-monospace" rows="3" id="@id"
placeholder='@($"JSON {DisplayType(schema).ToLowerInvariant()}")'
@oninput="e => SetJson(path, (string?)e.Value)">@AsRaw(path)</textarea>
@RenderError(path)
break;
}
};
private string AsRaw(string name) =>
_rawText.TryGetValue(name, out var raw) ? raw : "";
private bool AsBool(string name) =>
Values.TryGetValue(name, out var v) && v is bool b && b;
private async Task SetString(string name, string? raw)
private RenderFragment RenderObject(InboundApiSchema schema, string path) => __builder =>
{
_rawText[name] = raw ?? "";
_parseErrors.Remove(name);
Values[name] = raw ?? "";
await ValuesChanged.InvokeAsync(Values);
<div class="border-start ps-3 d-flex flex-column gap-2 param-object">
@foreach (var field in schema.Fields)
{
var childPath = JoinField(path, field.Name);
<div class="row g-2 @(IsScalar(field.Schema) ? "align-items-center" : "align-items-start")">
<div class="col-sm-4">
<label class="form-label small mb-0" for="@FieldId(childPath)">
<code>@field.Name</code>
<span class="text-muted ms-1">@DisplayType(field.Schema)@(field.Required ? "" : "?")</span>
</label>
</div>
<div class="col-sm-8">
@RenderNode(field.Schema, childPath, FieldId(childPath))
</div>
</div>
}
</div>
};
private RenderFragment RenderArray(InboundApiSchema schema, string path) => __builder =>
{
var count = ListCount(path);
var itemSchema = schema.Items ?? new InboundApiSchema { Type = "string" };
<div class="d-flex flex-column gap-2 param-list" data-path="@path">
@for (var i = 0; i < count; i++)
{
var index = i;
var itemPath = $"{path}[{index}]";
<div class="d-flex gap-2 align-items-start param-list-row">
<div class="flex-grow-1">
@RenderItem(itemSchema, itemPath)
</div>
<button type="button" class="btn btn-sm btn-outline-danger param-list-remove"
@onclick="() => RemoveListItem(path, index)" title="Remove item">&times;</button>
</div>
}
<div>
<button type="button" class="btn btn-sm btn-outline-secondary param-list-add"
@onclick="() => AddListItem(path, itemSchema)">+ Add item</button>
</div>
</div>
};
// Item editors mark their scalar input with the param-list-item class so the
// add/remove list rows are addressable in tests and styling.
private RenderFragment RenderItem(InboundApiSchema schema, string path) => __builder =>
{
switch (schema.Type)
{
case "boolean":
<div class="form-check">
<input class="form-check-input param-list-item" type="checkbox" id="@FieldId(path)"
checked="@AsBool(path)"
@onchange="e => SetBool(path, (bool)(e.Value ?? false))" />
</div>
break;
case "integer":
<input class="form-control form-control-sm param-list-item" type="number" step="1" id="@FieldId(path)"
value="@AsRaw(path)"
@oninput="e => SetNumeric(path, (string?)e.Value, integerOnly: true)" />
@RenderError(path)
break;
case "number":
<input class="form-control form-control-sm param-list-item" type="number" step="any" id="@FieldId(path)"
value="@AsRaw(path)"
@oninput="e => SetNumeric(path, (string?)e.Value, integerOnly: false)" />
@RenderError(path)
break;
case "string":
<input class="form-control form-control-sm param-list-item" type="text" id="@FieldId(path)"
value="@AsRaw(path)"
@oninput="e => SetString(path, (string?)e.Value)" />
@RenderError(path)
break;
default: // nested object/array/unknown item — recurse with the full renderer.
@RenderNode(schema, path, FieldId(path))
break;
}
};
private RenderFragment RenderError(string path) => __builder =>
{
if (_parseErrors.TryGetValue(path, out var err))
{
<div class="text-danger small mt-1">@err</div>
}
};
// ---- Path-addressed value tree -----------------------------------------
//
// The single source of truth is the Values dictionary holding the nested
// value tree: scalars as bool/long/double/string, objects as
// Dictionary<string,object?>, lists as List<object?> — exactly the shape the
// consuming pages serialize to JSON. Reads/writes navigate by canonical path
// ("a.b", "a.b[2]"); structural containers are created lazily on first write.
private record PathSeg(string? Field, int? Index);
private static IReadOnlyList<PathSeg> ParsePath(string path)
{
var segs = new List<PathSeg>();
var i = 0;
while (i < path.Length)
{
if (path[i] == '[')
{
var close = path.IndexOf(']', i);
var num = path.Substring(i + 1, close - i - 1);
segs.Add(new PathSeg(null, int.Parse(num, System.Globalization.CultureInfo.InvariantCulture)));
i = close + 1;
}
else
{
if (path[i] == '.') i++;
var start = i;
while (i < path.Length && path[i] != '.' && path[i] != '[') i++;
segs.Add(new PathSeg(path.Substring(start, i - start), null));
}
}
return segs;
}
private async Task SetBool(string name, bool value)
private object? GetValue(string path)
{
_parseErrors.Remove(name);
Values[name] = value;
await ValuesChanged.InvokeAsync(Values);
object? node = Values;
foreach (var seg in ParsePath(path))
{
if (seg.Field is not null)
{
if (node is Dictionary<string, object?> dict && dict.TryGetValue(seg.Field, out var v)) node = v;
else return null;
}
else
{
if (node is List<object?> list && seg.Index!.Value >= 0 && seg.Index.Value < list.Count) node = list[seg.Index.Value];
else return null;
}
}
return node;
}
private async Task SetNumeric(string name, string? raw, bool integerOnly)
private void SetValue(string path, object? value)
{
_rawText[name] = raw ?? "";
var segs = ParsePath(path);
object? node = Values;
for (var s = 0; s < segs.Count; s++)
{
var seg = segs[s];
var last = s == segs.Count - 1;
if (seg.Field is not null)
{
var dict = (Dictionary<string, object?>)node!;
if (last) { dict[seg.Field] = value; return; }
if (dict.TryGetValue(seg.Field, out var child) && child is (Dictionary<string, object?> or List<object?>)) node = child;
else { var created = NextContainer(segs[s + 1]); dict[seg.Field] = created; node = created; }
}
else
{
var list = (List<object?>)node!;
var idx = seg.Index!.Value;
if (last) { while (list.Count <= idx) list.Add(null); list[idx] = value; return; }
if (idx < list.Count && list[idx] is (Dictionary<string, object?> or List<object?>)) node = list[idx];
else { while (list.Count <= idx) list.Add(null); var created = NextContainer(segs[s + 1]); list[idx] = created; node = created; }
}
}
}
private void RemoveValue(string path)
{
var segs = ParsePath(path);
object? node = Values;
for (var s = 0; s < segs.Count - 1; s++)
{
var seg = segs[s];
node = seg.Field is not null
? (node is Dictionary<string, object?> d && d.TryGetValue(seg.Field, out var v) ? v : null)
: (node is List<object?> l && seg.Index!.Value < l.Count ? l[seg.Index.Value] : null);
if (node is null) return;
}
var leaf = segs[^1];
if (leaf.Field is not null && node is Dictionary<string, object?> dict) dict.Remove(leaf.Field);
else if (node is List<object?> list && leaf.Index!.Value >= 0 && leaf.Index.Value < list.Count) list.RemoveAt(leaf.Index.Value);
}
private static object NextContainer(PathSeg next) =>
next.Field is not null ? new Dictionary<string, object?>() : new List<object?>();
// ---- List add/remove ---------------------------------------------------
private int ListCount(string path) => GetValue(path) is List<object?> list ? list.Count : 0;
private async Task AddListItem(string path, InboundApiSchema itemSchema)
{
if (GetValue(path) is not List<object?> list)
{
list = new List<object?>();
SetValue(path, list);
}
// Seed structural items with an empty container so their nested inputs render.
list.Add(itemSchema.Type switch
{
"object" => new Dictionary<string, object?>(),
"array" => new List<object?>(),
_ => null,
});
await Emit();
}
private async Task RemoveListItem(string path, int index)
{
if (GetValue(path) is List<object?> list && index >= 0 && index < list.Count)
{
list.RemoveAt(index);
// Re-index raw-text / error state for shifted item paths.
ShiftListState(path, index);
}
await Emit();
}
// After a removal, item paths above the removed index shift down by one; clear
// their stale raw-text/error entries so the re-rendered rows read fresh.
private void ShiftListState(string path, int removedIndex)
{
var prefix = $"{path}[";
foreach (var key in _rawText.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).ToList())
{
_rawText.Remove(key);
}
foreach (var key in _parseErrors.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).ToList())
{
_parseErrors.Remove(key);
}
}
// ---- Scalar setters -----------------------------------------------------
private string AsRaw(string path)
{
if (_rawText.TryGetValue(path, out var raw)) return raw;
var v = GetValue(path);
return v switch { null => "", bool b => b.ToString(), _ => Convert.ToString(v, System.Globalization.CultureInfo.InvariantCulture) ?? "" };
}
private bool AsBool(string path) => GetValue(path) is bool b && b;
private async Task SetString(string path, string? raw)
{
_rawText[path] = raw ?? "";
SetValue(path, raw ?? "");
await Emit();
}
private async Task SetBool(string path, bool value)
{
SetValue(path, value);
await Emit();
}
private async Task SetNumeric(string path, string? raw, bool integerOnly)
{
_rawText[path] = raw ?? "";
if (string.IsNullOrWhiteSpace(raw))
{
_parseErrors.Remove(name);
Values.Remove(name);
await ValuesChanged.InvokeAsync(Values);
RemoveValue(path);
await Emit();
return;
}
if (integerOnly && long.TryParse(raw, out var i))
{
_parseErrors.Remove(name);
Values[name] = i;
_parseErrors.Remove(path);
SetValue(path, i);
}
else if (!integerOnly && double.TryParse(raw,
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var d))
{
_parseErrors.Remove(name);
Values[name] = d;
_parseErrors.Remove(path);
SetValue(path, d);
}
else
{
_parseErrors[name] = integerOnly ? "Not a valid integer." : "Not a valid number.";
Values.Remove(name);
// Park the unparseable raw text, drop the value, and mark the field. The
// "Not a valid" prefix tags this as a raw-parse marker so the schema
// validation pass leaves it intact.
_parseErrors[path] = integerOnly ? "Not a valid integer." : "Not a valid number.";
RemoveValue(path);
}
await ValuesChanged.InvokeAsync(Values);
await Emit();
}
private async Task SetJson(string name, string? raw)
private async Task SetJson(string path, string? raw)
{
_rawText[name] = raw ?? "";
_rawText[path] = raw ?? "";
if (string.IsNullOrWhiteSpace(raw))
{
_parseErrors.Remove(name);
Values.Remove(name);
await ValuesChanged.InvokeAsync(Values);
RemoveValue(path);
await Emit();
return;
}
try
{
using var doc = JsonDocument.Parse(raw);
Values[name] = JsonElementToObject(doc.RootElement.Clone());
_parseErrors.Remove(name);
_parseErrors.Remove(path);
SetValue(path, JsonElementToObject(doc.RootElement.Clone()));
}
catch (JsonException ex)
{
_parseErrors[name] = $"JSON parse error: {ex.Message}";
Values.Remove(name);
_parseErrors[path] = $"JSON parse error: {ex.Message}";
RemoveValue(path);
}
await Emit();
}
// ---- Emit + validate ----------------------------------------------------
private async Task Emit()
{
Validate();
await ValuesChanged.InvokeAsync(Values);
}
// Validates the whole value tree against the parsed schema and projects each
// path-qualified error onto the input that owns it. Raw-text parse failures
// (numeric / JSON) are added directly by the setters; schema validation adds
// type/required errors for the structured fields.
private void Validate()
{
// Clear schema-derived errors but keep raw-parse markers set this cycle.
foreach (var field in _topLevelFields)
{
ClearSchemaErrors(field.Name);
}
if (_rootSchema is null) return;
var errors = new List<string>();
try
{
var element = JsonSerializer.SerializeToElement(Values);
_rootSchema.Validate(element, string.Empty, errors);
}
catch (JsonException)
{
return;
}
foreach (var error in errors)
{
var path = ExtractPath(error);
if (path is not null && !_parseErrors.ContainsKey(path))
{
_parseErrors[path] = error;
}
}
}
// The validator quotes the offending path as 'a.b[2].c'; pull it back so the
// message can be attached to the input at that path. Returns null for
// whole-value / object-level messages with no quoted path.
private static string? ExtractPath(string message)
{
var open = message.IndexOf('\'');
if (open < 0) return null;
var close = message.IndexOf('\'', open + 1);
if (close < 0) return null;
var path = message.Substring(open + 1, close - open - 1);
return path.Length == 0 ? null : path;
}
private void ClearSchemaErrors(string topField)
{
foreach (var key in _parseErrors.Keys
.Where(k => k == topField || k.StartsWith(topField + ".", StringComparison.Ordinal) || k.StartsWith(topField + "[", StringComparison.Ordinal))
.ToList())
{
// Only drop schema-derived markers; raw-text parse markers carry the
// "Not a valid" / "JSON parse error" prefix and are owned by the setter.
var msg = _parseErrors[key];
if (!msg.StartsWith("Not a valid", StringComparison.Ordinal) && !msg.StartsWith("JSON parse error", StringComparison.Ordinal))
{
_parseErrors.Remove(key);
}
}
}
private static string JoinField(string path, string field) =>
string.IsNullOrEmpty(path) ? field : $"{path}.{field}";
private static object? JsonElementToObject(JsonElement element)
{
return element.ValueKind switch
@@ -0,0 +1,187 @@
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&lt;string,object?&gt;</c>, lists as <c>List&lt;object?&gt;</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"));
}
}