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]") {