diff --git a/docs/spec/forms/forms.md b/docs/spec/forms/forms.md index f4622275e..7f26c8f62 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -977,8 +977,10 @@ a required member of any row is blank. An empty array is a value: `[]` is submitted for it. Cells are always read in the member's **canonical** unit: a row has no unit -selector of its own. Only a **top-level** collection is handed to a slot; one -nested inside a row, or inside a nested object, stays unrepresentable. A label +selector of its own. Only a **top-level** collection is handed to a slot. One +inside a [nested object a slot claims](#nested-objects--a-host-slot-draws-them) +is encoded by that object's encoder, with the same row rules; one nested inside +a row stays unrepresentable. A label inside a row resolves through an explicit `x-i18nKey` or its literal only — the derived `.` key names top-level members, and no row-level stem is defined on the C++ side. @@ -987,6 +989,68 @@ is defined on the C++ side. against submitted bodies. Replacing the cell encoder with plain string quoting reddens 3 of its 19 cases. +### Nested objects — a host slot draws them + +A member whose type is a struct — `IgnitionSpecimen specimen`, or +`std::optional determination1` — is an object schema +with `properties`: inlined into the property when the type is used once, +otherwise a `$ref` into `$defs`, and for a `std::optional` wrapped in +`{"anyOf": [, {"type": "null"}]}`. Its members are annotated like the +action's own, `fieldMetadata` included, at every depth and through the +`std::optional` (see [Nested aggregates](#nested-aggregates-recursive-cycle-safe)). +The built-in controls cannot collect it, so the member is +[unrepresentable](#what-ready-claims) — **unless a host +[slot](#theming--component-override-registry) claims it**, exactly as for a +[collection of objects](#collections-of-objects--a-host-slot-draws-them): + +| Field descriptor key | Meaning | +|---|---| +| `isObject` | `true` for a member whose schema resolves to an object with `properties` that no typed control claims — not a `Quantity` or a `Choice`, whose own controls encode their object shape. `kind` is `"object"`. | +| `objectFields` | The object's member descriptors, in `x-order` order, the same shape as a top-level entry of `fields` (`label`, `unit`, `decimals`, `readOnly`, `required`, the kind flags, …). **Recursive:** a member that is itself an object carries its own `objectFields`, and a collection member its `itemFields`. Described for a top-level member and inside another nested object, down to `maxObjectDepth` (4) levels below the action, and never twice for the same `$defs` type on one path, so a self-referential type stops at its first repetition — that inner member is left unrepresentable. | +| `claimedBySlot` | `true` when a registered slot resolves for this top-level member. `unrepresentable` is then `""`, for it and for every object or collection described inside it. | + +The slot edits the value as **cell texts**: `setObject({massOfContainer: +"512.3", testTemperature: "538"})`, or `setValue` with the same object as JSON +text. A leaf cell holds what the member's built-in control would hold, as in a +row; a nested object member holds a nested object of the same shape; a +collection member holds an array of row objects. The form encodes **every leaf +with the encoder the same member would get at the top level** +(`encodeFieldText`: locale normalisation, precision limit, declared bounds), a +nested object recursively, and a collection with the row encoder. So for +`MaxDensitySection { PycnometerTestData data; }`: + +```js +setObject({ useSpecificGravity: "false", testLiquidTemperature: "25.0", testLiquidName: "", + determination1: { massPycnometerEmpty: "1450.10", massPycnometerAndSample: "3450.25", + excluded: "false" }, + determination2: {} }) +// → {"data":{"useSpecificGravity":false,"testLiquidTemperature":25.0, +// "determination1":{"massPycnometerEmpty":1450.10,"massPycnometerAndSample":3450.25, +// "excluded":false}}} +``` + +- A **blank optional leaf** is omitted. A **blank optional object** — absent, + `{}`, or one whose every member is blank (an empty collection counts as + blank here) — is omitted too. +- A **blank required leaf**, or a **blank required object**, makes the literal + `null`: no value, so the form is not ready. A required object therefore needs + at least one member filled to count as present, even when all of its own + members are optional. A value that is not an object, or a cell that does not + encode, does the same. +- A top-level object left blank (`setObject({})`) is simply unfilled: omitted + when optional, the ordinary submit gate when required. + +Cells are read in each member's **canonical** unit. [Prefill](#prefill--loading-a-stored-payload-for-editing) +decodes a stored object back into this shape (`decodeFieldValue`, member by +member, the inverse of the encoder), so prefill → no edit → submit carries the +same values; a stored optional object whose members are all absent decodes to +nothing and is then omitted. + +`src/qt/forms/tests/tst_DynamicFormObjectSlot.qml` pins the contract against +submitted bodies, with the two consumer shapes above (`BinderIgnitionSection`, +`MaxDensitySection`). Replacing the leaf encoder with plain string quoting +reddens 9 of its 24 cases. + ### Boolean fields — `type: "boolean"` glaze emits `{"type": "boolean"}` for a `bool` member, and @@ -1448,10 +1512,12 @@ fields" — advice no input can act on — and, being the label the [accessibility slice](#renderer-conformance-kit) mirrors into `Accessible.description`, announces it rather than merely tinting it. -**A slot can supply the missing encoding for a collection of objects.** When a -registered slot claims a top-level `std::vector` member, `unrepresentable` -is `""` for it and the form encodes the rows the slot writes — see -[Collections of objects](#collections-of-objects--a-host-slot-draws-them). The +**A slot can supply the missing encoding for a collection of objects or a +nested object.** When a registered slot claims a top-level `std::vector` +member or a top-level struct member, `unrepresentable` is `""` for it and the +form encodes the rows or members the slot writes — see +[Collections of objects](#collections-of-objects--a-host-slot-draws-them) and +[Nested objects](#nested-objects--a-host-slot-draws-them). The descriptor therefore depends on the slot registry as well as on the schema; the `fields` binding reads `SlotRegistry.revision`, so a slot registered after the form was built is picked up. @@ -1585,7 +1651,7 @@ forking the renderer: built-in controls use, so an override participates in the required-gate and auto-fire without special-casing. - A slot may also declare any of four **optional** members, each assigned only + A slot may also declare any of six **optional** members, each assigned only when declared (so an existing slot is unaffected): | Member | Assigned as | Use | @@ -1593,12 +1659,15 @@ forking the renderer: | `fieldText` | a binding to the field's retained text | Seed and track the control's value. A prefill, `resetFields()`, a `setFieldValue` from code and a tab switch that rebuilds the slot all reach it; without it a slot sees only what it wrote itself. | | `rows` | a binding to the rows of a [collection of objects](#collections-of-objects--a-host-slot-draws-them), as a JS array of `{member: cellText}` (`[]` when none) | Draw the grid. | | `setRows` | `function (rows)` | Write the rows; equivalent to `setValue(JSON.stringify(rows))`. | + | `objectValue` | a binding to the value of a [nested object](#nested-objects--a-host-slot-draws-them), as a JS object of `{member: cellText \| nestedValue}` (`{}` when blank) | Draw the sub-form. | + | `setObject` | `function (value)` | Write the object; equivalent to `setValue(JSON.stringify(value))`. | | `form` | the `DynamicForm` itself | Reach `encodeFieldText(field, text, 0)` (does this cell encode?) and the rest of the form's public surface. | - Both bindings re-evaluate on `rulesRevision`, which `revalidate()` bumps after - every write to the draft. A slot that claims a collection of objects is also - what makes that member representable — see - [Collections of objects](#collections-of-objects--a-host-slot-draws-them). `SlotRegistry.revision` is bumped on every + The bindings re-evaluate on `rulesRevision`, which `revalidate()` bumps after + every write to the draft. A slot that claims a collection of objects or a + nested object is also what makes that member representable — see + [Collections of objects](#collections-of-objects--a-host-slot-draws-them) and + [Nested objects](#nested-objects--a-host-slot-draws-them). `SlotRegistry.revision` is bumped on every `by*()` call and read inside `resolve()`, for the same reason `I18nCatalog.revision` exists: `_byField`/`_byWidget`/`_byUnit`/`_byType` are plain objects mutated in place, which does not by itself notify a binding @@ -1620,7 +1689,7 @@ renderer would draw, decided in the order its encoder is chosen: | `date` | `format: "date"` (drawn as a plain text field by the built-in renderer) | | `quantity` | a `Quantity`, or any property with `x-decimalPlaces` | | `integer` / `boolean` / `number` | `type` of that name | -| `object` | a nested aggregate (unrepresentable without a slot) | +| `object` | a nested aggregate, plain or `std::optional` (unrepresentable without a slot; see [Nested objects](#nested-objects--a-host-slot-draws-them)) | | `string` | everything else | A host registers one kit component per kind (`byKind("quantity", …)`, @@ -2979,7 +3048,10 @@ nested (a measurement with a repeated specimen sub-record, a document with a nested address, a category tree), including domains nested more than one level deep (an address with a nested geo-coordinate sub-record, say). -Two schema shapes exist for a nested aggregate, and both are recursed into: +A `std::optional` member is recursed into as well: glaze wraps `Sub`'s +schema in `{"anyOf": [, {"type": "null"}]}`, and the non-null branch is +annotated like a plain member's node. Two schema shapes exist for a nested +aggregate, and both are recursed into: - **Deduplicated (`$ref`/`$defs`)** — glaze shares one `$defs` entry, `$ref`'d from every property, when the nested type is used **two or more times** @@ -3218,8 +3290,11 @@ depends on the rendering answer yet. So: an action with a nested-aggregate member — cyclic or otherwise — is a document morph generates completely, a form morph draws only down to the nesting, and a body morph declines to assemble — unless the member is a -top-level collection of objects a host slot draws, which the form then encodes -row by row (see [Collections of objects](#collections-of-objects--a-host-slot-draws-them)). +top-level collection of objects or a top-level nested object a host slot draws, +which the form then encodes row by row or member by member (see +[Collections of objects](#collections-of-objects--a-host-slot-draws-them) and +[Nested objects](#nested-objects--a-host-slot-draws-them)). Everything above +describes a member **no slot claims**, and is unchanged by that. Computed fields, `formLayout`/`fieldSpans`, and `formRules` remain **top-level only** regardless of nesting depth: a nested aggregate declaring any of those diff --git a/include/morph/forms/forms.hpp b/include/morph/forms/forms.hpp index 4209f66fb..fba0add93 100644 --- a/include/morph/forms/forms.hpp +++ b/include/morph/forms/forms.hpp @@ -2511,7 +2511,7 @@ template void recurseIntoNestedAggregateIfAny(SchemaDomRef dom, glz::generic_u64& property, NestedDefsVisited& visited); /// @brief Recurses into @p property's own object schema if @p Member (or, for -/// `std::vector`, its element type) is itself a +/// `std::vector` and `std::optional`, the `Sub`) is itself a /// `ReflectableAggregate` -- the single decision point shared by /// `mergeSchemaExtras`'s top-level loop and `annotateNestedAggregate`'s /// own loop, so there is exactly one implementation of it. @@ -2568,6 +2568,20 @@ void recurseIntoNestedAggregateIfAny(SchemaDomRef dom, glz::generic_u64& propert // standing suppression to say why the subscript was safe. annotateNestedAggregateRef(dom, *items, visited); } + } else if constexpr (isStdOptional) { + // glaze spells `std::optional` as `{"anyOf": [, + // {"type": "null"}]}`, so an optional nested aggregate's object schema + // sits in the non-null branch. Each branch goes through the same + // decision for `T` (the null one matches nothing), which covers an + // optional `std::vector` as well. + using ValueType = typename std::remove_cvref_t::value_type; + if (auto* const branches = findMember(property, "anyOf")) { + if (auto* const list = branches->get_if()) { + for (auto& branch : *list) { + recurseIntoNestedAggregateIfAny(dom, branch, visited); + } + } + } } } diff --git a/src/qt/forms/qml/DynamicForm.qml b/src/qt/forms/qml/DynamicForm.qml index 54bcbf6ba..dc72d01be 100644 --- a/src/qt/forms/qml/DynamicForm.qml +++ b/src/qt/forms/qml/DynamicForm.qml @@ -26,10 +26,11 @@ // collection whose items are objects -- is *unrepresentable*: no typed text // encodes to the shape the schema asks for. Such a member is named in its // field descriptor's `unrepresentable` and keeps the form short of `ready` -// (docs/spec/forms/forms.md, "What `ready` claims"). The one exception is a -// collection of objects a host slot claims (SlotRegistry): the slot edits the -// rows as cell texts and this form encodes each cell with the same encoders -// its own controls use -- see "Collections of objects" in forms.md. +// (docs/spec/forms/forms.md, "What `ready` claims"). The exception is a +// top-level collection of objects or nested object a host slot claims +// (SlotRegistry): the slot edits the rows or members as cell texts and this +// form encodes each cell with the same encoders its own controls use -- see +// "Collections of objects" and "Nested objects" in forms.md. // // 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). @@ -528,11 +529,32 @@ Frame { // // Reading the registry's revision makes a slot registered after the first // evaluation re-describe the member it claims: whether a collection of - // objects is representable depends on it (see describeObject). + // objects or a nested object is representable depends on it (see + // describeObject). property var fields: { if (slotRegistry) slotRegistry.revision - return describeObject(schemaData, 0) + return describeObject(schemaData, 0, "action", []) + } + + // How deep `objectFields` / `itemFields` are described below the action. + // A bound on what a wide acyclic type graph can cost, not the cycle guard + // (that is describeObject's `refChain`). + readonly property int maxObjectDepth: 4 + + // The `$defs` reference a property's object schema comes from -- its own + // `$ref`, or a branch's for a nullable one -- or "" for an inlined one. + function objectRefName(raw) { + if (!raw) + return "" + if (typeof raw["$ref"] === "string") + return raw["$ref"] + const branches = Array.isArray(raw.anyOf) ? raw.anyOf : (Array.isArray(raw.oneOf) ? raw.oneOf : []) + for (let i = 0; i < branches.length; ++i) { + if (branches[i] && typeof branches[i]["$ref"] === "string") + return branches[i]["$ref"] + } + return "" } // Whether a host slot claims the member `name` -- the same resolution the @@ -543,13 +565,17 @@ Frame { } // Field descriptors for one object schema's properties, in x-order order. - // `depth` is 0 for the action itself and 1 for the element of a top-level - // collection of objects (`itemFields` below); element members are - // described once and never recursed into further, so a self-referential - // row type cannot loop. At depth 1 a label resolves through an explicit - // x-i18nKey or the literal only: the derived "." key names - // top-level members. - function describeObject(objectSchema, depth) { + // `depth` is 0 for the action itself and grows by one per level below it. + // `container` names what is described: "action" (depth 0), "object" (a + // nested object's members, `objectFields`) or "row" (a collection's + // element, `itemFields`). Only the action and a nested object describe + // their own object-valued members further; a row's members are described + // once and never recursed into. `refChain` holds the `$defs` references on + // the path here, so a self-referential type stops at its first repetition + // rather than looping, and `maxObjectDepth` bounds the rest. Below depth 0 + // a label resolves through an explicit x-i18nKey or the literal only: the + // derived "." key names top-level members. + function describeObject(objectSchema, depth, container, refChain) { const props = (objectSchema && objectSchema.properties) || {} const required = (objectSchema && objectSchema.required) || [] return Object.keys(props) @@ -618,13 +644,34 @@ Frame { const itemSchema = types.indexOf("array") !== -1 ? resolveProp(p.items) : {} const isObjectArray = jsonTypes(itemSchema).indexOf("object") !== -1 && itemSchema.properties !== undefined + // A nested aggregate (a plain or std::optional struct member): + // an object schema with members of its own that no typed + // control claims. A Quantity's {num,den,dp} object is claimed + // by its own control and is therefore not one. + const isObject = !typedControl && types.indexOf("object") !== -1 && p.properties !== undefined const jsonType = types.length > 0 ? types[0] : "" const kind = fieldKind(p, types, dp, optionsAction, enumOptionRows.length > 0) - // Only a top-level collection is handed to a slot: its rows - // are stored in the collection's own fieldValues entry, which - // a member one level down does not have. - const claimedBySlot = depth === 0 && isObjectArray + // Only a top-level member is handed to a slot: its value is + // stored in the member's own fieldValues entry, which a + // member one level down does not have. + const claimedBySlot = depth === 0 && (isObjectArray || isObject) && slotClaims(name, opt(widget, ""), opt(extUnits.unitAscii, ""), jsonType, kind) + // Whether this member's own members are described: at the + // top level and inside a nested object, within the depth + // bound, and not for a type already on the path here. + const refName = objectRefName(isObjectArray ? raw.items : raw) + const describesNested = (container === "action" || container === "object") + && depth < maxObjectDepth && (refName === "" || refChain.indexOf(refName) === -1) + const nestedChain = refName === "" ? refChain : refChain.concat([refName]) + const objectFields = (isObject && describesNested) + ? describeObject(p, depth + 1, "object", nestedChain) : [] + const itemFields = (isObjectArray && describesNested) + ? describeObject(itemSchema, depth + 1, "row", nestedChain) : [] + // Inside a nested object, an object or collection member that + // is described is encoded by its container's encoder, so it is + // representable there. + const encodedByContainer = container === "object" && describesNested + && (isObject || isObjectArray) const derivedKey = function (slot) { return depth === 0 ? i18nFieldKey(name, slot) : undefined } return { name: name, @@ -697,9 +744,18 @@ Frame { // readOnly, required, and the kind flags that say how a // cell's text is encoded). Empty for any other member. isObjectArray: isObjectArray, - itemFields: (isObjectArray && depth === 0) ? describeObject(itemSchema, depth + 1) : [], - // True when a registered slot draws this collection, which - // is what makes it representable (see `unrepresentable`). + itemFields: itemFields, + // A nested object, and -- at the top level and inside + // another nested object -- its member descriptors in + // x-order order, each carrying its own `objectFields` / + // `itemFields` when it is an object or a collection too: + // the layout a sub-form slot draws. Empty for any other + // member. + isObject: isObject, + objectFields: objectFields, + // True when a registered slot draws this collection or + // object, which is what makes it representable (see + // `unrepresentable`). claimedBySlot: claimedBySlot, // Why no control here can collect what the schema asks // for, or "" for every member this renderer represents -- @@ -707,10 +763,13 @@ Frame { // reason makes the member unencodable, so the form reports // ready only for a payload that legitimately omits it. // - // A top-level collection of objects a slot claims is the - // exception: the slot collects each row's cell texts and - // encodeObjectArray encodes them, so an encoding exists. - unrepresentable: claimedBySlot ? "" : unrepresentableMemberReason(p, types, typedControl), + // A top-level collection of objects or nested object a + // slot claims is the exception: the slot collects the cell + // texts and encodeObjectArray / encodeObjectValue encode + // them, so an encoding exists -- as it does for an object + // or a collection described inside such an object. + unrepresentable: (claimedBySlot || encodedByContainer) + ? "" : unrepresentableMemberReason(p, types, typedControl), required: required.indexOf(name) !== -1, // `resolveRef` merges the property node *over* the `$def` // it points at, so these three read a per-field bound @@ -1449,6 +1508,98 @@ Frame { return "[" + encodedRows.join(",") + "]" } + // A JSON object value that is neither an array nor an exact-integer + // wrapper (JsonExact) -- the shape a nested object's value takes. + function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) && !JsonExact.isExact(value) + } + + // Whether a nested object's value holds nothing: absent, blank text, an + // empty collection, or an object all of whose members are blank in turn. + function objectValueBlank(value) { + if (value === undefined || value === null) + return true + if (typeof value === "string") + return value.trim() === "" + if (Array.isArray(value)) + return value.length === 0 + if (isPlainObject(value)) { + for (const key in value) { + if (!objectValueBlank(value[key])) + return false + } + return true + } + return false + } + + // A nested-object field's value, as the JS object a slot wrote with + // setObject (JSON text in fieldValues), or {} when there is none yet or + // the text is not an object. + function objectDraft(text) { + if (text === undefined || text === null || String(text).trim() === "") + return ({}) + try { + const parsed = JSON.parse(text) + return isPlainObject(parsed) ? parsed : ({}) + } catch (ignored) { + return ({}) + } + } + + // Whether a field's retained text counts as left blank: empty, or -- for + // a nested object -- an object that holds nothing (objectValueBlank). + function draftIsBlank(f, text) { + if (text === "") + return true + if (!f.isObject) + return false + try { + const parsed = JSON.parse(text) + return isPlainObject(parsed) && objectValueBlank(parsed) + } catch (ignored) { + return false + } + } + + // Encodes a nested object from its members' cell texts: `value` is a JS + // object of `{member: cellText | nestedValue}`, keyed like `memberFields` + // (an `objectFields` list). A leaf cell goes through encodeFieldText with + // the member's own descriptor, exactly like a row cell in + // encodeObjectArray; a nested object member recurses, and a collection + // member (an array of row objects, or its JSON text) goes through + // encodeObjectArray. A blank optional member -- for a nested object, one + // that holds nothing -- is omitted. Returns null -- no literal -- when a + // member does not encode, or a required member is blank. + function encodeObjectValue(memberFields, value) { + const parts = [] + for (let m = 0; m < memberFields.length; ++m) { + const member = memberFields[m] + const cell = value[member.name] + const blank = cell === undefined || cell === null + || (typeof cell === "string" && cell.trim() === "") + || (member.isObject && objectValueBlank(cell)) + if (blank) { + if (member.required) + return null + continue + } + if (member.unrepresentable !== "") + return null + let literal = null + if (member.isObject) + literal = isPlainObject(cell) ? encodeObjectValue(member.objectFields, cell) : null + else if (member.isObjectArray) + literal = encodeObjectArray(member, Array.isArray(cell) ? JSON.stringify(cell) : String(cell)) + else + literal = encodeFieldText(member, String(cell), 0) + if (literal === null) + return null + parts.push(JSON.stringify(member.name) + ":" + literal) + } + return "{" + parts.join(",") + "}" + } + function arrayJsonLiteral(text) { const items = text.split(",") .map(function (item) { return item.trim() }) @@ -1481,6 +1632,17 @@ Frame { // makes the form report ready. if (f.unrepresentable !== "") return null + if (f.isObject) { + let value + try { + value = JSON.parse(text) + } catch (ignored) { + return null + } + if (!isPlainObject(value) || objectValueBlank(value)) + return null + return encodeObjectValue(f.objectFields, value) + } if (f.isObjectArray) { return encodeObjectArray(f, text) } @@ -1648,7 +1810,7 @@ Frame { const text = (opt(fieldValues[f.name], "")).trim() const literal = fieldJsonLiteral(f) if (literal === null) { - if (text !== "" || f.required || isDynamicallyRequired(f.name)) { + if (!draftIsBlank(f, text) || f.required || isDynamicallyRequired(f.name)) { ok = false // An unrepresentable member blocks submission only when // the payload would have to carry it -- the schema @@ -1774,6 +1936,10 @@ Frame { function decodeFieldValue(f, value) { if (value === undefined || value === null) return "" + if (f.isObject) { + const cells = decodeObjectValue(f.objectFields, value) + return (cells === null || objectValueBlank(cells)) ? "" : JSON.stringify(cells) + } if (f.isObjectArray) { if (!Array.isArray(value)) return "" @@ -1810,6 +1976,33 @@ Frame { return typeof value === "string" ? value : "" } + // A nested object's wire value as the `{member: cellText | nestedValue}` + // object encodeObjectValue reads -- its inverse, member by member through + // decodeFieldValue, with a nested object that decodes to nothing and an + // absent member left out. A collection member decodes to its array of row + // cells. Returns null for a value that is not an object. + function decodeObjectValue(memberFields, value) { + if (!isPlainObject(value)) + return null + const cells = {} + for (let m = 0; m < memberFields.length; ++m) { + const member = memberFields[m] + const memberValue = value[member.name] + if (memberValue === undefined || memberValue === null) + continue + if (member.isObject) { + const nested = decodeObjectValue(member.objectFields, memberValue) + if (nested !== null && !objectValueBlank(nested)) + cells[member.name] = nested + continue + } + const text = decodeFieldValue(member, memberValue) + if (text !== "") + cells[member.name] = member.isObjectArray ? JSON.parse(text) : text + } + return cells + } + // Canonical decimal text (-?\d+(\.\d+)?) in the display locale, as typed. function localeDraftNumber(canonical) { return formatCanonicalNumber(canonical, { @@ -2207,8 +2400,10 @@ Frame { // `fieldText` (the retained text, kept current -- a prefill, // a reset or a rebuilt tab reaches the slot through it), // `rows` / `setRows(rows)` (a collection of objects as a JS - // array of {member: cellText}), and `form` (this form, for - // encodeFieldText and the rest of its public surface). + // array of {member: cellText}), `objectValue` / + // `setObject(obj)` (a nested object as a JS object of + // {member: cellText | nestedValue}), and `form` (this form, + // for encodeFieldText and the rest of its public surface). onLoaded: { const name = fieldColumn.modelData.name item.field = fieldColumn.modelData @@ -2227,6 +2422,13 @@ Frame { }) if ("setRows" in item) item.setRows = function (rows) { form.setFieldValue(name, JSON.stringify(rows)) } + if ("objectValue" in item) + item.objectValue = Qt.binding(function () { + form.rulesRevision + return form.objectDraft(form.fieldValues[name]) + }) + if ("setObject" in item) + item.setObject = function (value) { form.setFieldValue(name, JSON.stringify(value)) } if ("form" in item) item.form = form } diff --git a/src/qt/forms/tests/tst_DynamicFormObjectSlot.qml b/src/qt/forms/tests/tst_DynamicFormObjectSlot.qml new file mode 100644 index 000000000..400672978 --- /dev/null +++ b/src/qt/forms/tests/tst_DynamicFormObjectSlot.qml @@ -0,0 +1,608 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// A nested object member (a plain or `std::optional` struct member) drawn by +// a host slot. +// +// Without a slot the renderer has no encoding for such a member and reports it +// unrepresentable (tst_DynamicFormNestedAggregate.qml pins that, unchanged). +// With one, the slot is handed the object's member descriptors -- recursively, +// so a member that is itself an object carries its own -- writes the value as +// a `{member: cellText | nestedValue}` object, and the form encodes every leaf +// cell with the encoder the same member would get at the top level. The cases +// assert the body the controller receives, because "the slot wrote something" +// is not the claim; "the payload satisfies the schema" is. + +pragma ComponentBehavior: Bound + +import QtQuick +import QtTest +import MorphForms + +TestCase { + id: testCase + name: "DynamicFormObjectSlot" + 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, "[]") + } + } + + function init() { + mockController.submitCount = 0 + mockController.lastBody = "" + } + + // The document schemaJson() emits for + // + // struct IgnitionSpecimen { double massOfContainer = 0.0; + // double massOfContainerAndSampleBeforeIgnition = 0.0; + // std::optional readoutBinderContent; int testTemperature = 0; }; + // struct BinderIgnitionSection { IgnitionMethod method; + // IgnitionSpecimen specimen = {}; double calibrationFactor = 0.0; + // bool driedSample = false; std::optional testedByNr; }; + // + // with FieldMeta units and display decimals on the specimen's masses. The + // specimen type is used once, so glaze inlines it into the property. + property var ignitionSchema: ({ + type: "object", + "$defs": { + "double": { type: "number", minimum: -1.7976931348623157e+308, maximum: 1.7976931348623157e+308 }, + "int32_t": { type: "integer", minimum: -2147483648, maximum: 2147483647 } + }, + properties: { + method: { + type: "string", + oneOf: [{ title: "Furnace", "const": "Furnace" }, { title: "Infrared", "const": "Infrared" }], + "x-order": 0, title: "Method" + }, + specimen: { + type: "object", + properties: { + massOfContainer: { + "$ref": "#/$defs/double", "x-order": 0, title: "Container", + ExtUnits: { unitAscii: "g", unitUnicode: "g" }, "x-displayDecimals": 1 + }, + massOfContainerAndSampleBeforeIgnition: { + "$ref": "#/$defs/double", "x-order": 1, title: "Container + sample", + ExtUnits: { unitAscii: "g", unitUnicode: "g" }, "x-displayDecimals": 1 + }, + readoutBinderContent: { + anyOf: [{ "$ref": "#/$defs/double" }, { type: "null" }], + "x-order": 2, title: "Readout binder content", + ExtUnits: { unitAscii: "pct", unitUnicode: "%" }, "x-displayDecimals": 2 + }, + testTemperature: { "$ref": "#/$defs/int32_t", "x-order": 3, title: "Test Temperature" } + }, + additionalProperties: false, + required: ["massOfContainer", "massOfContainerAndSampleBeforeIgnition", "testTemperature"], + "x-order": 1, title: "Specimen" + }, + calibrationFactor: { "$ref": "#/$defs/double", "x-order": 2, title: "Calibration Factor" }, + driedSample: { type: "boolean", "x-order": 3, title: "Dried Sample" }, + testedByNr: { anyOf: [{ "$ref": "#/$defs/int32_t" }, { type: "null" }], "x-order": 4, title: "Tested By Nr" } + }, + additionalProperties: false, + required: ["method", "specimen", "calibrationFactor", "driedSample"] + }) + + // The document schemaJson() emits for + // + // struct PycnometerDetermination { std::optional massPycnometerEmpty; + // double massPycnometerAndSample = 0.0; bool excluded = false; + // std::optional computedDensity; }; + // struct PycnometerTestData { bool useSpecificGravity = false; + // std::optional testLiquidTemperature; + // std::optional testLiquidName; + // std::optional determination1; + // std::optional determination2; }; + // struct MaxDensitySection { PycnometerTestData data; }; + // + // PycnometerDetermination is used twice, so it sits under `$defs` and each + // optional member is the nullable `anyOf` over its `$ref`. + property var densitySchema: ({ + type: "object", + "$defs": { + "double": { type: "number", minimum: -1.7976931348623157e+308, maximum: 1.7976931348623157e+308 }, + "lab::PycnometerDetermination": { + type: "object", + properties: { + massPycnometerEmpty: { + anyOf: [{ "$ref": "#/$defs/double" }, { type: "null" }], + "x-order": 0, title: "Pycnometer empty", + ExtUnits: { unitAscii: "g", unitUnicode: "g" }, "x-displayDecimals": 2 + }, + massPycnometerAndSample: { + "$ref": "#/$defs/double", "x-order": 1, title: "Pycnometer + sample", + ExtUnits: { unitAscii: "g", unitUnicode: "g" }, "x-displayDecimals": 2 + }, + excluded: { type: "boolean", "x-order": 2, title: "Excluded" }, + computedDensity: { + anyOf: [{ "$ref": "#/$defs/double" }, { type: "null" }], + "x-order": 3, title: "Computed Density", "x-readonly": true + } + }, + additionalProperties: false, + required: ["massPycnometerAndSample", "excluded"] + } + }, + properties: { + data: { + type: "object", + properties: { + useSpecificGravity: { type: "boolean", "x-order": 0, title: "Use Specific Gravity" }, + testLiquidTemperature: { + anyOf: [{ "$ref": "#/$defs/double" }, { type: "null" }], + "x-order": 1, title: "Test Liquid Temperature", + ExtUnits: { unitAscii: "degC", unitUnicode: "°C" }, "x-displayDecimals": 1 + }, + testLiquidName: { type: ["string", "null"], "x-order": 2, title: "Test Liquid Name" }, + determination1: { + anyOf: [{ "$ref": "#/$defs/lab::PycnometerDetermination" }, { type: "null" }], + "x-order": 3, title: "Determination 1" + }, + determination2: { + anyOf: [{ "$ref": "#/$defs/lab::PycnometerDetermination" }, { type: "null" }], + "x-order": 4, title: "Determination 2" + } + }, + additionalProperties: false, + required: ["useSpecificGravity"], + "x-order": 0, title: "Data" + } + }, + additionalProperties: false, + required: ["data"] + }) + + // A collection of objects inside a nested object: the row encoder is + // reused for it. `struct Row { double sieve; Quantity passing; }; + // struct Grading { std::string label; std::vector rows; }; + // struct GradingSection { Grading grading; };` + property var gradingSchema: ({ + type: "object", + "$defs": { + "Row": { + type: "object", + properties: { + sieve: { type: "number", "x-order": 0, title: "Sieve" }, + passing: { + type: ["object", "null"], + properties: { num: { type: "integer" }, den: { type: "integer" }, dp: { type: "integer" } }, + ExtUnits: { unitAscii: "pct", unitUnicode: "%" }, "x-decimalPlaces": 1, + "x-order": 1, title: "Passing" + } + }, + required: ["sieve", "passing"] + } + }, + properties: { + grading: { + type: "object", + properties: { + label: { type: "string", "x-order": 0, title: "Label" }, + rows: { type: "array", items: { "$ref": "#/$defs/Row" }, "x-order": 1, title: "Rows" } + }, + required: ["label", "rows"], + "x-order": 0, title: "Grading" + } + }, + required: ["grading"] + }) + + // A self-referential type below a claimed object: its description stops + // at the first repetition of the type instead of looping. + property var treeSchema: ({ + type: "object", + "$defs": { + "TreeNode": { + type: "object", + properties: { + name: { type: "string", "x-order": 0, title: "Name" }, + child: { anyOf: [{ "$ref": "#/$defs/TreeNode" }, { type: "null" }], "x-order": 1, title: "Child" } + }, + required: ["name"] + } + }, + properties: { root: { "$ref": "#/$defs/TreeNode", "x-order": 0, title: "Root" } }, + required: ["root"] + }) + + // A sub-form slot: it declares the whole optional contract. + Component { + id: objectSlot + Item { + objectName: "objectSlot" + property var field + property var setValue + property var objectValue: ({}) + property var setObject + property string fieldText + property var form + } + } + + // A slot declaring only the two mandatory members. + Component { + id: minimalSlot + Item { + objectName: "minimalSlot" + property var field + property var setValue + } + } + + Component { + id: registryComponent + SlotRegistry {} + } + + Component { + id: ignitionForm + DynamicForm { actionType: "T_Ignition"; schema: testCase.ignitionSchema; controller: mockController } + } + + Component { + id: densityForm + DynamicForm { actionType: "T_Density"; schema: testCase.densitySchema; controller: mockController } + } + + Component { + id: gradingForm + DynamicForm { actionType: "T_Grading"; schema: testCase.gradingSchema; controller: mockController } + } + + Component { + id: treeForm + DynamicForm { actionType: "T_Tree"; schema: testCase.treeSchema; controller: mockController } + } + + function formWithSlot(formComponent, action, member, slotComponent, properties) { + const registry = createTemporaryObject(registryComponent, testCase) + registry.byField(action, member, slotComponent) + return createTemporaryObject(formComponent, testCase, + Object.assign({ slotRegistry: registry }, properties || {})) + } + + function ignitionWithSlot(properties) { + return formWithSlot(ignitionForm, "T_Ignition", "specimen", objectSlot, properties) + } + + function densityWithSlot(properties) { + return formWithSlot(densityForm, "T_Density", "data", objectSlot, properties) + } + + // The top-level scalars of the ignition section, through their controls. + function fillIgnitionScalars(form) { + form.setFieldValue("method", '"Furnace"') + form.setFieldValue("calibrationFactor", "0.25") + form.setFieldValue("driedSample", "true") + } + + // ── without a slot: unchanged ──────────────────────────────────────────── + + function test_without_a_slot_the_object_stays_unrepresentable() { + const form = createTemporaryObject(ignitionForm, testCase) + const specimen = form.fieldByName["specimen"] + compare(specimen.isObject, true) + compare(specimen.kind, "object") + compare(specimen.claimedBySlot, false) + verify(specimen.unrepresentable !== "") + fillIgnitionScalars(form) + findChild(form, "field_specimen").text = '{"massOfContainer":"1"}' + compare(form.ready, false) + verify(form.unrepresentableReason.indexOf("specimen: ") === 0) + compare(mockController.submitCount, 0) + } + + // A Quantity is an object with properties too; its own control claims it. + function test_a_quantity_is_not_a_nested_object() { + const form = createTemporaryObject(gradingForm, testCase) + const passing = form.fieldByName["grading"].objectFields[1].itemFields[1] + compare(passing.isQuantity, true) + compare(passing.isObject, false) + compare(passing.objectFields.length, 0) + } + + // ── the claim ──────────────────────────────────────────────────────────── + + function test_a_slot_claims_the_object_and_makes_it_representable() { + const form = ignitionWithSlot() + const slot = findChild(form, "objectSlot") + verify(slot !== null) + compare(slot.field.name, "specimen") + compare(slot.field.isObject, true) + compare(slot.field.claimedBySlot, true) + compare(slot.field.unrepresentable, "") + verify(slot.form === form) + } + + function test_a_slot_registered_by_kind_claims_the_object() { + const registry = createTemporaryObject(registryComponent, testCase) + registry.byKind("object", objectSlot) + const form = createTemporaryObject(densityForm, testCase, { slotRegistry: registry }) + const slot = findChild(form, "objectSlot") + verify(slot !== null) + compare(slot.field.name, "data") + compare(slot.field.claimedBySlot, true) + compare(slot.field.unrepresentable, "") + } + + function test_a_slot_registered_after_the_form_is_built_is_picked_up() { + const registry = createTemporaryObject(registryComponent, testCase) + const form = createTemporaryObject(ignitionForm, testCase, { slotRegistry: registry }) + verify(form.fieldByName["specimen"].unrepresentable !== "") + registry.byField("T_Ignition", "specimen", objectSlot) + compare(form.fieldByName["specimen"].unrepresentable, "") + tryVerify(function () { return findChild(form, "objectSlot") !== null }) + } + + // ── the descriptors a sub-form draws from ──────────────────────────────── + + function test_object_fields_carry_order_labels_units_and_decimals() { + const form = ignitionWithSlot() + const members = findChild(form, "objectSlot").field.objectFields + compare(members.length, 4) + compare(members[0].name, "massOfContainer") + compare(members[0].label, "Container") + compare(members[0].unit, "g") + compare(members[0].isNumber, true) + compare(members[0].decimals, 1) + compare(members[0].decimalsDeclared, true) + compare(members[0].required, true) + compare(members[1].name, "massOfContainerAndSampleBeforeIgnition") + compare(members[2].name, "readoutBinderContent") + compare(members[2].unit, "%") + compare(members[2].decimals, 2) + compare(members[2].required, false) + compare(members[3].isInteger, true) + compare(members[3].required, true) + } + + function test_object_fields_recurse_into_optional_nested_objects() { + const form = densityWithSlot() + const data = findChild(form, "objectSlot").field + compare(data.objectFields.map(function (m) { return m.name }).join(","), + "useSpecificGravity,testLiquidTemperature,testLiquidName,determination1,determination2") + const determination = data.objectFields[3] + compare(determination.isObject, true) + compare(determination.required, false) + compare(determination.unrepresentable, "") + compare(determination.label, "Determination 1") + compare(determination.objectFields.length, 4) + const sample = determination.objectFields[1] + compare(sample.name, "massPycnometerAndSample") + compare(sample.label, "Pycnometer + sample") + compare(sample.unit, "g") + compare(sample.decimals, 2) + compare(sample.required, true) + compare(determination.objectFields[3].readOnly, true) + // Both uses of the shared $defs entry are described alike. + compare(data.objectFields[4].objectFields.length, 4) + } + + function test_a_self_referential_type_is_described_once_and_stops() { + const form = formWithSlot(treeForm, "T_Tree", "root", objectSlot) + const root = form.fieldByName["root"] + compare(root.objectFields.length, 2) + const child = root.objectFields[1] + compare(child.isObject, true) + compare(child.objectFields.length, 0) + verify(child.unrepresentable !== "") + // The repeated type has no encoding, but an absent optional one is fine. + findChild(form, "objectSlot").setObject({ name: "top" }) + compare(mockController.lastBody, '{"root":{"name":"top"}}') + findChild(form, "objectSlot").setObject({ name: "top", child: { name: "leaf" } }) + compare(form.ready, false) + } + + // ── encoding ───────────────────────────────────────────────────────────── + + function test_the_binder_ignition_section_encodes_member_by_member() { + const form = ignitionWithSlot() + fillIgnitionScalars(form) + compare(form.ready, false) // the required specimen is still blank + findChild(form, "objectSlot").setObject({ + massOfContainer: "512.3", massOfContainerAndSampleBeforeIgnition: "2012.8", + readoutBinderContent: "5.25", testTemperature: "538" + }) + compare(form.ready, true) + compare(mockController.lastBody, + '{"method":"Furnace","specimen":{"massOfContainer":512.3,' + + '"massOfContainerAndSampleBeforeIgnition":2012.8,"readoutBinderContent":5.25,' + + '"testTemperature":538},"calibrationFactor":0.25,"driedSample":true}') + } + + function test_the_max_density_section_encodes_two_levels_deep() { + const form = densityWithSlot() + findChild(form, "objectSlot").setObject({ + useSpecificGravity: "false", testLiquidTemperature: "25.0", testLiquidName: "water", + determination1: { massPycnometerEmpty: "1450.10", massPycnometerAndSample: "3450.25", excluded: "false" } + }) + compare(form.ready, true) + compare(mockController.lastBody, + '{"data":{"useSpecificGravity":false,"testLiquidTemperature":25.0,"testLiquidName":"water",' + + '"determination1":{"massPycnometerEmpty":1450.10,"massPycnometerAndSample":3450.25,' + + '"excluded":false}}}') + } + + function test_blank_optional_leaves_and_objects_are_omitted() { + const form = densityWithSlot() + findChild(form, "objectSlot").setObject({ + useSpecificGravity: "true", testLiquidTemperature: "", testLiquidName: " ", + determination1: {}, determination2: { massPycnometerEmpty: "", excluded: "" } + }) + compare(form.ready, true) + compare(mockController.lastBody, '{"data":{"useSpecificGravity":true}}') + } + + function test_a_blank_required_leaf_leaves_the_form_unready() { + const form = ignitionWithSlot() + fillIgnitionScalars(form) + const slot = findChild(form, "objectSlot") + slot.setObject({ massOfContainer: "1.0", massOfContainerAndSampleBeforeIgnition: "2.0" }) + compare(form.ready, false) + compare(form.previewLine, "") + compare(mockController.submitCount, 0) + } + + function test_a_partly_filled_optional_object_needs_its_required_members() { + const form = densityWithSlot() + const slot = findChild(form, "objectSlot") + slot.setObject({ useSpecificGravity: "false", determination2: { massPycnometerEmpty: "10.00" } }) + compare(form.ready, false) + slot.setObject({ useSpecificGravity: "false", + determination2: { massPycnometerEmpty: "10.00", massPycnometerAndSample: "20.00", + excluded: "true" } }) + compare(form.ready, true) + compare(mockController.lastBody, + '{"data":{"useSpecificGravity":false,"determination2":{"massPycnometerEmpty":10.00,' + + '"massPycnometerAndSample":20.00,"excluded":true}}}') + } + + function test_a_blank_required_object_leaves_the_form_unready() { + const form = densityWithSlot() + const slot = findChild(form, "objectSlot") + slot.setObject({}) + compare(form.ready, false) + slot.setObject({ testLiquidName: "" }) + compare(form.ready, false) + compare(mockController.submitCount, 0) + } + + function test_a_cell_that_does_not_encode_leaves_the_form_unready() { + const form = ignitionWithSlot() + fillIgnitionScalars(form) + const slot = findChild(form, "objectSlot") + const specimen = { massOfContainerAndSampleBeforeIgnition: "2.0", testTemperature: "538" } + // More fraction digits than x-displayDecimals is refused, not rounded. + slot.setObject(Object.assign({ massOfContainer: "1.25" }, specimen)) + compare(form.ready, false) + slot.setObject(Object.assign({ massOfContainer: "abc" }, specimen)) + compare(form.ready, false) + slot.setObject(Object.assign({ massOfContainer: "1.2" }, specimen, { testTemperature: "1.5" })) + compare(form.ready, false) + slot.setObject(Object.assign({ massOfContainer: "1.2" }, specimen)) + compare(form.ready, true) + } + + function test_cells_are_read_in_the_display_locale() { + const form = ignitionWithSlot({ displayLocale: "de_DE" }) + form.setFieldValue("method", '"Furnace"') + form.setFieldValue("calibrationFactor", "0,25") + form.setFieldValue("driedSample", "true") + findChild(form, "objectSlot").setObject({ + massOfContainer: "1.512,3", massOfContainerAndSampleBeforeIgnition: "2012,8", + readoutBinderContent: "5,25", testTemperature: "538" + }) + compare(form.ready, true) + compare(mockController.lastBody, + '{"method":"Furnace","specimen":{"massOfContainer":1512.3,' + + '"massOfContainerAndSampleBeforeIgnition":2012.8,"readoutBinderContent":5.25,' + + '"testTemperature":538},"calibrationFactor":0.25,"driedSample":true}') + } + + function test_a_value_that_is_not_an_object_has_no_literal() { + const form = ignitionWithSlot() + fillIgnitionScalars(form) + const slot = findChild(form, "objectSlot") + slot.setValue("[1, 2]") + compare(form.ready, false) + slot.setValue("not json") + compare(form.ready, false) + slot.setValue('"text"') + compare(form.ready, false) + compare(mockController.submitCount, 0) + } + + function test_rows_inside_a_claimed_object_reuse_the_row_encoder() { + const form = formWithSlot(gradingForm, "T_Grading", "grading", objectSlot) + const grading = form.fieldByName["grading"] + compare(grading.objectFields[1].isObjectArray, true) + compare(grading.objectFields[1].itemFields.length, 2) + compare(grading.objectFields[1].unrepresentable, "") + const slot = findChild(form, "objectSlot") + slot.setObject({ label: "A", rows: [{ sieve: "8", passing: "55.0" }, { sieve: "2" }] }) + compare(form.ready, false) // the second row's required cell is blank + slot.setObject({ label: "A", rows: [{ sieve: "8", passing: "55.0" }] }) + compare(mockController.lastBody, + '{"grading":{"label":"A","rows":[{"sieve":8,"passing":{"num":550,"den":10,"dp":1}}]}}') + slot.setObject({ label: "A", rows: [] }) + compare(mockController.lastBody, '{"grading":{"label":"A","rows":[]}}') + } + + function test_a_slot_declaring_only_the_mandatory_contract_still_works() { + const form = formWithSlot(densityForm, "T_Density", "data", minimalSlot) + const slot = findChild(form, "minimalSlot") + verify(slot !== null) + slot.setValue(JSON.stringify({ useSpecificGravity: "true" })) + compare(mockController.lastBody, '{"data":{"useSpecificGravity":true}}') + } + + // ── reading the value back ─────────────────────────────────────────────── + + function test_the_slot_reads_back_what_it_wrote_and_a_reset_clears_it() { + const form = densityWithSlot() + const slot = findChild(form, "objectSlot") + slot.setObject({ useSpecificGravity: "true", determination1: { excluded: "true" } }) + compare(slot.objectValue.useSpecificGravity, "true") + compare(slot.objectValue.determination1.excluded, "true") + form.resetFields() + compare(Object.keys(slot.objectValue).length, 0) + compare(slot.fieldText, "") + compare(form.ready, false) + } + + function test_a_prefill_decodes_into_the_object_value_and_round_trips() { + const form = densityWithSlot() + const stored = '{"data":{"useSpecificGravity":false,"testLiquidTemperature":25.5,' + + '"determination1":{"massPycnometerEmpty":1450.1,"massPycnometerAndSample":3450.25,' + + '"excluded":true,"computedDensity":2.412}}}' + verify(form.prefillFromJson(stored)) + compare(mockController.submitCount, 0) // a prefill never submits + const slot = findChild(form, "objectSlot") + compare(slot.objectValue.useSpecificGravity, "false") + compare(slot.objectValue.testLiquidTemperature, "25.5") + // Padded to the declared display decimals, as the built-in control is. + compare(slot.objectValue.determination1.massPycnometerEmpty, "1450.10") + compare(slot.objectValue.determination1.excluded, "true") + compare(slot.objectValue.determination2, undefined) + compare(form.ready, true) + form.submit() + compare(mockController.lastBody, + '{"data":{"useSpecificGravity":false,"testLiquidTemperature":25.5,' + + '"determination1":{"massPycnometerEmpty":1450.10,"massPycnometerAndSample":3450.25,' + + '"excluded":true,"computedDensity":2.412}}}') + } + + function test_a_prefill_uses_the_display_locale_for_cells() { + const form = ignitionWithSlot({ displayLocale: "de_DE" }) + verify(form.prefill({ + method: "Infrared", calibrationFactor: 0.5, driedSample: false, + specimen: { massOfContainer: 512.3, massOfContainerAndSampleBeforeIgnition: 2012.8, testTemperature: 538 } + })) + const slot = findChild(form, "objectSlot") + compare(slot.objectValue.massOfContainer, "512,3") + compare(slot.objectValue.readoutBinderContent, undefined) + compare(form.ready, true) + form.submit() + compare(mockController.lastBody, + '{"method":"Infrared","specimen":{"massOfContainer":512.3,' + + '"massOfContainerAndSampleBeforeIgnition":2012.8,"testTemperature":538},' + + '"calibrationFactor":0.5,"driedSample":false}') + } +} diff --git a/tests/test_nested_forms.cpp b/tests/test_nested_forms.cpp index 6762ee3cd..c8a3b14c4 100644 --- a/tests/test_nested_forms.cpp +++ b/tests/test_nested_forms.cpp @@ -341,21 +341,72 @@ struct BareQuantityRecord { BareQuantitySub bare; }; +// The two lab-section shapes a host slot draws: a required nested object, and +// a nested object whose own members are *optional* nested objects (depth 2). +// glaze spells `std::optional` as an `anyOf` over Sub's schema and null, +// which the recursion has to see through for Sub's FieldMeta to be emitted. +struct IgnitionSpecimen { + double massOfContainer = 0.0; + std::optional readoutBinderContent; + int testTemperature = 0; + + static constexpr std::array fieldMetadata{ + morph::forms::FieldMeta{.field = "massOfContainer", .unit = "g", .decimals = DecimalPlaces{1}}, + }; +}; + +struct BinderIgnitionSection { + IgnitionSpecimen specimen = {}; + double calibrationFactor = 0.0; +}; + +struct PycnometerDetermination { + std::optional massPycnometerEmpty; + double massPycnometerAndSample = 0.0; + bool excluded = false; + + static constexpr std::array fieldMetadata{ + morph::forms::FieldMeta{.field = "massPycnometerAndSample", + .label = "Pycnometer + sample", + .unit = "g", + .decimals = DecimalPlaces{2}}, + }; +}; + +struct PycnometerTestData { + bool useSpecificGravity = false; + std::optional testLiquidTemperature; + std::optional determination1; + std::optional determination2; +}; + +struct MaxDensitySection { + PycnometerTestData data; +}; + +// PycnometerDetermination used exactly once, through the optional. +struct SingleOptionalRecord { + std::optional only; +}; + } // namespace nestedforms using nestedforms::Attachment; using nestedforms::Ay; using nestedforms::BareQuantityRecord; +using nestedforms::BinderIgnitionSection; using nestedforms::DeclaredOptionalRecord; using nestedforms::DeepChain; using nestedforms::DeepRecord; using nestedforms::DeepSpecimen; using nestedforms::kDeepChainLevels; +using nestedforms::MaxDensitySection; using nestedforms::Origin; using nestedforms::PlainMetaRecord; using nestedforms::Provenance; using nestedforms::Record; using nestedforms::RichRecord; +using nestedforms::SingleOptionalRecord; using nestedforms::SingleUseRecord; using nestedforms::SingleUseVectorRecord; using nestedforms::Specimen; @@ -933,3 +984,74 @@ TEST_CASE("Forms::SchemaJson::NestedAggregate: a deep acyclic chain compiles and REQUIRE(leafOrder != nullptr); CHECK(leafOrder->get() == 0); } + +// ── std::optional: the object schema is inside the anyOf ─────────────── + +namespace { + +// The non-null branch of the `anyOf` glaze writes for a `std::optional`. +const glz::generic_u64& nonNullBranch(const glz::generic_u64& property MORPH_LIFETIMEBOUND) { + REQUIRE(property.contains("anyOf")); + auto const& branches = property["anyOf"].get(); + auto const found = std::find_if(branches.begin(), branches.end(), [](const glz::generic_u64& branch) { + auto const* const type = branch.contains("type") ? branch["type"].get_if() : nullptr; + return type == nullptr || *type != "null"; + }); + REQUIRE(found != branches.end()); + return *found; +} + +} // namespace + +TEST_CASE("Forms::SchemaJson::NestedAggregate: a required nested member's own FieldMeta unit and decimals are emitted", + "[forms][nested]") { + auto const schema = morph::forms::schemaJson(); + glz::generic_u64 dom{}; + REQUIRE_FALSE(glz::read_json(dom, schema)); + + auto const& def = resolveNestedSchema(dom, dom["properties"]["specimen"]); + auto const& mass = def["properties"]["massOfContainer"]; + CHECK(mass["ExtUnits"]["unitAscii"].get() == "g"); + CHECK(mass["x-displayDecimals"].as() == 1); + auto const requiredNames = requiredNamesOf(def); + CHECK(std::find(requiredNames.begin(), requiredNames.end(), "massOfContainer") != requiredNames.end()); + CHECK(std::find(requiredNames.begin(), requiredNames.end(), "readoutBinderContent") == requiredNames.end()); +} + +TEST_CASE("Forms::SchemaJson::NestedAggregate: an optional nested member two levels down is annotated ($ref form)", + "[forms][nested]") { + auto const schema = morph::forms::schemaJson(); + glz::generic_u64 dom{}; + REQUIRE_FALSE(glz::read_json(dom, schema)); + + auto const& data = resolveNestedSchema(dom, dom["properties"]["data"]); + auto const dataRequired = requiredNamesOf(data); + CHECK(std::find(dataRequired.begin(), dataRequired.end(), "determination1") == dataRequired.end()); + CHECK(data["properties"]["determination2"]["x-order"].as() == 3); + + // Used twice, so the optional's non-null branch is a $ref into $defs. + auto const& branch = nonNullBranch(data["properties"]["determination1"]); + REQUIRE(branch.contains("$ref")); + auto const& def = resolveNestedSchema(dom, branch); + CHECK(def["properties"]["massPycnometerEmpty"]["x-order"].as() == 0); + CHECK(def["properties"]["massPycnometerEmpty"]["title"].get() == "Mass Pycnometer Empty"); + auto const& sample = def["properties"]["massPycnometerAndSample"]; + CHECK(sample["title"].get() == "Pycnometer + sample"); + CHECK(sample["ExtUnits"]["unitUnicode"].get() == "g"); + CHECK(sample["x-displayDecimals"].as() == 2); + CHECK(requiredNamesOf(def) == std::vector{"massPycnometerAndSample", "excluded"}); +} + +TEST_CASE("Forms::SchemaJson::NestedAggregate: a singly-used optional nested member is annotated in its anyOf branch", + "[forms][nested]") { + auto const schema = morph::forms::schemaJson(); + glz::generic_u64 dom{}; + REQUIRE_FALSE(glz::read_json(dom, schema)); + + auto const& branch = nonNullBranch(dom["properties"]["only"]); + CHECK_FALSE(branch.contains("$ref")); // used once -> inlined into the branch + auto const& def = resolveNestedSchema(dom, branch); + CHECK(def["properties"]["excluded"]["x-order"].as() == 2); + CHECK(def["properties"]["massPycnometerAndSample"]["x-displayDecimals"].as() == 2); + REQUIRE(def.contains("required")); +}