forms(qml): ready claims the payload, so a member the renderer cannot represent makes the form unready (fixes #800) - #801
Merged
Conversation
…o the form is not ready (fixes #800) `DynamicForm.ready` was `true` for a payload the schema rejects: an object-typed member reached one text field and went out as a JSON *string* (`{"id":7,"root":"anything"}`), and an array-of-objects member took the `type: "array"` control and went out as an array of strings (`{"name":"top","children":["a","b"]}`). `ready` is a claim about the payload, not about which controls happen to be filled -- the spec already applied that reading twice elsewhere (a value outside a closed set leaves the form not ready though the combo box holds it; an unreflected enum drawn as a checkbox is called "a form that lies about being ready"). So the flag was defective, not misnamed, and the code is what moves. A member with no encoding is now *unrepresentable*: an object-typed member no typed control claims, or an array whose `items` resolve to objects. Such a member yields no JSON literal, whatever was typed, so it behaves exactly like a field whose text does not encode -- required, or typed into, it keeps the form short of `ready` and nothing is submitted. A Quantity and a Choice are `"type": "object"` too and are excluded: their controls encode the shape their schema asks for. The test is whether an encoding exists, not what the JSON type is. Nothing new is drawn and nothing is dropped. Whether the renderer should eventually draw a sub-form, decline the schema or keep flattening is a separate question, and the readiness answer is the same under all three. An *optional* unrepresentable member left blank is still omitted and the form still reaches ready -- that body is one the schema accepts, and the member is declined rather than dropped silently, because its descriptor names it. Two reads carry the reason: `fields[i].unrepresentable` (a property of the schema, readable before anything is typed) and `unrepresentableReason` on the form (`"<wire name>: <reason>"`, written by `revalidate()` in the same pass as `ready`). The status label shows it in place of "fill the required (*) fields" -- advice no input can act on -- and so announces it through the accessible description the accessibility slice already mirrors. The pinning suite grows from 5 cases to 7: the three readiness cases now assert not-ready with a reason and no body, an optional-member case pins the boundary in both directions, and a flat form (integer, string, array-of-string, Quantity) pins the regression this most easily causes. Measured, not inferred: removing `fieldJsonLiteral`'s unrepresentable check turns 4 of the 7 red on the `ready` comparison; removing `revalidate()`'s `unrepresentableReason` assignment turns the same 4 red on the reason instead; the two structural cases and the flat form stay green under both. Full suite 301 passed / 0 failed, full ctest 1667/1667. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #800.
Which reading of
readywon, and whyThe ticket left one question open: is
ready"the payload satisfies theschema" (so the flag is defective) or "every control the renderer drew is
filled" (so it is merely misnamed, and the spec sentence is what to fix)?
It is the payload. Not by preference — the spec already applies that reading
twice, in text that has nothing to do with nested aggregates:
forms.md, "Closed sets — a reflectedenum class"): a valueoutside the set "must leave the form not ready, exactly as a
booleanfield refuses anything but
true/false". The combo box is drawn and filledthere; readiness still turns on whether the value is one the schema admits.
glz::meta, a few lines later: the renderer "draws thatwildcard as a checkbox, reporting the form ready for a value nobody chose", and
schemaJson<A>()static_asserts rather than "shipping a form that liesabout being ready".
Under the controls-filled reading, both of those paragraphs are wrong, and
readywould carry no gate a caller could act on — every field is always"filled" with whatever text is in it. So the flag was doing the wrong thing, and
the code moves. The spec's own "arguably wrong" sentence is replaced by the
statement it was hedging.
What the fix is
A member with no encoding is unrepresentable, recognised in two shapes:
itemsresolve to objects or arrays — thetype: "array"control encodes each entry as a JSON string, so a control was drawn and the
member is unrepresentable regardless. That case is on its own why "a control
was filled" cannot be the contract.
Such a member yields no JSON literal from
fieldJsonLiteral, whatever wastyped, so it joins the path a field whose text does not encode has always taken:
required (statically or through a
requiredWhen), or typed into, it keeps theform short of
ready,previewLineempty andsubmitIfValiduncalled.A
Quantityand aChoiceare"type": "object"in the schema too and areexcluded: their controls encode the shape their schema asks for. The test is
whether an encoding exists, not what the JSON type is.
The three things it does not do
only the claim about the body changed. What the renderer should draw for a
nested member stays on forms: DynamicForm flattens every nested aggregate into one text field and calls the form ready — decide whether to draw it, decline it, or keep it #759, and the readiness answer is the same under all
three of its options.
omitted — the form refuses to submit at all.
$refcycledoes not loop; the member is one control, not a sub-form; an acyclic nesting
flattens identically) are descriptions of behaviour and are unchanged, with
their assertions intact.
One judgement call worth review. An optional unrepresentable member left
blank still lets the form reach
ready: the body omits it, and a body that omitsan optional member is one the schema accepts. That is not the "silently dropped"
case the ticket forbids — the member is named on its descriptor, so a caller can
decline the form — and typing into its control does make the form unready, since
that text has no encoding. Pinned in both directions.
What the caller can now see
fields[i].unrepresentable"". A property of the schema, so it is readable before anything is typed — a caller can decline the form up front.unrepresentableReason"<wire name>: <reason>"for the member submission is stuck on, or"". Written byrevalidate()in the same pass asready, so the two cannot disagree. Empty while ready and while merely unfilled — a blank required field is the ordinary gate, which the user can act on.The status label shows that reason in place of "fill the required (*) fields",
which is advice no input can act on here, and therefore announces it through the
Accessible.descriptionthe accessibility slice already mirrors.Proof
tst_DynamicFormNestedAggregate.qmlgoes from 5 cases to 7: the three readinesscases now assert not-ready-with-a-reason and an empty body, plus an
optional-member case (the boundary in both directions) and a flat form (integer,
string, array-of-string,
Quantity) that names nothing unrepresentable andreaches
ready— the regression this most easily causes.Before the fix, with the new suite's predecessors in place, the three
readiness cases were the only failures — 296 passed, 3 failed — which is what
established that this change touches the readiness claim and nothing else.
Two mutations, each aimed at one half of the claim. Removing
fieldJsonLiteral's unrepresentable check (which restoresready == trueforboth wrong payloads):
Removing
revalidate()'sunrepresentableReasonassignment instead reddens thesame four, on the reason rather than the flag:
Green under both: the two structural cases (the cycle builds a form; the member
is one control, not a sub-form) and the flat form — so the four failures are a
statement about readiness, not about the suite.
Gates
morph_forms_qml_tests -input src/qt/forms/tests: 301 passed, 0 failed(299 on
master+ the 2 new cases), Qt 6.11.2,QT_QPA_PLATFORM=offscreen.ctest: 1667/1667 passed (MORPH_BUILD_TESTS=ON,MORPH_BUILD_QT=ON,MORPH_BUILD_FORMS_QML=ON), 123 s.qmllint(all_qmllint): no new diagnostic. The one warning it prints ispre-existing and in
WizardView.qml.otherwise would be the failure mode
AGENTS.mdnames. The change is two.qmlfiles and one.md;git diff --name-onlynames zero C/C++ files,clang-format22.1.8 is installed and has no input, andclang-tidy-diff.py -p1overorigin/master...HEADanswers "No relevantchanges found." The CI job will be green for the same reason — its
unbuilt-source filter keeps a non-C++ section, so the diff is not empty and
its "green gate over nothing" guard does not fire. No public header changed,
so the Doxygen gate has nothing to say either.
Spec
docs/spec/forms/forms.mdgains "Whatreadyclaims" under the shippedrenderer (the contract, the two unrepresentable shapes, the
Quantity/Choiceexclusion, the two reads, and the optional-member boundary), and the
nested-aggregate contract's point 4 is rewritten from "the payload is wrong and
the form reports itself ready" to the refusal, with the mutation results above as
its verification statement. The stale claim that the old 5-case suite reddens 4
under a
fields-builder filter is dropped rather than restated: it measured asuite that no longer exists, and re-asserting it here would be inferring a
measurement.
Noted in passing, not filed and not folded in
A member whose declared type is plain
"number"(a baredouble/float, nox-decimalPlaces) has no branch infieldJsonLiteraleither, so it fallsthrough to the same JSON-string encoding the object-typed member used to get.
It is not flagged here: the unrepresentable test is deliberately scoped to the
nested-aggregate shapes the ticket is about, and widening it would change the
readiness verdict for schema shapes no case in the tree exercises and no suite
pins. Inferred from reading
fieldJsonLiteral's dispatch, not reproduced —no fixture in the tree declares such a member, which is also why it is not
urgent. Per the lane's instructions no issue was filed; this paragraph is the
record.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW