fix(m9/T30): empty-state guard keys off resolved fields (top-level \$ref); shift list raw-text on item removal

This commit is contained in:
Joseph Doherty
2026-06-18 13:06:59 -04:00
parent 6bc2bb5430
commit 95b8caf284
2 changed files with 134 additions and 7 deletions
@@ -184,4 +184,102 @@ public class ParameterValueFormTests : BunitContext
// 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"));
}
}