From c963e4582f8492f82df96db4bc97ae57853328c4 Mon Sep 17 00:00:00 2001 From: yaraslau Date: Thu, 24 Sep 2026 23:47:51 +0200 Subject: [PATCH] forms(qml): prefill(values) / prefillFromJson(text) -- load a stored record into DynamicForm for editing (fixes #814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every prefill path wrote *control text*: CollectionView sets a `field_` TextField, WizardView calls setFieldValue with text. An editing flow -- open a saved sample, read its section DTO, prefill the form, edit, submit -- had to turn each wire value into the text its control holds by hand: a {num,den,dp} into locale digits at the declared precision, an ISO instant into the display zone, a closed-set value into its valueJson, and every row of a std::vector into cell texts, the inverse of the encoder #809 added. `decodeFieldValue(field, value)` is that inverse, kind by kind, on exact digits (a Quantity's num/den are divided as digit strings, an int64 id from JsonExact.parse stays exact). `prefill(values)` replaces the whole draft from a payload object, resets unit selectors to the canonical unit, bumps `prefillRevision` so every drawn control re-seeds (a fetched Choice also re-selects when its options arrive -- before this it showed "— select —" for a retained value), re-fetches dependent Choices, and revalidates *inside* the programmaticEdit window. That last point is measured, not assumed: the first version reused withoutAutoSubmit, whose final revalidate runs after the suppression is lifted, and the round-trip case caught the ready prefilled form submitting itself. `prefillFromJson(text)` parses with JsonExact. tst_DynamicFormPrefill.qml (11 cases) asserts the round trip against previewLine for every member kind, the drawn controls and slots holding the values, de_DE and a UTC+2 display zone, a later edit, replacement semantics, a fetched Choice with an id past 2^53, and no submission. Removing the re-seed reddens 5 cases; a wrong Quantity decoder reddens 8. forms_qml_logic: 348 passed on MSVC 14.51 / Qt 6.11.1. Stacked on #809 (the rows / fieldText slot contract and itemFields it decodes into). Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 11 + docs/spec/forms/forms.md | 40 +++ src/qt/forms/qml/DynamicForm.qml | 229 +++++++++++++++ src/qt/forms/tests/tst_DynamicFormPrefill.qml | 276 ++++++++++++++++++ 4 files changed, 556 insertions(+) create mode 100644 src/qt/forms/tests/tst_DynamicFormPrefill.qml diff --git a/CHANGELOG.md b/CHANGELOG.md index cc526007d..c5856f026 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -119,6 +119,17 @@ API surface). ### Added +- **`DynamicForm.prefill(values)` / `prefillFromJson(text)` — load a stored + record for editing.** Every prefill path wrote *control text*, so an editing + flow had to turn each wire value into the text its control holds by hand — + a `{num,den,dp}` into locale digits, an ISO instant into the display zone, a + row of a `std::vector` into cell texts. `decodeFieldValue(field, value)` + is now the inverse of the encoders, and `prefill` replaces the draft from a + payload, re-seeds every control and slot (`fieldText`, `rows`), re-selects + fetched `Choice`s and never submits. Prefilling and editing nothing + assembles the same payload. See `docs/spec/forms/forms.md`, "Prefill" + (fixes #814). + - **A host slot can draw a `std::vector` member, and the form encodes it.** A collection of objects had no built-in control and was reported unrepresentable, so a form carrying one could not be submitted whatever the diff --git a/docs/spec/forms/forms.md b/docs/spec/forms/forms.md index 9163e2700..c4b5d3efd 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -1293,6 +1293,46 @@ which is what `maximum: Infinity` already meant, and matches JSON Schema giving a null numeric keyword no meaning. Without that, a null bound would read as the bound `0` and reject every positive value. +### Prefill — loading a stored payload for editing + +An editing flow opens a saved record, reads its DTO and wants the form to show +it. `DynamicForm.prefill(values)` takes the payload as an object keyed by wire +name (a parsed action or section DTO); `prefillFromJson(text)` takes its JSON +and parses it with `JsonExact`, so an id past 2^53 arrives digit for digit. +Either replaces the whole draft — a member absent from the payload starts +blank, as after `resetFields()` — and returns `false`, changing nothing, for +input that is not an object. + +Each value becomes the text the built-in control for that member would hold, +through `decodeFieldValue(field, value)`, the inverse of `encodeFieldText`: + +| Member | Wire value | Draft text | +|---|---|---| +| `Quantity` | `{num,den,dp}` | exact digits at the field's canonical `x-decimalPlaces`, rounded half-up, in the display locale (`2450.50`, `2450,50` in `de_DE`); the unit selector returns to the canonical unit | +| plain number | JSON number | never exponent form; padded to `x-displayDecimals` when declared, never rounded to it | +| integer | JSON integer | its exact digits | +| `Timestamp` | ISO-8601 (zone designator optional, read as UTC when absent) | wall clock in `displayOffsetMinutes` | +| `boolean` | `true`/`false` | `"true"`/`"false"` | +| closed set / `Choice` | the value | its `valueJson` | +| `std::vector` | array | entries joined by `", "` | +| `std::vector` | array of objects | the rows as `{member: cellText}`, each cell decoded by the row member's own descriptor | +| string | string | itself | + +A value whose shape does not match its field decodes to `""` (blank). The +round trip is the contract: prefilling a form from a payload and editing +nothing assembles the same payload, in the canonical spelling the encoders +produce. Every drawn control re-seeds from the new draft (`prefillRevision`), +a fetched `Choice` re-selects its row whenever its options arrive, dependent +`Choice`s are re-fetched for their prefilled parents, and slots see the values +through `fieldText` / `rows`. + +**A prefill never submits**, in auto-submit mode included: the final +revalidation runs inside the `programmaticEdit` window, so a ready prefilled +form waits for the user. `src/qt/forms/tests/tst_DynamicFormPrefill.qml` pins +the round trip for every member kind, locale and zone, slots, the fetched +`Choice`, and the no-submit rule; removing the control re-seed reddens 5 of its +11 cases and a wrong `Quantity` decoder 8. + ### What `ready` claims `DynamicForm.ready` is a claim about the **payload**: `true` only when the body diff --git a/src/qt/forms/qml/DynamicForm.qml b/src/qt/forms/qml/DynamicForm.qml index 3f2065447..391ed13a3 100644 --- a/src/qt/forms/qml/DynamicForm.qml +++ b/src/qt/forms/qml/DynamicForm.qml @@ -117,6 +117,10 @@ Frame { // counter, not a flag, so nested writes (a reset that itself triggers // refreshDependents) cannot re-enable submission early. property int programmaticEdit: 0 + + // Bumped by prefill(): every drawn control re-reads its value from + // fieldValues, exactly as it does when a tab switch recreates it. + property int prefillRevision: 0 property string previewLine: "" property string resultText: "" property bool resultOk: true @@ -1655,6 +1659,174 @@ Frame { }) } + // --- prefill: a stored payload back into the draft --------------------- + + // The draft text a built-in control would hold for the wire value `value` + // of field `f` -- the inverse of encodeFieldText, so that + // encodeFieldText(f, decodeFieldValue(f, v), 0) re-encodes `v`. `value` is + // parsed JSON, ideally from JsonExact.parse so an integer past 2^53 is + // still exact. Numbers come out in the display locale (decimal separator + // and digits, no grouping) and a Timestamp in the display zone, because + // that is what the form's own entry path reads. Returns "" for an absent + // or null value, and for one whose shape does not match the field's. + function decodeFieldValue(f, value) { + if (value === undefined || value === null) + return "" + if (f.isObjectArray) { + if (!Array.isArray(value)) + return "" + const rows = [] + for (let r = 0; r < value.length; ++r) { + const row = value[r] + const cells = {} + if (row !== null && typeof row === "object" && !JsonExact.isExact(row)) { + for (let m = 0; m < f.itemFields.length; ++m) { + const member = f.itemFields[m] + const cell = decodeFieldValue(member, row[member.name]) + if (cell !== "") + cells[member.name] = cell + } + } + rows.push(cells) + } + return JSON.stringify(rows) + } + if (f.isArray) + return Array.isArray(value) ? value.map(function (item) { return JsonExact.text(item) }).join(", ") : "" + if (f.isEnum || f.isChoice) + return JsonExact.literal(value) + if (f.isDateTime) + return typeof value === "string" ? utcIsoToZoned(value, displayOffsetMinutes) : "" + if (f.isQuantity) + return quantityDraftText(value, f.canonDp) + if (f.isBoolean) + return value === true ? "true" : (value === false ? "false" : "") + if (f.isInteger) + return (JsonExact.isExact(value) || typeof value === "number") ? JsonExact.text(value) : "" + if (f.isNumber) + return numberDraftText(value, f.displayDecimals) + return typeof value === "string" ? value : "" + } + + // Canonical decimal text (-?\d+(\.\d+)?) in the display locale, as typed. + function localeDraftNumber(canonical) { + return formatCanonicalNumber(canonical, { + decimalSeparator: qtLocale.decimalPoint, + groupSeparator: "", + negativeSign: qtLocale.negativeSign, + zeroDigit: qtLocale.zeroDigit + }) + } + + // A {num,den,dp} Quantity node as draft text at `dp` fraction digits + // (the field's canonical precision), rounded half-up on exact digits. + function quantityDraftText(node, dp) { + if (node === null || typeof node !== "object" || node.num === undefined || node.den === undefined) + return "" + const numText = JsonExact.text(node.num) + const den = Number(JsonExact.text(node.den)) + if (!/^-?\d+$/.test(numText) || !(den > 0) || den > 1e14 || Math.floor(den) !== den) + return "" + const neg = numText.startsWith("-") + const digits = divRoundDigits((neg ? numText.slice(1) : numText) + "0".repeat(dp), den) + let canonical = digits + if (dp > 0) { + const padded = digits.padStart(dp + 1, "0") + canonical = padded.slice(0, -dp) + "." + padded.slice(-dp) + } + const isZero = /^[0.]*$/.test(canonical) + return localeDraftNumber((neg && !isZero ? "-" : "") + canonical) + } + + // A plain JSON number as draft text: never in exponent form, padded to + // a declared display precision, never rounded to it -- a stored value + // finer than that stays visible, and the entry gate then says so. + function numberDraftText(value, displayDecimals) { + if (JsonExact.isExact(value)) + return localeDraftNumber(JsonExact.text(value)) + if (typeof value !== "number" || !isFinite(value)) + return "" + let canonical = String(value) + if (/e/i.test(canonical)) + canonical = value.toFixed(20).replace(/\.?0+$/, "") + if (displayDecimals !== undefined) { + const fraction = canonical.split(".")[1] || "" + if (fraction.length < displayDecimals) + canonical = (fraction === "" ? canonical + (displayDecimals > 0 ? "." : "") : canonical) + + "0".repeat(displayDecimals - fraction.length) + } + return localeDraftNumber(canonical) + } + + // An ISO-8601 instant as the display zone's wall clock + // ("YYYY-MM-DDTHH:MM:SS"), the inverse of zonedToUtcIso. Text with no zone + // designator is read as UTC, which is what a Timestamp serialises to. + function utcIsoToZoned(text, offsetMinutes) { + const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/.exec(text) + if (!m) + return "" + let zoneMinutes = 0 + if (m[7] !== undefined && m[7] !== "Z") { + const zone = m[7].replace(":", "") + zoneMinutes = (zone.charAt(0) === "-" ? -1 : 1) + * (parseInt(zone.slice(1, 3)) * 60 + parseInt(zone.slice(3, 5))) + } + const utcMillis = Date.UTC(parseInt(m[1]), parseInt(m[2]) - 1, parseInt(m[3]), parseInt(m[4]), + parseInt(m[5]), m[6] === undefined ? 0 : parseInt(m[6])) + - zoneMinutes * 60000 + const d = new Date(utcMillis + offsetMinutes * 60000) + const pad = (v, w) => String(v).padStart(w, "0") + return pad(d.getUTCFullYear(), 4) + "-" + pad(d.getUTCMonth() + 1, 2) + "-" + pad(d.getUTCDate(), 2) + + "T" + pad(d.getUTCHours(), 2) + ":" + pad(d.getUTCMinutes(), 2) + ":" + pad(d.getUTCSeconds(), 2) + } + + // Loads a stored payload -- an object keyed by wire name, e.g. a parsed + // action or section DTO -- into the form for editing. Replaces the whole + // draft (a member absent from `values` starts blank, as after + // resetFields), resets every unit selector to the canonical unit, re-seeds + // every drawn control and every slot, re-fetches dependent Choices, and + // never submits: prefilling is not a user action. Returns false, changing + // nothing, when `values` is not an object. + function prefill(values) { + if (values === null || typeof values !== "object" || Array.isArray(values) || JsonExact.isExact(values)) + return false + // Not withoutAutoSubmit: that revalidates *after* lifting the + // suppression, and a prefilled form is usually ready, so the final + // pass would submit the record the user only opened. + form.programmaticEdit++ + try { + const draft = {} + for (let i = 0; i < form.fields.length; ++i) { + const f = form.fields[i] + const text = form.decodeFieldValue(f, values[f.name]) + if (text !== "") + draft[f.name] = text + } + form.fieldValues = draft + form.fieldUnits = ({}) + form.prefillRevision++ + for (const parentName in form.dependents) + form.refreshDependents(parentName) + form.revalidate() + } finally { + form.programmaticEdit-- + } + return true + } + + // prefill() over JSON text, parsed exactly (JsonExact), so an id past + // 2^53 reaches the form digit for digit. Returns false for text that does + // not parse to an object. + function prefillFromJson(jsonText) { + let parsed + try { + parsed = JsonExact.parse(String(jsonText)) + } catch (ignored) { + return false + } + return form.prefill(parsed) + } + // The JSON body to send a Choice field's options action: {parentName: // value, ...} built from the current values of its declared parents // (x-optionsDependsOn). Returns null when any parent is not yet engaged @@ -1820,6 +1992,59 @@ Frame { font.pixelSize: 12 } + // prefill() rewrote fieldValues: re-read it into every control of + // this field, the way each already does when it is created. A + // fetched Choice (combo or radio group) also re-selects whenever + // its options arrive, since the prefilled value may predate them. + function reseedFromDraft() { + form.withoutAutoSubmit(function () { + const name = fieldColumn.modelData.name + const retained = form.opt(form.fieldValues[name], "") + entry.text = retained + arrayEntry.text = retained + notesArea.text = retained + if (fieldColumn.modelData.isDateTime) + dateTimeEntry.text = retained + if (fieldColumn.modelData.isBoolean) { + // The rule the CheckBox's creation applies: a required + // box always shows a state, so it holds one. + if (retained === "" && fieldColumn.modelData.required) + form.setFieldValue(name, "false") + boolEntry.checked = retained === "true" + } + if (fieldColumn.modelData.isSlider && retained !== "") + levelSlider.value = Number(retained) + unitSelector.currentIndex = 0 + fieldColumn.reselectOption() + }) + } + + function reselectOption() { + const data = fieldColumn.modelData + if (!data.isEnum && !data.isChoice) + return + const retained = form.opt(form.fieldValues[data.name], "") + const rows = data.isEnum ? data.enumOptions : (form.fieldOptions[data.name] || []) + let index = -1 + for (let i = 0; i < rows.length; ++i) { + if (rows[i].valueJson === retained) { + index = i + break + } + } + choiceEntry.currentIndex = index + radioGroup.checkedIndex = index + } + + Connections { + target: form + function onPrefillRevisionChanged() { fieldColumn.reseedFromDraft() } + function onOptionsRevisionChanged() { + if (fieldColumn.modelData.isChoice) + fieldColumn.reselectOption() + } + } + RowLayout { id: controlsRow Layout.fillWidth: true @@ -1976,6 +2201,8 @@ Frame { } DateTimePicker { + id: dateTimeEntry + objectName: "datetime_" + fieldColumn.modelData.name visible: overrideLoader.sourceComponent === null && fieldColumn.modelData.isDateTime enabled: !fieldColumn.modelData.readOnly Layout.fillWidth: true @@ -2162,6 +2389,8 @@ Frame { // Unit selector when the unit system declares convertible // alternatives: switching recalculates the entry exactly. ComboBox { + id: unitSelector + objectName: "unit_" + fieldColumn.modelData.name visible: overrideLoader.sourceComponent === null && fieldColumn.modelData.isQuantity && fieldColumn.modelData.unitOptions.length > 1 enabled: !fieldColumn.modelData.readOnly diff --git a/src/qt/forms/tests/tst_DynamicFormPrefill.qml b/src/qt/forms/tests/tst_DynamicFormPrefill.qml new file mode 100644 index 000000000..b7c6974f4 --- /dev/null +++ b/src/qt/forms/tests/tst_DynamicFormPrefill.qml @@ -0,0 +1,276 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Loading a stored payload into DynamicForm for editing: prefill(values) / +// prefillFromJson(text), the inverse of the form's own encoders. +// +// The claim is a round trip: prefilling a form from a payload and changing +// nothing must assemble the same payload again. Every case below therefore +// asserts previewLine against the JSON it was fed (in the canonical spelling +// the encoders produce), plus the half a user sees -- the drawn control, or a +// slot's fieldText/rows, holding the value -- and that prefilling never +// submits. + +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Controls +import QtTest +import MorphForms + +TestCase { + id: testCase + name: "DynamicFormPrefill" + visible: true + width: 600 + height: 900 + + QtObject { + id: recordingController + property int submissions: 0 + signal replyReceived(string actionType, bool ok, string payload) + signal optionsReceived(string optionsAction, bool ok, string payload) + function submitIfValid(actionType, bodyJson) { submissions++ } + function fetchOptions(optionsAction, bodyJson) { + optionsReceived(optionsAction, true, '[{"id":9007199254740993,"name":"Big"},{"id":2,"name":"Two"}]') + } + } + + // One member of every kind the section DTOs use, in schemaJson() shape. + property var sampleSchema: ({ + "$defs": { + "double": { type: "number" }, + "int64_t": { type: "integer" }, + "Row": { + type: "object", + properties: { + sieve: { "$ref": "#/$defs/double", "x-order": 0 }, + passing: { + type: ["object", "null"], + properties: { num: { type: "integer" }, den: { type: "integer" }, dp: { type: "integer" } }, + "x-decimalPlaces": 1, "x-order": 1 + } + }, + required: ["sieve", "passing"] + } + }, + properties: { + id: { "$ref": "#/$defs/int64_t", "x-order": 0 }, + density: { + type: ["object", "null"], + properties: { num: { type: "integer" }, den: { type: "integer" }, dp: { type: "integer" } }, + ExtUnits: { unitAscii: "kg_per_m3", unitUnicode: "kg/m³" }, "x-decimalPlaces": 2, "x-order": 1, + "x-unitAlternatives": [{ id: "g_per_cm3", display: "g/cm³", decimals: 5, num: 1000, den: 1 }] + }, + temperature: { "$ref": "#/$defs/double", "x-order": 2 }, + takenAt: { type: "string", format: "date-time", "x-order": 3 }, + done: { type: "boolean", "x-order": 4 }, + role: { type: "string", oneOf: [{ title: "A", const: "A" }, { title: "B", const: "B" }], "x-order": 5 }, + note: { type: ["string", "null"], "x-order": 6 }, + tags: { type: "array", items: { type: "string" }, "x-order": 7 }, + rows: { type: "array", items: { "$ref": "#/$defs/Row" }, "x-order": 8 } + }, + required: ["id", "density", "temperature", "takenAt", "done", "role", "rows"] + }) + + readonly property string storedPayload: + '{"id":9007199254740993,"density":{"num":245050,"den":100,"dp":2},"temperature":21.5,' + + '"takenAt":"2026-07-20T09:00:00Z","done":true,"role":"B","note":"retest","tags":["a","b"],' + + '"rows":[{"sieve":31.5,"passing":{"num":1000,"den":10,"dp":1}},{"sieve":0.1,"passing":{"num":42,"den":10,"dp":1}}]}' + + // The payload in the spelling the encoders produce -- here, the same one. + readonly property string canonicalPayload: + '{"id":9007199254740993,"density":{"num":245050,"den":100,"dp":2},"temperature":21.5,' + + '"takenAt":"2026-07-20T09:00:00Z","done":true,"role":"B","note":"retest","tags":["a","b"],' + + '"rows":[{"sieve":31.5,"passing":{"num":1000,"den":10,"dp":1}},{"sieve":0.1,"passing":{"num":42,"den":10,"dp":1}}]}' + + property var choiceSchema: ({ + properties: { sample: { type: "integer", "x-optionsAction": "ListSamples", "x-order": 0 } }, + required: ["sample"] + }) + + Component { + id: gridSlot + Item { + objectName: "gridSlot" + property var field + property var setValue + property var rows: [] + property string fieldText + } + } + + Component { + id: noteSlot + Item { + objectName: "noteSlot" + property var field + property var setValue + property string fieldText + } + } + + Component { + id: registryComponent + SlotRegistry {} + } + + Component { + id: sampleForm + DynamicForm { actionType: "T_Sample"; schema: testCase.sampleSchema; controller: recordingController } + } + + Component { + id: choiceForm + DynamicForm { actionType: "T_Choice"; schema: testCase.choiceSchema; controller: recordingController } + } + + function makeSampleForm(extra) { + const registry = createTemporaryObject(registryComponent, testCase) + registry.byField("T_Sample", "rows", gridSlot) + registry.byField("T_Sample", "note", noteSlot) + const props = { slotRegistry: registry } + for (const key in (extra || {})) + props[key] = extra[key] + return createTemporaryObject(sampleForm, testCase, props) + } + + // ── the round trip ─────────────────────────────────────────────────────── + + function test_a_stored_payload_round_trips_through_the_form() { + recordingController.submissions = 0 + const form = makeSampleForm() + verify(form.prefillFromJson(testCase.storedPayload)) + compare(form.ready, true) + compare(form.previewLine, testCase.canonicalPayload) + // A ready form after a prefill still did not submit. + compare(recordingController.submissions, 0) + } + + function test_the_drawn_controls_show_the_prefilled_values() { + const form = makeSampleForm() + form.prefillFromJson(testCase.storedPayload) + compare(findChild(form, "field_id").text, "9007199254740993") + compare(findChild(form, "field_density").text, "2450.50") + compare(findChild(form, "field_temperature").text, "21.5") + compare(findChild(form, "datetime_takenAt").text, "2026-07-20T09:00:00") + compare(findChild(form, "field_done").checked, true) + compare(findChild(form, "field_role").currentText, "B") + compare(findChild(form, "field_tags").text, "a, b") + } + + function test_slots_receive_the_prefilled_values() { + const form = makeSampleForm() + form.prefillFromJson(testCase.storedPayload) + compare(findChild(form, "noteSlot").fieldText, "retest") + const rows = findChild(form, "gridSlot").rows + compare(rows.length, 2) + compare(rows[0].sieve, "31.5") + compare(rows[0].passing, "100.0") + compare(rows[1].sieve, "0.1") + compare(rows[1].passing, "4.2") + } + + function test_an_edit_after_prefill_is_submitted_as_edited() { + const form = makeSampleForm() + form.prefillFromJson(testCase.storedPayload) + findChild(form, "field_temperature").text = "22.00" + verify(form.previewLine.indexOf('"temperature":22.00') !== -1) + verify(form.previewLine.indexOf('"id":9007199254740993') !== -1) + } + + // ── locale and zone ────────────────────────────────────────────────────── + + function test_numbers_are_prefilled_in_the_display_locale() { + const form = makeSampleForm({ displayLocale: "de_DE" }) + form.prefillFromJson(testCase.storedPayload) + compare(findChild(form, "field_density").text, "2450,50") + compare(findChild(form, "field_temperature").text, "21,5") + compare(form.previewLine, testCase.canonicalPayload) + } + + function test_a_timestamp_is_prefilled_in_the_display_zone() { + const form = makeSampleForm({ displayOffsetMinutes: 120 }) + form.prefillFromJson(testCase.storedPayload) + compare(findChild(form, "datetime_takenAt").text, "2026-07-20T11:00:00") + verify(form.previewLine.indexOf('"takenAt":"2026-07-20T09:00:00Z"') !== -1) + } + + // ── what a prefill replaces ────────────────────────────────────────────── + + function test_a_member_absent_from_the_payload_starts_blank() { + const form = makeSampleForm() + form.prefillFromJson(testCase.storedPayload) + form.prefillFromJson('{"id":5}') + compare(findChild(form, "field_temperature").text, "") + compare(findChild(form, "gridSlot").rows.length, 0) + compare(findChild(form, "noteSlot").fieldText, "") + compare(form.ready, false) + } + + function test_the_unit_selector_returns_to_the_canonical_unit() { + const form = makeSampleForm() + const selector = findChild(form, "unit_density") + selector.currentIndex = 1 + selector.activated(1) + form.prefillFromJson(testCase.storedPayload) + compare(selector.currentIndex, 0) + compare(findChild(form, "field_density").text, "2450.50") + verify(form.previewLine.indexOf('"density":{"num":245050,"den":100,"dp":2}') !== -1) + } + + function test_text_that_is_not_an_object_changes_nothing() { + const form = makeSampleForm() + form.prefillFromJson(testCase.storedPayload) + const before = form.previewLine + verify(!form.prefillFromJson("not json")) + verify(!form.prefillFromJson("[1,2]")) + compare(form.previewLine, before) + } + + // ── a fetched Choice ───────────────────────────────────────────────────── + + function test_a_fetched_choice_is_selected_once_its_options_arrive() { + const form = createTemporaryObject(choiceForm, testCase) + form.prefillFromJson('{"sample":9007199254740993}') + compare(form.previewLine, '{"sample":9007199254740993}') + // Options arrived at construction; the prefilled id selects its row. + const combos = [] + function collect(item) { + if (!item) + return + if (item instanceof ComboBox && item.visible) + combos.push(item) + for (let i = 0; i < item.children.length; ++i) + collect(item.children[i]) + } + collect(form) + compare(combos.length, 1) + compare(combos[0].currentText, "Big") + // A later refetch keeps the selection. + recordingController.fetchOptions("ListSamples", "{}") + compare(combos[0].currentText, "Big") + } + + // ── the decoder on its own ─────────────────────────────────────────────── + + function test_decode_is_the_inverse_of_encode_per_kind() { + const form = makeSampleForm() + const density = form.fieldByName["density"] + compare(form.decodeFieldValue(density, { num: 5, den: 4, dp: 2 }), "1.25") + compare(form.decodeFieldValue(density, { num: -1, den: 3, dp: 2 }), "-0.33") + compare(form.decodeFieldValue(density, "oops"), "") + const temperature = form.fieldByName["temperature"] + compare(form.decodeFieldValue(temperature, 1e-7), "0.0000001") + compare(form.decodeFieldValue(temperature, 3), "3") + // A declared display precision (FieldMeta::decimals' x-displayDecimals) + // pads, and never rounds a finer stored value away. + const twoPlaces = { isNumber: true, displayDecimals: 2 } + compare(form.decodeFieldValue(twoPlaces, 3), "3.00") + compare(form.decodeFieldValue(twoPlaces, 21.5), "21.50") + compare(form.decodeFieldValue(twoPlaces, 1.23456), "1.23456") + compare(form.decodeFieldValue(form.fieldByName["done"], false), "false") + compare(form.decodeFieldValue(form.fieldByName["role"], "A"), '"A"') + compare(form.decodeFieldValue(form.fieldByName["takenAt"], "2026-01-01T00:30:00+01:00"), + "2025-12-31T23:30:00") + } +}