Skip to content

forms(qml): ready claims the payload, so a member the renderer cannot represent makes the form unready (fixes #800) - #801

Merged
Yaraslaut merged 1 commit into
masterfrom
forms/ready-claims-the-payload
Sep 24, 2026
Merged

Yaraslaut merged 1 commit into
masterfrom
forms/ready-claims-the-payload

Conversation

@Yaraslaut

Copy link
Copy Markdown
Member

Fixes #800.

Which reading of ready won, and why

The ticket left one question open: is ready "the payload satisfies the
schema"
(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:

  • Closed sets (forms.md, "Closed sets — a reflected enum class"): a value
    outside the set "must leave the form not ready, exactly as a boolean
    field refuses anything but true/false". The combo box is drawn and filled
    there; readiness still turns on whether the value is one the schema admits.
  • An enum with no glz::meta, a few lines later: the renderer "draws that
    wildcard as a checkbox, reporting the form ready for a value nobody chose", and
    schemaJson<A>() static_asserts rather than "shipping a form that lies
    about being ready
    ".

Under the controls-filled reading, both of those paragraphs are wrong, and
ready would 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:

  • an object-typed member no typed control claims (a nested aggregate);
  • an array whose items resolve to objects or arrays — the type: "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 was
typed, 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 the
form short of ready, previewLine empty and submitIfValid uncalled.

A Quantity and a Choice are "type": "object" in the schema 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.

The three things it does not do

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 omits
an 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

Read Meaning
fields[i].unrepresentable Why no control here can collect what the schema asks for, or "". 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 by revalidate() in the same pass as ready, 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.description the accessibility slice already mirrors.

Proof

tst_DynamicFormNestedAggregate.qml goes from 5 cases to 7: the three readiness
cases 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 and
reaches 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 restores ready == true for
both wrong payloads):

FAIL!  : DynamicFormNestedAggregate::test_the_cyclic_member_leaves_the_form_unready_and_says_why()
   Actual   (): true
   Expected (): false
FAIL!  : DynamicFormNestedAggregate::test_an_acyclic_nested_aggregate_is_flattened_the_same_way()
FAIL!  : DynamicFormNestedAggregate::test_a_recursive_collection_gets_the_array_control_but_no_payload()
FAIL!  : DynamicFormNestedAggregate::test_an_optional_unrepresentable_member_may_be_omitted()
Totals: 297 passed, 4 failed, 0 skipped, 0 blacklisted

Removing revalidate()'s unrepresentableReason assignment instead reddens the
same four, on the reason rather than the flag:

FAIL!  : ...test_the_cyclic_member_leaves_the_form_unready_and_says_why() 'verify()' returned FALSE.
   Loc: [.../tst_DynamicFormNestedAggregate.qml(148)]
Totals: 297 passed, 4 failed, 0 skipped, 0 blacklisted

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.
  • Full 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 is
    pre-existing and in WizardView.qml.
  • clang-format and clang-tidy-diff measure nothing on this branch, and saying
    otherwise would be the failure mode AGENTS.md names.
    The change is two
    .qml files and one .md; git diff --name-only names zero C/C++ files,
    clang-format 22.1.8 is installed and has no input, and
    clang-tidy-diff.py -p1 over origin/master...HEAD answers "No relevant
    changes 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.md gains "What ready claims" under the shipped
renderer (the contract, the two unrepresentable shapes, the Quantity/Choice
exclusion, 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 a
suite 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 bare double/float, no
x-decimalPlaces) has no branch in fieldJsonLiteral either, so it falls
through 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

…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

codecov Bot commented Sep 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

forms: DynamicForm.ready is true for a payload the schema rejects

1 participant