From 95b8caf284ea9acff520aeecf20b1d96d01193b0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 18 Jun 2026 13:06:59 -0400 Subject: [PATCH] fix(m9/T30): empty-state guard keys off resolved fields (top-level \$ref); shift list raw-text on item removal --- .../Shared/ParameterValueForm.razor | 43 ++++++-- .../Design/ParameterValueFormTests.cs | 98 +++++++++++++++++++ 2 files changed, 134 insertions(+), 7 deletions(-) diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor index 38dba798..bbb7e6b4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/ParameterValueForm.razor @@ -18,7 +18,7 @@ values. *@ -@if (_shapes.Count == 0) +@if (_topLevelFields.Count == 0) {
No parameters declared.
} @@ -399,18 +399,47 @@ else 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. + // After a removal at removedIndex, paths for items above that index shift down + // by one. Mirror the value-list RemoveAt by: (1) for each index > removedIndex + // found in a dict, re-key it as index-1; (2) clear the now-vacated top slot + // (the original highest index). Items below the removed index are unaffected. private void ShiftListState(string path, int removedIndex) + { + ShiftDict(_rawText, path, removedIndex); + ShiftDict(_parseErrors, path, removedIndex); + } + + private static void ShiftDict(Dictionary dict, string path, int removedIndex) { var prefix = $"{path}["; - foreach (var key in _rawText.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).ToList()) + // Collect keys under this list prefix, parse the item index, and build the + // shift map in one pass so in-place mutation does not interfere with iteration. + var toShift = new List<(string Key, int ItemIndex, string Suffix)>(); + foreach (var key in dict.Keys) { - _rawText.Remove(key); + if (!key.StartsWith(prefix, StringComparison.Ordinal)) continue; + // Extract the integer after the '['. + var rest = key.AsSpan(prefix.Length); // e.g. "2]" or "2].field" + var closePos = rest.IndexOf(']'); + if (closePos < 0) continue; + if (!int.TryParse(rest[..closePos], System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var idx)) continue; + // Only touch items ABOVE the removed index; items at lower indices are stable. + if (idx <= removedIndex) continue; + var suffix = rest[(closePos + 1)..].ToString(); // everything after ']' + toShift.Add((key, idx, suffix)); } - foreach (var key in _parseErrors.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).ToList()) + + // Apply: remove old key, write shifted key. Higher indices first so we never + // overwrite a key that is itself still pending a shift (would happen if we + // iterated lowest-to-highest and the same path base appeared at consecutive + // indices). Processing highest → lowest avoids the collision. + foreach (var (key, idx, suffix) in toShift.OrderByDescending(t => t.ItemIndex)) { - _parseErrors.Remove(key); + var value = dict[key]; + dict.Remove(key); + var shiftedKey = $"{path}[{idx - 1}]{suffix}"; + dict[shiftedKey] = value; } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs index bb63acab..f968b3c8 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Design/ParameterValueFormTests.cs @@ -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()) + .Returns(new Dictionary { ["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(p => p + .Add(x => x.ParameterDefinitions, paramSchema) + .Add(x => x.Values, new Dictionary())); + + // 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(p => p + .Add(x => x.ParameterDefinitions, schema) + .Add(x => x.Values, new Dictionary()) + .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")); + } }