diff --git a/docs/user-guide.md b/docs/user-guide.md index ec8c9325..13c09a91 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -362,9 +362,10 @@ the factors of the two axes it shows, and the isometric wireframe in the lower-right panel is stretched by all three, so a shallow ocean's grid boxes stay visible there too. With a [companion plotfile](#companion-plotfiles) open, the axis perpendicular to the shared -plane has one factor per dataset. The factors reset to 1 when you open a -new dataset or sequence and are kept while stepping through a sequence's -frames; the proportion persists across sessions. +plane has one factor per dataset. Factors range from 0.01 to 10000 and are +saved: they carry over to the datasets you open next and to later sessions, +until **Reset** puts them back to 1. The companion's own factor starts at 1 +with each companion. The proportion persists across sessions too. The stretch is applied on screen only. The slice raster keeps one sample per finest cell, readouts and overlays follow the stretch, and image and animation @@ -375,7 +376,8 @@ cells, in Physical Size mode equal axis factors. Vector glyphs are drawn in cell units, so a stretched display skews their arrows. The controls are unavailable for 2-D spherical plotfiles, whose R-Z view is -already physical, and Physical Size is unavailable for standalone FABs and +already physical; saved axis factors do not apply to them in any layout and +wait for the next plotfile. Physical Size is unavailable for standalone FABs and MultiFabs, which carry no cell sizes. While a [mapped grid](#mapped-grids) is shown the display is physical too: the menu shows Physical Size in effect, Axis Scaling still applies, and the persisted proportion is untouched. @@ -923,8 +925,8 @@ their neutral gray under every skin, so a colormap looks the same whichever one you pick. Window geometry, logarithmic mapping, palette, skin, number format, -animation speed, aspect ratio proportion, the mapped-grid display, and the -isosurface color persist across sessions. +animation speed, aspect ratio proportion, axis scaling factors, the +mapped-grid display, and the isosurface color persist across sessions. Each open dataset has a 1 GiB data cache by default, and volume rendering fills a second cache of the same size with the grids it samples the field into (an diff --git a/src/qt/AspectMode.hpp b/src/qt/AspectMode.hpp index b31d0ab0..d6604ab8 100644 --- a/src/qt/AspectMode.hpp +++ b/src/qt/AspectMode.hpp @@ -21,6 +21,10 @@ enum class AspectMode : int { PhysicalSize = 1, }; +// The range an Axis Scaling factor may take, in the dialog and from settings. +inline constexpr double minimumAxisScale = 0.01; +inline constexpr double maximumAxisScale = 10000.0; + // The display stretch of each dataset axis: the user's factor times, in // PhysicalSize mode, the finest level's cell size. The result is normalized // so the smallest factor over the dataset's dimensions is one, which makes a diff --git a/src/qt/MainWindow.cpp b/src/qt/MainWindow.cpp index a636f89a..657be617 100644 --- a/src/qt/MainWindow.cpp +++ b/src/qt/MainWindow.cpp @@ -1683,8 +1683,12 @@ std::array MainWindow::displayStretchPerAxis() const // (updateAspectControls shows the mode in effect). Only the spherical R-Z // warp is physical in its pixels. const auto mode = displayIsMapped() ? AspectMode::PhysicalSize : m_aspectMode; + // Axis Scaling is unavailable for a spherical plotfile, so its saved + // factors wait for the next dataset rather than stretch r and theta. + const auto factors = displayIsSpherical() + ? std::array{1.0, 1.0, 1.0} : m_axisScale; return amrvis::qt::displayStretchPerAxis(primary().session->metadata(), - mode, m_axisScale, displayIsSpherical()); + mode, factors, displayIsSpherical()); } std::array MainWindow::displayStretchFor( diff --git a/src/qt/MainWindow.hpp b/src/qt/MainWindow.hpp index 36746820..2b1db4ed 100644 --- a/src/qt/MainWindow.hpp +++ b/src/qt/MainWindow.hpp @@ -497,6 +497,8 @@ class MainWindow final : public QMainWindow { { applyAxisScale(axisScale); } + [[nodiscard]] std::array axisScaleForTest() const { return m_axisScale; } + void showAxisScalingDialogForTest() { showAxisScalingDialog(); } [[nodiscard]] bool aspectMenuEnabledForTest() const; [[nodiscard]] double activeViewStretchRatioForTest() const; // The isometric view's outlined domain in its display coordinates. @@ -1243,14 +1245,14 @@ class MainWindow final : public QMainWindow { void showLengthUnitsDialog(); void applyLengthUnit(const QString& unitId); // View > Aspect Ratio: the per-axis display stretch (see AspectMode.hpp). - // The dialog edits m_axisScale; applyAxisScale installs a new set and - // resetAxisScale returns to unit factors when a dataset is opened. + // The dialog edits m_axisScale; applyAxisScale installs a new set, saved + // in the settings and kept across opens. void showAxisScalingDialog(); // The per-axis factors (the primary's along every axis) and, with a // companion, the companion's factor along the perpendicular axis. void applyAxisScale(const std::array& axisScale, std::optional companionPerpendicularScale = std::nullopt); - void resetAxisScale(); + void closeAxisScalingDialog(); void setAspectMode(AspectMode mode); [[nodiscard]] std::array displayStretchPerAxis() const; // The two factors a panel shows, normalized so the smaller is one. @@ -1331,6 +1333,7 @@ class MainWindow final : public QMainWindow { void updateWindowTitle(); void restoreSettings(); void saveSettings(); + void saveAxisScale(); // Per-view wiring and display updates. A panel's ImageView is wired once // for the signals that belong to the panel (zoom, fit, resize, scroll, diff --git a/src/qt/MainWindowDataset.cpp b/src/qt/MainWindowDataset.cpp index f7df0e7a..76abdde9 100644 --- a/src/qt/MainWindowDataset.cpp +++ b/src/qt/MainWindowDataset.cpp @@ -10,6 +10,13 @@ namespace amrvis::qt { namespace { +QString axisScaleKey(std::size_t axis) +{ + constexpr std::array keys{ + "aspect/axisScaleX", "aspect/axisScaleY", "aspect/axisScaleZ"}; + return QString::fromLatin1(keys[axis]); +} + struct ExportChoices { bool colorBar; bool axes; @@ -231,6 +238,12 @@ void MainWindow::restoreSettings() } } } + for (std::size_t axis = 0; axis < 3; ++axis) { + bool ok = false; + const auto value = settings.value(axisScaleKey(axis), 1.0).toDouble(&ok); + m_axisScale[axis] = ok && std::isfinite(value) && value > 0.0 + ? std::clamp(value, minimumAxisScale, maximumAxisScale) : 1.0; + } applySpeed(); const auto geometry = settings.value(QStringLiteral("geometry")).toByteArray(); @@ -271,6 +284,16 @@ void MainWindow::saveSettings() settings.setValue(QStringLiteral("mappedGrid/enabled"), m_mappedGrid); } +void MainWindow::saveAxisScale() +{ + // Written only when the factors change, not by saveSettings: another + // window's unrelated save would put back its own factors. + auto settings = makeSettings(); + for (std::size_t axis = 0; axis < 3; ++axis) { + settings.setValue(axisScaleKey(axis), m_axisScale[axis]); + } +} + void MainWindow::updateWindowTitle() { if (!primary().openMetadata) { @@ -948,7 +971,7 @@ void MainWindow::openDatasetImpl(const std::filesystem::path& path, closeSequence(); resetRangeState(); resetLengthUnit(); - resetAxisScale(); + closeAxisScalingDialog(); closeCompanion(); // The new dataset arrives fitted -- setPlaceholder below puts every view // back to Fit -- so the scale report has to come back with it. Without diff --git a/src/qt/MainWindowInteraction.cpp b/src/qt/MainWindowInteraction.cpp index e2c7f181..a3f4401c 100644 --- a/src/qt/MainWindowInteraction.cpp +++ b/src/qt/MainWindowInteraction.cpp @@ -256,24 +256,26 @@ void MainWindow::showAxisScalingDialog() auto* explanation = new QLabel(tr( "Stretch each axis of the slice views by a factor. Factors apply on " - "top of the Aspect Ratio mode and reset when a dataset is opened."), + "top of the Aspect Ratio mode and are kept for later datasets and " + "sessions."), dialog); explanation->setWordWrap(true); const int dimension = primary().session ? primary().session->metadata().dimension : 3; auto* form = new QFormLayout; - std::array spins{nullptr, nullptr, nullptr}; + std::array spins{nullptr, nullptr, nullptr}; // With a companion, the axis perpendicular to the shared plane has a // second factor for the companion, so each dataset can be stretched on // its own; the primary's is the ordinary axis factor. - QDoubleSpinBox* companionSpin = nullptr; + ScientificDoubleSpinBox* companionSpin = nullptr; const std::array names{tr("X"), tr("Y"), tr("Z")}; const std::array objectNames{ "axisScaleSpinX", "axisScaleSpinY", "axisScaleSpinZ"}; + // Shortest form ("1", "0.125", "1000"), so a factor is typed without + // deleting trailing zeros; "1e3" works too. const auto makeSpin = [dialog](const char* objectName, double value, bool enabled) { - auto* spin = new QDoubleSpinBox(dialog); + auto* spin = new ScientificDoubleSpinBox(dialog); spin->setObjectName(QLatin1String(objectName)); - spin->setDecimals(3); - spin->setRange(0.01, 100.0); + spin->setRange(minimumAxisScale, maximumAxisScale); spin->setSingleStep(0.1); spin->setValue(value); spin->setEnabled(enabled); @@ -355,7 +357,8 @@ void MainWindow::applyAxisScale(const std::array& axisScale, for (std::size_t axis = 0; axis < 3; ++axis) { factors[axis] = sane(axisScale[axis]); } - bool changed = factors != m_axisScale; + const bool axisChanged = factors != m_axisScale; + bool changed = axisChanged; m_axisScale = factors; if (companionPerpendicularScale) { const auto value = sane(*companionPerpendicularScale); @@ -366,23 +369,19 @@ void MainWindow::applyAxisScale(const std::array& axisScale, clearNavigation(); applyDisplayStretches(); } + // The axis factors persist; the companion's own factor belongs to it. + if (axisChanged) { + saveAxisScale(); + } } -void MainWindow::resetAxisScale() +void MainWindow::closeAxisScalingDialog() { - // Axis factors belong to this dataset, including any unapplied edit. + // The factors stay, but an open dialog's rows (and unapplied edits) belong + // to the outgoing dataset and its companion. if (m_axisScalingDialog != nullptr) { m_axisScalingDialog->reject(); } - m_axisScale = {1.0, 1.0, 1.0}; - // The views still show the outgoing dataset, and keep showing it if the - // new one fails to load, so they take the unit factors now. No remote - // re-request: that dataset is on its way out. - for (auto* state : currentViews()) { - applyDisplayStretch(*state); - } - updateScaleBarAvailability(); - updateScaleBars(); } void MainWindow::validateVectorMode() diff --git a/src/qt/MainWindowSlice.cpp b/src/qt/MainWindowSlice.cpp index 65130c55..ea06dc95 100644 --- a/src/qt/MainWindowSlice.cpp +++ b/src/qt/MainWindowSlice.cpp @@ -2349,7 +2349,7 @@ void MainWindow::prepareSequence(std::size_t frameCount) closeSequence(); resetRangeState(); resetLengthUnit(); - resetAxisScale(); + closeAxisScalingDialog(); closeCompanion(); m_fabNavigator->reset(); m_particleController->cancel(); diff --git a/src/qt/ScientificDoubleSpinBox.cpp b/src/qt/ScientificDoubleSpinBox.cpp index 7cfa7eb8..3f4c2105 100644 --- a/src/qt/ScientificDoubleSpinBox.cpp +++ b/src/qt/ScientificDoubleSpinBox.cpp @@ -4,9 +4,11 @@ #include #include #include +#include #include #include +#include #include namespace amrvis::qt { @@ -31,6 +33,16 @@ QValidator::State validateNumber(const QString& text, int position, return validator.validate(candidate, position); } +// A whole decimal number in either locale's notation. QLocale::toDouble +// rejects one only when it underflows. +bool isCompleteNumber(const QString& text, const QLocale& locale) +{ + const QRegularExpression pattern(QStringLiteral( + "^[+-]?(\\d+(%1\\d*)?|%1\\d+)([eE][+-]?\\d+)?$") + .arg(QRegularExpression::escape(locale.decimalPoint()))); + return pattern.match(text).hasMatch(); +} + } // namespace ScientificDoubleSpinBox::ScientificDoubleSpinBox(QWidget* parent) @@ -99,6 +111,36 @@ double ScientificDoubleSpinBox::valueFromText(const QString& text) const return ok ? localizedValue : QDoubleSpinBox::valueFromText(text); } +void ScientificDoubleSpinBox::fixup(QString& input) const +{ + // A number past the range is clamped to it. Left alone, Qt puts the old + // value back without a word. An overflow parses as an infinity, and a + // complete number that still fails to parse has underflowed to zero. + const auto number = numberText(input); + const auto parse = [&number](const QLocale& locale, double& value) { + bool ok = false; + value = locale.toDouble(number, &ok); + if (!ok && !std::isinf(value) && isCompleteNumber(number, locale)) { + value = 0.0; + ok = true; + } + return ok || std::isinf(value); + }; + double value = 0.0; + if ((!parse(cNumberLocale(), value) && !parse(locale(), value)) || std::isnan(value)) { + QDoubleSpinBox::fixup(input); + return; + } + // Full precision, not the display format: the text is parsed again, and a + // short format can round the bound past itself (DBL_MAX to 1.8e+308). No + // group separator either: a comma-decimal locale reads 10,000 as 10. The + // committed value is then shown in the display format. + input = prefix() + + QString::number(std::clamp(value, minimum(), maximum()), 'g', + std::numeric_limits::max_digits10) + + suffix(); +} + QValidator::State ScientificDoubleSpinBox::validate( QString& input, int& position) const { diff --git a/src/qt/ScientificDoubleSpinBox.hpp b/src/qt/ScientificDoubleSpinBox.hpp index 18bc9c94..b831e438 100644 --- a/src/qt/ScientificDoubleSpinBox.hpp +++ b/src/qt/ScientificDoubleSpinBox.hpp @@ -18,6 +18,7 @@ class ScientificDoubleSpinBox : public QDoubleSpinBox { protected: [[nodiscard]] QString textFromValue(double value) const override; [[nodiscard]] double valueFromText(const QString& text) const override; + void fixup(QString& input) const override; QValidator::State validate(QString& input, int& position) const override; private: diff --git a/src/qt/SmokeHarnessZoom.cpp b/src/qt/SmokeHarnessZoom.cpp index 743993d9..4d10466d 100644 --- a/src/qt/SmokeHarnessZoom.cpp +++ b/src/qt/SmokeHarnessZoom.cpp @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -139,6 +140,94 @@ Outcome dispatchZoom(Context& context) QTimer::singleShot(15000, &application, [&application] { application.exit(4); }); QTimer::singleShot(0, &window, [&window, path] { window.openDataset(path); }); + } else if ((argc == 4 || argc == 5) + && std::string_view(argv[1]) == "--axis-scale-settings-smoke-test") { + // Axis Scaling is saved and kept. "save" sets the factors, reopens, + // checks the dialog shows plain factors up to 10000, and has a second + // window, still on unit factors, save its other settings. "load", a + // second process on the same settings, starts from the saved factors + // and applies them, but not to a spherical plotfile's r-theta display, + // where Axis Scaling is unavailable. + const bool save = std::string_view(argv[2]) == "save"; + const std::filesystem::path path(argv[3]); + const std::filesystem::path spherical(argc == 5 ? argv[4] : ""); + const std::array stretched{1.0, 2.0, 1000.0}; + const auto fail = [&application](const char* message) { + qCritical("%s", message); + application.exit(1); + }; + if (window.axisScaleForTest() != (save ? std::array{1.0, 1.0, 1.0} : stretched)) { + qCritical("Axis Scaling started at %g, %g, %g", window.axisScaleForTest()[0], + window.axisScaleForTest()[1], window.axisScaleForTest()[2]); + return {true, 1}; + } + // Built now, so it holds unit factors when it saves later. + auto other = save ? std::make_shared() : nullptr; + auto opened = std::make_shared(0); + QObject::connect(&window, &amrvis::qt::MainWindow::initialSliceFinished, + &application, [&window, &application, fail, save, path, spherical, stretched, + opened, other](bool success) { + if (!success) { + fail("the plotfile did not open"); + return; + } + const auto stage = ++*opened; + if (save && stage == 1) { + window.setAxisScaleForTest(stretched); + other->setSlicePlanesVisibleForTest(false); // saves its settings + QTimer::singleShot(0, &window, [&window, path] { window.openDataset(path); }); + return; + } + if (!save && stage == 2) { + // r across, theta up: the saved Y factor must not apply. + if (window.displayIsSphericalWarpForTest() + || std::abs(window.activeViewStretchRatioForTest() - 1.0) > 0.02) { + qCritical("r-theta stretch %g", window.activeViewStretchRatioForTest()); + fail("saved factors stretched a spherical plotfile"); + return; + } + application.exit(window.axisScaleForTest() == stretched ? 0 : 1); + return; + } + if (window.axisScaleForTest() != stretched) { + fail(save ? "reopening reset Axis Scaling" : "Axis Scaling was not restored"); + return; + } + // XZ: X across, Z up, so the stretch is the Z factor. + window.setActiveViewForTest(1); + if (std::abs(window.activeViewStretchRatioForTest() / 1000.0 - 1.0) > 0.02) { + qCritical("XZ stretch %g", window.activeViewStretchRatioForTest()); + fail("the kept Z factor does not stretch the view"); + return; + } + if (save) { + window.showAxisScalingDialogForTest(); + const auto* x = window.findChild(QStringLiteral("axisScaleSpinX")); + const auto* z = window.findChild(QStringLiteral("axisScaleSpinZ")); + if (x == nullptr || z == nullptr || x->text() != QStringLiteral("1") + || z->text() != QStringLiteral("1000") || z->maximum() != 10000.0) { + qCritical("dialog shows X '%s', Z '%s' up to %g", + x != nullptr ? qUtf8Printable(x->text()) : "?", + z != nullptr ? qUtf8Printable(z->text()) : "?", + z != nullptr ? z->maximum() : 0.0); + fail("the Axis Scaling dialog does not show plain factors up to 10000"); + return; + } + application.exit(0); + return; + } + if (spherical.empty()) { + fail("no spherical plotfile to load"); + return; + } + QTimer::singleShot(0, &window, [&window, spherical] { + window.selectSphericalDisplayForTest(1); // r-theta + window.openDataset(spherical); + }); + }); + QTimer::singleShot(15000, &application, + [&application] { application.exit(4); }); + QTimer::singleShot(0, &window, [&window, path] { window.openDataset(path); }); } else if (argc == 3 && std::string_view(argv[1]) == "--mapped-grid-smoke-test") { // View > Mapped Grid on plotfile_3d_mapped (4^3 cells, dx = 0.25, diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d3dc35ce..7837b5db 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1296,6 +1296,20 @@ if(TARGET amrexplorer_qt) -P "${CMAKE_CURRENT_SOURCE_DIR}/qt_smoke_driver.cmake" ) set_tests_properties(qt_physical_aspect_smoke PROPERTIES TIMEOUT 30) + # Axis Scaling factors are saved, kept across opens and restored by the + # next process. + add_test( + NAME qt_axis_scale_settings_smoke + COMMAND "${CMAKE_COMMAND}" + "-DMATERIALIZER=$" + "-DAMREXPLORER_QT=$" + "-DSOURCE=${CMAKE_CURRENT_SOURCE_DIR}/data/plotfile_3d" + "-DSOURCE2=${CMAKE_CURRENT_SOURCE_DIR}/data/plotfile_2d_spherical" + "-DWORK=${CMAKE_CURRENT_BINARY_DIR}/fixtures_axis_scale_settings" + -DMODE=axis-scale-settings + -P "${CMAKE_CURRENT_SOURCE_DIR}/qt_smoke_driver.cmake" + ) + set_tests_properties(qt_axis_scale_settings_smoke PROPERTIES TIMEOUT 60) # In Physical Size a fixed scale is the same pixels per length on every # 3-D panel: x is as wide on the XY panel as on the XZ panel beside it. diff --git a/tests/qt_smoke_driver.cmake b/tests/qt_smoke_driver.cmake index 20c1c1a7..198ed031 100644 --- a/tests/qt_smoke_driver.cmake +++ b/tests/qt_smoke_driver.cmake @@ -36,7 +36,7 @@ # scale-state | effective-scale | # arrow-key-routing | animation-dock-role | open-failure | # idle-ui-state | menu-shortcuts | sequence-scale-report | -# slice-planes | panel-layout | +# slice-planes | panel-layout | axis-scale-settings | # spherical-scale-report | # fixed-scale-centre | fab-overlap-failure | # fab-direct-open-failure @@ -259,6 +259,13 @@ elseif(MODE STREQUAL "remote-cell-aspect") elseif(MODE STREQUAL "physical-aspect") run_or_die("${MATERIALIZER}" "${SOURCE}" "${WORK}/plt") run_or_die("${AMREXPLORER_QT}" --physical-aspect-smoke-test "${WORK}/plt") +elseif(MODE STREQUAL "axis-scale-settings") + # Two processes on the one config directory cleared above. + run_or_die("${MATERIALIZER}" "${SOURCE}" "${WORK}/plt") + run_or_die("${MATERIALIZER}" "${SOURCE2}" "${WORK}/spherical") + run_or_die("${AMREXPLORER_QT}" --axis-scale-settings-smoke-test save "${WORK}/plt") + run_or_die("${AMREXPLORER_QT}" --axis-scale-settings-smoke-test load "${WORK}/plt" + "${WORK}/spherical") elseif(MODE STREQUAL "physical-fixed-scale") run_or_die("${MATERIALIZER}" "${SOURCE}" "${WORK}/plt") run_or_die("${AMREXPLORER_QT}" --physical-fixed-scale-smoke-test "${WORK}/plt") diff --git a/tests/unit/test_scientific_double_spin_box.cpp b/tests/unit/test_scientific_double_spin_box.cpp index 9d1d4120..309315e3 100644 --- a/tests/unit/test_scientific_double_spin_box.cpp +++ b/tests/unit/test_scientific_double_spin_box.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -151,4 +152,66 @@ int main(int argc, char* argv[]) require(spinBox.value() == 0.5 && !spinBox.editor()->isModified(), "committing identical text changed the value or left a pending edit"); } + + // A number typed past a bounded range is clamped to the bound, visibly, + // rather than silently replaced by the previous value. + TestScientificDoubleSpinBox bounded; + bounded.setRange(0.01, 10000.0); + bounded.setValue(1.0); + const auto commit = [&bounded](const QString& text, bool focusOut) { + bounded.editor()->setText(text); + bounded.editor()->setModified(true); + if (focusOut) { + QFocusEvent event(QEvent::FocusOut, Qt::TabFocusReason); + QApplication::sendEvent(&bounded, &event); + } else { + bounded.interpretText(); + } + }; + commit(QStringLiteral("10000000000000000000"), false); + require(bounded.value() == 10000.0 && bounded.cleanText() == QStringLiteral("10000"), + "a number above the range was not clamped to the maximum"); + bounded.setValue(1.0); + commit(QStringLiteral("1e400"), true); + require(bounded.value() == 10000.0, + "a number past double range was not clamped to the maximum"); + commit(QStringLiteral("0.001"), true); + require(bounded.value() == 0.01 && bounded.cleanText() == QStringLiteral("0.01"), + "a number below the range was not clamped to the minimum"); + bounded.setValue(1.0); + commit(QStringLiteral("1e-400"), true); + require(bounded.value() == 0.01, + "a number past double range toward zero was not clamped to the minimum"); + bounded.setValue(1.0); + commit(QStringLiteral("1e"), true); + require(bounded.value() == 1.0, + "an unfinished number did not keep the previous value"); + + // The clamped value is parsed again, so a short display format must not + // round it past the bound: %.3g shows DBL_MAX as 1.8e+308, an overflow, + // and 1.2356 as 1.24. + bounded.setNumberFormat(QStringLiteral("%.3g")); + bounded.setRange(-std::numeric_limits::max(), std::numeric_limits::max()); + bounded.setValue(1.0); + commit(QStringLiteral("1e400"), true); + require(bounded.value() == std::numeric_limits::max(), + "a short format rounded the clamped maximum into an overflow"); + bounded.setRange(0.0, 1.2356); + bounded.setValue(1.0); + commit(QStringLiteral("5"), false); + require(bounded.value() == 1.2356 && bounded.cleanText() == QStringLiteral("1.24"), + "a short format rounded the clamped maximum past the bound"); + + // Under a comma-decimal locale the clamped text must not carry a group + // separator, which that locale would read as a decimal point (10,000 as 10). + TestScientificDoubleSpinBox german; + german.setLocale(QLocale(QLocale::German, QLocale::Germany)); + german.setRange(0.01, 10000.0); + german.setValue(1.0); + german.editor()->setText(QStringLiteral("1,5e4")); + german.editor()->setModified(true); + QFocusEvent germanFocusOut(QEvent::FocusOut, Qt::TabFocusReason); + QApplication::sendEvent(&german, &germanFocusOut); + require(german.value() == 10000.0, + "a comma-decimal locale read the clamped maximum's group separator as a decimal"); }