From be37448a7ea93edb4961311de5ee89785ee73c0d Mon Sep 17 00:00:00 2001 From: Don Gagne Date: Wed, 2 Sep 2026 10:02:59 -0700 Subject: [PATCH] feat(Settings): allow custom builds to extend generated settings pages Custom builds (QGC_CUSTOM_DIR) can now add, replace, reposition, or remove generated settings pages without overriding the stock generated QML: - SettingsPages.json overlay merged at configure time (insertAfter/ insertBefore/replace-by-name/remove) with hard-error validation - Custom *.SettingsUI.json page definitions shadow stock ones - Custom *.SettingsGroup.json fact metadata feeds the generator; accessor collisions with stock SettingsManager Q_PROPERTYs are rejected - SettingsManager now derives from QQmlPropertyMap and exposes registerCustomSettingsGroup (takes ownership; rejected groups deleted), invoked via the new QGCCorePlugin::registerCustomSettings hook - CMake computes the generated output list via the generator's --list-outputs mode when an overlay is active - custom-example includes a complete working example page --- custom-example/CMakeLists.txt | 12 + .../AppSettings/pages/Custom.SettingsUI.json | 20 + .../src/AppSettings/pages/SettingsPages.json | 14 + custom-example/src/CustomPlugin.cc | 16 +- custom-example/src/CustomPlugin.h | 6 +- .../src/Settings/Custom.SettingsGroup.json | 29 ++ custom-example/src/Settings/CustomSettings.cc | 9 + custom-example/src/Settings/CustomSettings.h | 23 + .../views/settings_generation.md | 18 + src/API/QGCCorePlugin.h | 6 + src/AppSettings/CMakeLists.txt | 76 ++- src/Settings/SettingsManager.cc | 34 +- src/Settings/SettingsManager.h | 15 +- test/Settings/CMakeLists.txt | 3 + test/Settings/SettingsManagerTest.cc | 102 ++++ test/Settings/SettingsManagerTest.h | 16 + tools/generators/settings_qml/README.md | 32 ++ tools/generators/settings_qml/emit.py | 47 +- .../generators/settings_qml/generate_pages.py | 73 ++- tools/generators/settings_qml/metadata.py | 89 +++- tools/generators/settings_qml/model.py | 167 ++++++- .../generators/settings_qml/page_generator.py | 4 + tools/tests/test_settings_qml_generator.py | 440 ++++++++++++++++++ 23 files changed, 1141 insertions(+), 110 deletions(-) create mode 100644 custom-example/src/AppSettings/pages/Custom.SettingsUI.json create mode 100644 custom-example/src/AppSettings/pages/SettingsPages.json create mode 100644 custom-example/src/Settings/Custom.SettingsGroup.json create mode 100644 custom-example/src/Settings/CustomSettings.cc create mode 100644 custom-example/src/Settings/CustomSettings.h create mode 100644 test/Settings/SettingsManagerTest.cc create mode 100644 test/Settings/SettingsManagerTest.h diff --git a/custom-example/CMakeLists.txt b/custom-example/CMakeLists.txt index 3d2fb68b1045..9b59b10777d3 100644 --- a/custom-example/CMakeLists.txt +++ b/custom-example/CMakeLists.txt @@ -124,6 +124,15 @@ qt_add_resources(${CMAKE_PROJECT_NAME} custom_json FILES "${CUSTOM_JSON_RESOURCE}" ) +# CustomSettings group metadata: SettingsGroup loads it from :/json at runtime and +# the settings QML generator reads it from src/Settings at configure time. +set(CUSTOM_SETTINGS_JSON_RESOURCE "${CMAKE_CURRENT_SOURCE_DIR}/src/Settings/Custom.SettingsGroup.json") +qgc_set_qt_resource_alias("${CUSTOM_SETTINGS_JSON_RESOURCE}") +qt_add_resources(${CMAKE_PROJECT_NAME} custom_settings_json + PREFIX "/json" + FILES "${CUSTOM_SETTINGS_JSON_RESOURCE}" +) + list(APPEND QML_IMPORT_PATH "${CMAKE_CURRENT_SOURCE_DIR}/res") set(QML_IMPORT_PATH "${QML_IMPORT_PATH}" CACHE STRING "Extra QML import paths" FORCE) @@ -200,6 +209,8 @@ set(CUSTOM_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/MissionManager/PerimeterScanComplexItem.h ${CMAKE_CURRENT_SOURCE_DIR}/src/MissionManager/PerimeterScanPlanCreator.cc ${CMAKE_CURRENT_SOURCE_DIR}/src/MissionManager/PerimeterScanPlanCreator.h + ${CMAKE_CURRENT_SOURCE_DIR}/src/Settings/CustomSettings.cc + ${CMAKE_CURRENT_SOURCE_DIR}/src/Settings/CustomSettings.h PARENT_SCOPE ) @@ -218,6 +229,7 @@ set(CUSTOM_INCLUDE_DIRECTORIES ${CMAKE_CURRENT_SOURCE_DIR}/src/AutoPilotPlugin ${CMAKE_CURRENT_SOURCE_DIR}/src/FirmwarePlugin ${CMAKE_CURRENT_SOURCE_DIR}/src/MissionManager + ${CMAKE_CURRENT_SOURCE_DIR}/src/Settings PARENT_SCOPE ) diff --git a/custom-example/src/AppSettings/pages/Custom.SettingsUI.json b/custom-example/src/AppSettings/pages/Custom.SettingsUI.json new file mode 100644 index 000000000000..5af21b1efffb --- /dev/null +++ b/custom-example/src/AppSettings/pages/Custom.SettingsUI.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "fileType": "SettingsUI", + "groups": [ + { + "heading": "Custom Example", + "controls": [ + { + "setting": "customSettings.showAttitudeWidget" + }, + { + "setting": "customSettings.updateInterval" + }, + { + "setting": "customSettings.operatorName" + } + ] + } + ] +} diff --git a/custom-example/src/AppSettings/pages/SettingsPages.json b/custom-example/src/AppSettings/pages/SettingsPages.json new file mode 100644 index 000000000000..d5bcde4a46fa --- /dev/null +++ b/custom-example/src/AppSettings/pages/SettingsPages.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "fileType": "SettingsPages", + "comment": "Custom-build overlay merged into src/AppSettings/pages/SettingsPages.json. Entries can append, insertAfter/insertBefore, replace (same name), or remove pages.", + "pages": [ + { + "name": "Custom", + "qml": "CustomSettings.qml", + "icon": "qrc:/res/gear-white.svg", + "pageDefinition": "Custom.SettingsUI.json", + "insertAfter": "General" + } + ] +} diff --git a/custom-example/src/CustomPlugin.cc b/custom-example/src/CustomPlugin.cc index 7058ef3847fc..0ee987a82752 100644 --- a/custom-example/src/CustomPlugin.cc +++ b/custom-example/src/CustomPlugin.cc @@ -1,11 +1,12 @@ #include "CustomPlugin.h" +#include "CustomSettings.h" #include "PerimeterScanComplexItem.h" #include "PerimeterScanPlanCreator.h" -#include "QmlComponentInfo.h" #include "QGCLoggingCategory.h" #include "QGCPalette.h" #include "QGCMAVLink.h" #include "AppSettings.h" +#include "SettingsManager.h" #include #include @@ -52,18 +53,9 @@ void CustomPlugin::_advancedChanged(bool changed) emit _options->showFirmwareUpgradeChanged(changed); } -void CustomPlugin::_addSettingsEntry(const QString &title, const char *qmlFile, const char *iconFile) +void CustomPlugin::registerCustomSettings(SettingsManager *settingsManager) { - Q_CHECK_PTR(qmlFile); - // 'this' instance will take ownership on the QmlComponentInfo instance - _customSettingsList.append(QVariant::fromValue( - new QmlComponentInfo( - title, - QUrl::fromUserInput(qmlFile), - !iconFile ? QUrl() : QUrl::fromUserInput(iconFile), - this) - ) - ); + settingsManager->registerCustomSettingsGroup(QStringLiteral("customSettings"), new CustomSettings()); } void CustomPlugin::adjustSettingMetaData(const QString& settingsGroup, FactMetaData& metaData, bool &userVisible) diff --git a/custom-example/src/CustomPlugin.h b/custom-example/src/CustomPlugin.h index 6662dfcfaac6..8db735f4f44a 100644 --- a/custom-example/src/CustomPlugin.h +++ b/custom-example/src/CustomPlugin.h @@ -11,7 +11,6 @@ class PlanCreator; class CustomOptions; class CustomPlugin; -class CustomSettings; class QQmlApplicationEngine; Q_DECLARE_LOGGING_CATEGORY(CustomLog) @@ -84,17 +83,16 @@ class CustomPlugin : public QGCCorePlugin const QString &kmlOrShpFile = QString()) final; /// Adds the Perimeter Scan plan creator to the New Plan dialog. QList planCreators(PlanMasterController *planMasterController) final; + /// Registers the CustomSettings group so the generated Custom settings page can access it. + void registerCustomSettings(SettingsManager *settingsManager) final; private slots: void _advancedChanged(bool advanced); private: - void _addSettingsEntry(const QString& title, const char* qmlFile, const char* iconFile = nullptr); - CustomOptions *_options = nullptr; QQmlApplicationEngine *_qmlEngine = nullptr; class CustomOverrideInterceptor *_urlInterceptor = nullptr; - QVariantList _customSettingsList; // Not to be mixed up with QGCCorePlugin implementation }; /*===========================================================================*/ diff --git a/custom-example/src/Settings/Custom.SettingsGroup.json b/custom-example/src/Settings/Custom.SettingsGroup.json new file mode 100644 index 000000000000..61b30118bae5 --- /dev/null +++ b/custom-example/src/Settings/Custom.SettingsGroup.json @@ -0,0 +1,29 @@ +{ + "version": 1, + "fileType": "FactMetaData", + "QGC.MetaData.Facts": [ + { + "name": "showAttitudeWidget", + "shortDesc": "Show the custom attitude widget in the fly view.", + "type": "bool", + "default": true, + "label": "Show attitude widget" + }, + { + "name": "updateInterval", + "shortDesc": "How often the custom widgets refresh their values.", + "type": "uint32", + "enumStrings": "Slow,Normal,Fast", + "enumValues": "0,1,2", + "default": 1, + "label": "Widget update rate" + }, + { + "name": "operatorName", + "shortDesc": "Operator name shown in the custom fly view overlay.", + "type": "string", + "default": "", + "label": "Operator name" + } + ] +} diff --git a/custom-example/src/Settings/CustomSettings.cc b/custom-example/src/Settings/CustomSettings.cc new file mode 100644 index 000000000000..cb4cfc93898e --- /dev/null +++ b/custom-example/src/Settings/CustomSettings.cc @@ -0,0 +1,9 @@ +#include "CustomSettings.h" + +DECLARE_SETTINGGROUP(Custom, "Custom") +{ +} + +DECLARE_SETTINGSFACT(CustomSettings, showAttitudeWidget) +DECLARE_SETTINGSFACT(CustomSettings, updateInterval) +DECLARE_SETTINGSFACT(CustomSettings, operatorName) diff --git a/custom-example/src/Settings/CustomSettings.h b/custom-example/src/Settings/CustomSettings.h new file mode 100644 index 000000000000..24077814ef5b --- /dev/null +++ b/custom-example/src/Settings/CustomSettings.h @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include "SettingsGroup.h" + +/// Example custom-build settings group. Registered at runtime via +/// CustomPlugin::registerCustomSettings so generated settings pages can reference +/// facts as QGroundControl.settingsManager.customSettings.. +class CustomSettings : public SettingsGroup +{ + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("") +public: + CustomSettings(QObject* parent = nullptr); + + DEFINE_SETTING_NAME_GROUP() + + DEFINE_SETTINGFACT(showAttitudeWidget) + DEFINE_SETTINGFACT(updateInterval) + DEFINE_SETTINGFACT(operatorName) +}; diff --git a/docs/en/qgc-dev-guide/views/settings_generation.md b/docs/en/qgc-dev-guide/views/settings_generation.md index 3997359e7259..97c5d06dc5d1 100644 --- a/docs/en/qgc-dev-guide/views/settings_generation.md +++ b/docs/en/qgc-dev-guide/views/settings_generation.md @@ -81,6 +81,24 @@ Search terms are derived from: - Add your new QML filename to `_generated_qml_names`. 4. Build QGC to generate and include the new page. +## Custom Build Settings Pages + +Custom builds (`QGC_CUSTOM_DIR`) can add, replace, reposition, or remove generated settings pages without overriding the stock generated QML: + +1. **Page list overlay** — create `/src/AppSettings/pages/SettingsPages.json`. Its entries are merged into the stock page list at configure time: + - An entry whose `name` matches a stock page **replaces** it in place. + - New entries support `insertAfter`/`insertBefore` (referencing a stock page `name`); otherwise they append. + - `{ "remove": "" }` removes a stock page. +2. **Page definitions** — put `*.SettingsUI.json` files in the same custom pages dir. A file with the same name as a stock definition shadows it. +3. **Custom settings groups** — to reference facts that don't exist in stock QGC: + - Add `/src/Settings/.SettingsGroup.json` fact metadata (also compile it into the app under the `:/json` resource prefix). + - Create a `SettingsGroup` subclass for it. + - Override `QGCCorePlugin::registerCustomSettings` and call `SettingsManager::registerCustomSettingsGroup("", new MySettings())` (the manager takes ownership). The accessor must be the camelCase JSON stem plus `Settings` (e.g. `Custom.SettingsGroup.json` → `customSettings`) so generated pages resolve `QGroundControl.settingsManager..`. + +CMake wires this automatically when the custom directories exist; the generated output list is computed by the generator's `--list-outputs` mode. + +The `custom-example` build in the repo includes a complete working example of all of the above: a page list overlay adding a custom settings page, its page definition, the custom settings group (fact metadata and `SettingsGroup` subclass), and the plugin registration override. + ## Important Notes - If a page in `SettingsPages.json` has no `pageDefinition`, it is treated as hand-written QML/URL content and not generated. diff --git a/src/API/QGCCorePlugin.h b/src/API/QGCCorePlugin.h index 4804cced5b2b..ee729d72a72f 100644 --- a/src/API/QGCCorePlugin.h +++ b/src/API/QGCCorePlugin.h @@ -17,6 +17,7 @@ class QGeoPositionInfoSource; class QmlObjectListModel; class QQmlApplicationEngine; class QQuickItem; +class SettingsManager; class Vehicle; class VideoReceiver; class VideoSink; @@ -84,6 +85,11 @@ class QGCCorePlugin : public QObject /// If not overridden, metaData and userVisible are left unchanged. virtual void adjustSettingMetaData(const QString &settingsGroup, FactMetaData &metaData, bool &userVisible); + /// Called at the end of SettingsManager::init. Override to register custom build + /// settings groups via SettingsManager::registerCustomSettingsGroup so generated + /// settings pages can reference them as QGroundControl.settingsManager.. + virtual void registerCustomSettings(SettingsManager *settingsManager) { Q_UNUSED(settingsManager); } + /// @return The message to show to the user when they are prompted to confirm turning on advanced ui. virtual QString showAdvancedUIMessage() const; diff --git a/src/AppSettings/CMakeLists.txt b/src/AppSettings/CMakeLists.txt index d4a864db840b..c24f7aad9206 100644 --- a/src/AppSettings/CMakeLists.txt +++ b/src/AppSettings/CMakeLists.txt @@ -19,29 +19,71 @@ file(GLOB _generator_sources CONFIGURE_DEPENDS file(GLOB _page_definitions CONFIGURE_DEPENDS "${SETTINGS_PAGES_DIR}/*.json") file(GLOB _settings_metadata CONFIGURE_DEPENDS "${SETTINGS_METADATA_DIR}/*.SettingsGroup.json") -# Generated QML outputs (must match what generate_pages.py produces) -set(_generated_qml_names - ADSBServerSettings.qml - CommLinksSettings.qml - FlyViewSettings.qml - GeneralSettings.qml - LoggingSettings.qml - MapsSettings.qml - NTRIPSettings.qml - PlanViewSettings.qml - PX4LogTransferSettings.qml - RemoteIDSettings.qml - SettingsPagesModel.qml - TelemetrySettings.qml - VideoSettings.qml - Viewer3DSettings.qml -) +# Custom builds may overlay the page list (SettingsPages.json), add/shadow page +# definitions, and add custom SettingsGroup.json fact metadata. +set(_custom_codegen_args "") +set(CUSTOM_SETTINGS_PAGES_DIR "${CMAKE_SOURCE_DIR}/${QGC_CUSTOM_DIR}/src/AppSettings/pages") +set(CUSTOM_SETTINGS_METADATA_DIR "${CMAKE_SOURCE_DIR}/${QGC_CUSTOM_DIR}/src/Settings") +if(QGC_CUSTOM_BUILD AND IS_DIRECTORY "${CUSTOM_SETTINGS_PAGES_DIR}") + list(APPEND _custom_codegen_args --custom-pages-dir "${CUSTOM_SETTINGS_PAGES_DIR}") + file(GLOB _custom_page_definitions CONFIGURE_DEPENDS "${CUSTOM_SETTINGS_PAGES_DIR}/*.json") + list(APPEND _page_definitions ${_custom_page_definitions}) +endif() +if(QGC_CUSTOM_BUILD AND IS_DIRECTORY "${CUSTOM_SETTINGS_METADATA_DIR}") + list(APPEND _custom_codegen_args --custom-settings-dir "${CUSTOM_SETTINGS_METADATA_DIR}") + file(GLOB _custom_settings_metadata CONFIGURE_DEPENDS "${CUSTOM_SETTINGS_METADATA_DIR}/*.SettingsGroup.json") + list(APPEND _settings_metadata ${_custom_settings_metadata}) +endif() + +if(_custom_codegen_args) + # The overlay can add/remove/replace pages, so ask the generator for the + # resulting output file list instead of hardcoding it. + execute_process( + COMMAND ${Python3_EXECUTABLE} -m tools.generators.settings_qml.generate_pages + --list-outputs ${_custom_codegen_args} + WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}" + OUTPUT_VARIABLE _generated_qml_names + RESULT_VARIABLE _list_outputs_result + ERROR_VARIABLE _list_outputs_error + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + if(NOT _list_outputs_result EQUAL 0) + message(FATAL_ERROR "Settings QML generator --list-outputs failed:\n${_list_outputs_error}") + endif() + # Python emits CRLF on Windows; execute_process does not normalize it + string(REPLACE "\r" "" _generated_qml_names "${_generated_qml_names}") + string(REPLACE "\n" ";" _generated_qml_names "${_generated_qml_names}") +else() + # Generated QML outputs (must match what generate_pages.py produces) + set(_generated_qml_names + ADSBServerSettings.qml + CommLinksSettings.qml + FlyViewSettings.qml + GeneralSettings.qml + LoggingSettings.qml + MapsSettings.qml + NTRIPSettings.qml + PlanViewSettings.qml + PX4LogTransferSettings.qml + RemoteIDSettings.qml + SettingsPagesModel.qml + TelemetrySettings.qml + VideoSettings.qml + Viewer3DSettings.qml + ) +endif() + +set(_custom_extra_args_keyword "") +if(_custom_codegen_args) + set(_custom_extra_args_keyword EXTRA_ARGS ${_custom_codegen_args}) +endif() qgc_add_qml_codegen(GenerateSettingsQml GENERATE_AT_CONFIGURE GENERATOR_MODULE tools.generators.settings_qml.generate_pages OUTPUT_DIR "${SETTINGS_QML_GEN_DIR}" QML_NAMES ${_generated_qml_names} + ${_custom_extra_args_keyword} DEPENDS ${_generator_sources} ${_page_definitions} ${_settings_metadata} COMMENT "Generating QML settings pages from JSON definitions" ) diff --git a/src/Settings/SettingsManager.cc b/src/Settings/SettingsManager.cc index cd3b396396de..1df21f646be6 100644 --- a/src/Settings/SettingsManager.cc +++ b/src/Settings/SettingsManager.cc @@ -27,15 +27,17 @@ #include "Viewer3DSettings.h" #include "JsonParsing.h" #include "QGCCorePlugin.h" +#include "SettingsGroup.h" #include +#include QGC_LOGGING_CATEGORY(SettingsManagerLog, "Utilities.SettingsManager") Q_APPLICATION_STATIC(SettingsManager, _settingsManagerInstance); SettingsManager::SettingsManager(QObject *parent) - : QObject(parent) + : QQmlPropertyMap(this, parent) { qCDebug(SettingsManagerLog) << this; } @@ -79,6 +81,36 @@ void SettingsManager::init() _viewer3DSettings = new Viewer3DSettings(this); _adsbVehicleManagerSettings = new ADSBVehicleManagerSettings(this); _apmMavlinkStreamRateSettings = new APMMavlinkStreamRateSettings(this); + + QGCCorePlugin::instance()->registerCustomSettings(this); +} + +void SettingsManager::registerCustomSettingsGroup(const QString &accessorName, SettingsGroup *group) +{ + // Must be a valid QML identifier or generated pages can't resolve the group via dot notation + static const QRegularExpression validAccessorRe(QStringLiteral("^[a-z_][A-Za-z0-9_]*$")); + if (!validAccessorRe.match(accessorName).hasMatch() || !group) { + qCWarning(SettingsManagerLog) << "registerCustomSettingsGroup: invalid accessor name or null group" << accessorName; + delete group; + return; + } + if (contains(accessorName)) { + qCWarning(SettingsManagerLog) << "registerCustomSettingsGroup: accessor already registered" << accessorName; + // Re-registering the stored group itself must not destroy it + if (group != value(accessorName).value()) { + delete group; + } + return; + } + if (staticMetaObject.indexOfProperty(accessorName.toUtf8().constData()) != -1) { + qCWarning(SettingsManagerLog) << "registerCustomSettingsGroup: accessor collides with a built-in settings group" << accessorName; + delete group; + return; + } + + group->setParent(this); + insert(accessorName, QVariant::fromValue(group)); + qCDebug(SettingsManagerLog) << "Registered custom settings group" << accessorName; } ADSBVehicleManagerSettings *SettingsManager::adsbVehicleManagerSettings() const { return _adsbVehicleManagerSettings; } diff --git a/src/Settings/SettingsManager.h b/src/Settings/SettingsManager.h index 0844a7200bd1..29086596834f 100644 --- a/src/Settings/SettingsManager.h +++ b/src/Settings/SettingsManager.h @@ -1,9 +1,9 @@ #pragma once -#include -#include #include #include +#include +#include class ADSBVehicleManagerSettings; class APMMavlinkStreamRateSettings; @@ -30,10 +30,11 @@ class FactMetaData; class JoystickManagerSettings; class LogManagerSettings; class LogViewerSettings; +class SettingsGroup; /// \brief Provides access to all app settings /// -class SettingsManager : public QObject +class SettingsManager : public QQmlPropertyMap { Q_OBJECT QML_ELEMENT @@ -100,6 +101,14 @@ class SettingsManager : public QObject /// @param userVisible - true: Setting should be visible in ui, false: Setting should not be shown in ui (default value will be used as value) static void adjustSettingMetaData(const QString &settingsGroup, FactMetaData &metaData, bool &userVisible); + /// Registers a custom build settings group so QML can access it as + /// QGroundControl.settingsManager.. Called from a + /// QGCCorePlugin::registerCustomSettings override. The accessor name must be the + /// camelCase form of the group's SettingsGroup.json stem plus "Settings" + /// (e.g. Custom.SettingsGroup.json -> "customSettings") so the generated settings + /// pages resolve to the same name. Takes ownership of the group; a rejected group is deleted. + void registerCustomSettingsGroup(const QString &accessorName, SettingsGroup *group); + ADSBVehicleManagerSettings *adsbVehicleManagerSettings() const; APMMavlinkStreamRateSettings *apmMavlinkStreamRateSettings() const; AppSettings *appSettings() const; diff --git a/test/Settings/CMakeLists.txt b/test/Settings/CMakeLists.txt index ae4bd64a1731..dd4c0253f99f 100644 --- a/test/Settings/CMakeLists.txt +++ b/test/Settings/CMakeLists.txt @@ -13,6 +13,8 @@ target_sources(${CMAKE_PROJECT_NAME} FlyViewSettingsTest.h RemoteIDSettingsTest.cc RemoteIDSettingsTest.h + SettingsManagerTest.cc + SettingsManagerTest.h ) target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) @@ -21,3 +23,4 @@ add_qgc_test(AppSettingsTest LABELS Unit) add_qgc_test(AutoConnectSettingsTest LABELS Unit) add_qgc_test(FlyViewSettingsTest LABELS Unit) add_qgc_test(RemoteIDSettingsTest LABELS Unit) +add_qgc_test(SettingsManagerTest LABELS Unit) diff --git a/test/Settings/SettingsManagerTest.cc b/test/Settings/SettingsManagerTest.cc new file mode 100644 index 000000000000..220d4513ed6c --- /dev/null +++ b/test/Settings/SettingsManagerTest.cc @@ -0,0 +1,102 @@ +#include "SettingsManagerTest.h" + +#include "SettingsManager.h" +#include "Viewer3DSettings.h" + +#include +#include + +UT_REGISTER_TEST(SettingsManagerTest, TestLabel::Unit) + +void SettingsManagerTest::_registerCustomGroup() +{ + SettingsManager settingsManager; + auto *group = new Viewer3DSettings(nullptr); + + settingsManager.registerCustomSettingsGroup(QStringLiteral("myCustomSettings"), group); + + QVERIFY(settingsManager.contains(QStringLiteral("myCustomSettings"))); + QCOMPARE(settingsManager.value(QStringLiteral("myCustomSettings")).value(), group); + QCOMPARE(group->parent(), &settingsManager); +} + +void SettingsManagerTest::_registerRejectsBuiltInAccessorCollision() +{ + SettingsManager settingsManager; + QPointer group = new Viewer3DSettings(nullptr); + + expectLogMessage("Utilities.SettingsManager", QtWarningMsg, QRegularExpression(QStringLiteral("collides with a built-in settings group"))); + settingsManager.registerCustomSettingsGroup(QStringLiteral("appSettings"), group); + verifyExpectedLogMessage(); + + QVERIFY(!settingsManager.contains(QStringLiteral("appSettings"))); + QVERIFY(group.isNull()); // Rejected group is deleted +} + +void SettingsManagerTest::_registerRejectsDuplicateAccessor() +{ + SettingsManager settingsManager; + auto *firstGroup = new Viewer3DSettings(nullptr); + QPointer secondGroup = new Viewer3DSettings(nullptr); + + settingsManager.registerCustomSettingsGroup(QStringLiteral("myCustomSettings"), firstGroup); + + expectLogMessage("Utilities.SettingsManager", QtWarningMsg, QRegularExpression(QStringLiteral("already registered"))); + settingsManager.registerCustomSettingsGroup(QStringLiteral("myCustomSettings"), secondGroup); + verifyExpectedLogMessage(); + + QCOMPARE(settingsManager.value(QStringLiteral("myCustomSettings")).value(), firstGroup); + QVERIFY(secondGroup.isNull()); // Rejected group is deleted +} + +void SettingsManagerTest::_registerSamePointerTwiceKeepsGroupAlive() +{ + SettingsManager settingsManager; + QPointer group = new Viewer3DSettings(nullptr); + + settingsManager.registerCustomSettingsGroup(QStringLiteral("myCustomSettings"), group); + + expectLogMessage("Utilities.SettingsManager", QtWarningMsg, QRegularExpression(QStringLiteral("already registered"))); + settingsManager.registerCustomSettingsGroup(QStringLiteral("myCustomSettings"), group); + verifyExpectedLogMessage(); + + QVERIFY(!group.isNull()); // The stored group must not be deleted + QCOMPARE(settingsManager.value(QStringLiteral("myCustomSettings")).value(), group.data()); +} + +void SettingsManagerTest::_registerRejectsInvalidArguments() +{ + SettingsManager settingsManager; + QPointer group = new Viewer3DSettings(nullptr); + + expectLogMessage("Utilities.SettingsManager", QtWarningMsg, QRegularExpression(QStringLiteral("invalid accessor name or null group"))); + settingsManager.registerCustomSettingsGroup(QString(), group); + verifyExpectedLogMessage(); + QVERIFY(group.isNull()); // Rejected group is deleted + + expectLogMessage("Utilities.SettingsManager", QtWarningMsg, QRegularExpression(QStringLiteral("invalid accessor name or null group"))); + settingsManager.registerCustomSettingsGroup(QStringLiteral("myCustomSettings"), nullptr); + verifyExpectedLogMessage(); + + QVERIFY(!settingsManager.contains(QStringLiteral("myCustomSettings"))); +} + +void SettingsManagerTest::_registerRejectsInvalidQmlIdentifier() +{ + SettingsManager settingsManager; + + const QStringList badAccessors = { + QStringLiteral("3DSettings"), + QStringLiteral("my-settings"), + QStringLiteral("MySettings"), + QStringLiteral("my settings"), + }; + for (const QString &accessor : badAccessors) { + QPointer group = new Viewer3DSettings(nullptr); + expectLogMessage("Utilities.SettingsManager", QtWarningMsg, QRegularExpression(QStringLiteral("invalid accessor name or null group"))); + settingsManager.registerCustomSettingsGroup(accessor, group); + verifyExpectedLogMessage(); + QVERIFY2(!settingsManager.contains(accessor), qPrintable(accessor)); + QVERIFY2(group.isNull(), qPrintable(accessor)); // Rejected group is deleted + } +} diff --git a/test/Settings/SettingsManagerTest.h b/test/Settings/SettingsManagerTest.h new file mode 100644 index 000000000000..20dc2d7bca03 --- /dev/null +++ b/test/Settings/SettingsManagerTest.h @@ -0,0 +1,16 @@ +#pragma once + +#include "UnitTest.h" + +class SettingsManagerTest : public UnitTest +{ + Q_OBJECT + +private slots: + void _registerCustomGroup(); + void _registerRejectsBuiltInAccessorCollision(); + void _registerRejectsDuplicateAccessor(); + void _registerSamePointerTwiceKeepsGroupAlive(); + void _registerRejectsInvalidArguments(); + void _registerRejectsInvalidQmlIdentifier(); +}; diff --git a/tools/generators/settings_qml/README.md b/tools/generators/settings_qml/README.md index ede7eb03e27a..333a483da10c 100644 --- a/tools/generators/settings_qml/README.md +++ b/tools/generators/settings_qml/README.md @@ -63,6 +63,38 @@ A page entry must have exactly one of `pageDefinition` (generated) or `url` (han --- +## Custom build overlay + +A custom build (`QGC_CUSTOM_DIR`) can pass `--custom-pages-dir ` and +`--custom-settings-dir ` (CMake does this automatically when +`/src/AppSettings/pages` / `/src/Settings` exist): + +- `/SettingsPages.json` is merged into the stock page list. + Overlay entries support three extra keys: + + | Key | Type | Description | + | --- | --- | --- | + | `insertAfter` | string | Insert the new page after the named stock page | + | `insertBefore` | string | Insert the new page before the named stock page | + | `remove` | string | Remove the named stock page (no other keys allowed) | + + An overlay entry whose `name` matches an existing page replaces it in place + (positioning keys are not allowed on a replace). +- `*.SettingsUI.json` files in the custom pages dir are found first, so a file + with the same name as a stock definition shadows it. +- `*.SettingsGroup.json` files in the custom settings dir provide fact metadata + for custom groups registered at runtime via + `SettingsManager::registerCustomSettingsGroup` (accessor = camelCase JSON + stem plus `Settings`, e.g. `Custom.SettingsGroup.json` → `customSettings`). + A custom stem that maps to a stock `SettingsManager` accessor is rejected. + +`--list-outputs` prints the QML file names that would be generated (used by +CMake to compute the output list when an overlay is active). + +See the `custom-example` build for a working reference. + +--- + ## `*.SettingsUI.json` Defines the layout of a single settings page. diff --git a/tools/generators/settings_qml/emit.py b/tools/generators/settings_qml/emit.py index da04bb408d84..0cc7112fc0c4 100644 --- a/tools/generators/settings_qml/emit.py +++ b/tools/generators/settings_qml/emit.py @@ -14,9 +14,9 @@ render_slider, render_textfield, ) -from ..common.validation import reject_unknown_keys, require_list, require_qml_safe_string +from ..common.validation import require_qml_safe_string from .metadata import get_fact_type, has_enum_strings -from .model import ControlDef, PageDef, load_page_def +from .model import ControlDef, PageDef, load_page_def, load_pages_data, resolve_page_def_path _env = make_env(Path(__file__).parent / "templates") @@ -41,7 +41,7 @@ def _wrap_with_description(control_qml: str, fact_ref: str, vis_expr: str, inden ) -def _qml_control(ctrl: ControlDef, settings_dir: Path, json_context: str = "") -> str: +def _qml_control(ctrl: ControlDef, settings_dirs: Path | tuple[Path, ...], json_context: str = "") -> str: """Generate QML for a single control.""" indent = " " fact_ref = f"QGroundControl.settingsManager.{ctrl.setting}" @@ -110,8 +110,8 @@ def _vis_expr() -> str: elif ctrl.control == "textfield": use_checkbox, use_combobox = False, False else: - fact_type = get_fact_type(ctrl.setting, settings_dir) - has_enums = has_enum_strings(ctrl.setting, settings_dir) + fact_type = get_fact_type(ctrl.setting, settings_dirs) + has_enums = has_enum_strings(ctrl.setting, settings_dirs) use_checkbox = fact_type == "bool" use_combobox = not use_checkbox and has_enums @@ -138,7 +138,7 @@ def _vis_expr() -> str: ) return _wrap_with_description(control_qml, fact_ref, _vis_expr(), indent) - fact_type = get_fact_type(ctrl.setting, settings_dir) + fact_type = get_fact_type(ctrl.setting, settings_dirs) extra: list[str] = [f'objectName: "settingsTextField_{ctrl.fact_name}"'] if fact_type == "string": extra.append("textFieldPreferredWidth: _stringFieldWidth") @@ -159,11 +159,11 @@ def _qml_missing_placeholder(description: str) -> str: return _env.get_template("missing_placeholder.qml.j2").render(description=description) -def _needs_string_field_width(page: PageDef, settings_dir: Path) -> bool: +def _needs_string_field_width(page: PageDef, settings_dirs: Path | tuple[Path, ...]) -> bool: for grp in page.groups: for ctrl in grp.controls: - fact_type = get_fact_type(ctrl.setting, settings_dir) - has_enums = has_enum_strings(ctrl.setting, settings_dir) + fact_type = get_fact_type(ctrl.setting, settings_dirs) + has_enums = has_enum_strings(ctrl.setting, settings_dirs) if fact_type == "string" and not has_enums: return True return False @@ -203,7 +203,10 @@ def _qml_translate(context: str, text: str) -> str: def generate_page_qml( - page: PageDef, settings_dir: Path, json_context: str = "", page_name: str = "" + page: PageDef, + settings_dirs: Path | tuple[Path, ...], + json_context: str = "", + page_name: str = "", ) -> str: """Generate a complete QML settings page from a page definition.""" _tr = ( @@ -248,7 +251,7 @@ def generate_page_qml( ) seen_object_names[group_object_name] = grp.heading - blocks = [_qml_control(ctrl, settings_dir, json_context) for ctrl in grp.controls] + blocks = [_qml_control(ctrl, settings_dirs, json_context) for ctrl in grp.controls] blocks.extend(_qml_missing_placeholder(desc) for desc in grp.missing) group_blocks.append(_env.get_template("group_settings.qml.j2").render( object_name=group_object_name, @@ -271,31 +274,19 @@ def generate_page_qml( return _env.get_template("page.qml.j2").render( imports=page.imports, object_name=page_object_name, - has_string_fields=_needs_string_field_width(page, settings_dir), + has_string_fields=_needs_string_field_width(page, settings_dirs), bindings=bindings, groups=group_blocks, ) + "\n" -_ALLOWED_PAGES_ROOT_KEYS = frozenset({"fileType", "version", "comment", "pages"}) -_ALLOWED_PAGE_ENTRY_KEYS = frozenset({ - "comment", "divider", "name", "url", "qml", "icon", "visible", "pageDefinition", -}) - - -def generate_pages_model_qml(pages_json_path: Path) -> str: - """Generate SettingsPagesModel.qml from SettingsPages.json.""" - with open(pages_json_path, encoding="utf-8") as f: - data = json.load(f) - - reject_unknown_keys(data, _ALLOWED_PAGES_ROOT_KEYS, "pages file", pages_json_path) - +def generate_pages_model_qml(pages_json_path: Path, custom_pages_dir: Path | None = None) -> str: + """Generate SettingsPagesModel.qml from SettingsPages.json plus the optional custom overlay.""" pages_dir = pages_json_path.parent entries: list[dict] = [] imports: list[str] = [] - for entry in require_list(data.get("pages", []), "'pages'", pages_json_path): - reject_unknown_keys(entry, _ALLOWED_PAGE_ENTRY_KEYS, "page entry", pages_json_path) + for entry in load_pages_data(pages_json_path, custom_pages_dir): if entry.get("divider"): entries.append({"divider": True}) continue @@ -312,7 +303,7 @@ def generate_pages_model_qml(pages_json_path: Path) -> str: section_state_name = "" page_def_name = entry.get("pageDefinition") if page_def_name: - page_def_path = pages_dir / page_def_name + page_def_path = resolve_page_def_path(page_def_name, pages_dir, custom_pages_dir) if page_def_path.exists(): page_def = load_page_def(page_def_path) if page_def.bindings or any(group.showWhen for group in page_def.groups): diff --git a/tools/generators/settings_qml/generate_pages.py b/tools/generators/settings_qml/generate_pages.py index 0c9141657e25..d6999aa1ecb4 100644 --- a/tools/generators/settings_qml/generate_pages.py +++ b/tools/generators/settings_qml/generate_pages.py @@ -18,7 +18,6 @@ from __future__ import annotations import argparse -import json import sys from pathlib import Path @@ -29,7 +28,14 @@ from common.io import write_text_if_changed # noqa: E402 -from .page_generator import generate_page_qml, generate_pages_model_qml, load_page_def # noqa: E402 +from .page_generator import ( # noqa: E402 + generate_page_qml, + generate_pages_model_qml, + load_page_def, + load_pages_data, + load_settings_metadata, + resolve_page_def_path, +) PAGES_DIR = Path("src/AppSettings/pages") SETTINGS_DIR = Path("src/Settings") @@ -37,50 +43,73 @@ def main() -> int: parser = argparse.ArgumentParser(description="Generate QML settings pages from UI definitions") + parser.add_argument("--output-dir", "-o", help="Output directory for generated QML files") parser.add_argument( - "--output-dir", "-o", required=True, help="Output directory for generated QML files" + "--dry-run", "-n", action="store_true", help="Print what would be generated without writing" ) parser.add_argument( - "--dry-run", "-n", action="store_true", help="Print what would be generated without writing" + "--custom-pages-dir", + type=Path, + help="Custom-build pages dir with an optional SettingsPages.json overlay " + "and shadowing/extra page definition files", + ) + parser.add_argument( + "--custom-settings-dir", + type=Path, + help="Custom-build dir with additional *.SettingsGroup.json fact metadata", + ) + parser.add_argument( + "--list-outputs", + action="store_true", + help="Print the QML file names that would be generated, one per line, without writing", ) args = parser.parse_args() - output_dir = Path(args.output_dir) - pages_json = PAGES_DIR / "SettingsPages.json" + if not args.list_outputs and not args.output_dir: + parser.error("--output-dir is required unless --list-outputs is given") + pages_json = PAGES_DIR / "SettingsPages.json" if not pages_json.exists(): print(f"ERROR: {pages_json} not found", file=sys.stderr) return 1 - with open(pages_json, encoding="utf-8") as f: - pages_data = json.load(f) + settings_dirs: tuple[Path, ...] = (SETTINGS_DIR,) + if args.custom_settings_dir: + settings_dirs += (args.custom_settings_dir,) + # Metadata is otherwise loaded lazily per control; validate custom accessor + # collisions/grammar unconditionally, even when no generated page needs metadata + load_settings_metadata(settings_dirs) + page_entries = load_pages_data(pages_json, args.custom_pages_dir) + output_names: list[str] = [] generated = 0 # Generate per-page QML files - for entry in pages_data.get("pages", []): + for entry in page_entries: if entry.get("divider"): continue page_def_name = entry.get("pageDefinition") - if not page_def_name: continue - # Determine output file name from "qml" or "outputFile" field - qml_name = entry.get("qml") or entry.get("outputFile") + qml_name = entry.get("qml") if not qml_name: - print(f"SKIP {page_def_name}: no 'qml' or 'outputFile' field", file=sys.stderr) + print(f"SKIP {page_def_name}: no 'qml' field", file=sys.stderr) continue - page_def_path = PAGES_DIR / page_def_name + page_def_path = resolve_page_def_path(page_def_name, PAGES_DIR, args.custom_pages_dir) if not page_def_path.exists(): - print(f"SKIP {qml_name}: {page_def_path} not found", file=sys.stderr) + print(f"ERROR: {qml_name}: {page_def_path} not found", file=sys.stderr) + return 1 + + output_names.append(qml_name) + if args.list_outputs: continue page = load_page_def(page_def_path) qml = generate_page_qml( - page, SETTINGS_DIR, json_context=page_def_name, page_name=entry.get("name", "") + page, settings_dirs, json_context=page_def_name, page_name=entry.get("name", "") ) if args.dry_run: @@ -88,21 +117,25 @@ def main() -> int: print("\n".join(qml.split("\n")[:20])) print("...\n") else: - page_output_dir = Path(entry["outputDir"]) if "outputDir" in entry else output_dir - output_path = page_output_dir / qml_name + output_path = Path(args.output_dir) / qml_name output_path.parent.mkdir(parents=True, exist_ok=True) write_text_if_changed(output_path, qml) print(f"Generated: {output_path}") generated += 1 + output_names.append("SettingsPagesModel.qml") + if args.list_outputs: + print("\n".join(output_names)) + return 0 + # Generate SettingsPagesModel.qml - model_qml = generate_pages_model_qml(pages_json) + model_qml = generate_pages_model_qml(pages_json, args.custom_pages_dir) if args.dry_run: print("=== SettingsPagesModel.qml ===") print(model_qml) else: - model_path = output_dir / "SettingsPagesModel.qml" + model_path = Path(args.output_dir) / "SettingsPagesModel.qml" write_text_if_changed(model_path, model_qml) print(f"Generated: {model_path}") diff --git a/tools/generators/settings_qml/metadata.py b/tools/generators/settings_qml/metadata.py index ca10c2a07343..7467ac2651b0 100644 --- a/tools/generators/settings_qml/metadata.py +++ b/tools/generators/settings_qml/metadata.py @@ -11,10 +11,13 @@ import re import sys from functools import cache -from pathlib import Path # noqa: TC003 +from pathlib import Path _ACCESSOR_RE = re.compile(r"Q_PROPERTY\s*\(\s*QObject\s*\*\s*(\w+Settings)\s+READ") +# Accessors are QML property names; anything else fails resolution in generated pages. +_QML_ID_RE = re.compile(r"[a-z_][A-Za-z0-9_]*") + def stem_to_accessor(stem: str) -> str: """Convert a SettingsGroup JSON stem to its SettingsManager Q_PROPERTY accessor. @@ -46,35 +49,77 @@ def valid_accessors(settings_dir: Path) -> frozenset[str]: return frozenset(_ACCESSOR_RE.findall(header.read_text(encoding="utf-8"))) +def _as_dirs(settings_dirs: Path | tuple[Path, ...]) -> tuple[Path, ...]: + if isinstance(settings_dirs, Path): + return (settings_dirs,) + return tuple(settings_dirs) + + @cache -def load_settings_metadata(settings_dir: Path) -> dict[str, dict]: - """Build {".": fact-metadata} for every SettingsGroup.json.""" - valid = valid_accessors(settings_dir) +def _load_settings_metadata(settings_dirs: tuple[Path, ...]) -> dict[str, dict]: + valid = valid_accessors(settings_dirs[0]) + if len(settings_dirs) > 1 and not valid: + print( + f"warning: no Q_PROPERTY accessors found in {settings_dirs[0]}/SettingsManager.h; " + "custom settings group collision checking is disabled.", + file=sys.stderr, + ) metadata: dict[str, dict] = {} - for json_path in settings_dir.glob("*.SettingsGroup.json"): - stem = json_path.name.replace(".SettingsGroup.json", "") - accessor = stem_to_accessor(stem) - if valid and accessor not in valid: - print( - f"warning: {json_path.name} maps to {accessor!r} but no matching " - f"Q_PROPERTY exists in SettingsManager.h; skipping.", - file=sys.stderr, - ) - continue - with open(json_path, encoding="utf-8") as f: - data = json.load(f) - for fact in data.get("QGC.MetaData.Facts", []): - metadata[f"{accessor}.{fact['name']}"] = fact + custom_accessors: set[str] = set() + for dir_index, settings_dir in enumerate(settings_dirs): + for json_path in sorted(settings_dir.glob("*.SettingsGroup.json")): + stem = json_path.name.replace(".SettingsGroup.json", "") + accessor = stem_to_accessor(stem) + if dir_index == 0: + if valid and accessor not in valid: + print( + f"warning: {json_path.name} maps to {accessor!r} but no matching " + f"Q_PROPERTY exists in SettingsManager.h; skipping.", + file=sys.stderr, + ) + continue + elif accessor in valid: + # Custom groups register at runtime; they can't shadow a stock Q_PROPERTY + raise ValueError( + f"{json_path}: custom settings group maps to accessor {accessor!r} which " + f"collides with a stock SettingsManager Q_PROPERTY" + ) + elif not _QML_ID_RE.fullmatch(accessor): + raise ValueError( + f"{json_path}: custom settings group maps to accessor {accessor!r} which " + f"is not a valid QML identifier" + ) + if dir_index > 0: + if accessor in custom_accessors: + raise ValueError( + f"{json_path}: custom settings group maps to accessor {accessor!r} which " + f"is already used by another custom settings group" + ) + custom_accessors.add(accessor) + with open(json_path, encoding="utf-8") as f: + data = json.load(f) + for fact in data.get("QGC.MetaData.Facts", []): + metadata[f"{accessor}.{fact['name']}"] = fact return metadata -def get_fact_type(setting: str, settings_dir: Path) -> str: +def load_settings_metadata(settings_dirs: Path | tuple[Path, ...]) -> dict[str, dict]: + """Build {".": fact-metadata} for every SettingsGroup.json. + + The first directory is the stock src/Settings dir (accessors validated against + SettingsManager.h); additional directories hold custom-build settings groups + registered at runtime via SettingsManager::registerCustomSettingsGroup. + """ + return _load_settings_metadata(_as_dirs(settings_dirs)) + + +def get_fact_type(setting: str, settings_dirs: Path | tuple[Path, ...]) -> str: """Look up the declared type for a `.` setting; default 'string'.""" - fact = load_settings_metadata(settings_dir).get(setting, {}) + fact = load_settings_metadata(settings_dirs).get(setting, {}) return fact.get("type", "string").lower() -def has_enum_strings(setting: str, settings_dir: Path) -> bool: +def has_enum_strings(setting: str, settings_dirs: Path | tuple[Path, ...]) -> bool: """True when the fact metadata declares enumStrings.""" - fact = load_settings_metadata(settings_dir).get(setting, {}) + fact = load_settings_metadata(settings_dirs).get(setting, {}) return bool(fact.get("enumStrings", "")) diff --git a/tools/generators/settings_qml/model.py b/tools/generators/settings_qml/model.py index 822a231e5b15..2c6b7aa7be4f 100644 --- a/tools/generators/settings_qml/model.py +++ b/tools/generators/settings_qml/model.py @@ -5,7 +5,10 @@ import json import re from dataclasses import dataclass, field -from pathlib import Path # noqa: TC003 +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path from ..common.controls import ( BaseControlDef, @@ -24,8 +27,9 @@ _TRANSLATED_LIST_RE = re.compile("[,,、]") # Fact-backed control settings: "settingsGroupAccessor.factName" (nested fact names allowed). -# ASCII-only, non-empty segments: fact_name feeds objectNames, which must stay grep-able. -_SETTING_RE = re.compile(r"[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+") +# Segments are QML property names (no leading digit); fact_name also feeds objectNames, +# which must stay grep-able. +_SETTING_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+") # QML property names emitted verbatim into generated QML; a bad name must fail here # with context, not as a qmllint/build error pointing at generated code. @@ -206,3 +210,160 @@ def load_page_def(json_path: Path) -> PageDef: grp.controls.append(ctrl) page.groups.append(grp) return page + + +_ALLOWED_PAGES_ROOT_KEYS = frozenset({"fileType", "version", "comment", "pages"}) +_ALLOWED_PAGE_ENTRY_KEYS = frozenset({ + "comment", "divider", "name", "url", "qml", "icon", "visible", "pageDefinition", +}) +_OVERLAY_POSITION_KEYS = frozenset({"insertAfter", "insertBefore"}) +_ALLOWED_OVERLAY_ENTRY_KEYS = _ALLOWED_PAGE_ENTRY_KEYS | _OVERLAY_POSITION_KEYS | {"remove"} + + +def _load_pages_file(pages_json_path: Path, allowed_entry_keys: frozenset[str]) -> list[dict]: + with open(pages_json_path, encoding="utf-8") as f: + data = json.load(f) + reject_unknown_keys(data, _ALLOWED_PAGES_ROOT_KEYS, "pages file", pages_json_path) + entries = require_list(data.get("pages", []), "'pages'", pages_json_path) + for entry in entries: + reject_unknown_keys(entry, allowed_entry_keys, "page entry", pages_json_path) + for key in ("pageDefinition", "qml"): + if key not in entry: + continue + value = entry[key] + if not isinstance(value, str) or not value: + raise ValueError( + f"{pages_json_path}: {key!r} must be a non-empty string, " + f"got: {value!r}" + ) + # Reject both separators: '\\' is not a separator on POSIX but is on Windows + if "/" in value or "\\" in value: + raise ValueError( + f"{pages_json_path}: {key!r} must be a bare file name, " + f"got: {value!r}" + ) + return entries + + +def _entry_index(entries: list[dict], name: str) -> int: + for i, entry in enumerate(entries): + if not entry.get("divider") and entry.get("name") == name: + return i + return -1 + + +def _merge_overlay(entries: list[dict], overlay_entries: list[dict], overlay_path: Path) -> list[dict]: + # Entries already inserted after each anchor, so repeated insertAfter keeps overlay order + inserted_after: dict[str, list[dict]] = {} + for raw in overlay_entries: + if "remove" in raw: + if set(raw) - {"remove", "comment"}: + raise ValueError( + f"{overlay_path}: a 'remove' entry must not have other keys " + f"(entry: {clamped_repr(raw)})" + ) + index = _entry_index(entries, raw["remove"]) + if index == -1: + raise ValueError( + f"{overlay_path}: 'remove' references unknown page {raw['remove']!r}" + ) + del entries[index] + continue + + for key in _OVERLAY_POSITION_KEYS & set(raw): + if not isinstance(raw[key], str) or not raw[key]: + raise ValueError( + f"{overlay_path}: {key!r} must be a non-empty string " + f"(entry: {clamped_repr(raw)})" + ) + if "insertAfter" in raw and "insertBefore" in raw: + raise ValueError( + f"{overlay_path}: 'insertAfter' and 'insertBefore' are mutually exclusive " + f"(entry: {clamped_repr(raw)})" + ) + insert_after = raw.get("insertAfter") + insert_before = raw.get("insertBefore") + + entry = {k: v for k, v in raw.items() if k not in _OVERLAY_POSITION_KEYS} + if not entry.get("divider"): + if not entry.get("name"): + raise ValueError( + f"{overlay_path}: page entry must have a 'name' " + f"(entry: {clamped_repr(raw)})" + ) + existing = _entry_index(entries, entry["name"]) + if existing != -1: + if insert_after or insert_before: + raise ValueError( + f"{overlay_path}: cannot combine a replace of existing page " + f"{entry['name']!r} with 'insertAfter'/'insertBefore'" + ) + entries[existing] = entry + continue + + if insert_after or insert_before: + anchor = str(insert_after or insert_before) + index = _entry_index(entries, anchor) + if index == -1: + raise ValueError( + f"{overlay_path}: 'insertAfter'/'insertBefore' references unknown page " + f"{anchor!r}" + ) + if insert_after: + tail = inserted_after.setdefault(anchor, []) + pos = index + 1 + while pos < len(entries) and any(entries[pos] is prior for prior in tail): + pos += 1 + entries.insert(pos, entry) + tail.append(entry) + else: + entries.insert(index, entry) + else: + entries.append(entry) + return entries + + +def load_pages_data(pages_json_path: Path, custom_pages_dir: Path | None = None) -> list[dict]: + """Load the stock pages list and merge the custom-build overlay (if present). + + The overlay is `/SettingsPages.json`. Its entries may append, + position (`insertAfter`/`insertBefore`), replace (same `name`), or `remove` pages. + """ + entries = _load_pages_file(pages_json_path, _ALLOWED_PAGE_ENTRY_KEYS) + error_source = pages_json_path + if custom_pages_dir is not None: + overlay_path = custom_pages_dir / "SettingsPages.json" + if overlay_path.is_file(): + overlay_entries = _load_pages_file(overlay_path, _ALLOWED_OVERLAY_ENTRY_KEYS) + entries = _merge_overlay(list(entries), overlay_entries, overlay_path) + # Post-merge duplicates are most plausibly introduced by the overlay + error_source = overlay_path + + seen_qml: dict[str, str] = {} + for entry in entries: + qml = entry.get("qml") + if not qml: + continue + # casefold: macOS/Windows filesystems are case-insensitive, so Foo.qml/foo.qml collide + key = qml.casefold() + if key == "settingspagesmodel.qml": + raise ValueError( + f"{error_source}: page {entry.get('name')!r} uses reserved output " + f"file name 'SettingsPagesModel.qml'" + ) + if key in seen_qml: + raise ValueError( + f"{error_source}: pages {seen_qml[key]!r} and {entry.get('name')!r} " + f"both output qml file {qml!r}" + ) + seen_qml[key] = entry.get("name", "") + return entries + + +def resolve_page_def_path(page_def_name: str, pages_dir: Path, custom_pages_dir: Path | None) -> Path: + """Resolve a pageDefinition file: custom dir shadows the stock pages dir.""" + if custom_pages_dir is not None: + candidate = custom_pages_dir / page_def_name + if candidate.is_file(): + return candidate + return pages_dir / page_def_name diff --git a/tools/generators/settings_qml/page_generator.py b/tools/generators/settings_qml/page_generator.py index a81db5c27adf..09d51304b404 100644 --- a/tools/generators/settings_qml/page_generator.py +++ b/tools/generators/settings_qml/page_generator.py @@ -18,7 +18,9 @@ GroupDef, PageDef, load_page_def, + load_pages_data, parse_keywords, + resolve_page_def_path, split_translated_list, ) @@ -31,8 +33,10 @@ "get_fact_type", "has_enum_strings", "load_page_def", + "load_pages_data", "load_settings_metadata", "parse_keywords", + "resolve_page_def_path", "split_translated_list", "stem_to_accessor", "valid_accessors", diff --git a/tools/tests/test_settings_qml_generator.py b/tools/tests/test_settings_qml_generator.py index f5376c9d661a..62ad3253f791 100644 --- a/tools/tests/test_settings_qml_generator.py +++ b/tools/tests/test_settings_qml_generator.py @@ -1,6 +1,7 @@ """Tests for the settings QML page generator.""" import json +import re import sys from pathlib import Path @@ -13,7 +14,9 @@ PageDef, generate_page_qml, generate_pages_model_qml, + get_fact_type, load_page_def, + load_settings_metadata, ) from ._helpers import REPO_ROOT @@ -1367,3 +1370,440 @@ def test_cli_preserves_unchanged_output_timestamps(tmp_path: Path, monkeypatch) assert timestamps assert timestamps == {path.name: path.stat().st_mtime_ns for path in output_dir.glob("*.qml")} + + +class TestCustomOverlay: + """Custom-build overlay merging into the stock pages model.""" + + @pytest.fixture + def stock_setup(self, tmp_path: Path) -> tuple[Path, Path]: + """Stock pages dir with Alpha/Beta pages plus an empty custom overlay dir.""" + pages_dir = tmp_path / "pages" + pages_dir.mkdir() + page_def = { + "version": 1, + "groups": [{"heading": "Stock Section", "controls": [{"setting": "appSettings.x"}]}], + } + (pages_dir / "Alpha.SettingsUI.json").write_text(json.dumps(page_def), encoding="utf-8") + (pages_dir / "Beta.SettingsUI.json").write_text(json.dumps(page_def), encoding="utf-8") + pages_json = { + "version": 1, + "pages": [ + { + "name": "Alpha", + "qml": "Alpha.qml", + "icon": "qrc:/alpha.svg", + "pageDefinition": "Alpha.SettingsUI.json", + }, + {"divider": True}, + { + "name": "Beta", + "qml": "Beta.qml", + "icon": "qrc:/beta.svg", + "pageDefinition": "Beta.SettingsUI.json", + }, + ], + } + pages_path = pages_dir / "SettingsPages.json" + pages_path.write_text(json.dumps(pages_json), encoding="utf-8") + custom_dir = tmp_path / "custom_pages" + custom_dir.mkdir() + return pages_path, custom_dir + + def _write_overlay(self, custom_dir: Path, pages: list[dict]) -> None: + overlay = {"version": 1, "pages": pages} + (custom_dir / "SettingsPages.json").write_text(json.dumps(overlay), encoding="utf-8") + + def _gamma_entry(self, custom_dir: Path, **extra) -> dict: + page_def = { + "version": 1, + "groups": [{"heading": "Custom Section", "controls": [{"setting": "appSettings.x"}]}], + } + (custom_dir / "Gamma.SettingsUI.json").write_text(json.dumps(page_def), encoding="utf-8") + return { + "name": "Gamma", + "qml": "Gamma.qml", + "icon": "qrc:/gamma.svg", + "pageDefinition": "Gamma.SettingsUI.json", + **extra, + } + + def test_no_overlay_file_keeps_stock_pages(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + qml = generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + assert 'nameKey: "Alpha"' in qml + assert 'nameKey: "Beta"' in qml + + def test_append_page(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [self._gamma_entry(custom_dir)]) + qml = generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + assert 'nameKey: "Gamma"' in qml + assert 'qsTranslate("Gamma.SettingsUI.json", "Custom Section")' in qml + assert qml.index('nameKey: "Beta"') < qml.index('nameKey: "Gamma"') + + def test_insert_after(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [self._gamma_entry(custom_dir, insertAfter="Alpha")]) + qml = generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + assert qml.index('nameKey: "Alpha"') < qml.index('nameKey: "Gamma"') + assert qml.index('nameKey: "Gamma"') < qml.index('nameKey: "Beta"') + + def test_insert_before(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [self._gamma_entry(custom_dir, insertBefore="Alpha")]) + qml = generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + assert qml.index('nameKey: "Gamma"') < qml.index('nameKey: "Alpha"') + + def test_multiple_insert_after_same_anchor_preserves_order(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + gamma = self._gamma_entry(custom_dir, insertAfter="Alpha") + delta = { + "name": "Delta", + "qml": "Delta.qml", + "icon": "qrc:/delta.svg", + "pageDefinition": "Gamma.SettingsUI.json", + "insertAfter": "Alpha", + } + self._write_overlay(custom_dir, [gamma, delta]) + qml = generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + assert ( + qml.index('nameKey: "Alpha"') + < qml.index('nameKey: "Gamma"') + < qml.index('nameKey: "Delta"') + < qml.index('nameKey: "Beta"') + ) + + def test_non_string_qml_rejected(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [self._gamma_entry(custom_dir, qml=0)]) + with pytest.raises(ValueError, match="'qml'"): + generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + + def test_non_string_page_definition_rejected(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [self._gamma_entry(custom_dir, pageDefinition=[])]) + with pytest.raises(ValueError, match="'pageDefinition'"): + generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + + def test_backslash_qml_path_rejected(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [self._gamma_entry(custom_dir, qml="sub\\Gamma.qml")]) + with pytest.raises(ValueError, match="bare file name"): + generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + + def test_remove_page(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [{"remove": "Beta"}]) + qml = generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + assert 'nameKey: "Alpha"' in qml + assert 'nameKey: "Beta"' not in qml + + def test_replace_page_keeps_position(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + page_def = { + "version": 1, + "groups": [{"heading": "Replaced Section", "controls": [{"setting": "appSettings.x"}]}], + } + (custom_dir / "AlphaCustom.SettingsUI.json").write_text(json.dumps(page_def), encoding="utf-8") + self._write_overlay(custom_dir, [{ + "name": "Alpha", + "qml": "Alpha.qml", + "icon": "qrc:/alpha-custom.svg", + "pageDefinition": "AlphaCustom.SettingsUI.json", + }]) + qml = generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + assert qml.count('nameKey: "Alpha"') == 1 + assert "qrc:/alpha-custom.svg" in qml + assert "Replaced Section" in qml + assert qml.index('nameKey: "Alpha"') < qml.index('nameKey: "Beta"') + + def test_custom_page_def_shadows_stock(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + page_def = { + "version": 1, + "groups": [{"heading": "Shadowed Section", "controls": [{"setting": "appSettings.x"}]}], + } + (custom_dir / "Alpha.SettingsUI.json").write_text(json.dumps(page_def), encoding="utf-8") + qml = generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + assert "Shadowed Section" in qml + assert "Stock Section" not in qml.split('nameKey: "Beta"')[0] + + def test_remove_unknown_page_rejected(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [{"remove": "Nonexistent"}]) + with pytest.raises(ValueError, match="Nonexistent"): + generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + + def test_insert_after_unknown_rejected(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [self._gamma_entry(custom_dir, insertAfter="Nonexistent")]) + with pytest.raises(ValueError, match="Nonexistent"): + generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + + def test_both_position_keys_rejected(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay( + custom_dir, [self._gamma_entry(custom_dir, insertAfter="Alpha", insertBefore="Beta")] + ) + with pytest.raises(ValueError, match="insertAfter"): + generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + + def test_replace_with_position_key_rejected(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [{ + "name": "Alpha", + "qml": "Alpha.qml", + "icon": "qrc:/alpha.svg", + "pageDefinition": "Alpha.SettingsUI.json", + "insertAfter": "Beta", + }]) + with pytest.raises(ValueError, match="replace"): + generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + + def test_remove_with_extra_keys_rejected(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [{"remove": "Beta", "icon": "qrc:/x.svg"}]) + with pytest.raises(ValueError, match="remove"): + generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + + def test_missing_name_rejected(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + entry = self._gamma_entry(custom_dir) + del entry["name"] + self._write_overlay(custom_dir, [entry]) + with pytest.raises(ValueError, match="'name'"): + generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + + def test_empty_position_key_rejected(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [self._gamma_entry(custom_dir, insertAfter="")]) + with pytest.raises(ValueError, match="insertAfter"): + generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + + def test_non_string_position_key_rejected(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [self._gamma_entry(custom_dir, insertBefore=1)]) + with pytest.raises(ValueError, match="insertBefore"): + generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + + def test_qml_with_path_rejected(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [self._gamma_entry(custom_dir, qml="./Gamma.qml")]) + with pytest.raises(ValueError, match="bare file name"): + generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + + def test_case_insensitive_duplicate_qml_rejected(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [self._gamma_entry(custom_dir, qml="alpha.qml")]) + with pytest.raises(ValueError, match=re.escape("alpha.qml")): + generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + + def test_pages_model_qml_name_reserved(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [self._gamma_entry(custom_dir, qml="settingsPagesModel.qml")]) + with pytest.raises(ValueError, match=re.escape("SettingsPagesModel.qml")): + generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + + def test_duplicate_qml_rejected(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [self._gamma_entry(custom_dir, qml="Alpha.qml")]) + with pytest.raises(ValueError, match=re.escape("Alpha.qml")): + generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + + def test_duplicate_qml_error_names_overlay_file(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [self._gamma_entry(custom_dir, qml="Alpha.qml")]) + with pytest.raises(ValueError, match=re.escape(str(custom_dir / "SettingsPages.json"))): + generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + + def test_overlay_unknown_key_rejected(self, stock_setup: tuple[Path, Path]): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [self._gamma_entry(custom_dir, bogusKey=True)]) + with pytest.raises(ValueError, match="bogusKey"): + generate_pages_model_qml(pages_path, custom_pages_dir=custom_dir) + + def test_overlay_keys_rejected_in_stock_file(self, stock_setup: tuple[Path, Path]): + pages_path, _ = stock_setup + data = json.loads(pages_path.read_text(encoding="utf-8")) + data["pages"][0]["insertAfter"] = "Beta" + pages_path.write_text(json.dumps(data), encoding="utf-8") + with pytest.raises(ValueError, match="insertAfter"): + generate_pages_model_qml(pages_path) + + def test_cli_list_outputs(self, stock_setup: tuple[Path, Path], tmp_path: Path, monkeypatch, capsys): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [self._gamma_entry(custom_dir), {"remove": "Beta"}]) + settings_dir = _make_settings_dir( + tmp_path, + {"App": [{"name": "x", "type": "bool", "shortDesc": "X", "label": "X"}]}, + ) + monkeypatch.setattr(settings_generator, "PAGES_DIR", pages_path.parent) + monkeypatch.setattr(settings_generator, "SETTINGS_DIR", settings_dir) + monkeypatch.setattr( + sys, + "argv", + ["generate_pages", "--list-outputs", "--custom-pages-dir", str(custom_dir)], + ) + assert settings_generator.main() == 0 + lines = capsys.readouterr().out.strip().splitlines() + assert lines == ["Alpha.qml", "Gamma.qml", "SettingsPagesModel.qml"] + + def test_cli_missing_page_definition_fatal( + self, stock_setup: tuple[Path, Path], monkeypatch, capsys + ): + pages_path, custom_dir = stock_setup + entry = self._gamma_entry(custom_dir) + (custom_dir / "Gamma.SettingsUI.json").unlink() + self._write_overlay(custom_dir, [entry]) + monkeypatch.setattr(settings_generator, "PAGES_DIR", pages_path.parent) + monkeypatch.setattr( + sys, + "argv", + ["generate_pages", "--list-outputs", "--custom-pages-dir", str(custom_dir)], + ) + assert settings_generator.main() != 0 + assert "Gamma.SettingsUI.json" in capsys.readouterr().err + + def test_cli_collision_rejected_without_generated_pages( + self, stock_setup: tuple[Path, Path], tmp_path: Path, monkeypatch + ): + """Accessor collisions must fail configure even when no generated page needs metadata.""" + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [{"remove": "Alpha"}, {"remove": "Beta"}]) + settings_dir = _make_settings_dir( + tmp_path, {"App": [{"name": "x", "type": "bool", "shortDesc": "X", "label": "X"}]} + ) + (settings_dir / "SettingsManager.h").write_text( + "Q_PROPERTY(QObject *appSettings READ appSettings CONSTANT)\n", encoding="utf-8" + ) + custom_settings_root = tmp_path / "custom_settings" + custom_settings_root.mkdir() + custom_settings_dir = _make_settings_dir( + custom_settings_root, + {"App": [{"name": "z", "type": "bool", "shortDesc": "Z", "label": "Z"}]}, + ) + monkeypatch.setattr(settings_generator, "PAGES_DIR", pages_path.parent) + monkeypatch.setattr(settings_generator, "SETTINGS_DIR", settings_dir) + monkeypatch.setattr( + sys, + "argv", + [ + "generate_pages", + "--list-outputs", + "--custom-pages-dir", str(custom_dir), + "--custom-settings-dir", str(custom_settings_dir), + ], + ) + with pytest.raises(ValueError, match="appSettings"): + settings_generator.main() + + def test_cli_generates_custom_page(self, stock_setup: tuple[Path, Path], tmp_path: Path, monkeypatch): + pages_path, custom_dir = stock_setup + self._write_overlay(custom_dir, [self._gamma_entry(custom_dir)]) + settings_dir = _make_settings_dir( + tmp_path, + {"App": [{"name": "x", "type": "bool", "shortDesc": "X", "label": "X"}]}, + ) + output_dir = tmp_path / "generated" + monkeypatch.setattr(settings_generator, "PAGES_DIR", pages_path.parent) + monkeypatch.setattr(settings_generator, "SETTINGS_DIR", settings_dir) + monkeypatch.setattr( + sys, + "argv", + [ + "generate_pages", + "--output-dir", str(output_dir), + "--custom-pages-dir", str(custom_dir), + ], + ) + assert settings_generator.main() == 0 + generated = sorted(p.name for p in output_dir.glob("*.qml")) + assert generated == ["Alpha.qml", "Beta.qml", "Gamma.qml", "SettingsPagesModel.qml"] + assert "Custom Section" in (output_dir / "Gamma.qml").read_text(encoding="utf-8") + + +class TestCustomSettingsMetadata: + """Custom-build SettingsGroup.json metadata directories.""" + + def test_custom_accessor_fact_type(self, tmp_path: Path): + stock_dir = _make_settings_dir( + tmp_path, {"App": [{"name": "x", "type": "bool", "shortDesc": "X", "label": "X"}]} + ) + custom_root = tmp_path / "custom" + custom_root.mkdir() + custom_dir = _make_settings_dir( + custom_root, {"Custom": [{"name": "z", "type": "bool", "shortDesc": "Z", "label": "Z"}]} + ) + assert get_fact_type("customSettings.z", (stock_dir, custom_dir)) == "bool" + assert get_fact_type("appSettings.x", (stock_dir, custom_dir)) == "bool" + + def test_page_generation_with_custom_accessor(self, tmp_path: Path): + stock_dir = _make_settings_dir( + tmp_path, {"App": [{"name": "x", "type": "bool", "shortDesc": "X", "label": "X"}]} + ) + custom_root = tmp_path / "custom" + custom_root.mkdir() + custom_dir = _make_settings_dir( + custom_root, {"Custom": [{"name": "z", "type": "bool", "shortDesc": "Z", "label": "Z"}]} + ) + page = PageDef(groups=[ + GroupDef(heading="G", controls=[ControlDef(setting="customSettings.z")]), + ]) + qml = generate_page_qml(page, (stock_dir, custom_dir)) + assert "FactCheckBoxSlider" in qml + assert "QGroundControl.settingsManager.customSettings.z" in qml + + def test_missing_accessor_header_warns_for_custom_dir(self, tmp_path: Path, capsys): + stock_dir = _make_settings_dir( + tmp_path, {"App": [{"name": "x", "type": "bool", "shortDesc": "X", "label": "X"}]} + ) + custom_root = tmp_path / "custom" + custom_root.mkdir() + custom_dir = _make_settings_dir( + custom_root, {"Custom": [{"name": "z", "type": "bool", "shortDesc": "Z", "label": "Z"}]} + ) + load_settings_metadata((stock_dir, custom_dir)) + assert "collision checking is disabled" in capsys.readouterr().err + + def test_custom_accessor_invalid_qml_identifier_rejected(self, tmp_path: Path): + stock_dir = _make_settings_dir( + tmp_path, {"App": [{"name": "x", "type": "bool", "shortDesc": "X", "label": "X"}]} + ) + custom_root = tmp_path / "custom" + custom_root.mkdir() + custom_dir = _make_settings_dir( + custom_root, {"3D": [{"name": "z", "type": "bool", "shortDesc": "Z", "label": "Z"}]} + ) + with pytest.raises(ValueError, match="3DSettings"): + load_settings_metadata((stock_dir, custom_dir)) + + def test_custom_accessor_collision_rejected(self, tmp_path: Path): + stock_dir = _make_settings_dir( + tmp_path, {"App": [{"name": "x", "type": "bool", "shortDesc": "X", "label": "X"}]} + ) + (stock_dir / "SettingsManager.h").write_text( + "Q_PROPERTY(QObject *appSettings READ appSettings CONSTANT)\n", encoding="utf-8" + ) + custom_root = tmp_path / "custom" + custom_root.mkdir() + custom_dir = _make_settings_dir( + custom_root, {"App": [{"name": "z", "type": "bool", "shortDesc": "Z", "label": "Z"}]} + ) + with pytest.raises(ValueError, match="appSettings"): + load_settings_metadata((stock_dir, custom_dir)) + + def test_duplicate_derived_custom_accessor_rejected(self, tmp_path: Path): + stock_dir = _make_settings_dir( + tmp_path, {"App": [{"name": "x", "type": "bool", "shortDesc": "X", "label": "X"}]} + ) + # Two dirs: ABC/Abc as sibling files would collide on case-insensitive filesystems + dirs = [] + for sub, stem in (("custom1", "ABC"), ("custom2", "Abc")): + root = tmp_path / sub + root.mkdir() + dirs.append(_make_settings_dir( + root, {stem: [{"name": "z", "type": "bool", "shortDesc": "Z", "label": "Z"}]} + )) + with pytest.raises(ValueError, match="abcSettings"): + load_settings_metadata((stock_dir, *dirs))