diff --git a/docs/spec/forms/forms.md b/docs/spec/forms/forms.md index dbd58b1c..3254dab3 100644 --- a/docs/spec/forms/forms.md +++ b/docs/spec/forms/forms.md @@ -572,6 +572,8 @@ below) `DynamicForm.qml`'s `resolveProp` does exactly this dual read. | `x-widget` | property node (sibling of `$ref`) | string | The preferred control id: `"textarea"`, `"slider"`, `"radio"`, `"combo"`, `"password"`, `"checkbox"`, … A `fieldMetadata`-shaped override (a `.field`/`.widget` entry, read structurally — see [widget_hints.md](widget_hints.md)) wins; else the field type's own `widget()` (`Multiline`, `Ranged`). **Advisory** — a renderer that lacks the named control falls back to the type-default control (text area → text field, slider → numeric input, radio → combo). Omitted when neither a wrapper type nor an override supplies one. | | `x-min` | property node (sibling of `$ref`) | number | Slider lower bound, from `Ranged::min()`. Emitted only for a `Ranged` field. Distinct from glaze's schema `minimum` (a *validation* bound, when present) — `x-min` is the *control track* start and is never enforced. | | `x-max` | property node (sibling of `$ref`) | number | Slider upper bound, from `Ranged::max()`. Emitted only for a `Ranged` field. | +| `x-exactMinimum` | wherever `minimum` sits (property node, or the `$def` reached through its `$ref`) | string | Exact decimal spelling of `minimum`, emitted **only** when the bound's magnitude exceeds 2^53 — i.e. when an IEEE-754 double cannot hold it. See [Exact numeric bounds](#exact-numeric-bounds--x-minimumtext--x-maximumtext). | +| `x-exactMaximum` | wherever `maximum` sits | string | Exact decimal spelling of `maximum`, under the same condition. | | `x-step` | property node (sibling of `$ref`) | number | Slider / numeric increment, from `Ranged::step()`. Emitted only for a `Ranged` field. For a `Quantity` the entry granularity remains `x-decimalPlaces` (above); `x-step` is not emitted for `Quantity`. | | `x-minimum` | property node (sibling of `$ref`) | object `{num,den,dp}` | Inclusive lower bound for the field's value, from a model's `InstanceConstraints` — an exact `Rational` in the same wire shape as the value it bounds, never a `double`. Emitted only for a decorated instance schema ([Per-instance constraints](#per-instance-constraints--values-that-live-in-data)); never by `schemaJson()`. Distinct from `x-min` (a *slider track* start, which is never checked). | | `x-maximum` | property node (sibling of `$ref`) | object `{num,den,dp}` | Inclusive upper bound, same source and shape as `x-minimum`. | @@ -694,6 +696,46 @@ constraints such as `minimum`/`maximum`. Integers on this path are emitted as bare, exact numbers — the payload is assembled as JSON *text*, never round-tripped through `JSON.parse`, so values beyond 2^53 (including `INT64_MAX`) survive intact. +### Exact numeric bounds — `x-exactMinimum` / `x-exactMaximum` + +`minimum` and `maximum` are standard JSON-Schema vocabulary, stamped by glaze. +They are JSON *numbers*, and a renderer reaches them by parsing the schema — +every shipped app does `JSON.parse(controller.schemasJson)`. JavaScript numbers +are IEEE-754 doubles, so any bound above 2^53 loses precision at that moment: + +``` +schema maximum for an int64_t field: 9223372036854775807 + after JSON.parse into a JS number: 9223372036854775808 (rounded up) +``` + +That breaks the client-side gate at exactly the value it is closest to failing +on. `INT64_MAX + 1` compared against a maximum rounded *up* to +`9223372036854775808` is judged **equal, not greater**, so the renderer's own +validation admits an out-of-range value. Nothing is corrupted — the payload is +assembled as JSON text and keeps the exact digits, and the server rejects it +with `parse_number_failure` — but the client claimed a value was valid that +never was. + +`schemaJson()` therefore also emits the bound as an exact decimal **string**, +which `JSON.parse` cannot round. A renderer that validates integer input should +prefer `x-exactMinimum`/`x-exactMaximum` when present and fall back to the numeric +`minimum`/`maximum` otherwise. The shipped `DynamicForm.qml` compares digits +directly in that case, since no JS number can hold the bound. + +Two deliberate limits: + +- **Emitted only above 2^53.** An ordinary bound (`int32_t`, a `Ranged` slider, + a hand-written `maximum: 10`) loses nothing to a double, so its schema is + byte-for-byte what it was before this key existed. Only the definitions that + genuinely need it — `$defs/int64_t`, `$defs/uint64_t` — carry the companion. +- **The numeric bound stays.** The companion is additive: `minimum`/`maximum` + remain exactly as glaze emitted them, so a renderer that ignores the new keys + behaves precisely as it did before, per the versioning stance below. + +Note the companion sits **wherever the bound sits**. For a `std::int64_t` +member that is the `$defs` entry the property's `$ref` points at, not the +property node — a renderer reads it from the merged node after resolving the +`$ref` (or the non-null `anyOf` branch), the same way it reads `type`. ### Versioning stance diff --git a/include/morph/forms/forms.hpp b/include/morph/forms/forms.hpp index 96d9b0cd..372cc218 100644 --- a/include/morph/forms/forms.hpp +++ b/include/morph/forms/forms.hpp @@ -2135,6 +2135,80 @@ void rejectUnsatisfiableRules(const glz::generic_u64::array_t& xRules, } } +/// @brief Largest magnitude an IEEE-754 double holds exactly: 2^53. +/// +/// A JSON number beyond this cannot survive `JSON.parse` intact, so a bound +/// above it needs an exact companion the renderer can read instead. +inline constexpr std::uint64_t kExactDoubleLimit = 9007199254740992ULL; + +/// @brief Signed spelling of `kExactDoubleLimit`, for the negative bound. +inline constexpr std::int64_t kExactDoubleLimitSigned = 9007199254740992LL; + +/// @brief Adds an exact decimal-string companion for one numeric bound, when +/// the bound is too large for a double to hold exactly. +/// +/// `minimum`/`maximum` are standard JSON-Schema vocabulary stamped by glaze, +/// and `mergeSchemaExtras` reads the schema in u64 number mode precisely so +/// they are not rounded on the C++ side. They are rounded anyway the moment a +/// renderer does `JSON.parse(controller.schemasJson)`, which every shipped app +/// does -- `INT64_MAX` becomes `9223372036854775808`, and a client-side gate +/// comparing against it then admits `INT64_MAX + 1` as "not greater" +/// (morph#213). The exact digits travel as a string, which `JSON.parse` cannot +/// round. +/// +/// Emitted only above `kExactDoubleLimit`: an ordinary bound loses nothing to a +/// double, so schemas that do not need this are byte-for-byte unchanged. +/// +/// @param node Schema node to annotate in place (a property or a `$defs` entry). +/// @param key Bound to read: `"minimum"` or `"maximum"`. +/// @param textKey Companion key to write: `"x-exactMinimum"` or `"x-exactMaximum"`. +// NOLINTBEGIN(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) -- glaze DOM requires operator[] +inline void annotateExactBound(glz::generic_u64& node, const std::string& key, const std::string& textKey) { + if (!node.contains(key)) { + return; + } + auto const& bound = node[key]; + // std::cmp_* rather than a cast: the two bounds arrive in different + // signednesses and the limit is unsigned, so a cast would be the very + // sign-mismatch this comparison exists to get right. + if (bound.template holds()) { + auto const value = bound.template get(); + if (std::cmp_greater(value, kExactDoubleLimit)) { + node[textKey] = std::to_string(value); + } + } else if (bound.template holds()) { + auto const value = bound.template get(); + if (std::cmp_greater(value, kExactDoubleLimit) || std::cmp_less(value, -kExactDoubleLimitSigned)) { + node[textKey] = std::to_string(value); + } + } +} + +/// @brief Walks a schema DOM, adding `x-exactMinimum`/`x-exactMaximum` wherever a +/// bound is too large for a double. +/// +/// Recursive over the whole document rather than over `properties` alone, +/// because the bounds that actually matter live in `$defs`: a `std::int64_t` +/// member is emitted as a `$ref` to `$defs/int64_t`, and that definition is +/// where `minimum`/`maximum` sit. +/// +/// @param node Node to walk; objects and arrays recurse, scalars are left alone. +// NOLINTNEXTLINE(misc-no-recursion) -- walking a JSON tree is inherently recursive +inline void annotateExactNumericBounds(glz::generic_u64& node) { + if (node.is_object()) { + annotateExactBound(node, "minimum", "x-exactMinimum"); + annotateExactBound(node, "maximum", "x-exactMaximum"); + for (auto& [childKey, child] : node.get_object()) { + annotateExactNumericBounds(child); + } + } else if (node.is_array()) { + for (auto& child : node.get_array()) { + annotateExactNumericBounds(child); + } + } +} +// NOLINTEND(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) + /// @brief The DOM post-merge behind `schemaJson`: adds the derived `required` /// array, `x-order`, `x-decimalPlaces`, and (for actions declaring /// `computedFields`) `x-computed`/`x-readonly` to a glaze-produced schema. @@ -2311,6 +2385,10 @@ template dom["x-rules"] = xRules; } + // Exact companions for any bound a double cannot hold (morph#213). Last, + // so it also covers nodes added by the passes above. + annotateExactNumericBounds(dom); + // value_or without a move: the copy is irrelevant (schemaJson memoises), // and keeping the fallback branch inside glaze's expected avoids an // untestable line here (write_json of a DOM we just built cannot fail). diff --git a/src/qt/forms/qml/DynamicForm.qml b/src/qt/forms/qml/DynamicForm.qml index 452feb6f..57c9f423 100644 --- a/src/qt/forms/qml/DynamicForm.qml +++ b/src/qt/forms/qml/DynamicForm.qml @@ -98,6 +98,29 @@ Frame { return value === undefined ? fallback : value } + // Three-way compare of two integers held as decimal strings: -1, 0, 1. + // Needed because a JS number cannot hold an int64 bound exactly, so the + // comparison has to happen on digits (morph#213). Inputs are already + // /^-?\d+$/-validated by the caller. + function compareIntText(left, right) { + const leftNeg = left.charAt(0) === "-" + const rightNeg = right.charAt(0) === "-" + if (leftNeg !== rightNeg) + return leftNeg ? -1 : 1 + // Strip sign and leading zeros so "007" and "7" compare equal. + const leftDigits = left.replace(/^-?0*/, "") || "0" + const rightDigits = right.replace(/^-?0*/, "") || "0" + let cmp = 0 + if (leftDigits.length !== rightDigits.length) + cmp = leftDigits.length < rightDigits.length ? -1 : 1 + else if (leftDigits < rightDigits) + cmp = -1 + else if (leftDigits > rightDigits) + cmp = 1 + // Both negative reverses the magnitude ordering. + return leftNeg ? -cmp : cmp + } + // Follow a $ref into $defs; attributes on the field win over the def's. function resolveRef(prop) { if (prop && prop["$ref"] !== undefined) { @@ -262,6 +285,16 @@ Frame { required: required.indexOf(name) !== -1, minimum: p.minimum, maximum: p.maximum, + // Exact decimal-string companions for a bound a double + // cannot hold. `p.minimum`/`p.maximum` reached this object + // through JSON.parse (every app does + // `JSON.parse(controller.schemasJson)`), so an int64 bound + // is already rounded by the time it gets here -- INT64_MAX + // arrives as 9223372036854775808. These strings are not + // (morph#213). Undefined for any bound a double holds + // exactly, which is the overwhelmingly common case. + exactMinimum: p["x-exactMinimum"], + exactMaximum: p["x-exactMaximum"], section: opt(raw["x-section"], p["x-section"]), colspan: opt(opt(raw["x-colspan"], p["x-colspan"]), 1), isMultiline: widget === "textarea", @@ -719,13 +752,26 @@ Frame { if (f.isInteger) { if (!/^-?\d+$/.test(text)) return null + // Normalise "007" -> "7": JSON forbids leading zeros in numbers. + const normalised = text.replace(/^(-?)0+(?=\d)/, "$1") + // Prefer the exact string bound when the schema carries one: a + // double-valued bound rounds at 2^53, and comparing INT64_MAX + 1 + // against a maximum rounded *up* to 9223372036854775808 judges it + // "not greater" and lets it through the gate (morph#213). const value = parseInt(text) - if (f.minimum !== undefined && value < f.minimum) + if (f.exactMinimum !== undefined) { + if (compareIntText(normalised, f.exactMinimum) < 0) + return null + } else if (f.minimum !== undefined && value < f.minimum) { return null - if (f.maximum !== undefined && value > f.maximum) + } + if (f.exactMaximum !== undefined) { + if (compareIntText(normalised, f.exactMaximum) > 0) + return null + } else if (f.maximum !== undefined && value > f.maximum) { return null - // Normalise "007" -> "7": JSON forbids leading zeros in numbers. - return text.replace(/^(-?)0+(?=\d)/, "$1") + } + return normalised } if (f.isBoolean) { // Emitted bare, never quoted. The CheckBox only ever stores these diff --git a/src/qt/forms/tests/tst_DynamicFormExactBounds.qml b/src/qt/forms/tests/tst_DynamicFormExactBounds.qml new file mode 100644 index 00000000..d655d8d9 --- /dev/null +++ b/src/qt/forms/tests/tst_DynamicFormExactBounds.qml @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// The client-side integer bounds gate at INT64 extremes (morph#213). +// +// `minimum`/`maximum` reach this renderer through +// `JSON.parse(controller.schemasJson)`, which every shipped app does, so an +// int64 bound is an IEEE-754 double by the time DynamicForm sees it and +// INT64_MAX arrives already rounded up to 9223372036854775808. Comparing +// INT64_MAX + 1 against that judges it "not greater" and passes it through the +// gate the schema meant to close. +// +// `schemaJson()` therefore also emits `x-exactMinimum`/`x-exactMaximum` -- +// exact decimal strings, which JSON.parse cannot round -- and the gate prefers +// them. The schemas below carry both keys exactly as a generated schema does; +// tests/test_forms_exact_bounds.cpp pins that the C++ side really emits them. +// +// Every fixture here sits AT the boundary on purpose: a schema with small +// bounds passes whether or not this bug exists. + +import QtQuick +import QtTest +import MorphForms + +TestCase { + id: testCase + name: "DynamicFormExactBounds" + visible: true + + QtObject { + id: mockController + signal replyReceived(string actionType, bool ok, string payload) + signal optionsReceived(string optionsAction, bool ok, string payload) + function submitIfValid(actionType, bodyJson) { + replyReceived(actionType, true, JSON.stringify({ok: true})) + } + function fetchOptions(optionsAction) { optionsReceived(optionsAction, true, "[]") } + } + + // Note the deliberately *rounded* numeric bounds: these are what a real + // JSON.parse produces for INT64_MIN/INT64_MAX, so the fixture reproduces + // the renderer's actual input rather than an idealised one. + property var i64Schema: ({ + "$defs": { + "int64_t": { + type: "integer", + minimum: -9223372036854775808, + maximum: 9223372036854775807, + "x-exactMinimum": "-9223372036854775808", + "x-exactMaximum": "9223372036854775807" + } + }, + properties: { id: { "$ref": "#/$defs/int64_t", "x-order": 0 } }, + required: ["id"] + }) + + // The same field with no exact companions: the pre-#213 shape, kept so the + // numeric fallback path stays covered. + property var smallBoundSchema: ({ + properties: { n: { type: "integer", minimum: -10, maximum: 10, "x-order": 0 } }, + required: ["n"] + }) + + // The anyOf shape: a bare std::optional. Before morph#189 this + // had no resolved type at all, so no bounds applied and the gate never ran. + // Now that resolveProp follows the non-null anyOf branch, the field inherits + // $defs/int64_t's bounds -- including the exact companions (morph#213). + property var anyOfI64Schema: ({ + "$defs": { + "int64_t": { + type: "integer", + minimum: -9223372036854775808, + maximum: 9223372036854775807, + "x-exactMinimum": "-9223372036854775808", + "x-exactMaximum": "9223372036854775807" + } + }, + properties: { optId: { anyOf: [{ "$ref": "#/$defs/int64_t" }, { type: "null" }], "x-order": 0 } }, + required: [] + }) + + Component { + id: anyOfI64Form + DynamicForm { actionType: "T_AnyOfI64"; schema: testCase.anyOfI64Schema; controller: mockController } + } + + Component { + id: i64Form + DynamicForm { actionType: "T_I64"; schema: testCase.i64Schema; controller: mockController } + } + + Component { + id: smallForm + DynamicForm { actionType: "T_Small"; schema: testCase.smallBoundSchema; controller: mockController } + } + + function typeInto(form, field, text) { + findChild(form, field).text = text + } + + // ── at the boundary: legal values stay legal ───────────────────────────── + + function test_int64_max_is_accepted_and_stays_exact() { + var form = createTemporaryObject(i64Form, testCase) + typeInto(form, "field_id", "9223372036854775807") + compare(form.ready, true) + verify(form.previewLine.indexOf('"id":9223372036854775807') !== -1) + } + + function test_int64_min_is_accepted_and_stays_exact() { + var form = createTemporaryObject(i64Form, testCase) + typeInto(form, "field_id", "-9223372036854775808") + compare(form.ready, true) + verify(form.previewLine.indexOf('"id":-9223372036854775808') !== -1) + } + + // ── one past the boundary: the gate must close ─────────────────────────── + + function test_int64_max_plus_one_is_rejected() { + var form = createTemporaryObject(i64Form, testCase) + // The whole point. Against the rounded double maximum this compares + // equal, not greater, and was admitted -- then rejected by the server + // with parse_number_failure. + typeInto(form, "field_id", "9223372036854775808") + compare(form.ready, false) + } + + function test_int64_min_minus_one_is_rejected() { + var form = createTemporaryObject(i64Form, testCase) + typeInto(form, "field_id", "-9223372036854775809") + compare(form.ready, false) + } + + function test_a_value_far_above_the_maximum_is_rejected() { + var form = createTemporaryObject(i64Form, testCase) + typeInto(form, "field_id", "99999999999999999999") + compare(form.ready, false) + } + + // ── the digit comparison itself ────────────────────────────────────────── + + function test_leading_zeros_do_not_defeat_the_comparison() { + var form = createTemporaryObject(i64Form, testCase) + // Same magnitude as INT64_MAX, written with padding: still legal. + typeInto(form, "field_id", "0009223372036854775807") + compare(form.ready, true) + verify(form.previewLine.indexOf('"id":9223372036854775807') !== -1) + } + + function test_ordinary_midrange_values_are_unaffected() { + var form = createTemporaryObject(i64Form, testCase) + typeInto(form, "field_id", "42") + compare(form.ready, true) + verify(form.previewLine.indexOf('"id":42') !== -1) + typeInto(form, "field_id", "-42") + compare(form.ready, true) + verify(form.previewLine.indexOf('"id":-42') !== -1) + } + + // ── the numeric fallback still works where no exact bound is emitted ───── + + function test_small_numeric_bounds_still_gate_without_exact_companions() { + var form = createTemporaryObject(smallForm, testCase) + typeInto(form, "field_n", "10") + compare(form.ready, true) + typeInto(form, "field_n", "11") + compare(form.ready, false) + typeInto(form, "field_n", "-10") + compare(form.ready, true) + typeInto(form, "field_n", "-11") + compare(form.ready, false) + } + + // ── the two fixes composing ────────────────────────────────────────────── + + function test_anyOf_field_inherits_the_exact_bounds_and_rejects_past_them() { + var form = createTemporaryObject(anyOfI64Form, testCase) + // Measured on morph#189's branch before this fix: an anyOf int64 field + // admitted INT64_MAX + 1, because the bound it compared against had been + // rounded up by JSON.parse to exactly that value. + typeInto(form, "field_optId", "9223372036854775808") + compare(form.ready, false) + } + + function test_anyOf_field_still_accepts_int64_max_exactly() { + var form = createTemporaryObject(anyOfI64Form, testCase) + typeInto(form, "field_optId", "9223372036854775807") + compare(form.ready, true) + verify(form.previewLine.indexOf('"optId":9223372036854775807') !== -1) + } +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1f5643c0..68e0598e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -46,6 +46,7 @@ add_executable(morph_tests test_async_registration.cpp test_network_monitor.cpp test_forms_boolean_anyof_wire.cpp + test_forms_exact_bounds.cpp test_offline_queue.cpp test_file_offline_queue.cpp test_sync_worker.cpp diff --git a/tests/test_forms_exact_bounds.cpp b/tests/test_forms_exact_bounds.cpp new file mode 100644 index 00000000..ff51d41a --- /dev/null +++ b/tests/test_forms_exact_bounds.cpp @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// `x-exactMinimum`/`x-exactMaximum`: exact decimal companions for a numeric bound +// a double cannot hold (morph#213). +// +// `mergeSchemaExtras` already reads the schema in u64 number mode so int64 +// bounds are not rounded on the C++ side. They are rounded anyway the moment a +// renderer runs `JSON.parse(controller.schemasJson)` -- which every shipped app +// does -- so `INT64_MAX` reaches the renderer as 9223372036854775808 and a gate +// comparing against it admits `INT64_MAX + 1` as "not greater". A string +// survives JSON.parse intact; these tests pin that the string is emitted, and +// emitted *only* where a double would actually lose something. +// +// src/qt/forms/tests/tst_DynamicFormExactBounds.qml pins the renderer half +// against the same key names. + +#include +#include +#include +#include +#include +#include +#include + +// File-scope (not anonymous-namespaced): glaze's reflection needs a type with +// linkage. EB prefix keeps these unique for the file-scope-collision CI check. +// +// NOLINTBEGIN(misc-use-internal-linkage) -- an anonymous namespace is exactly +// what these cannot have; same suppression as tests/test_shared_instances.cpp. +// NOLINTBEGIN(cert-err58-cpp,bugprone-throwing-static-initialization,misc-const-correctness) -- the +// BRIDGE_REGISTER_* macros register through throwing static initialisers by +// design; every test that registers a model has this shape. +struct EBWideAction { + std::int64_t id = 0; + std::uint64_t tag = 0; +}; + +struct EBWideModel { + std::int64_t lastSeen = 0; + + bool execute(const EBWideAction& action) { + lastSeen = action.id; + return true; + } +}; + +BRIDGE_REGISTER_MODEL(EBWideModel, "Test_EBWide_Model") +BRIDGE_REGISTER_ACTION(EBWideModel, EBWideAction, "Test_EBWide_Action") + +struct EBNarrowAction { + std::int32_t small = 0; +}; + +struct EBNarrowModel { + std::int32_t lastSeen = 0; + + bool execute(const EBNarrowAction& action) { + lastSeen = action.small; + return true; + } +}; + +BRIDGE_REGISTER_MODEL(EBNarrowModel, "Test_EBNarrow_Model") +BRIDGE_REGISTER_ACTION(EBNarrowModel, EBNarrowAction, "Test_EBNarrow_Action") +// NOLINTEND(cert-err58-cpp,bugprone-throwing-static-initialization,misc-const-correctness) +// NOLINTEND(misc-use-internal-linkage) + +TEST_CASE("schemaJson emits exact text companions for int64 bounds", "[forms][bounds]") { + auto const schema = morph::forms::schemaJson(); + // The digits must be exact, not the double-rounded 9223372036854775808. + CHECK(schema.contains(R"("x-exactMaximum":"9223372036854775807")")); + CHECK(schema.contains(R"("x-exactMinimum":"-9223372036854775808")")); +} + +TEST_CASE("schemaJson emits an exact text companion for a uint64 maximum", "[forms][bounds]") { + auto const schema = morph::forms::schemaJson(); + CHECK(schema.contains(R"("x-exactMaximum":"18446744073709551615")")); +} + +TEST_CASE("schemaJson leaves bounds a double holds exactly untouched", "[forms][bounds]") { + // The reason this is not emitted unconditionally: an ordinary schema loses + // nothing to a double, and stays byte-for-byte what it was before #213. + auto const schema = morph::forms::schemaJson(); + CHECK_FALSE(schema.contains("x-exactMinimum")); + CHECK_FALSE(schema.contains("x-exactMaximum")); +} + +TEST_CASE("the exact companion sits beside the bound it belongs to, in $defs", "[forms][bounds]") { + // Parsed rather than substring-matched: the renderer resolves a property's + // `$ref` into `$defs` and reads the companion from the merged node, so a + // companion written to the wrong node would be invisible to it. + auto const schema = morph::forms::schemaJson(); + auto parsed = glz::read_json(schema); + REQUIRE(parsed.has_value()); + // NOLINTBEGIN(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) -- glaze DOM requires operator[] + auto const& root = parsed.value(); + REQUIRE(root.contains("$defs")); + auto const& defs = root["$defs"]; + REQUIRE(defs.contains("int64_t")); + auto const& int64Def = defs["int64_t"]; + REQUIRE(int64Def.contains("x-exactMaximum")); + CHECK(int64Def["x-exactMaximum"].get() == "9223372036854775807"); + // And the numeric bound it shadows is still present, unchanged. + CHECK(int64Def.contains("maximum")); + // NOLINTEND(cppcoreguidelines-pro-bounds-avoid-unchecked-container-access) +} diff --git a/tests/test_widget_hints.cpp b/tests/test_widget_hints.cpp index c64ae324..e9e79a09 100644 --- a/tests/test_widget_hints.cpp +++ b/tests/test_widget_hints.cpp @@ -188,11 +188,17 @@ TEST_CASE("Forms::SchemaJson::RangedFloatingBounds", "[forms][widget-hints]") { TEST_CASE("Forms::SchemaJson::PlainFieldsEmitNoWidgetHint", "[forms][widget-hints]") { // Regression guard: a plain field with no wrapper and no override emits // no x-widget/x-min/x-max/x-step at all — today's schema is unchanged. + // + // Matched as JSON keys (`"x-min":`) rather than bare substrings. The loose + // form also matched any *longer* key sharing the prefix, so an unrelated + // key named `x-min...` failed this test with a message pointing at the + // slider bounds — which is how `x-exactMinimum` was first named, before + // this caught it (morph#213). auto const schema = morph::forms::schemaJson(); - CHECK_FALSE(schema.contains("x-widget")); - CHECK_FALSE(schema.contains("x-min")); - CHECK_FALSE(schema.contains("x-max")); - CHECK_FALSE(schema.contains("x-step")); + CHECK_FALSE(schema.contains(R"("x-widget":)")); + CHECK_FALSE(schema.contains(R"("x-min":)")); + CHECK_FALSE(schema.contains(R"("x-max":)")); + CHECK_FALSE(schema.contains(R"("x-step":)")); } TEST_CASE("Forms::SchemaJson::FieldMetaOverrideWinsOverWrapper", "[forms][widget-hints]") {