diff --git a/docs/spec/core/bridge.md b/docs/spec/core/bridge.md index 84782af4..ba312e44 100644 --- a/docs/spec/core/bridge.md +++ b/docs/spec/core/bridge.md @@ -346,7 +346,7 @@ the action's validator** (see below), calls `execute`, and serializes the result back to JSON. Throws `std::runtime_error` if the action was never registered. -Between decode and dispatch the registry executor applies three +Between decode and dispatch the registry executor applies four normalisations, in order, so the request/reply path matches the schema and the reactive `set<>` path rather than trusting the raw wire body: @@ -356,6 +356,15 @@ the reactive `set<>` path rather than trusting the raw wire body: the schema's advertised `x-decimalPlaces` instead of whatever runtime `dp` the client sent. It is a no-op for actions with no `Quantity` members and for actions whose type glaze cannot reflect. See [forms.md](../forms/forms.md). +- **Pre-decode wire validation.** `morph::forms::enforceQuantityBounds` rejects + a `Quantity` field whose engaged value falls outside its unit's declared + bounds (`UnitTraits::bounds`), throwing `QuantityDecodeError` — caught by + the same catch block as every other decode/validation failure on this path. + Runs after precision reconciliation and before the validator check below, so + an out-of-bounds value never reaches business-rule validation or the + handler. No-op for actions with no `Quantity` members, or whose units + declare no `bounds()`. See [forms.md](../forms/forms.md), "Pre-decode wire + validation". - **Computed-field recompute.** `morph::forms::recomputeAll` overwrites every `A::computedFields` destination from its declared inputs, discarding whatever value the wire carried for it — a computed field is never trusted diff --git a/docs/spec/core/registry.md b/docs/spec/core/registry.md index 03ce1a53..f815ba6f 100644 --- a/docs/spec/core/registry.md +++ b/docs/spec/core/registry.md @@ -225,9 +225,15 @@ struct ValidationError : std::runtime_error { (`morph::forms::reconcileDeclaredPrecision`) before the `ready()` check, so a hand-built wire payload's `Quantity` values match the schema's advertised `x-decimalPlaces` the same way the client bridge dispatch path already -normalises them (see [forms.md](../forms/forms.md)). `Bridge::executeVia`'s -`localOp` does not reconcile precision — that path never decodes JSON, so -there is no wire `dp` to reconcile against. +normalises them (see [forms.md](../forms/forms.md)). Immediately after +reconciliation, `morph::forms::enforceQuantityBounds` rejects any `Quantity` +field whose engaged value falls outside its unit's declared bounds +(`UnitTraits::bounds`), throwing `QuantityDecodeError` before the `ready()` +check — a no-op for actions with no `Quantity` members, or whose units +declare no `bounds()` (see [forms.md](../forms/forms.md), "Pre-decode wire +validation"). `Bridge::executeVia`'s `localOp` does not reconcile precision or +enforce bounds — that path never decodes JSON, so there is no wire `dp` or +wire value to check against. `ValidationError` derives from `std::runtime_error`, so it is caught by existing generic `catch (const std::exception&)` handling on both paths @@ -386,12 +392,15 @@ class ActionDispatcher { ``` - `registerAction` registers a runner that deserialises, reconciles any - `Quantity` fields to their declared precision, overwrites any declared + `Quantity` fields to their declared precision, rejects any `Quantity` field + outside its unit's declared bounds (`morph::forms::enforceQuantityBounds`, + throwing `QuantityDecodeError`; a no-op for actions with no `Quantity` + members or whose units declare no `bounds()`), overwrites any declared computed fields from their inputs (`morph::forms::recomputeAll`, [forms.md](../forms/forms.md) — a no-op for actions with no - `computedFields`; runs after precision reconciliation and before the - validator check, so the validator sees the authoritative computed value), - enforces `ActionValidator::ready(action)` (throwing `ValidationError` + `computedFields`; runs after precision reconciliation and bounds + enforcement and before the validator check, so the validator sees the + authoritative computed value), enforces `ActionValidator::ready(action)` (throwing `ValidationError` on `false`, before `Model::execute` runs), then calls `Model::execute(action)` inside a `try`/`catch (const std::exception&)`: on success it serialises the result and records a `LogEntry` with `outcome = Outcome::Succeeded` (when diff --git a/docs/spec/forms/forms.md b/docs/spec/forms/forms.md index b7591db4..cf9af73e 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -570,10 +570,72 @@ below) `DynamicForm.qml`'s `resolveProp` does exactly this dual read. | `x-section` | property node (sibling of `$ref`) | non-negative integer | The 0-based index of this field's group in `x-layout.groups`. Omitted under the same conditions as `x-group`. | | `x-colspan` | property node (sibling of `$ref`) | positive integer | Number of grid columns the field should span, from `FieldSpan::colspan`. Emitted only when greater than `1` (the default, single-column width). A renderer laying fields out in a grid widens the control; a single-column renderer ignores it. | | `x-rules` | top-level (object) | array of rule objects | Cross-field rules the renderer must satisfy before enabling submit, and should surface live as inline errors. Emitted only when the action declares `formRules`; absent otherwise. A renderer that ignores it falls back to per-field `required` only. | -| ↳ `kind` | rule / condition object | string | One of the closed vocabulary ids in the "Cross-field rules" section's table above (or a condition id: `engaged`, `notEngaged`, `equals`). An unrecognised `kind` must be treated as "cannot evaluate" — the renderer leaves the gate to the server rather than passing the rule (fail-closed). | -| ↳ `fields` | rule / condition object | array of strings | Wire field names the rule ranges over, in declaration order (operand order is significant for `greater`/`less`). | -| ↳ `when` | `requiredWhen` / `visibleWhen` / `readonlyWhen` object | rule/condition object | The nested condition the rule keys on. Present only on these condition-bearing kinds. | +| ↳ `kind` | rule / condition object | string | One of the closed vocabulary ids in the "Cross-field rules" section's table above (or a condition id: `engaged`, `notEngaged`, `equals`, `and`, `or`, `not`). An unrecognised `kind` must be treated as "cannot evaluate" — the renderer leaves the gate to the server rather than passing the rule (fail-closed). | +| ↳ `fields` | rule / condition object | array of strings | Wire field names the rule ranges over, in declaration order (operand order is significant for `greater`/`less`). Absent on `and`/`or`/`not`, which range over nested conditions (`conditions`/`condition` below) instead of fields directly. | +| ↳ `when` | `requiredWhen` / `visibleWhen` / `readonlyWhen` object | rule/condition object | The nested condition the rule keys on. Present only on these condition-bearing kinds. May itself be an `and`/`or`/`not` node (a compound condition), nested to any depth — see [Compound conditions](#compound-conditions--andof--orof--notof). | | ↳ `value` | `equals` condition object | scalar / `{num,den}` | The literal an `equals` condition compares against; a numeric literal is the exact `Rational` `{num, den}`, never a `double`. | +| ↳ `conditions` | `and` / `or` condition object | array of condition objects | The nested conditions combined by boolean AND / OR, in declaration order; each element is itself a full condition/rule object (any `kind`, including a nested `and`/`or`/`not`) — see [Compound conditions](#compound-conditions--andof--orof--notof). | +| ↳ `condition` | `not` condition object | condition object | The single nested condition negated by boolean NOT (singular key, since `not` wraps exactly one child). | +| `x-submitMode` | top-level (object) | string | `"explicit"` opts a side-effectful (non-query) action out of the shipped renderer's default auto-submit-on-validity behavior — see [Explicit submit mode](#explicit-submit-mode--x-submitmode). Absent, or any value other than `"explicit"`, keeps the default. Not emitted by `schemaJson()`; a schema author sets it by hand (or a hand-authored schema fixture/example does), the same way `x-layout`/`x-widget` overrides are authored today. | + +### Explicit submit mode — `x-submitMode` + +The shipped `DynamicForm.qml` renderer's default behavior is to call +`controller.submitIfValid(actionType, bodyJson)` the instant every field and +rule is satisfied — safe for a read-only query action, but unsafe for any +side-effectful (mutating) action: a mutation would fire on every keystroke +that happens to leave the form momentarily valid, with no user confirmation. + +Setting the top-level `"x-submitMode": "explicit"` schema key opts a form out +of that default: + +- `revalidate()` still recomputes `ready`/`previewLine` live (so `x-rules`, + `required`, and every other live-validation affordance are unaffected) but + never calls `submitIfValid` on its own. +- The renderer instead shows an explicit **Submit** button (`objectName: + "submitButton"`), enabled only while `ready` — matching the existing + `x-order`/required-asterisk convention of gating on the same readiness + state the auto-submit label already reflected. Clicking it is the sole + trigger; `DynamicForm.submit()` is the function it calls, itself a no-op + unless the form is currently ready. +- The button is loaded (via a `Loader`, `active: explicitSubmitMode`) only + when the schema opts in — a default (auto-submit) form has no such control + anywhere in its item tree, not merely a hidden one. + +Any schema describing a side-effectful action should carry this flag before +being safely rendered by the shipped renderer; a schema that omits it (every +existing schema, and any read-only query action) renders exactly as before — +zero behavior change. + +### Array fields — `type: "array"` + +glaze emits `{"type": "array", "items": {...}}` for a `std::vector` +member, standard JSON-Schema vocabulary rather than an `x-*` extension. The +shipped `DynamicForm.qml` renderer gives it a dedicated +comma-separated-with-validation `TextField` control (`objectName: "field_" + +name`, exactly like a scalar field's control — the two are mutually +exclusive per field, so exactly one claims that name) instead of falling +through to the plain-text control, whose fallback (`JSON.stringify(text)`) +would wrap the typed text as a JSON *string*, not an array — a body the +server's schema validation always rejects. + +Typed text is split on comma, each entry trimmed of surrounding whitespace, +and empty entries dropped: `"red, green, blue"` → `["red","green","blue"]`, +`" red ,, green ,"` → `["red","green"]`. A field with today's scope — +array-of-string — is fully supported; an `items` type other than `"string"` +still renders this control and still encodes each comma-separated entry as a +JSON string (not, e.g., a JSON number), so a `std::vector` field is +usable but not yet type-checked per element the way a scalar `Quantity`/ +integer field is. The submitted literal for a **fully-blank** array field +follows the same blank-means-unengaged convention as every other field +(`fieldJsonLiteral` returns `null` for empty/whitespace-only text), so an +optional, untouched array field is omitted from the request body entirely +rather than submitted as `[]`. Once the field holds *any* non-whitespace +text, though — including a comma-only entry like `" , , "`, which is not +blank by that check even though every individual entry is dropped — it +encodes to a genuine empty array `[]`, not `null`; a `required` array field +is satisfied by engagement (non-blank text), not by having at least one +surviving entry. ### Versioning stance @@ -597,7 +659,10 @@ renderer for it, Qt/QML, as a reusable component rather than example code. resolution/dual-read, the exact rational digit arithmetic, the unit selector, the required-field submit gate, the options-fetch, layout/ grouping into sections/tabs, the widget-hint controls — textarea, slider, - radio group — and the localisation dual-read), `DateTimePicker.qml` (manual + radio group — the comma-separated-with-validation `"array"`-typed field + control (see [Array fields](#array-fields--type-array)), the explicit + submit mode (see [Explicit submit mode](#explicit-submit-mode--x-submitmode)), + and the localisation dual-read), `DateTimePicker.qml` (manual ISO-8601 entry plus a calendar/time popup), `SlotRegistry.qml` (below), and `I18nCatalog.hpp`/`.cpp` (a `QObject`/`QML_ELEMENT` in-memory `TranslationProvider` realization — see @@ -991,6 +1056,9 @@ the client and the server evaluate identically from the same serialized form. | `readonlyWhen(field, cond)` | **Presentation:** `field` is editable only while `cond` does **not** hold. | `"readonlyWhen"` | no | | `engaged(field)` / `notEngaged(field)` | `field` is / is not engaged. | `"engaged"` / `"notEngaged"` | yes (condition-only) | | `equals(field, literal)` | `field`'s engaged value equals `literal`. | `"equals"` | yes (condition-only) | +| `andOf(cond1, cond2, ...)` | Every listed condition holds (boolean AND). | `"and"` | yes — also usable directly as a top-level rule | +| `orOf(cond1, cond2, ...)` | At least one listed condition holds (boolean OR). | `"or"` | yes — also usable directly as a top-level rule | +| `notOf(cond)` | The nested condition does **not** hold (boolean NOT). | `"not"` | yes — also usable directly as a top-level rule | `engaged`/`notEngaged`/`requiredWhen`/the membership rules accept any `EngageableField` — an `EmptyCapableField` (`Quantity`/`Choice`/`Timestamp`) or @@ -1025,6 +1093,82 @@ JSON string either way. Passing an explicit `std::string` still stores a `std::string` and still cannot be `constexpr` when it allocates; that is inherent to the type the caller chose. +### Compound conditions — `andOf` / `orOf` / `notOf` + +The single-node conditions above (`engaged`, `notEngaged`, `equals`, and the +comparison kinds reused as booleans) compose into a **recursive condition +tree** via three more factories: + +```cpp +struct BookRoom { + // ... + static constexpr auto formRules = morph::forms::ruleList( + // discount required only when BOTH promo and a loyalty code are engaged + morph::forms::requiredWhen( + &BookRoom::discount, + morph::forms::andOf(morph::forms::engaged(&BookRoom::promo), + morph::forms::engaged(&BookRoom::loyaltyCode)))); +}; +``` + +- **`andOf(cond1, cond2, ...)`** — holds when every listed condition holds + (at least two conditions; `test()` short-circuits left to right). +- **`orOf(cond1, cond2, ...)`** — holds when at least one listed condition + holds (at least two conditions; `test()` short-circuits left to right). +- **`notOf(cond)`** — holds when the single nested condition does **not** + hold. + +Each factory accepts **any** condition or rule node as a child — a leaf +(`engaged`, `equals`, `greater`, …) or another `andOf`/`orOf`/`notOf` — so a +tree nests to any depth: `orOf(notOf(engaged(&A::x)), andOf(engaged(&A::y), +engaged(&A::z)))` is a valid `when` clause. All three nodes share this +uniform shape with every existing rule/condition node (`kind`, `test(const +A&) const noexcept`, `emitNode()`), which is what makes them substitutable +everywhere an existing single-node condition already worked: + +- **Nested inside a `when` clause** — `requiredWhen`/`visibleWhen`/`readonlyWhen` + accept a compound condition in the same `when` position a leaf condition + occupies, with no change to those three rule kinds themselves. +- **Directly as a top-level `formRules` entry** — `andOf`/`orOf`/`notOf` + declare `isPresentation = false` and a `test()`, so `ruleList(andOf(...))` + is itself a valid, directly-gating rule — "a single rule with a compound + condition tree", not only a condition factored inside another rule. + +`andOf`/`orOf`/`notOf` add no new closed-vocabulary *rule* kinds — they are +closed-vocabulary *conditions*, matching the existing "closed, typed" design +of every other node in this table: an application still cannot supply an +arbitrary lambda, only compose the existing typed primitives into a tree. + +#### Schema emission — nested `conditions` / `condition` + +`andOf`/`orOf` emit a `"conditions"` array of nested condition nodes; +`notOf` emits a single nested `"condition"` object (singular, since it wraps +exactly one child): + +```json +{ "kind": "requiredWhen", "fields": ["discount"], + "when": { "kind": "and", "conditions": [ + { "kind": "engaged", "fields": ["promo"] }, + { "kind": "engaged", "fields": ["loyaltyCode"] } + ]} +} +``` + +```json +{ "kind": "or", "conditions": [ + { "kind": "not", "condition": { "kind": "engaged", "fields": ["promo"] } }, + { "kind": "and", "conditions": [ + { "kind": "engaged", "fields": ["email"] }, + { "kind": "engaged", "fields": ["phone"] } + ]} +]} +``` + +A renderer that does not recognise `"and"`/`"or"`/`"not"` treats them as an +unrecognised `kind` per the existing fail-closed rule (see "Renderer +fallback" below) — it defers enforcement to the server rather than guessing +at the nested structure, exactly like any other unrecognised `kind`. + ### Presentation rules never gate `visibleWhen`/`readonlyWhen` are the only two **presentation** kinds: they @@ -1248,7 +1392,8 @@ for the exhaustive tables and design rationale. | `ruleList(rules...)` | function template | Composes rule/condition nodes into the `RuleList` an action assigns to `formRules`. | | `HasFormRules` | concept | `true` when `A` declares a `static constexpr formRules` member. | | `allRulesSatisfied(action)` | function template | `true` when every **validation** rule in `A::formRules` holds (or there are none); skips presentation rules. `noexcept`. | -| `engaged`/`notEngaged`/`equals`/`greater`/`greaterOrEqual`/`less`/`lessOrEqual`/`requiredWhen`/`exactlyOneOf`/`atLeastOneOf`/`mutuallyExclusive`/`visibleWhen`/`readonlyWhen` | function templates | Factories building one typed rule/condition node each; see the kind table above. | +| `engaged`/`notEngaged`/`equals`/`greater`/`greaterOrEqual`/`less`/`lessOrEqual`/`requiredWhen`/`exactlyOneOf`/`atLeastOneOf`/`mutuallyExclusive`/`visibleWhen`/`readonlyWhen`/`andOf`/`orOf`/`notOf` | function templates | Factories building one typed rule/condition node each; see the kind table above. | +| `detail::ConditionActionType` | alias template | The action type `A` a condition/rule node's `test(const A&) const noexcept` ranges over, deduced from `&Cond::test`'s member-function-pointer type. Used internally by `andOf`/`orOf`/`notOf` to recover `A` without every leaf node separately naming it. | ### `computed()` / `computeList()` / `recomputeAll()` @@ -1439,6 +1584,37 @@ the server-side wire path (see [registry.md](../core/registry.md)), so `x-decimalPlaces` is now an enforced contract on every dispatch path — local, client-bridge, and remote wire. +### Pre-decode wire validation — `checkQuantityBounds` + +Reconciling declared precision (above) still leaves a wire payload that is +merely *representable*, not necessarily *physically or contractually +sensible* — a percentage of `250`, a mass of `-5`. `morph::forms:: +checkQuantityBounds(action)` closes that gap: it walks every reflected +`Quantity` member of a decoded action and checks +`Quantity::withinDeclaredBounds()` (see +[quantity_type.md, "Pre-decode wire validation"](../util/quantity_type.md#pre-decode-wire-validation--declared-bounds)) +against the optional `UnitTraits::bounds(E)` a unit may declare. It returns +the wire name of the first offending field, or `std::nullopt` — a no-op for +actions with no `Quantity` members, or whose units declare no bounds. +`morph::forms::enforceQuantityBounds(action)` is the throwing counterpart, +raising `morph::forms::QuantityDecodeError` naming that field. + +Both wire-decoding dispatch runners — `ActionDispatcher::registerAction`'s +server-side runner and `ActionExecuteRegistry::registerAction`'s client +bridge runner (see [registry.md](../core/registry.md)/[bridge.md](../core/bridge.md)) +— call `enforceQuantityBounds` immediately after `reconcileDeclaredPrecision` +and before `recomputeAll`/`ActionValidator::ready`, so an out-of-bounds +wire value is rejected **before an action's own `validate()` ever sees it**. +`QuantityDecodeError` is deliberately **not** `morph::model::ValidationError` +— the two error types stay distinct so a caller can tell "the wire payload +itself was impossible" (a decode-level, framework-enforced constraint) from +"the decoded action failed its own business rule" (a `validate()`-level +rejection an action author wrote). The in-process `Bridge::executeVia` +`localOp` path is unaffected — no JSON decode happens there (see "Advertised +precision is enforced on dispatch" above for why `reconcileDeclaredPrecision` +is likewise skipped on that path), so a `Quantity` a caller constructs +directly carries whatever value the caller gave it, unchecked at this seam. + ### One cached schema per type — no localisation Each type's schema is memoised in a function-local `static const std::string` diff --git a/docs/spec/util/quantity_type.md b/docs/spec/util/quantity_type.md index 2d78292a..5480ebe7 100644 --- a/docs/spec/util/quantity_type.md +++ b/docs/spec/util/quantity_type.md @@ -494,6 +494,68 @@ selector lists; the selector deliberately offers only the one-hop neighbours the as alternatives, but `g` lists only `kg` — even though `g → t` converts by chaining through `kg`.) +### Pre-decode wire validation — declared bounds + +`setWire` (above) is deliberately permissive: it silently clamps a hostile +`dp` and normalises a non-canonical numerator/denominator rather than +rejecting them, so decoding itself never throws. That leaves a gap for a +value that decodes *successfully* but is still physically or contractually +impossible for its unit — a percentage above 100, a mass below zero — with no +seam to reject it before an action's own `validate()` (a business-rule check, +not a decode-level one) runs. + +`Quantity::withinDeclaredBounds() -> bool` closes that gap, driven by +an **optional** customisation point: + +```cpp +template <> +struct morph::units::UnitTraits { + static constexpr UnitMeta meta(Unit u) noexcept { /* ... */ } + + // Optional: declares [min, max] for units that have a physical/contract range. + static constexpr morph::units::QuantityBounds bounds(Unit u) noexcept { + switch (u) { + case Unit::percent: + return {.min = Rational{0, DecimalPlaces{1}}, .max = Rational{100, DecimalPlaces{1}}}; + default: + return {.min = Rational{Numerator{std::numeric_limits::min()}, Denominator{1}, DecimalPlaces{1}}, + .max = Rational{Numerator{std::numeric_limits::max()}, Denominator{1}, DecimalPlaces{1}}}; + } + } +}; +``` + +- **`QuantityBounds { Rational min; Rational max; }`** — an inclusive range. +- **`HasUnitBounds`** — `true` when `UnitTraits` declares `bounds(E)`. + A unit enum with no `bounds()` declares none: every value its precision + allows is accepted, byte-for-byte the same as before this feature existed + — this is an opt-in check, not a new default restriction. +- **`withinDeclaredBounds()`** — `true` when the payload is empty (an + unengaged field has nothing to be out of bounds), when the unit declares no + `bounds()`, or when the engaged value satisfies `min <= value <= max`. + Comparison is on the exact `Rational` (via `operator<=>`), never a lossy + `double`. + +**The forms-layer seam.** `morph::forms::checkQuantityBounds(action)` +(`forms.hpp`) walks every reflected `Quantity` member of an action the same +way `reconcileDeclaredPrecision` does, and returns the wire name of the first +member failing `withinDeclaredBounds()` (or `std::nullopt`). +`morph::forms::enforceQuantityBounds(action)` throws +`morph::forms::QuantityDecodeError` naming that field. Both dispatch runners +that decode wire JSON into an action — `ActionDispatcher::registerAction`'s +server-side runner and `ActionExecuteRegistry::registerAction`'s client +bridge runner (`registry.hpp`/`bridge.hpp`) — call `enforceQuantityBounds` +immediately after `reconcileDeclaredPrecision` and before `recomputeAll`/the +`ActionValidator::ready` check, so an out-of-bounds wire value is rejected +before an action's own `validate()` ever sees it. `QuantityDecodeError` is +deliberately **not** `morph::model::ValidationError` — the two stay distinct +so a caller (or a test) can tell "the wire payload itself was impossible" +from "the decoded action failed its own business rule". The in-process +`localOp` execution path (`bridge.hpp`) is unaffected, for the same reason it +skips `reconcileDeclaredPrecision`: no JSON decode happens there, so there is +nothing to validate at that seam — a `Quantity` constructed directly by +calling code carries whatever bounds the caller gave it. + ## Unit conversion — `UnitRelation` and `convert` Arithmetic works only on values of the *same* unit, but the same physical @@ -697,6 +759,8 @@ readability). | `UnitEnum` | concept | Satisfied by an enum with a `UnitTraits::meta`. Constrains `Quantity`. | | `isQuantity` | `inline constexpr bool` variable template | Compile-time test: `true` when `T` is a `Quantity<...>`. | | `operator*`, `operator/` (on `E`) | `consteval` | Application-supplied unit algebra deducing cross-dimension result units; an unsupported combination fails to compile. | +| `QuantityBounds { min, max }` | struct | Inclusive `Rational` range returned by the optional `UnitTraits::bounds(E)` customisation point — the pre-decode wire validation seam (see "Pre-decode wire validation" above). | +| `HasUnitBounds` | concept | `true` when `UnitTraits` declares `bounds(E)`. A unit enum without it declares no bounds: every value its precision allows is accepted. | ### `Quantity` — compile-time members @@ -730,6 +794,7 @@ readability). | Member | Signature | Notes | |---|---|---| | `hasValue()` | `constexpr bool hasValue() const noexcept` | Engaged? No implicit `bool` conversion. | +| `withinDeclaredBounds()` | `constexpr bool withinDeclaredBounds() const noexcept` | The pre-decode wire validation seam: `true` when empty, when the unit declares no `bounds()`, or when the engaged value satisfies `min <= value <= max` (exact `Rational` comparison). See "Pre-decode wire validation" above. | | `value()` | `const std::optional& value() const noexcept` | The payload; pattern-match or `->` it. | | `value_or(fallback)` | `Rational value_or(Rational const&) const` | Payload if engaged, else the fallback. | | `operator*` | `const Rational& operator*() const` | Unchecked access to the engaged value (UB when empty, like `std::optional`). | diff --git a/examples/forms/gui_qml/CMakeLists.txt b/examples/forms/gui_qml/CMakeLists.txt index 63613140..0f1a3858 100644 --- a/examples/forms/gui_qml/CMakeLists.txt +++ b/examples/forms/gui_qml/CMakeLists.txt @@ -29,18 +29,6 @@ target_link_libraries(lab_forms_demo_module PUBLIC morph::morph morph::qt_forms target_compile_features(lab_forms_demo_module PUBLIC cxx_std_23) apply_bigobj(lab_forms_demo_module) -# FormsController.cpp instantiates schema/rule templates over every demo -# action type -- the same pattern examples/forms/CMakeLists.txt's -# morph_forms_demo target hit (see #80). Under MSVC Debug (no /Og folding, -# full /Zi debug info) this pushes the .obj's COFF section count past the -# 32-bit SN_LOFF format's limit, aborting with C1128 ("number of sections -# exceeded object file format limit"). /bigobj switches to the extended -# section-count format; harmless on Release and on other compilers, so it is -# unconditional for this target. -target_compile_options(lab_forms_demo_module PRIVATE - $<$:/bigobj> -) - qt_add_executable(morph_forms_qml main.cpp) target_link_libraries(morph_forms_qml PRIVATE lab_forms_demo_moduleplugin Qt6::Quick) diff --git a/include/morph/core/bridge.hpp b/include/morph/core/bridge.hpp index 337cda02..c3c06035 100644 --- a/include/morph/core/bridge.hpp +++ b/include/morph/core/bridge.hpp @@ -1708,6 +1708,15 @@ inline void ActionExecuteRegistry::registerAction(std::string_view modelId, std: // silently keeping whatever runtime `dp` the client sent. No-op for // actions with no Quantity members. See docs/spec/forms.md. ::morph::forms::reconcileDeclaredPrecision(action); + // Pre-decode wire validation seam: reject a Quantity field whose + // engaged value falls outside its unit's declared bounds + // (UnitTraits::bounds), before the validator/business-rule + // check below. No-op for actions with no Quantity members, or + // whose units declare no bounds(). See docs/spec/forms/forms.md, + // "Pre-decode wire validation". Thrown as QuantityDecodeError, + // caught by the same catch block as every other decode/validation + // failure on this path. + ::morph::forms::enforceQuantityBounds(action); // Overwrite any computed fields from their declared inputs -- a // computed field is never trusted from the client, on any path. // No-op for actions with no computedFields. See docs/spec/forms/forms.md. diff --git a/include/morph/core/registry.hpp b/include/morph/core/registry.hpp index a8fa856b..51ea3be0 100644 --- a/include/morph/core/registry.hpp +++ b/include/morph/core/registry.hpp @@ -236,6 +236,13 @@ class ActionDispatcher { // No-op for actions with no Quantity members. See // docs/spec/forms/forms.md. ::morph::forms::reconcileDeclaredPrecision(action); + // Pre-decode wire validation seam: reject a Quantity field whose + // engaged value falls outside its unit's declared bounds + // (UnitTraits::bounds), before the action's own validate() + // (a business-rule check) ever runs. No-op for actions with no + // Quantity members, or whose units declare no bounds(). See + // docs/spec/forms/forms.md, "Pre-decode wire validation". + ::morph::forms::enforceQuantityBounds(action); // Overwrite any computed fields from their declared inputs. This is // the true server-side execution site for every remote and Qt // WebSocket topology (RemoteServer -> ActionDispatcher::dispatch) -- diff --git a/include/morph/forms/forms.hpp b/include/morph/forms/forms.hpp index d2e5c8c8..2feaedb3 100644 --- a/include/morph/forms/forms.hpp +++ b/include/morph/forms/forms.hpp @@ -56,9 +56,11 @@ /// control-track bounds and increment — advisory, not a validation bound. /// - **`x-rules`** — for an action declaring a `static constexpr formRules` /// (`morph::forms::ruleList(...)`): a closed, typed cross-field rule -/// vocabulary (`requiredWhen`, comparisons, membership, presentation) -/// evaluated identically by the schema, the client, and the server. See -/// `morph::forms::allRulesSatisfied` below and docs/spec/forms/forms.md. +/// vocabulary (`requiredWhen`, comparisons, membership, presentation, and +/// the compound `andOf`/`orOf`/`notOf` conditions that nest a condition +/// tree to any depth) evaluated identically by the schema, the client, and +/// the server. See `morph::forms::allRulesSatisfied` below and +/// docs/spec/forms/forms.md. /// - **`x-computed` / `x-readonly`** — for a member listed as the destination /// of an action's `computedFields` declaration: the field is derived from /// sibling inputs (named in `x-computed.inputs`) and must not be rendered as @@ -138,6 +140,7 @@ #include #include #include +#include #include #include #include @@ -446,6 +449,30 @@ template return found; } +/// @brief Deduces the action type a condition/rule node's `test(const A&) +/// const` ranges over, from its member-function-pointer type. Every +/// condition/rule node in this header (`Engaged`, `Equals`, `Greater`, …, and +/// the compound `And`/`Or`/`Not` nodes) exposes exactly this shape, so +/// `andOf`/`orOf`/`notOf` use it to recover `A` without requiring every leaf +/// node to name it as a separate member type. +/// @tparam TestMemberPtr Pointer-to-member-function type of `&Cond::test`. +template +struct ConditionActionTypeFromTest; + +/// @brief Specialisation matching `bool (Cond::*)(const A&) const noexcept`. +/// @tparam Cond The condition/rule node type. +/// @tparam A The deduced action type. +template +struct ConditionActionTypeFromTest { + /// @brief The deduced action type. + using type = A; +}; + +/// @brief The action type @p Cond's `test()` ranges over. +/// @tparam Cond A condition/rule node type (must expose `test(const A&) const noexcept`). +template +using ConditionActionType = typename ConditionActionTypeFromTest::type; + /// @brief The closed set of cross-field rule and condition kinds `x-rules` /// carries in its `kind` field. One flat enum serves both top-level rules /// (`RequiredWhen`, `Greater`, `ExactlyOneOf`, `VisibleWhen`, ...) and the @@ -466,6 +493,9 @@ enum class RuleKind : std::uint8_t { MutuallyExclusive, VisibleWhen, ReadonlyWhen, + And, + Or, + Not, }; /// @brief The wire `"kind"` string for @p kind, exactly as documented in @@ -498,6 +528,12 @@ enum class RuleKind : std::uint8_t { return "visibleWhen"; case RuleKind::ReadonlyWhen: return "readonlyWhen"; + case RuleKind::And: + return "and"; + case RuleKind::Or: + return "or"; + case RuleKind::Not: + return "not"; default: return ""; } @@ -1318,6 +1354,146 @@ template return ReadonlyWhen{field, when}; } +/// @brief Compound condition: all of `Conds...` hold. Nests to any depth — +/// each `Cond` may itself be a leaf (`Engaged`, `Equals`, a comparison, …) or +/// another `And`/`Or`/`Not`. Usable both as a nested `when` clause and +/// directly as a top-level `formRules` entry (it declares `isPresentation` +/// and `test()` exactly like every other validation rule), which is what +/// lets a single rule carry a compound condition tree instead of factoring +/// the composition into multiple single-condition rules. +/// @tparam A Action type every nested condition ranges over. +/// @tparam Conds Nested condition node types, at least two. +template +struct And { + /// @brief The nested conditions, in declaration order. + std::tuple conditions; + /// @brief The wire `"kind"` this node emits: `"and"`. + static constexpr detail::RuleKind kind = detail::RuleKind::And; + /// @brief Validation rule (not presentation): participates in the gate + /// when used as a top-level `formRules` entry. + static constexpr bool isPresentation = false; + + /// @brief Evaluates the condition against @p action. + /// @param action The action snapshot to inspect. + /// @return `true` when every nested condition holds. + [[nodiscard]] constexpr bool test(const A& action) const noexcept { + return std::apply([&](const auto&... cond) { return (cond.test(action) && ...); }, conditions); + } + + /// @brief Emits this condition's `x-rules` JSON node. + /// @return `{"kind":"and","conditions":[{...}, ...]}`. + [[nodiscard]] glz::generic_u64 emitNode() const { + glz::generic_u64 node{}; + node["kind"] = std::string{detail::ruleKindName(kind)}; + glz::generic_u64::array_t nested{}; + std::apply([&](const auto&... cond) { (nested.emplace_back(cond.emitNode()), ...); }, conditions); + node["conditions"] = nested; + return node; + } +}; + +/// @brief Builds an `And` condition: every listed condition must +/// hold. +/// @tparam Cond0 First condition's node type (deduced); its action type `A` +/// is recovered from `test()` and shared by every other node. +/// @tparam Conds Remaining nested condition node types (deduced). +/// @param condition0 The first nested condition. +/// @param conditions The remaining nested conditions, at least one more. +/// @return The compound condition node. +template +[[nodiscard]] constexpr auto andOf(Cond0 condition0, Conds... conditions) { + return And, Cond0, Conds...>{ + std::tuple{std::move(condition0), std::move(conditions)...}}; +} + +/// @brief Compound condition: at least one of `Conds...` holds. Nests to any +/// depth, and is usable directly as a top-level `formRules` entry, exactly +/// like `And`. +/// @tparam A Action type every nested condition ranges over. +/// @tparam Conds Nested condition node types, at least two. +template +struct Or { + /// @brief The nested conditions, in declaration order. + std::tuple conditions; + /// @brief The wire `"kind"` this node emits: `"or"`. + static constexpr detail::RuleKind kind = detail::RuleKind::Or; + /// @brief Validation rule (not presentation): participates in the gate + /// when used as a top-level `formRules` entry. + static constexpr bool isPresentation = false; + + /// @brief Evaluates the condition against @p action. + /// @param action The action snapshot to inspect. + /// @return `true` when at least one nested condition holds. + [[nodiscard]] constexpr bool test(const A& action) const noexcept { + return std::apply([&](const auto&... cond) { return (cond.test(action) || ...); }, conditions); + } + + /// @brief Emits this condition's `x-rules` JSON node. + /// @return `{"kind":"or","conditions":[{...}, ...]}`. + [[nodiscard]] glz::generic_u64 emitNode() const { + glz::generic_u64 node{}; + node["kind"] = std::string{detail::ruleKindName(kind)}; + glz::generic_u64::array_t nested{}; + std::apply([&](const auto&... cond) { (nested.emplace_back(cond.emitNode()), ...); }, conditions); + node["conditions"] = nested; + return node; + } +}; + +/// @brief Builds an `Or` condition: at least one listed +/// condition must hold. +/// @tparam Cond0 First condition's node type (deduced); its action type `A` +/// is recovered from `test()` and shared by every other node. +/// @tparam Conds Remaining nested condition node types (deduced). +/// @param condition0 The first nested condition. +/// @param conditions The remaining nested conditions, at least one more. +/// @return The compound condition node. +template +[[nodiscard]] constexpr auto orOf(Cond0 condition0, Conds... conditions) { + return Or, Cond0, Conds...>{ + std::tuple{std::move(condition0), std::move(conditions)...}}; +} + +/// @brief Compound condition: the nested condition does **not** hold. Nests +/// to any depth, and is usable directly as a top-level `formRules` entry, +/// exactly like `And`/`Or`. +/// @tparam A Action type the nested condition ranges over. +/// @tparam Cond Nested condition node type. +template +struct Not { + /// @brief The negated condition. + Cond condition; + /// @brief The wire `"kind"` this node emits: `"not"`. + static constexpr detail::RuleKind kind = detail::RuleKind::Not; + /// @brief Validation rule (not presentation): participates in the gate + /// when used as a top-level `formRules` entry. + static constexpr bool isPresentation = false; + + /// @brief Evaluates the condition against @p action. + /// @param action The action snapshot to inspect. + /// @return `true` when the nested condition does **not** hold. + [[nodiscard]] constexpr bool test(const A& action) const noexcept { return !condition.test(action); } + + /// @brief Emits this condition's `x-rules` JSON node. + /// @return `{"kind":"not","condition":{...}}`. + [[nodiscard]] glz::generic_u64 emitNode() const { + glz::generic_u64 node{}; + node["kind"] = std::string{detail::ruleKindName(kind)}; + node["condition"] = condition.emitNode(); + return node; + } +}; + +/// @brief Builds a `Not` condition: negates @p condition. +/// @tparam Cond Nested condition node type (deduced); its action type `A` is +/// recovered from `test()`. +/// @param condition The condition to negate. +/// @return The compound condition node. +template +[[nodiscard]] constexpr auto notOf(Cond condition) { + return Not, Cond>{std::move(condition)}; +} + /// @brief Composed list of an action's declared cross-field rules — the /// value of `A::formRules`. Built by `ruleList(...)`; never constructed /// directly. @@ -2136,6 +2312,81 @@ constexpr void reconcileDeclaredPrecision(A& action) { } } +/// @brief Thrown when a decoded action has a `Quantity` field whose engaged +/// value falls outside its unit's declared bounds (`UnitTraits::bounds`). +/// +/// Distinct from `morph::model::ValidationError`: this is a **decode-level** +/// rejection — a wire payload that violates a physical/unit constraint baked +/// into the field's type, caught before an action's own `validate()` (a +/// business-rule check) ever runs. See docs/spec/forms/forms.md, "Pre-decode +/// wire validation — `checkQuantityBounds`". +struct QuantityDecodeError : std::runtime_error { + /// @brief Constructs the error with a message naming the offending field. + /// @param fieldName The wire (JSON) name of the out-of-bounds field. + explicit QuantityDecodeError(std::string_view fieldName) + : std::runtime_error("quantity field out of declared bounds: " + std::string{fieldName}) {} +}; + +/// @brief Checks every `Quantity` member of @p action against its unit's +/// declared bounds (`morph::units::Quantity::withinDeclaredBounds`, +/// driven by the optional `UnitTraits::bounds(E)` customisation +/// point). +/// +/// This is the **pre-decode wire validation seam**: called on the decode path +/// — right after `ActionTraits::fromJson` and `reconcileDeclaredPrecision`, +/// before `recomputeAll`/`ActionValidator::ready` — so a wire payload +/// carrying a value outside a field's declared physical/unit bounds (e.g. a +/// percentage above 100, a mass below zero) is rejected uniformly at the +/// framework level, before an action's own `validate()` (a business-rule +/// check, not a decode-level one) ever runs. No-op — always returns +/// `std::nullopt` — for actions with no `Quantity` members, or whose +/// `Quantity` members' units declare no `bounds()`: zero behaviour change, +/// backward compatible, exactly like `reconcileDeclaredPrecision`. +/// @tparam A Action type (a reflectable aggregate). +/// @param action Decoded action to check. +/// @return The wire name of the first out-of-bounds `Quantity` member +/// encountered (in declaration order), or `std::nullopt` when every +/// `Quantity` member is within its declared bounds (or the unit +/// declares none). +template +[[nodiscard]] inline std::optional checkQuantityBounds(const A& action) { + using Plain = std::remove_cvref_t; + std::optional offender; + if constexpr (glz::reflectable || glz::glaze_object_t) { + detail::forEachNamedMember(action, [&](std::string_view name, const auto& member) { + static_cast(I); + if (offender.has_value()) { + return; + } + using Member = std::remove_cvref_t; + if constexpr (units::isQuantity) { + if (!member.withinDeclaredBounds()) { + offender = std::string{name}; + } + } + }); + } else { + static_cast(action); + } + return offender; +} + +/// @brief Runs `checkQuantityBounds(action)` and throws `QuantityDecodeError` +/// naming the first out-of-bounds field, if any. The throwing counterpart used +/// directly on the decode path (registry.hpp/bridge.hpp call sites); a caller +/// that wants the field name without an exception uses `checkQuantityBounds` +/// itself. +/// @tparam A Action type (a reflectable aggregate). +/// @param action Decoded action to check. +/// @throws QuantityDecodeError if any `Quantity` member is outside its unit's +/// declared bounds. +template +inline void enforceQuantityBounds(const A& action) { + if (auto offender = checkQuantityBounds(action); offender.has_value()) { + throw QuantityDecodeError{*offender}; + } +} + /// @brief Whether every required empty-capable member of @p action is /// engaged (has a value). /// diff --git a/include/morph/util/quantity.hpp b/include/morph/util/quantity.hpp index 8167b99f..e49f883b 100644 --- a/include/morph/util/quantity.hpp +++ b/include/morph/util/quantity.hpp @@ -235,9 +235,25 @@ struct UnitAlternative { std::int64_t den{1}; }; +/// @brief Optional physical/wire bounds for one unit: the inclusive range a +/// decoded value must fall within to be accepted. Returned by the +/// optional `UnitTraits::bounds(E)` customisation point (detected +/// by `HasUnitBounds`) — e.g. a percentage unit bounding itself to +/// `[0, 100]`, or a mass unit rejecting a negative reading no scale +/// can physically produce. Absent by default: a unit with no declared +/// bounds accepts any value its precision allows, exactly as before +/// this feature existed. +struct QuantityBounds { + /// @brief Inclusive lower bound. + morph::math::Rational min; + /// @brief Inclusive upper bound. + morph::math::Rational max; +}; + /// @brief Customisation point: the application specialises this for its unit /// enum, returning a `UnitMeta` per enumerator and (optionally) a -/// `relations` array of `UnitRelation` entries. +/// `relations` array of `UnitRelation` entries and/or a `bounds(E)` +/// static method. /// @tparam E The application's unit enum type. template struct UnitTraits; @@ -254,6 +270,16 @@ concept HasUnitRelations = requires { { UnitTraits::relations }; }; +/// @brief Concept: `UnitTraits` declares an optional `static constexpr +/// QuantityBounds bounds(E)` — the pre-decode validation seam a field's +/// unit opts into. A unit enum with no `bounds()` simply has none: +/// every value its precision allows is accepted, unchanged from +/// before this feature existed. `E` is the application's unit enum type. +template +concept HasUnitBounds = requires(E unit) { + { UnitTraits::bounds(unit) } -> std::convertible_to; +}; + namespace detail { /// @brief Result of a compile-time conversion-ratio search. @@ -625,6 +651,32 @@ struct Quantity { /// @return `true` if the payload is engaged. [[nodiscard]] constexpr bool hasValue() const noexcept { return payload.has_value(); } + /// @brief Pre-decode validation seam: whether the current payload falls + /// within this field's unit-declared bounds (`UnitTraits::bounds`), + /// when the unit declares any. An empty payload, or a unit with no + /// declared `bounds()` (`HasUnitBounds` not satisfied), is always + /// within bounds — this is an *opt-in* check, not a new default + /// restriction, so a unit system that declares no bounds behaves + /// exactly as it did before this feature existed. + /// + /// Comparison is on the exact `Rational` value only (never a lossy + /// `double`), consistent with every other exact comparison in this + /// header. + /// @return `true` when empty, when the unit declares no bounds, or when + /// the engaged value satisfies `min <= value <= max`. + [[nodiscard]] constexpr bool withinDeclaredBounds() const noexcept { + if constexpr (HasUnitBounds) { + if (!payload.has_value()) { + return true; + } + auto const bounds = UnitTraits::bounds(U); + return (*payload <=> bounds.min) != std::strong_ordering::less && + (*payload <=> bounds.max) != std::strong_ordering::greater; + } else { + return true; + } + } + /// @brief The payload, for pattern-matching / `->` access. /// @return Const reference to the optional payload. [[nodiscard]] constexpr const std::optional& value() const noexcept { return payload; } diff --git a/src/qt/forms/qml/DynamicForm.qml b/src/qt/forms/qml/DynamicForm.qml index 485d37d0..390e7371 100644 --- a/src/qt/forms/qml/DynamicForm.qml +++ b/src/qt/forms/qml/DynamicForm.qml @@ -10,9 +10,21 @@ // x-widget -> control choice (textarea/slider/radio); unknown ids // and a missing key both fall back to the type default // x-min/x-max/x-step -> slider track bounds + increment (Ranged fields) +// type: "array" -> comma-separated-with-validation control; encodes to +// a genuine JSON array literal, e.g. "a, b" -> ["a","b"] +// x-submitMode: "explicit" -> suppresses auto-submit-on-validity; renders +// an explicit Submit button (enabled only while ready) +// instead -- see "Explicit submit mode" below // // Quantity payloads are assembled as JSON text from the typed digit string, // so they are exact at any magnitude (same contract as the HTML renderer). +// +// By default, the form calls controller.submitIfValid(...) automatically +// the instant every field/rule is satisfied (safe for a read-only query +// action). A schema for a side-effectful action should set the top-level +// "x-submitMode": "explicit" key: this suppresses that auto-call and instead +// requires the user to press the rendered Submit button, which is disabled +// until the form is ready. pragma ComponentBehavior: Bound @@ -58,6 +70,15 @@ Frame { property var rules: schema["x-rules"] || [] property int rulesRevision: 0 + // "x-submitMode": "explicit" (docs/spec/forms/forms.md, "Explicit submit + // mode"): opts a side-effectful (non-query) action out of the default + // auto-fire-on-validity behavior. When set, revalidate() still recomputes + // `ready`/`previewLine` live but never calls submitIfValid() on its own; + // an explicit submit Button (added to the layout below), enabled only + // while `ready`, is the sole way to fire. Absent (the default) or any + // other value keeps today's auto-submit-on-validity behavior unchanged. + property bool explicitSubmitMode: schema["x-submitMode"] === "explicit" + // i18n: a host-supplied translation catalog (see I18nCatalog.hpp) and the // BCP-47 locale to resolve against. `catalog: null` (the default) means // "no catalog installed" — every label/help/placeholder falls back to @@ -192,6 +213,15 @@ Frame { isQuantity: dp !== undefined, decimals: opt(dp, 0), isInteger: types.indexOf("integer") !== -1, + // "array" (glaze's std::vector schema shape: {"type": + // "array", "items": {...}}) -- a comma-separated-with- + // validation control, not the plain text field's + // fall-through (which would wrap the typed text as a + // JSON *string*, not an array). Scoped to array-of-string + // today; any other item type still renders this control + // but each entry is encoded as a JSON string, same as an + // array of strings, rather than silently misencoding. + isArray: types.indexOf("array") !== -1, required: required.indexOf(name) !== -1, minimum: p.minimum, maximum: p.maximum, @@ -319,9 +349,11 @@ Frame { } // Evaluates one condition node (`engaged` / `notEngaged` / `equals` / a - // comparison kind reused as a boolean). An unrecognised `kind` fails - // closed (`false`) -- the renderer defers enforcement to the server - // rather than passing an unknown validation condition. + // comparison kind reused as a boolean / the compound `and`/`or`/`not` + // kinds, which recurse into `conditions`/`condition` to any depth). An + // unrecognised `kind` fails closed (`false`) -- the renderer defers + // enforcement to the server rather than passing an unknown validation + // condition. function testCondition(cond) { const kind = cond.kind const names = cond.fields || [] @@ -346,14 +378,36 @@ Frame { if (kind === "less") return lv < rv return lv <= rv } + if (kind === "and") { + const nested = cond.conditions || [] + for (let i = 0; i < nested.length; ++i) { + if (!testCondition(nested[i])) + return false + } + return true + } + if (kind === "or") { + const nested = cond.conditions || [] + for (let i = 0; i < nested.length; ++i) { + if (testCondition(nested[i])) + return true + } + return false + } + if (kind === "not") + return !testCondition(cond.condition) return false } // Evaluates one top-level x-rules entry. Presentation kinds // (visibleWhen/readonlyWhen) always return true -- they never gate // submission, only presentation (see fieldVisible/fieldReadonly below). - // An unrecognised rule kind fails closed: the renderer defers - // enforcement to the server rather than passing the rule. + // `and`/`or`/`not` are valid directly as a top-level rule (not only + // nested inside a `when` clause) -- a single rule carrying a compound + // condition tree -- so they delegate to testCondition exactly like the + // comparison kinds already do. An unrecognised rule kind fails closed: + // the renderer defers enforcement to the server rather than passing the + // rule. function testRule(rule) { const kind = rule.kind const names = rule.fields || [] @@ -376,6 +430,8 @@ Frame { } if (kind === "visibleWhen" || kind === "readonlyWhen") return true + if (kind === "and" || kind === "or" || kind === "not") + return testCondition(rule) return false } @@ -566,6 +622,23 @@ Frame { return (scaled.neg ? "-" : "") + padded.slice(0, -to.decimals) + "." + padded.slice(-to.decimals) } + // Encodes an "array"-typed field's comma-separated entry text as a + // genuine JSON array literal of strings -- e.g. "red, green, blue" -> + // ["red","green","blue"] -- never the JSON *string* the generic + // fallback (`JSON.stringify(text)`) would have produced. Splits on + // comma, trims surrounding whitespace off each entry, and drops empty + // entries (so "red,, green," -> ["red","green"], not ["red","","green",""]). + // An entry list that is blank or entirely empty after trimming (","," ,") + // returns "[]" -- a genuinely empty array is still a valid array + // literal, distinct from the field itself being unengaged (handled by + // fieldJsonLiteral's blank-text check before this is ever called). + function arrayJsonLiteral(text) { + const items = text.split(",") + .map(function (item) { return item.trim() }) + .filter(function (item) { return item !== "" }) + return JSON.stringify(items) + } + // Encodes one field's current input text as the JSON literal morph // expects on the wire, applying the same per-kind syntax and bounds // checks as submission. Returns null when the field is blank or its @@ -576,6 +649,9 @@ Frame { const text = (opt(fieldValues[f.name], "")).trim() if (text === "") return null + if (f.isArray) { + return arrayJsonLiteral(text) + } if (f.isChoice) { return text // already a JSON literal (see the ComboBox's onActivated) } @@ -648,7 +724,18 @@ Frame { ready = ok previewLine = ok ? "{" + parts.join(",") + "}" : "" rulesRevision++ - if (ready && form.controller && form.programmaticEdit === 0) + // In explicit-submit mode the renderer never fires on its own -- + // only submit() (wired to the explicit submit Button below) does. + if (!form.explicitSubmitMode && ready && form.controller && form.programmaticEdit === 0) + form.controller.submitIfValid(form.actionType, form.previewLine) + } + + // Explicit submit mode's sole trigger: the submit Button's onClicked + // calls this. A no-op unless the form is currently ready -- the button + // is also disabled while !ready, so this guard is defense in depth, not + // the only gate. + function submit() { + if (ready && form.controller) form.controller.submitIfValid(form.actionType, form.previewLine) } @@ -949,10 +1036,11 @@ Frame { TextField { id: entry - objectName: "field_" + fieldColumn.modelData.name + objectName: fieldColumn.modelData.isArray ? "" : "field_" + fieldColumn.modelData.name visible: overrideLoader.sourceComponent === null && !fieldColumn.modelData.isChoice && !fieldColumn.modelData.isDateTime && !fieldColumn.modelData.isMultiline && !fieldColumn.modelData.isSlider + && !fieldColumn.modelData.isArray Layout.fillWidth: true readOnly: fieldColumn.modelData.readOnly placeholderText: fieldColumn.modelData.placeholder !== "" @@ -982,6 +1070,37 @@ Frame { + fieldColumn.modelData.description } + // "array" (glaze's std::vector schema shape) — a + // comma-separated-with-validation control: the typed text is + // split on comma, each entry trimmed, and encoded as a + // genuine JSON array literal by fieldJsonLiteral/ + // arrayJsonLiteral, never wrapped as a JSON *string* the way + // the plain TextField's fallback would. Reuses the plain + // TextField's field_ objectName -- the two are mutually + // exclusive per field (isArray), so exactly one claims it. + TextField { + id: arrayEntry + objectName: fieldColumn.modelData.isArray ? "field_" + fieldColumn.modelData.name : "" + visible: overrideLoader.sourceComponent === null && fieldColumn.modelData.isArray + Layout.fillWidth: true + readOnly: fieldColumn.modelData.readOnly + placeholderText: fieldColumn.modelData.placeholder !== "" + ? fieldColumn.modelData.placeholder + : "comma-separated (e.g. red, green, blue)" + onTextChanged: form.setFieldValue(fieldColumn.modelData.name, text) + // Re-seed from the retained value whenever this delegate + // is (re)created — see the plain TextField's comment + // above for why (tab-switch destroys/rebuilds delegates). + Component.onCompleted: form.withoutAutoSubmit(function() { + arrayEntry.text = form.opt(form.fieldValues[fieldColumn.modelData.name], "") + }) + Accessible.role: Accessible.EditableText + Accessible.name: fieldColumn.modelData.name + Accessible.description: (fieldColumn.modelData.required ? "Required. " : "") + + fieldColumn.modelData.description + + " Comma-separated list." + } + // x-widget: "textarea" (a Multiline field) — same wire string // as an ordinary TextField, just edited over multiple lines. TextArea { @@ -1190,7 +1309,11 @@ Frame { Label { Layout.topMargin: 8 - text: form.ready ? "✓ executes automatically as you type" : "fill the required (*) fields" + text: { + if (!form.ready) + return "fill the required (*) fields" + return form.explicitSubmitMode ? "✓ ready -- press Submit" : "✓ executes automatically as you type" + } opacity: 0.6 font.italic: true // A blocked submit is announced, not merely tinted (docs/spec/ @@ -1202,6 +1325,26 @@ Frame { Accessible.description: text } + // "x-submitMode": "explicit" (docs/spec/forms/forms.md, "Explicit + // submit mode"): the sole trigger for a side-effectful action's + // submission. Enabled only while `ready`, matching the required (*) + // asterisk / submit-gate convention documented in this file's header + // comment -- a disabled button communicates the same gate the + // auto-submit label does for the default mode. Loaded only when the + // schema opts in, so a default (auto-submit) schema has no such + // control anywhere in the item tree, not merely a hidden one. + Loader { + active: form.explicitSubmitMode + Layout.topMargin: 4 + sourceComponent: Button { + id: submitButton + objectName: "submitButton" + enabled: form.ready + text: "Submit" + onClicked: form.submit() + } + } + Label { visible: form.previewLine !== "" Layout.fillWidth: true diff --git a/src/qt/forms/tests/tst_DynamicFormArrayField.qml b/src/qt/forms/tests/tst_DynamicFormArrayField.qml new file mode 100644 index 00000000..de48a4ae --- /dev/null +++ b/src/qt/forms/tests/tst_DynamicFormArrayField.qml @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Covers DynamicForm's control for a JSON "array"-typed field (docs/spec/ +// forms/forms.md, "Array fields"): a comma-separated-with-validation text +// control that emits a genuine JSON array literal, not a stringified one. + +import QtQuick +import QtTest +import MorphForms + +TestCase { + id: testCase + name: "DynamicFormArrayField" + visible: true + + QtObject { + id: mockController + signal replyReceived(string actionType, bool ok, string payload) + signal optionsReceived(string optionsAction, bool ok, string payload) + + property int submitCount: 0 + property string lastBody: "" + + function submitIfValid(actionType, bodyJson) { + submitCount += 1 + lastBody = bodyJson + replyReceived(actionType, true, JSON.stringify({ok: true})) + } + + function fetchOptions(optionsAction) { + optionsReceived(optionsAction, true, "[]") + } + } + + property var arraySchema: ({ + properties: { + name: { type: "string", "x-order": 0 }, + tags: { type: "array", items: { type: "string" }, "x-order": 1 } + }, + required: ["name"] + }) + + Component { + id: formComponent + DynamicForm { + actionType: "CFR_TagRoom" + schema: testCase.arraySchema + controller: mockController + } + } + + function test_array_field_renders_its_own_control_not_the_plain_text_field() { + var form = createTemporaryObject(formComponent, testCase) + verify(form !== null) + var arrayControl = findChild(form, "field_tags") + verify(arrayControl !== null) + } + + function test_empty_array_field_is_not_required_by_default() { + var form = createTemporaryObject(formComponent, testCase) + verify(form !== null) + findChild(form, "field_name").text = "Alice" + compare(form.ready, true) // tags is optional (not in `required`) and blank + } + + function test_comma_separated_entry_encodes_as_a_json_array_of_strings() { + var form = createTemporaryObject(formComponent, testCase) + verify(form !== null) + findChild(form, "field_name").text = "Alice" + findChild(form, "field_tags").text = "red, green, blue" + compare(form.ready, true) + + var parsed = JSON.parse(form.previewLine) + verify(Array.isArray(parsed.tags)) + compare(parsed.tags.length, 3) + compare(parsed.tags[0], "red") + compare(parsed.tags[1], "green") + compare(parsed.tags[2], "blue") + } + + function test_blank_entries_and_surrounding_whitespace_are_dropped() { + var form = createTemporaryObject(formComponent, testCase) + verify(form !== null) + findChild(form, "field_name").text = "Alice" + findChild(form, "field_tags").text = " red ,, green , " + compare(form.ready, true) + + var parsed = JSON.parse(form.previewLine) + compare(parsed.tags.length, 2) + compare(parsed.tags[0], "red") + compare(parsed.tags[1], "green") + } + + function test_single_item_still_encodes_as_an_array() { + var form = createTemporaryObject(formComponent, testCase) + verify(form !== null) + findChild(form, "field_name").text = "Alice" + findChild(form, "field_tags").text = "solo" + compare(form.ready, true) + + var parsed = JSON.parse(form.previewLine) + verify(Array.isArray(parsed.tags)) + compare(parsed.tags.length, 1) + compare(parsed.tags[0], "solo") + } + + function test_fully_blank_array_field_is_omitted_from_the_preview_when_optional() { + var form = createTemporaryObject(formComponent, testCase) + verify(form !== null) + findChild(form, "field_name").text = "Alice" + compare(form.ready, true) + var parsed = JSON.parse(form.previewLine) + verify(parsed.tags === undefined) + } + + // --- required array field ------------------------------------------ + + property var requiredArraySchema: ({ + properties: { + name: { type: "string", "x-order": 0 }, + tags: { type: "array", items: { type: "string" }, "x-order": 1 } + }, + required: ["name", "tags"] + }) + + Component { + id: requiredFormComponent + DynamicForm { + actionType: "CFR_TagRoom" + schema: testCase.requiredArraySchema + controller: mockController + } + } + + function test_required_array_field_blocks_submit_until_engaged() { + var form = createTemporaryObject(requiredFormComponent, testCase) + verify(form !== null) + findChild(form, "field_name").text = "Alice" + compare(form.ready, false) // tags required, still blank + + findChild(form, "field_tags").text = "red" + compare(form.ready, true) + } + + // A comma-only entry is non-blank text (the field is "engaged" by the + // same trim-then-check-empty rule every other field type uses) even + // though every individual entry is dropped -- it encodes to a genuine + // empty array [], not null, and therefore satisfies a `required` array + // field (docs/spec/forms/forms.md, "Array fields"). + function test_comma_only_entry_is_engaged_and_encodes_as_an_empty_array() { + var form = createTemporaryObject(requiredFormComponent, testCase) + verify(form !== null) + findChild(form, "field_name").text = "Alice" + findChild(form, "field_tags").text = " , , " + compare(form.ready, true) + + var parsed = JSON.parse(form.previewLine) + verify(Array.isArray(parsed.tags)) + compare(parsed.tags.length, 0) + } +} diff --git a/src/qt/forms/tests/tst_DynamicFormRules.qml b/src/qt/forms/tests/tst_DynamicFormRules.qml index 1c1265a9..3a71bbed 100644 --- a/src/qt/forms/tests/tst_DynamicFormRules.qml +++ b/src/qt/forms/tests/tst_DynamicFormRules.qml @@ -126,4 +126,131 @@ TestCase { findChild(form, "field_phone").text = "555" compare(form.ready, false) // both engaged now } + + // ------------------------------------------------------------------ + // Compound conditions: and/or/not, nested inside a requiredWhen `when` + // clause (docs/spec/forms/forms.md, "Compound conditions"). + // ------------------------------------------------------------------ + + property var compoundSchema: ({ + properties: { + name: { type: "string", "x-order": 0 }, + promo: { type: "integer", "x-order": 1 }, + loyaltyCode: { type: "integer", "x-order": 2 }, + discount: { type: "integer", "x-order": 3 } + }, + required: ["name"], + "x-rules": [ + { kind: "requiredWhen", fields: ["discount"], + when: { kind: "and", conditions: [ + { kind: "engaged", fields: ["promo"] }, + { kind: "engaged", fields: ["loyaltyCode"] } + ]} + } + ] + }) + + Component { + id: compoundFormComponent + DynamicForm { + actionType: "CFR_BookRoom" + schema: testCase.compoundSchema + controller: mockController + } + } + + function test_and_condition_requires_both_operands_engaged() { + var form = createTemporaryObject(compoundFormComponent, testCase) + verify(form !== null) + + findChild(form, "field_name").text = "Alice" + compare(form.ready, true) // neither promo nor loyaltyCode engaged -> and() false -> not required + + findChild(form, "field_promo").text = "5" + compare(form.ready, true) // only promo engaged -> and() still false + + findChild(form, "field_loyaltyCode").text = "9" + compare(form.ready, false) // both engaged -> and() true -> discount now required + + findChild(form, "field_discount").text = "2" + compare(form.ready, true) + } + + property var orSchema: ({ + properties: { + name: { type: "string", "x-order": 0 }, + promo: { type: "integer", "x-order": 1 }, + loyaltyCode: { type: "integer", "x-order": 2 }, + discount: { type: "integer", "x-order": 3 } + }, + required: ["name"], + "x-rules": [ + { kind: "requiredWhen", fields: ["discount"], + when: { kind: "or", conditions: [ + { kind: "engaged", fields: ["promo"] }, + { kind: "engaged", fields: ["loyaltyCode"] } + ]} + } + ] + }) + + Component { + id: orFormComponent + DynamicForm { + actionType: "CFR_BookRoom" + schema: testCase.orSchema + controller: mockController + } + } + + function test_or_condition_requires_either_operand_engaged() { + var form = createTemporaryObject(orFormComponent, testCase) + verify(form !== null) + + findChild(form, "field_name").text = "Alice" + compare(form.ready, true) // neither engaged -> or() false -> not required + + findChild(form, "field_promo").text = "5" + compare(form.ready, false) // promo engaged -> or() true -> discount now required + + findChild(form, "field_discount").text = "2" + compare(form.ready, true) + } + + property var notSchema: ({ + properties: { + name: { type: "string", "x-order": 0 }, + promo: { type: "integer", "x-order": 1 }, + discount: { type: "integer", "x-order": 2 } + }, + required: ["name"], + "x-rules": [ + { kind: "requiredWhen", fields: ["discount"], + when: { kind: "not", condition: { kind: "engaged", fields: ["promo"] } } + } + ] + }) + + Component { + id: notFormComponent + DynamicForm { + actionType: "CFR_BookRoom" + schema: testCase.notSchema + controller: mockController + } + } + + function test_not_condition_negates_the_inner_condition() { + var form = createTemporaryObject(notFormComponent, testCase) + verify(form !== null) + + findChild(form, "field_name").text = "Alice" + compare(form.ready, false) // promo unengaged -> not(engaged) true -> discount required, still empty + + findChild(form, "field_discount").text = "2" + compare(form.ready, true) + + findChild(form, "field_promo").text = "5" + compare(form.ready, true) // promo engaged -> not(engaged) false -> no longer required + } } diff --git a/src/qt/forms/tests/tst_DynamicFormSubmitMode.qml b/src/qt/forms/tests/tst_DynamicFormSubmitMode.qml new file mode 100644 index 00000000..4564f83f --- /dev/null +++ b/src/qt/forms/tests/tst_DynamicFormSubmitMode.qml @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Covers the schema-carried "x-submitMode": "explicit" flag (docs/spec/ +// forms/forms.md, "Explicit submit mode"): a schema declaring it suppresses +// DynamicForm's default auto-fire-on-validity behavior and instead renders +// an explicit submit Button, enabled only while `ready`, that the user must +// activate to call controller.submitIfValid. A schema that omits the flag +// (or sets any other value) keeps today's auto-submit-on-validity behavior, +// unchanged. + +import QtQuick +import QtTest +import MorphForms + +TestCase { + id: testCase + name: "DynamicFormSubmitMode" + visible: true + + QtObject { + id: mockController + signal replyReceived(string actionType, bool ok, string payload) + signal optionsReceived(string optionsAction, bool ok, string payload) + + property int submitCount: 0 + property string lastBody: "" + + function submitIfValid(actionType, bodyJson) { + submitCount += 1 + lastBody = bodyJson + replyReceived(actionType, true, JSON.stringify({booked: true})) + } + + function fetchOptions(optionsAction) { + optionsReceived(optionsAction, true, "[]") + } + } + + // --- default (auto-submit) schema: no x-submitMode at all --------------- + + property var autoSchema: ({ + properties: { + name: { type: "string", "x-order": 0 } + }, + required: ["name"] + }) + + Component { + id: autoFormComponent + DynamicForm { + actionType: "CFR_BookRoom" + schema: testCase.autoSchema + controller: mockController + } + } + + function test_default_schema_still_auto_submits_on_validity() { + mockController.submitCount = 0 + var form = createTemporaryObject(autoFormComponent, testCase) + verify(form !== null) + + findChild(form, "field_name").text = "Alice" + compare(form.ready, true) + compare(mockController.submitCount, 1) // unchanged auto-fire behavior + } + + function test_default_schema_renders_no_explicit_submit_button() { + var form = createTemporaryObject(autoFormComponent, testCase) + verify(form !== null) + var button = findChild(form, "submitButton") + compare(button, null) + } + + // --- explicit-submit schema: "x-submitMode": "explicit" ----------------- + + property var explicitSchema: ({ + properties: { + name: { type: "string", "x-order": 0 } + }, + required: ["name"], + "x-submitMode": "explicit" + }) + + Component { + id: explicitFormComponent + DynamicForm { + actionType: "CFR_BookRoom" + schema: testCase.explicitSchema + controller: mockController + } + } + + function test_explicit_submit_mode_suppresses_auto_fire() { + mockController.submitCount = 0 + var form = createTemporaryObject(explicitFormComponent, testCase) + verify(form !== null) + + findChild(form, "field_name").text = "Alice" + compare(form.ready, true) + compare(mockController.submitCount, 0) // ready, but must not auto-fire + } + + function test_explicit_submit_mode_renders_a_submit_button() { + var form = createTemporaryObject(explicitFormComponent, testCase) + verify(form !== null) + var button = findChild(form, "submitButton") + verify(button !== null) + } + + function test_explicit_submit_button_disabled_until_ready() { + var form = createTemporaryObject(explicitFormComponent, testCase) + verify(form !== null) + var button = findChild(form, "submitButton") + verify(button !== null) + compare(button.enabled, false) // name still blank -> not ready + + findChild(form, "field_name").text = "Alice" + compare(form.ready, true) + compare(button.enabled, true) + } + + function test_clicking_explicit_submit_button_calls_submitIfValid() { + mockController.submitCount = 0 + var form = createTemporaryObject(explicitFormComponent, testCase) + verify(form !== null) + + findChild(form, "field_name").text = "Alice" + compare(form.ready, true) + compare(mockController.submitCount, 0) + + var button = findChild(form, "submitButton") + mouseClick(button) + compare(mockController.submitCount, 1) + compare(mockController.lastBody, form.previewLine) + } + + function test_explicit_submit_button_stays_disabled_while_not_ready() { + mockController.submitCount = 0 + var form = createTemporaryObject(explicitFormComponent, testCase) + verify(form !== null) + + var button = findChild(form, "submitButton") + verify(button !== null) + compare(button.enabled, false) + compare(mockController.submitCount, 0) + } +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 15d59522..17e22017 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -63,6 +63,7 @@ add_executable(morph_tests test_quantity.cpp test_tagged.cpp test_quantity_forms.cpp + test_quantity_decode_validation.cpp test_nested_forms.cpp test_flows_apps.cpp test_views.cpp diff --git a/tests/test_forms_rules.cpp b/tests/test_forms_rules.cpp index 213408a9..a743cccf 100644 --- a/tests/test_forms_rules.cpp +++ b/tests/test_forms_rules.cpp @@ -362,6 +362,179 @@ TEST_CASE("Forms::Rules::SchemaJson::EqualsEmitsRationalValueExactly", "[forms][ CHECK(schema.contains(R"("when":{"kind":"equals","fields":["promo"],"value":{"num":5,"den":1}})")); } +// --------------------------------------------------------------------------- +// Compound conditions: and / or / not, nesting to any depth. +// --------------------------------------------------------------------------- + +struct CFRCompoundForm { + CFRMoney promo; + CFRMoney discount; + std::optional email; + std::optional phone; + + // discount required when (promo engaged AND email engaged) + static constexpr auto formRules = morph::forms::ruleList(morph::forms::requiredWhen( + &CFRCompoundForm::discount, + morph::forms::andOf(morph::forms::engaged(&CFRCompoundForm::promo), + morph::forms::engaged(&CFRCompoundForm::email)))); + + [[nodiscard]] bool validate() const { return morph::forms::allRulesSatisfied(*this); } +}; + +struct CFROrForm { + CFRMoney promo; + CFRMoney discount; + std::optional email; + std::optional phone; + + // discount required when (promo engaged OR email engaged) + static constexpr auto formRules = morph::forms::ruleList(morph::forms::requiredWhen( + &CFROrForm::discount, + morph::forms::orOf(morph::forms::engaged(&CFROrForm::promo), morph::forms::engaged(&CFROrForm::email)))); + + [[nodiscard]] bool validate() const { return morph::forms::allRulesSatisfied(*this); } +}; + +struct CFRNotForm { + CFRMoney promo; + CFRMoney discount; + + // discount required when promo is NOT engaged. + static constexpr auto formRules = morph::forms::ruleList( + morph::forms::requiredWhen(&CFRNotForm::discount, morph::forms::notOf(morph::forms::engaged(&CFRNotForm::promo)))); + + [[nodiscard]] bool validate() const { return morph::forms::allRulesSatisfied(*this); } +}; + +struct CFRNestedForm { + CFRMoney promo; + CFRMoney discount; + std::optional email; + std::optional phone; + + // discount required when NOT(promo engaged) OR (email engaged AND phone engaged) + static constexpr auto formRules = morph::forms::ruleList(morph::forms::requiredWhen( + &CFRNestedForm::discount, + morph::forms::orOf( + morph::forms::notOf(morph::forms::engaged(&CFRNestedForm::promo)), + morph::forms::andOf(morph::forms::engaged(&CFRNestedForm::email), + morph::forms::engaged(&CFRNestedForm::phone))))); + + [[nodiscard]] bool validate() const { return morph::forms::allRulesSatisfied(*this); } +}; + +// A compound condition usable directly as a top-level rule too (not only +// nested inside a requiredWhen/visibleWhen/readonlyWhen `when` clause) -- +// "a single rule with a compound condition tree", per the issue. +struct CFRTopLevelCompoundForm { + CFRMoney promo; + std::optional email; + + static constexpr auto formRules = morph::forms::ruleList( + morph::forms::andOf(morph::forms::engaged(&CFRTopLevelCompoundForm::promo), + morph::forms::engaged(&CFRTopLevelCompoundForm::email))); + + [[nodiscard]] bool validate() const { return morph::forms::allRulesSatisfied(*this); } +}; + +TEST_CASE("Forms::Rules::And::BothMustHold", "[forms][rules][compound]") { + CFRCompoundForm form{}; + CHECK(morph::forms::allRulesSatisfied(form)); // neither engaged -> and() false -> not required + + form.promo = Rational{5, DecimalPlaces{2}}; + CHECK(morph::forms::allRulesSatisfied(form)); // only promo engaged -> and() still false + + form.email = "a@b.com"; + CHECK_FALSE(morph::forms::allRulesSatisfied(form)); // both engaged -> and() true -> discount now required + + form.discount = Rational{1, DecimalPlaces{2}}; + CHECK(morph::forms::allRulesSatisfied(form)); +} + +TEST_CASE("Forms::Rules::Or::EitherSuffices", "[forms][rules][compound]") { + CFROrForm form{}; + CHECK(morph::forms::allRulesSatisfied(form)); // neither engaged -> or() false -> not required + + form.promo = Rational{5, DecimalPlaces{2}}; + CHECK_FALSE(morph::forms::allRulesSatisfied(form)); // promo engaged -> or() true -> discount required + + form.discount = Rational{1, DecimalPlaces{2}}; + CHECK(morph::forms::allRulesSatisfied(form)); +} + +TEST_CASE("Forms::Rules::Not::NegatesInnerCondition", "[forms][rules][compound]") { + CFRNotForm form{}; + CHECK_FALSE(morph::forms::allRulesSatisfied(form)); // promo unengaged -> not(engaged) true -> discount required + + form.discount = Rational{1, DecimalPlaces{2}}; + CHECK(morph::forms::allRulesSatisfied(form)); + + form.promo = Rational{5, DecimalPlaces{2}}; + form.discount = CFRMoney{}; + CHECK(morph::forms::allRulesSatisfied(form)); // promo engaged -> not(engaged) false -> not required +} + +TEST_CASE("Forms::Rules::Compound::NestsToAnyDepth", "[forms][rules][compound]") { + CFRNestedForm form{}; + // promo unengaged -> notOf(engaged(promo)) is true -> or() true -> required + CHECK_FALSE(morph::forms::allRulesSatisfied(form)); + + form.promo = Rational{5, DecimalPlaces{2}}; + // promo engaged -> notOf branch false; email/phone both unengaged -> andOf branch false -> or() false + CHECK(morph::forms::allRulesSatisfied(form)); + + form.email = "a@b.com"; + form.phone = "555"; + // promo engaged (notOf branch false) but email AND phone both engaged (andOf branch true) -> or() true + CHECK_FALSE(morph::forms::allRulesSatisfied(form)); + + form.discount = Rational{1, DecimalPlaces{2}}; + CHECK(morph::forms::allRulesSatisfied(form)); +} + +TEST_CASE("Forms::Rules::Compound::UsableDirectlyAsATopLevelRule", "[forms][rules][compound]") { + CFRTopLevelCompoundForm form{}; + CHECK_FALSE(morph::forms::allRulesSatisfied(form)); // and() false while both unengaged + + form.promo = Rational{5, DecimalPlaces{2}}; + CHECK_FALSE(morph::forms::allRulesSatisfied(form)); + + form.email = "a@b.com"; + CHECK(morph::forms::allRulesSatisfied(form)); // both engaged -> and() true -> the rule itself holds +} + +TEST_CASE("Forms::Rules::SchemaJson::AndOrNotEmitXRulesAsNestedNodes", "[forms][rules][compound]") { + auto const schema = morph::forms::schemaJson(); + CHECK(schema.contains( + R"("x-rules":[{"kind":"requiredWhen","fields":["discount"],)" + R"("when":{"kind":"and","conditions":[)" + R"({"kind":"engaged","fields":["promo"]},)" + R"({"kind":"engaged","fields":["email"]}]}}])")); + + auto const orSchema = morph::forms::schemaJson(); + CHECK(orSchema.contains(R"("kind":"or","conditions":[)")); + + auto const notSchema = morph::forms::schemaJson(); + CHECK(notSchema.contains(R"("when":{"kind":"not","condition":{"kind":"engaged","fields":["promo"]}}}])")); +} + +TEST_CASE("Forms::Rules::And::VariadicAcceptsMoreThanTwoConditions", "[forms][rules][compound]") { + struct CFRTriple { + CFRMoney a; + CFRMoney b; + CFRMoney c; + }; + CFRTriple triple{}; + auto const cond = + morph::forms::andOf(morph::forms::engaged(&CFRTriple::a), morph::forms::engaged(&CFRTriple::b), + morph::forms::engaged(&CFRTriple::c)); + CHECK_FALSE(cond.test(triple)); + triple.a = Rational{1, DecimalPlaces{2}}; + triple.b = Rational{1, DecimalPlaces{2}}; + triple.c = Rational{1, DecimalPlaces{2}}; + CHECK(cond.test(triple)); +} + // --------------------------------------------------------------------------- // Presentation rules: visibleWhen / readonlyWhen (never gate the submit check). // --------------------------------------------------------------------------- diff --git a/tests/test_quantity_decode_validation.cpp b/tests/test_quantity_decode_validation.cpp new file mode 100644 index 00000000..0317a64a --- /dev/null +++ b/tests/test_quantity_decode_validation.cpp @@ -0,0 +1,260 @@ +// SPDX-License-Identifier: Apache-2.0 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +using morph::math::DecimalPlaces; +using morph::math::Denominator; +using morph::math::Numerator; +using morph::math::Rational; + +// --------------------------------------------------------------------------- +// A miniature unit system where one unit (percent) declares bounds [0, 100] +// via the optional UnitTraits::bounds(E) customisation point, and another +// (mass) declares none -- exercising both the opt-in check and its absence. +// --------------------------------------------------------------------------- + +enum class QDVUnit : std::uint8_t { percent, mass }; + +template <> +struct morph::units::UnitTraits { + static constexpr morph::units::UnitMeta meta(QDVUnit unit) noexcept { + switch (unit) { + case QDVUnit::percent: + return {.id = "percent", .display = "%", .defaultDecimals = 1}; + case QDVUnit::mass: + return {.id = "mass", .display = "kg", .defaultDecimals = 3}; + default: + return {.id = "?", .display = "?", .defaultDecimals = 1}; + } + } + + // Only `percent` declares bounds; `mass` intentionally declares none, so + // HasUnitBounds is still true overall (bounds() is defined for + // the enum) but the mass branch below returns a wide-open range, + // matching "the unit declares none" behaviour per-value rather than + // per-enum. See the per-unit test below. + static constexpr morph::units::QuantityBounds bounds(QDVUnit unit) noexcept { + switch (unit) { + case QDVUnit::percent: + return {.min = Rational{Numerator{0}, Denominator{1}, DecimalPlaces{1}}, + .max = Rational{Numerator{100}, Denominator{1}, DecimalPlaces{1}}}; + case QDVUnit::mass: + default: + return {.min = Rational{Numerator{-1'000'000'000}, Denominator{1}, DecimalPlaces{1}}, + .max = Rational{Numerator{1'000'000'000}, Denominator{1}, DecimalPlaces{1}}}; + } + } +}; + +using Percent = morph::units::Quantity; +using Mass = morph::units::Quantity; + +static_assert(morph::units::HasUnitBounds); + +// A unit system with NO bounds() at all, to confirm the opt-in default. +enum class QDVNoBoundsUnit : std::uint8_t { scalar }; + +template <> +struct morph::units::UnitTraits { + static constexpr morph::units::UnitMeta meta(QDVNoBoundsUnit) noexcept { + return {.id = "scalar", .display = "", .defaultDecimals = 2}; + } +}; + +using UnboundedScalar = morph::units::Quantity; + +static_assert(!morph::units::HasUnitBounds); + +// --------------------------------------------------------------------------- +// Quantity::withinDeclaredBounds() -- the type-level check. +// --------------------------------------------------------------------------- + +TEST_CASE("Quantity::WithinDeclaredBounds::EmptyIsAlwaysWithinBounds", "[quantity][decode]") { + Percent const empty{}; + CHECK(empty.withinDeclaredBounds()); +} + +TEST_CASE("Quantity::WithinDeclaredBounds::InRangeValuePasses", "[quantity][decode]") { + Percent const p{Rational{Numerator{50}, Denominator{1}, DecimalPlaces{1}}}; + CHECK(p.withinDeclaredBounds()); + + Percent const atMin{Rational{Numerator{0}, Denominator{1}, DecimalPlaces{1}}}; + CHECK(atMin.withinDeclaredBounds()); // inclusive lower bound + + Percent const atMax{Rational{Numerator{100}, Denominator{1}, DecimalPlaces{1}}}; + CHECK(atMax.withinDeclaredBounds()); // inclusive upper bound +} + +TEST_CASE("Quantity::WithinDeclaredBounds::OutOfRangeValueFails", "[quantity][decode]") { + Percent const tooHigh{Rational{Numerator{101}, Denominator{1}, DecimalPlaces{1}}}; + CHECK_FALSE(tooHigh.withinDeclaredBounds()); + + Percent const negative{Rational{Numerator{-1}, Denominator{1}, DecimalPlaces{1}}}; + CHECK_FALSE(negative.withinDeclaredBounds()); +} + +TEST_CASE("Quantity::WithinDeclaredBounds::NoBoundsDeclaredAlwaysPasses", "[quantity][decode]") { + UnboundedScalar const huge{Rational{Numerator{999'999'999}, Denominator{1}, DecimalPlaces{2}}}; + CHECK(huge.withinDeclaredBounds()); + UnboundedScalar const negative{Rational{Numerator{-999'999'999}, Denominator{1}, DecimalPlaces{2}}}; + CHECK(negative.withinDeclaredBounds()); +} + +// --------------------------------------------------------------------------- +// morph::forms::checkQuantityBounds / enforceQuantityBounds. +// --------------------------------------------------------------------------- + +struct QDVReading { + Percent moisture; + Mass sampleMass; + std::optional note; + + [[nodiscard]] bool validate() const { return morph::forms::allRequiredEngaged(*this); } +}; + +TEST_CASE("Forms::CheckQuantityBounds::NoOffenderWhenEverythingWithinBounds", "[forms][decode]") { + QDVReading reading{.moisture = Percent{Rational{Numerator{45}, Denominator{1}, DecimalPlaces{1}}}, + .sampleMass = Mass{Rational{Numerator{10}, Denominator{1}, DecimalPlaces{1}}}, + .note = {}}; + CHECK_FALSE(morph::forms::checkQuantityBounds(reading).has_value()); +} + +TEST_CASE("Forms::CheckQuantityBounds::ReportsFirstOffendingFieldName", "[forms][decode]") { + QDVReading reading{.moisture = Percent{Rational{Numerator{250}, Denominator{1}, DecimalPlaces{1}}}, + .sampleMass = {}, + .note = {}}; + auto const offender = morph::forms::checkQuantityBounds(reading); + REQUIRE(offender.has_value()); + CHECK(*offender == "moisture"); +} + +TEST_CASE("Forms::CheckQuantityBounds::EmptyFieldsNeverOffend", "[forms][decode]") { + QDVReading const reading{}; // both Quantity members empty + CHECK_FALSE(morph::forms::checkQuantityBounds(reading).has_value()); +} + +// A non-aggregate (user-declared constructor, private member) is neither +// glz::reflectable nor glz::glaze_object_t, so checkQuantityBounds's +// if constexpr takes its else branch: a no-op, exactly like +// reconcileDeclaredPrecision's identical fallback for actions with +// hand-written codecs and no reflectable shape. Nothing to walk, nothing to +// flag -- but this path must still compile and return std::nullopt rather +// than being an untested assumption. +class QDVNonReflectable { +public: + explicit QDVNonReflectable(int value) : _value{value} {} + [[nodiscard]] int value() const { return _value; } + +private: + int _value; +}; + +TEST_CASE("Forms::CheckQuantityBounds::NonReflectableActionIsANoOp", "[forms][decode]") { + QDVNonReflectable const action{42}; + CHECK_FALSE(morph::forms::checkQuantityBounds(action).has_value()); +} + +TEST_CASE("Forms::EnforceQuantityBounds::ThrowsQuantityDecodeErrorNamingTheField", "[forms][decode]") { + QDVReading reading{.moisture = Percent{Rational{Numerator{-5}, Denominator{1}, DecimalPlaces{1}}}, + .sampleMass = {}, + .note = {}}; + REQUIRE_THROWS_AS(morph::forms::enforceQuantityBounds(reading), morph::forms::QuantityDecodeError); + try { + morph::forms::enforceQuantityBounds(reading); + FAIL("expected QuantityDecodeError"); + } catch (const morph::forms::QuantityDecodeError& err) { + CHECK(std::string{err.what()}.find("moisture") != std::string::npos); + } +} + +TEST_CASE("Forms::EnforceQuantityBounds::NoOpWhenWithinBounds", "[forms][decode]") { + QDVReading reading{.moisture = Percent{Rational{Numerator{50}, Denominator{1}, DecimalPlaces{1}}}, + .sampleMass = {}, + .note = {}}; + CHECK_NOTHROW(morph::forms::enforceQuantityBounds(reading)); +} + +// --------------------------------------------------------------------------- +// No-drift: the decode-level rejection happens before validate() runs, on the +// server dispatch path (ActionDispatcher) and the client bridge dispatch path +// (ActionExecuteRegistry via BridgeHandler::executeJson) -- distinct from a +// ValidationError, which stays reserved for validate()'s own business rules. +// --------------------------------------------------------------------------- + +struct QDVResult { + bool accepted = false; +}; + +struct QDVModel { + QDVResult execute(const QDVReading&) { return QDVResult{.accepted = true}; } +}; + +BRIDGE_REGISTER_MODEL(QDVModel, "QDV_Model") +BRIDGE_REGISTER_ACTION(QDVModel, QDVReading, "QDV_Reading") + +TEST_CASE("Forms::NoDrift::ActionDispatcherRejectsOutOfBoundsQuantityAsDecodeError", "[forms][decode][dispatch]") { + auto holder = morph::model::detail::ModelFactory::create(); + QDVReading const badReading{.moisture = Percent{Rational{Numerator{250}, Denominator{1}, DecimalPlaces{1}}}, + .sampleMass = {}, + .note = {}}; + auto const payload = morph::model::ActionTraits::toJson(badReading); + + REQUIRE_THROWS_AS( + morph::model::detail::ActionDispatcher::instance().dispatch("QDV_Model", "QDV_Reading", *holder, payload), + morph::forms::QuantityDecodeError); +} + +TEST_CASE("Forms::NoDrift::ActionDispatcherAcceptsInBoundsQuantity", "[forms][decode][dispatch]") { + auto holder = morph::model::detail::ModelFactory::create(); + QDVReading const goodReading{.moisture = Percent{Rational{Numerator{50}, Denominator{1}, DecimalPlaces{1}}}, + .sampleMass = Mass{Rational{Numerator{10}, Denominator{1}, DecimalPlaces{1}}}, + .note = {}}; + auto const payload = morph::model::ActionTraits::toJson(goodReading); + + auto const resultJson = + morph::model::detail::ActionDispatcher::instance().dispatch("QDV_Model", "QDV_Reading", *holder, payload); + auto const result = morph::model::ActionTraits::resultFromJson(resultJson); + CHECK(result.accepted); +} + +TEST_CASE("Forms::NoDrift::LocalBackendRejectsOutOfBoundsQuantityViaOnError", "[forms][decode][dispatch]") { + morph::exec::ThreadPoolExecutor pool{2}; + morph::testing::InlineExecutor cbExec; + morph::bridge::Bridge bridge{std::make_unique(pool)}; + morph::bridge::BridgeHandler handler{bridge, &cbExec}; + + QDVReading const badReading{.moisture = Percent{Rational{Numerator{-10}, Denominator{1}, DecimalPlaces{1}}}, + .sampleMass = {}, + .note = {}}; + auto const payload = morph::model::ActionTraits::toJson(badReading); + + std::atomic sawDecodeError{false}; + std::atomic done{false}; + handler.executeJson("QDV_Reading", payload) + .then([&](std::string) { done.store(true); }) + .onError([&](const std::exception_ptr& err) { + try { + std::rethrow_exception(err); + } catch (const morph::forms::QuantityDecodeError&) { + sawDecodeError.store(true); + } catch (...) { + } + done.store(true); + }); + + REQUIRE(morph::testing::waitUntil([&] { return done.load(); })); + REQUIRE(sawDecodeError.load()); +}